fullcourtdefense-cli 1.25.3 → 1.25.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -118,7 +118,10 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
118
118
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
119
119
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
120
120
|
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
121
|
-
|
|
121
|
+
`// Execution view (aligned with the CLI's stripInertDataSegments): commit messages, issue/PR bodies, and`,
|
|
122
|
+
`// here-strings/heredocs are DATA — inert unless the line can feed an interpreter or the region interpolates.`,
|
|
123
|
+
`function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]*\`/;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');o=o.replace(/((?:^|\\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\\s*)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')/gi,(w,f,q)=>q[0]==="'"?f+' fcd_inert_data ':K.test(q)?w:f+' fcd_inert_data ');return o;}`,
|
|
124
|
+
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
122
125
|
`function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){spawnSync(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{stdio:'ignore',windowsHide:true});}}catch{}}`,
|
|
123
126
|
`function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
|
|
124
127
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
@@ -55,6 +55,7 @@ export interface LocalSafetyScanOptions {
|
|
|
55
55
|
*/
|
|
56
56
|
trustedScriptPaths?: string[];
|
|
57
57
|
}
|
|
58
|
+
export declare function stripInertDataSegments(text: string): string;
|
|
58
59
|
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
59
60
|
export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
60
61
|
export declare function scanDeterministicPrompt(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.stripInertDataSegments = stripInertDataSegments;
|
|
36
37
|
exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
37
38
|
exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
|
|
38
39
|
exports.scanDeterministicPrompt = scanDeterministicPrompt;
|
|
@@ -615,10 +616,12 @@ function credentialCommandReason(value) {
|
|
|
615
616
|
function destructiveCommandReason(value) {
|
|
616
617
|
return matchCommand(value, (text, lower) => {
|
|
617
618
|
// Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`), and the target
|
|
618
|
-
// may be the root `/`, the root wildcard `/*`, or a bare `*`.
|
|
619
|
-
|
|
619
|
+
// may be the root `/`, the root wildcard `/*`, or a bare `*`. Terminators
|
|
620
|
+
// include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
|
|
621
|
+
// outer command — is caught too.
|
|
622
|
+
if (/\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
|
|
620
623
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
621
|
-
if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|])/.test(lower))
|
|
624
|
+
if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
|
|
622
625
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
623
626
|
// Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
|
|
624
627
|
// is independent (`/s /q` and `/q /s` both wipe), and the dequoted variant
|
|
@@ -779,13 +782,55 @@ function isSensitivePathContext(toolName, candidate) {
|
|
|
779
782
|
const lastKey = candidate.keyPath.split('.').pop() || '';
|
|
780
783
|
return /(?:path|file|filename|dir|directory|target|source|src|dest|location|glob|pattern|uri|url)/i.test(lastKey);
|
|
781
784
|
}
|
|
782
|
-
|
|
783
|
-
|
|
785
|
+
// ---- execution view: message/document DATA inside a command is inert ---------
|
|
786
|
+
// Kills the "wrote ABOUT a dangerous command" FP class: commit messages,
|
|
787
|
+
// issue/PR bodies, here-strings, doc heredocs quoting `terraform destroy` or
|
|
788
|
+
// `rm -rf /` are data, not execution. Safety invariants:
|
|
789
|
+
// 1. NOTHING is stripped when the text can feed an interpreter (| sh, iex,
|
|
790
|
+
// eval, xargs, base64 -d, -EncodedCommand, FromBase64String) — data that
|
|
791
|
+
// flows into an interpreter IS execution.
|
|
792
|
+
// 2. A region containing $( ) or `…` pairs is kept — interpolation executes
|
|
793
|
+
// BEFORE the outer command.
|
|
794
|
+
// 3. Secret-literal rules and honeypot/custom rules never use this view —
|
|
795
|
+
// a token pasted into a commit message still leaks.
|
|
796
|
+
const DATA_TO_INTERPRETER = /\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|\|\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b/i;
|
|
797
|
+
const INTERPOLATES = /\$\(|`[^`]*`/;
|
|
798
|
+
const INERT_DATA = ' fcd_inert_data ';
|
|
799
|
+
const TEXT_SINK_FLAG_ARG = /((?:^|\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/gi;
|
|
800
|
+
function stripInertDataSegments(text) {
|
|
801
|
+
if (DATA_TO_INTERPRETER.test(text))
|
|
802
|
+
return text;
|
|
803
|
+
let out = text;
|
|
804
|
+
// PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
|
|
805
|
+
out = out.replace(/@'[\s\S]*?'@/g, INERT_DATA);
|
|
806
|
+
out = out.replace(/@"[\s\S]*?"@/g, m => (m.includes('$(') ? m : INERT_DATA));
|
|
807
|
+
// POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
|
|
808
|
+
out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
|
|
809
|
+
out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
|
|
810
|
+
// Quoted message/text-flag arguments (commit messages, issue/PR bodies…).
|
|
811
|
+
out = out.replace(TEXT_SINK_FLAG_ARG, (whole, flag, quoted) => {
|
|
812
|
+
if (quoted.startsWith("'"))
|
|
813
|
+
return flag + INERT_DATA;
|
|
814
|
+
return INTERPOLATES.test(quoted) ? whole : flag + INERT_DATA;
|
|
815
|
+
});
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
function scanTextValue(toolName, rawValue, options) {
|
|
819
|
+
// Honeypot decoys and org custom patterns scan the RAW value: a decoy path
|
|
820
|
+
// even inside message data is exfiltration staging, and custom rules define
|
|
821
|
+
// their own scope.
|
|
822
|
+
const honeypot = honeypotTouch(rawValue, options);
|
|
784
823
|
if (honeypot)
|
|
785
824
|
return honeypot;
|
|
786
|
-
const custom = customBlock(
|
|
825
|
+
const custom = customBlock(rawValue, options);
|
|
787
826
|
if (custom)
|
|
788
827
|
return custom;
|
|
828
|
+
// Every rule below asks "can this text ACT?" — so it sees the execution
|
|
829
|
+
// view: quoted message/document data (commit -m, issue --body, here-strings,
|
|
830
|
+
// heredocs) is inert unless it can flow into an interpreter (see
|
|
831
|
+
// stripInertDataSegments invariants). This is the generic FP guard for
|
|
832
|
+
// "wrote ABOUT a dangerous command" vs "ran one".
|
|
833
|
+
const value = stripInertDataSegments(rawValue);
|
|
789
834
|
// NOTE: a disabled item must FALL THROUGH to the later rules, not end the
|
|
790
835
|
// scan — otherwise turning off (or gating) a path rule would mask a real
|
|
791
836
|
// reverse shell / destructive command in the same value.
|
|
@@ -104,7 +104,10 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
104
104
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
105
105
|
`const BLOCK_CODE=${BLOCK_CODE};`,
|
|
106
106
|
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
107
|
-
|
|
107
|
+
`// Execution view (aligned with the CLI's stripInertDataSegments): commit messages, issue/PR bodies, and`,
|
|
108
|
+
`// here-strings/heredocs are DATA — inert unless the line can feed an interpreter or the region interpolates.`,
|
|
109
|
+
`function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]*\`/;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');o=o.replace(/((?:^|\\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\\s*)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')/gi,(w,f,q)=>q[0]==="'"?f+' fcd_inert_data ':K.test(q)?w:f+' fcd_inert_data ');return o;}`,
|
|
110
|
+
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
108
111
|
`function triggerFlush(){try{if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore'});c.unref();}}catch{}}`,
|
|
109
112
|
`function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
|
|
110
113
|
`let argv=process.argv.slice(2);if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
|
|
@@ -485,14 +485,42 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
485
485
|
` }`,
|
|
486
486
|
`} catch { $global:FcdGuardRules = @() }`,
|
|
487
487
|
``,
|
|
488
|
+
`# Execution view (aligned with the CLI's stripInertDataSegments): message/`,
|
|
489
|
+
`# document DATA inside a command is inert — commit messages, issue/PR bodies,`,
|
|
490
|
+
`# here-strings. Never strips when the line can feed an interpreter (| sh,`,
|
|
491
|
+
`# iex, eval, xargs, base64 -d, -EncodedCommand) and keeps any region that`,
|
|
492
|
+
`# interpolates $( ) or backtick pairs — those execute BEFORE the command.`,
|
|
493
|
+
`function global:Get-FcdExecView {`,
|
|
494
|
+
` param([string]$CommandLine)`,
|
|
495
|
+
String.raw ` if ($CommandLine -match '(?i)\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|\|\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b') { return $CommandLine }`,
|
|
496
|
+
` $out = $CommandLine`,
|
|
497
|
+
" $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match '\\$\\(|" + '`[^`]*`' + "') { $m.Value } else { ' fcd_inert_data ' } }",
|
|
498
|
+
String.raw ` $out = [regex]::Replace($out, "@'[\s\S]*?'@", ' fcd_inert_data ')`,
|
|
499
|
+
` $out = [regex]::Replace($out, '@"[\\s\\S]*?"@', $keepIfInterpolates)`,
|
|
500
|
+
String.raw ` $out = [regex]::Replace($out, "<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)", ' fcd_inert_data ')`,
|
|
501
|
+
String.raw ` $out = [regex]::Replace($out, '<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)', $keepIfInterpolates)`,
|
|
502
|
+
String.raw ` $flagArg = '((?:^|\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\s*)("(?:[^"\\]|\\.)*"|''(?:[^''\\]|\\.)*'')'`,
|
|
503
|
+
" $out = [regex]::Replace($out, $flagArg, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Groups[2].Value.StartsWith(\"'\")) { $m.Groups[1].Value + ' fcd_inert_data ' } elseif ($m.Groups[2].Value -match '\\$\\(|" + '`[^`]*`' + "') { $m.Value } else { $m.Groups[1].Value + ' fcd_inert_data ' } }, 'IgnoreCase')",
|
|
504
|
+
` return $out`,
|
|
505
|
+
`}`,
|
|
506
|
+
``,
|
|
488
507
|
`function global:Test-FcdShellGuard {`,
|
|
489
508
|
` param([string]$CommandLine)`,
|
|
490
509
|
` if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null }`,
|
|
491
510
|
` $normalized = ($CommandLine -replace '\\s+', ' ').Trim()`,
|
|
511
|
+
` $execView = $CommandLine`,
|
|
512
|
+
` try { $execView = Get-FcdExecView -CommandLine $CommandLine } catch { }`,
|
|
513
|
+
` $execNormalized = ($execView -replace '\\s+', ' ').Trim()`,
|
|
492
514
|
` $warnHit = $null`,
|
|
493
515
|
` foreach ($rule in $global:FcdGuardRules) {`,
|
|
494
516
|
` try {`,
|
|
495
|
-
`
|
|
517
|
+
` # Built-in rules ask "can this text ACT?" — they see the execution`,
|
|
518
|
+
` # view, so a commit message quoting a dangerous command is inert.`,
|
|
519
|
+
` # Console custom rules define their own scope — they see the raw line.`,
|
|
520
|
+
` $isCustom = ([string]$rule.Source -eq 'custom')`,
|
|
521
|
+
` $target = if ($isCustom) { $CommandLine } else { $execView }`,
|
|
522
|
+
` $targetNorm = if ($isCustom) { $normalized } else { $execNormalized }`,
|
|
523
|
+
` if ($rule.Regex.IsMatch($targetNorm) -or $rule.Regex.IsMatch($target)) {`,
|
|
496
524
|
` # A block rule always outranks a warn rule matching the same line.`,
|
|
497
525
|
` if ($rule.Action -ne 'warn') { return $rule }`,
|
|
498
526
|
` if ($null -eq $warnHit) { $warnHit = $rule }`,
|
package/dist/version.json
CHANGED