fullcourtdefense-cli 1.11.0 → 1.14.0
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.
- package/dist/commands/cmdGuard.js +30 -9
- package/dist/commands/installAll.d.ts +1 -0
- package/dist/commands/installAll.js +13 -0
- package/dist/commands/posixShellGuard.d.ts +26 -0
- package/dist/commands/posixShellGuard.js +393 -0
- package/dist/commands/shellGuard.d.ts +21 -1
- package/dist/commands/shellGuard.js +195 -55
- package/dist/index.js +96 -13
- package/dist/telemetry.d.ts +1 -0
- package/dist/telemetry.js +3 -0
- package/dist/version.json +1 -1
- package/package.json +2 -1
|
@@ -60,12 +60,33 @@ const AUTORUN_BAT_PATH = path.join(os.homedir(), '.fullcourtdefense-cmd-autorun.
|
|
|
60
60
|
const CMD_AUTORUN_KEY = 'HKCU\\Software\\Microsoft\\Command Processor';
|
|
61
61
|
const AUTORUN_VALUE = 'AutoRun';
|
|
62
62
|
const AUTORUN_MARKER = 'fullcourtdefense-cmd-autorun.bat';
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* cmd builtins / exes we intercept via doskey (first token of the typed line).
|
|
65
|
+
*
|
|
66
|
+
* cmd.exe has NO general pre-execution hook (unlike PowerShell PSReadLine or the
|
|
67
|
+
* zsh/bash whole-line hooks), so coverage here is by enumeration: every command
|
|
68
|
+
* name that anchors a dangerous rule must be listed or its rule can never fire in
|
|
69
|
+
* interactive cmd. This is the known residual gap — a command whose leading name
|
|
70
|
+
* is not below runs unchecked. Keep in sync with the builtin rule command anchors
|
|
71
|
+
* in shellGuard.ts. Only leading-token names matter (doskey is first-token only),
|
|
72
|
+
* so pipes/`&&` chains where the dangerous command is not first are NOT caught by
|
|
73
|
+
* cmd (they ARE under PowerShell/zsh/bash and `shell-guard-check`).
|
|
74
|
+
*/
|
|
64
75
|
const INTERCEPTED_COMMANDS = [
|
|
65
|
-
|
|
66
|
-
'
|
|
67
|
-
'
|
|
68
|
-
|
|
76
|
+
// Destructive filesystem / disk
|
|
77
|
+
'del', 'erase', 'rd', 'rmdir', 'format', 'cipher', 'diskpart', 'fsutil',
|
|
78
|
+
'robocopy', 'xcopy', 'takeown', 'icacls', 'attrib', 'compact',
|
|
79
|
+
// Backup / recovery / shadow-copy tampering (ransomware patterns)
|
|
80
|
+
'vssadmin', 'wbadmin', 'bcdedit', 'wevtutil',
|
|
81
|
+
// Interpreters / download-and-exec vectors
|
|
82
|
+
'powershell', 'pwsh', 'curl', 'wget', 'certutil', 'bitsadmin', 'mshta',
|
|
83
|
+
'rundll32', 'regsvr32', 'wscript', 'cscript', 'wmic', 'wsl',
|
|
84
|
+
// Service / firewall / registry / accounts
|
|
85
|
+
'reg', 'net', 'net1', 'sc', 'netsh', 'schtasks', 'taskkill',
|
|
86
|
+
// Infra / cloud CLIs
|
|
87
|
+
'terraform', 'pulumi', 'kubectl', 'aws', 'gcloud', 'az', 'docker',
|
|
88
|
+
// VCS + database clients
|
|
89
|
+
'git', 'psql', 'mysql', 'mongo', 'mongosh', 'sqlcmd',
|
|
69
90
|
];
|
|
70
91
|
function regString(key, value) {
|
|
71
92
|
try {
|
|
@@ -95,15 +116,15 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
95
116
|
`const SPOOL_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense-spool.jsonl'))};`,
|
|
96
117
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
97
118
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
98
|
-
`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,reason:r.reason,source:r.source,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
|
|
119
|
+
`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,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
|
|
99
120
|
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();for(const r of rules){try{if(r.re.test(n)||r.re.test(line))return r;}catch{}}return null;}`,
|
|
100
|
-
`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,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{}}`,
|
|
121
|
+
`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{}}`,
|
|
101
122
|
`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);}`,
|
|
102
123
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
103
124
|
`const line=args.join(' ');const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
104
125
|
`if(!hit)delegate(args);`,
|
|
105
|
-
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block
|
|
106
|
-
`console.error('[FullCourtDefense] BLOCKED: '+hit.reason+' (rule '+hit.id+')');`,
|
|
126
|
+
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');delegate(args);}`,
|
|
127
|
+
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
107
128
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
108
129
|
`spoolEvent(hit,line,'block');process.exit(1);`,
|
|
109
130
|
``,
|
|
@@ -12,6 +12,7 @@ const restartNotice_1 = require("./restartNotice");
|
|
|
12
12
|
const windowsAudit_1 = require("./windowsAudit");
|
|
13
13
|
const shellGuard_1 = require("./shellGuard");
|
|
14
14
|
const cmdGuard_1 = require("./cmdGuard");
|
|
15
|
+
const posixShellGuard_1 = require("./posixShellGuard");
|
|
15
16
|
/**
|
|
16
17
|
* One-click install: discover/wrap every existing MCP server, install Cursor
|
|
17
18
|
* hooks for built-in Cursor actions, optional self-heal and fleet upload.
|
|
@@ -117,6 +118,18 @@ async function installAllCommand(args, config) {
|
|
|
117
118
|
console.log(`\x1b[33m⚠ Warning: cmd guard install failed (${error instanceof Error ? error.message : String(error)}). Installation continues.\x1b[0m`);
|
|
118
119
|
}
|
|
119
120
|
}
|
|
121
|
+
// Interactive macOS/Linux guard — same rules, zsh accept-line widget + bash
|
|
122
|
+
// function wrappers, from the local cache (no admin, no network hot path).
|
|
123
|
+
// Gated to darwin/linux so Windows install-all behavior is unchanged.
|
|
124
|
+
if ((process.platform === 'darwin' || process.platform === 'linux') && args.posixGuard !== 'false') {
|
|
125
|
+
console.log('\n\x1b[1mInstalling interactive shell guard (bash + zsh)…\x1b[0m');
|
|
126
|
+
try {
|
|
127
|
+
await (0, posixShellGuard_1.installPosixShellGuardCommand)();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
console.log(`\x1b[33m⚠ Warning: shell guard install failed (${error instanceof Error ? error.message : String(error)}). Installation continues.\x1b[0m`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
120
133
|
if (args.autoProtect === 'true') {
|
|
121
134
|
console.log('\n\x1b[1mScheduling self-healing MCP protection…\x1b[0m');
|
|
122
135
|
await (0, autoProtect_1.autoProtectCommand)({
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface PosixShellGuardStatus {
|
|
2
|
+
supported: boolean;
|
|
3
|
+
installed: boolean;
|
|
4
|
+
rcFiles: Array<{
|
|
5
|
+
shell: string;
|
|
6
|
+
rcPath: string;
|
|
7
|
+
installed: boolean;
|
|
8
|
+
}>;
|
|
9
|
+
rulesPresent: boolean;
|
|
10
|
+
ruleCount?: number;
|
|
11
|
+
mode?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Read-only probe. Never throws. */
|
|
14
|
+
export declare function getPosixShellGuardStatus(): PosixShellGuardStatus;
|
|
15
|
+
/** Lightweight boolean for the telemetry heartbeat / discovery report. */
|
|
16
|
+
export declare function isPosixShellGuardInstalled(): boolean;
|
|
17
|
+
export interface InstallPosixShellGuardArgs {
|
|
18
|
+
/** 'false' to write the guard scripts + rules without wiring rc files. */
|
|
19
|
+
profiles?: string;
|
|
20
|
+
}
|
|
21
|
+
/** `fullcourtdefense install-shell-guard` on macOS/Linux — enable the bash/zsh guard. */
|
|
22
|
+
export declare function installPosixShellGuardCommand(args?: InstallPosixShellGuardArgs): Promise<void>;
|
|
23
|
+
/** `fullcourtdefense uninstall-shell-guard` on macOS/Linux — remove the guard. */
|
|
24
|
+
export declare function uninstallPosixShellGuardCommand(): Promise<void>;
|
|
25
|
+
/** Refresh the shared rules JSON when the POSIX guard is installed. Never throws. */
|
|
26
|
+
export declare function refreshPosixShellGuardRules(): void;
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.getPosixShellGuardStatus = getPosixShellGuardStatus;
|
|
37
|
+
exports.isPosixShellGuardInstalled = isPosixShellGuardInstalled;
|
|
38
|
+
exports.installPosixShellGuardCommand = installPosixShellGuardCommand;
|
|
39
|
+
exports.uninstallPosixShellGuardCommand = uninstallPosixShellGuardCommand;
|
|
40
|
+
exports.refreshPosixShellGuardRules = refreshPosixShellGuardRules;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const os = __importStar(require("os"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const shellGuard_1 = require("./shellGuard");
|
|
45
|
+
/**
|
|
46
|
+
* Interactive macOS/Linux shell guard — real-time blocking of dangerous commands
|
|
47
|
+
* TYPED by a human into bash or zsh (inside or outside the IDE).
|
|
48
|
+
*
|
|
49
|
+
* Shares the exact ruleset the PowerShell/cmd guards use (writeShellGuardRules):
|
|
50
|
+
* the deterministic dangerous-command families + the org's cached custom blocks +
|
|
51
|
+
* the Shield mode (block vs monitor). One dashboard toggle disables a rule
|
|
52
|
+
* everywhere.
|
|
53
|
+
*
|
|
54
|
+
* Two hook mechanisms, one shared Node checker (~/.fullcourtdefense-posix-guard.js).
|
|
55
|
+
* Both are COMMAND-AGNOSTIC — they inspect the WHOLE typed line against every
|
|
56
|
+
* rule, so any command in any position is covered (not a fixed watchlist of
|
|
57
|
+
* names). Each shell gets its OWN sourced file (never cross-parsed — zsh-only
|
|
58
|
+
* expansions would otherwise break bash at source time):
|
|
59
|
+
* - zsh (macOS default), ~/.fullcourtdefense-shell-guard.zsh: a ZLE
|
|
60
|
+
* `accept-line` widget inspects the whole typed line before it runs (catches
|
|
61
|
+
* pipes, e.g. `curl … | sh`, and chained `foo && rm -rf /`). Block mode → the
|
|
62
|
+
* line is not accepted (never executes); monitor → warning + the line runs.
|
|
63
|
+
* Analogous to the PowerShell PSReadLine hook.
|
|
64
|
+
* - bash, ~/.fullcourtdefense-shell-guard.bash: a `DEBUG`-trap preexec hook
|
|
65
|
+
* (bash-preexec style) reads the full command line from history right before
|
|
66
|
+
* it runs and, with `extdebug`, skips it on an explicit block (checker exit
|
|
67
|
+
* 97). Whole-line, command-agnostic — the bash analogue of zsh's accept-line.
|
|
68
|
+
*
|
|
69
|
+
* Cost of whole-line coverage: the Node checker runs once per typed command. Off
|
|
70
|
+
* with `export FCD_SHELL_GUARD=off`. Benign lines exit the checker in ~1ms after
|
|
71
|
+
* regex matching; the only real overhead is Node startup per command.
|
|
72
|
+
*
|
|
73
|
+
* Safety: every layer fails OPEN. If the checker is missing or errors, the command
|
|
74
|
+
* runs — a guard bug must never brick a developer's shell. Only an explicit block
|
|
75
|
+
* (checker exit code 97) stops a command. Disable for a session with
|
|
76
|
+
* `export FCD_SHELL_GUARD=off`.
|
|
77
|
+
*
|
|
78
|
+
* Rules/mode refresh opportunistically on every telemetry flush (offline caches
|
|
79
|
+
* only — never blocks a shell). Blocks are spooled and reported to the fleet
|
|
80
|
+
* dashboard as `shell_terminal` events.
|
|
81
|
+
*/
|
|
82
|
+
const GUARD_RULES_PATH = path.join(os.homedir(), '.fullcourtdefense-shell-guard.json');
|
|
83
|
+
const GUARD_JS_PATH = path.join(os.homedir(), '.fullcourtdefense-posix-guard.js');
|
|
84
|
+
const GUARD_ZSH_PATH = path.join(os.homedir(), '.fullcourtdefense-shell-guard.zsh');
|
|
85
|
+
const GUARD_BASH_PATH = path.join(os.homedir(), '.fullcourtdefense-shell-guard.bash');
|
|
86
|
+
const SPOOL_PATH = path.join(os.homedir(), '.fullcourtdefense-spool.jsonl');
|
|
87
|
+
const MARKER_START = '# >>> fullcourtdefense shell guard >>>';
|
|
88
|
+
const MARKER_END = '# <<< fullcourtdefense shell guard <<<';
|
|
89
|
+
const BLOCK_CODE = 97;
|
|
90
|
+
function isPosixPlatform() {
|
|
91
|
+
return process.platform === 'darwin' || process.platform === 'linux';
|
|
92
|
+
}
|
|
93
|
+
function shSingleQuote(value) {
|
|
94
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
95
|
+
}
|
|
96
|
+
/** The shared Node checker: exits 97 on a confirmed block, 0 otherwise (fail-open). */
|
|
97
|
+
function buildGuardJs(nodePath, cliEntry) {
|
|
98
|
+
return [
|
|
99
|
+
`'use strict';`,
|
|
100
|
+
`const fs=require('fs');const crypto=require('crypto');const{spawn}=require('child_process');`,
|
|
101
|
+
`const RULES_PATH=${JSON.stringify(GUARD_RULES_PATH)};`,
|
|
102
|
+
`const SPOOL_PATH=${JSON.stringify(SPOOL_PATH)};`,
|
|
103
|
+
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
104
|
+
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
105
|
+
`const BLOCK_CODE=${BLOCK_CODE};`,
|
|
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,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
|
|
107
|
+
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();for(const r of rules){try{if(r.re.test(n)||r.re.test(line))return r;}catch{}}return null;}`,
|
|
108
|
+
`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
|
+
`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
|
+
`let argv=process.argv.slice(2);if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
|
|
111
|
+
`if(!line.trim())process.exit(0);`,
|
|
112
|
+
`if(process.env.FCD_SHELL_GUARD==='off')process.exit(0);`,
|
|
113
|
+
`const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
114
|
+
`if(!hit)process.exit(0);`,
|
|
115
|
+
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');process.exit(0);}`,
|
|
116
|
+
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
117
|
+
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
118
|
+
`spoolEvent(hit,line,'block');process.exit(BLOCK_CODE);`,
|
|
119
|
+
``,
|
|
120
|
+
].join('\n');
|
|
121
|
+
}
|
|
122
|
+
/** zsh guard — sourced ONLY from .zshrc (contains zsh-specific ZLE syntax). */
|
|
123
|
+
function buildGuardZsh(nodePath) {
|
|
124
|
+
return [
|
|
125
|
+
`# FullCourtDefense interactive shell guard for zsh (installed by fullcourtdefense-cli).`,
|
|
126
|
+
`# Disable for one session: export FCD_SHELL_GUARD=off`,
|
|
127
|
+
`# Remove permanently: fullcourtdefense uninstall-shell-guard`,
|
|
128
|
+
``,
|
|
129
|
+
`case $- in *i*) ;; *) return 0 2>/dev/null || true ;; esac`,
|
|
130
|
+
``,
|
|
131
|
+
`FCD_GUARD_NODE=${shSingleQuote(nodePath)}`,
|
|
132
|
+
`FCD_GUARD_JS=${shSingleQuote(GUARD_JS_PATH)}`,
|
|
133
|
+
``,
|
|
134
|
+
`# Inspect the WHOLE typed line against every rule before accepting it`,
|
|
135
|
+
`# (command-agnostic: catches any command, pipes, and chained segments).`,
|
|
136
|
+
`__fcd_zle_accept_line() {`,
|
|
137
|
+
` emulate -L zsh`,
|
|
138
|
+
` local __fcd_line=$BUFFER`,
|
|
139
|
+
` if [[ -n "$__fcd_line" && "\${FCD_SHELL_GUARD:-}" != "off" && -x "$FCD_GUARD_NODE" && -f "$FCD_GUARD_JS" ]]; then`,
|
|
140
|
+
` local __fcd_out`,
|
|
141
|
+
` __fcd_out="$("$FCD_GUARD_NODE" "$FCD_GUARD_JS" -- "$__fcd_line" 2>&1)"`,
|
|
142
|
+
` local __fcd_rc=$?`,
|
|
143
|
+
` if [[ $__fcd_rc -eq 97 ]]; then`,
|
|
144
|
+
` zle -M -- "$__fcd_out"`,
|
|
145
|
+
` return`,
|
|
146
|
+
` fi`,
|
|
147
|
+
` [[ -n "$__fcd_out" ]] && print -u2 -r -- "$__fcd_out"`,
|
|
148
|
+
` fi`,
|
|
149
|
+
` zle .accept-line`,
|
|
150
|
+
`}`,
|
|
151
|
+
`if [[ "\${FCD_SHELL_GUARD:-}" != "off" ]] && zle -l >/dev/null 2>&1; then`,
|
|
152
|
+
` zle -N accept-line __fcd_zle_accept_line`,
|
|
153
|
+
`fi`,
|
|
154
|
+
``,
|
|
155
|
+
].join('\n');
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* bash guard — sourced ONLY from .bashrc/.bash_profile (pure bash syntax).
|
|
159
|
+
*
|
|
160
|
+
* Command-agnostic whole-line hook via a DEBUG trap (bash-preexec style). The
|
|
161
|
+
* trap fires before each command; it reads the FULL interactive line the user
|
|
162
|
+
* typed from `history` (so pipelines like `curl … | sh` and chained lines like
|
|
163
|
+
* `foo && rm -rf /` are evaluated as one string, exactly like zsh's accept-line
|
|
164
|
+
* BUFFER — not per-simple-command). It de-duplicates so the checker runs once
|
|
165
|
+
* per typed line. With `extdebug` enabled, returning 1 from the DEBUG trap
|
|
166
|
+
* SKIPS the command — so an explicit block (checker exit 97) prevents execution.
|
|
167
|
+
* A missing/erroring checker returns 0 → the command runs (fail open).
|
|
168
|
+
*/
|
|
169
|
+
function buildGuardBash(nodePath) {
|
|
170
|
+
return [
|
|
171
|
+
`# FullCourtDefense interactive shell guard for bash (installed by fullcourtdefense-cli).`,
|
|
172
|
+
`# Disable for one session: export FCD_SHELL_GUARD=off`,
|
|
173
|
+
`# Remove permanently: fullcourtdefense uninstall-shell-guard`,
|
|
174
|
+
``,
|
|
175
|
+
`case $- in *i*) ;; *) return 0 2>/dev/null || true ;; esac`,
|
|
176
|
+
``,
|
|
177
|
+
`FCD_GUARD_NODE=${shSingleQuote(nodePath)}`,
|
|
178
|
+
`FCD_GUARD_JS=${shSingleQuote(GUARD_JS_PATH)}`,
|
|
179
|
+
`__fcd_last_line=""`,
|
|
180
|
+
``,
|
|
181
|
+
`# Run the shared checker on one full line. Returns 1 to cancel (extdebug).`,
|
|
182
|
+
`__fcd_check_line() {`,
|
|
183
|
+
` local __fcd_line="$1"`,
|
|
184
|
+
` [ -z "$__fcd_line" ] && return 0`,
|
|
185
|
+
` [ "\${FCD_SHELL_GUARD:-}" = "off" ] && return 0`,
|
|
186
|
+
` if [ ! -x "$FCD_GUARD_NODE" ] || [ ! -f "$FCD_GUARD_JS" ]; then return 0; fi`,
|
|
187
|
+
` local __fcd_out __fcd_rc`,
|
|
188
|
+
` __fcd_out="$("$FCD_GUARD_NODE" "$FCD_GUARD_JS" -- "$__fcd_line" 2>&1)"`,
|
|
189
|
+
` __fcd_rc=$?`,
|
|
190
|
+
` [ -n "$__fcd_out" ] && printf '%s\\n' "$__fcd_out" >&2`,
|
|
191
|
+
` [ "$__fcd_rc" -eq 97 ] && return 1`,
|
|
192
|
+
` return 0`,
|
|
193
|
+
`}`,
|
|
194
|
+
``,
|
|
195
|
+
`# DEBUG trap: skip completions and the guard functions, then read the whole`,
|
|
196
|
+
`# typed line from history (number stripped) and check it once per line.`,
|
|
197
|
+
`__fcd_debug_trap() {`,
|
|
198
|
+
` [ -n "\${COMP_LINE:-}" ] && return 0`,
|
|
199
|
+
` case "$BASH_COMMAND" in __fcd_*|__fcd_debug_trap) return 0 ;; esac`,
|
|
200
|
+
` local __fcd_hist __fcd_num __fcd_line HISTTIMEFORMAT=`,
|
|
201
|
+
` __fcd_hist=$(builtin history 1 2>/dev/null)`,
|
|
202
|
+
` read -r __fcd_num __fcd_line <<< "$__fcd_hist"`,
|
|
203
|
+
` [ -z "$__fcd_line" ] && return 0`,
|
|
204
|
+
` if [ "$__fcd_line" != "$__fcd_last_line" ]; then`,
|
|
205
|
+
` __fcd_last_line="$__fcd_line"`,
|
|
206
|
+
` __fcd_check_line "$__fcd_line" || return 1`,
|
|
207
|
+
` fi`,
|
|
208
|
+
` return 0`,
|
|
209
|
+
`}`,
|
|
210
|
+
`if [ "\${FCD_SHELL_GUARD:-}" != "off" ]; then`,
|
|
211
|
+
` shopt -s extdebug 2>/dev/null || true`,
|
|
212
|
+
` trap '__fcd_debug_trap' DEBUG`,
|
|
213
|
+
`fi`,
|
|
214
|
+
``,
|
|
215
|
+
].join('\n');
|
|
216
|
+
}
|
|
217
|
+
/** rc files we wire the guard into. .bash_profile only when it already exists. */
|
|
218
|
+
function resolveRcTargets() {
|
|
219
|
+
const home = os.homedir();
|
|
220
|
+
const out = [
|
|
221
|
+
{ shell: 'zsh', rcPath: path.join(home, '.zshrc'), guardPath: GUARD_ZSH_PATH },
|
|
222
|
+
{ shell: 'bash', rcPath: path.join(home, '.bashrc'), guardPath: GUARD_BASH_PATH },
|
|
223
|
+
];
|
|
224
|
+
const bashProfile = path.join(home, '.bash_profile');
|
|
225
|
+
if (fs.existsSync(bashProfile))
|
|
226
|
+
out.push({ shell: 'bash', rcPath: bashProfile, guardPath: GUARD_BASH_PATH });
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
function rcSnippet(guardPath) {
|
|
230
|
+
return [
|
|
231
|
+
MARKER_START,
|
|
232
|
+
`# Loads the FullCourtDefense dangerous-command guard for interactive sessions.`,
|
|
233
|
+
`[ -f ${shSingleQuote(guardPath)} ] && . ${shSingleQuote(guardPath)}`,
|
|
234
|
+
MARKER_END,
|
|
235
|
+
].join('\n');
|
|
236
|
+
}
|
|
237
|
+
function addSnippetToRc(target) {
|
|
238
|
+
fs.mkdirSync(path.dirname(target.rcPath), { recursive: true });
|
|
239
|
+
let content = '';
|
|
240
|
+
try {
|
|
241
|
+
content = fs.readFileSync(target.rcPath, 'utf8');
|
|
242
|
+
}
|
|
243
|
+
catch { /* new rc */ }
|
|
244
|
+
if (content.includes(MARKER_START))
|
|
245
|
+
return 'already';
|
|
246
|
+
const sep = content && !content.endsWith('\n') ? '\n\n' : content ? '\n' : '';
|
|
247
|
+
fs.writeFileSync(target.rcPath, content + sep + rcSnippet(target.guardPath) + '\n', 'utf8');
|
|
248
|
+
return 'installed';
|
|
249
|
+
}
|
|
250
|
+
function removeSnippetFromRc(rcPath) {
|
|
251
|
+
try {
|
|
252
|
+
const content = fs.readFileSync(rcPath, 'utf8');
|
|
253
|
+
const start = content.indexOf(MARKER_START);
|
|
254
|
+
const end = content.indexOf(MARKER_END);
|
|
255
|
+
if (start === -1 || end === -1)
|
|
256
|
+
return false;
|
|
257
|
+
const cleaned = (content.slice(0, start) + content.slice(end + MARKER_END.length)).replace(/\n{3,}/g, '\n\n');
|
|
258
|
+
fs.writeFileSync(rcPath, cleaned, 'utf8');
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Read-only probe. Never throws. */
|
|
266
|
+
function getPosixShellGuardStatus() {
|
|
267
|
+
if (!isPosixPlatform()) {
|
|
268
|
+
return { supported: false, installed: false, rcFiles: [], rulesPresent: false };
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const rcFiles = resolveRcTargets().map(entry => {
|
|
272
|
+
let installed = false;
|
|
273
|
+
try {
|
|
274
|
+
installed = fs.readFileSync(entry.rcPath, 'utf8').includes(MARKER_START);
|
|
275
|
+
}
|
|
276
|
+
catch { /* no rc */ }
|
|
277
|
+
return { shell: entry.shell, rcPath: entry.rcPath, installed };
|
|
278
|
+
});
|
|
279
|
+
let ruleCount;
|
|
280
|
+
let mode;
|
|
281
|
+
let rulesPresent = false;
|
|
282
|
+
try {
|
|
283
|
+
const parsed = JSON.parse(fs.readFileSync(GUARD_RULES_PATH, 'utf8'));
|
|
284
|
+
rulesPresent = Array.isArray(parsed?.rules) && parsed.rules.length > 0;
|
|
285
|
+
ruleCount = Array.isArray(parsed?.rules) ? parsed.rules.length : undefined;
|
|
286
|
+
mode = typeof parsed?.mode === 'string' ? parsed.mode : undefined;
|
|
287
|
+
}
|
|
288
|
+
catch { /* not written yet */ }
|
|
289
|
+
return {
|
|
290
|
+
supported: true,
|
|
291
|
+
installed: rcFiles.some(r => r.installed) && (fs.existsSync(GUARD_ZSH_PATH) || fs.existsSync(GUARD_BASH_PATH)),
|
|
292
|
+
rcFiles,
|
|
293
|
+
rulesPresent,
|
|
294
|
+
ruleCount,
|
|
295
|
+
mode,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return { supported: true, installed: false, rcFiles: [], rulesPresent: false };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** Lightweight boolean for the telemetry heartbeat / discovery report. */
|
|
303
|
+
function isPosixShellGuardInstalled() {
|
|
304
|
+
try {
|
|
305
|
+
if (!isPosixPlatform())
|
|
306
|
+
return false;
|
|
307
|
+
if (!fs.existsSync(GUARD_ZSH_PATH) && !fs.existsSync(GUARD_BASH_PATH))
|
|
308
|
+
return false;
|
|
309
|
+
for (const { rcPath } of resolveRcTargets()) {
|
|
310
|
+
try {
|
|
311
|
+
if (fs.readFileSync(rcPath, 'utf8').includes(MARKER_START))
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
catch { /* keep looking */ }
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** `fullcourtdefense install-shell-guard` on macOS/Linux — enable the bash/zsh guard. */
|
|
323
|
+
async function installPosixShellGuardCommand(args = {}) {
|
|
324
|
+
if (!isPosixPlatform()) {
|
|
325
|
+
console.log('The POSIX shell guard supports macOS and Linux (bash/zsh).');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const rules = (0, shellGuard_1.writeShellGuardRules)();
|
|
329
|
+
const nodePath = process.execPath;
|
|
330
|
+
const cliEntry = process.argv[1] || '';
|
|
331
|
+
fs.writeFileSync(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), { encoding: 'utf8', mode: 0o600 });
|
|
332
|
+
fs.writeFileSync(GUARD_ZSH_PATH, buildGuardZsh(nodePath), { encoding: 'utf8', mode: 0o600 });
|
|
333
|
+
fs.writeFileSync(GUARD_BASH_PATH, buildGuardBash(nodePath), { encoding: 'utf8', mode: 0o600 });
|
|
334
|
+
console.log(`Guard rules written: ${rules.rules.length} rule(s), mode "${rules.mode}".`);
|
|
335
|
+
if (args.profiles === 'false') {
|
|
336
|
+
console.log('Skipped rc wiring (--profiles false): guard scripts + rules written only.');
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
let wired = 0;
|
|
340
|
+
for (const target of resolveRcTargets()) {
|
|
341
|
+
try {
|
|
342
|
+
const outcome = addSnippetToRc(target);
|
|
343
|
+
if (outcome === 'installed')
|
|
344
|
+
wired++;
|
|
345
|
+
console.log(outcome === 'installed'
|
|
346
|
+
? `\x1b[32m✓ ${target.shell}: guard added\x1b[0m \x1b[2m(${target.rcPath})\x1b[0m`
|
|
347
|
+
: `\x1b[32m✓ ${target.shell}: guard already present\x1b[0m \x1b[2m(${target.rcPath})\x1b[0m`);
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
console.log(`\x1b[33m⚠ ${target.shell}: could not update ${target.rcPath} (${error instanceof Error ? error.message : String(error)})\x1b[0m`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
console.log('\x1b[2mTakes effect in NEW terminals (or run: source ~/.zshrc). Dangerous typed commands are blocked (or warned in monitor mode) and reported to the fleet dashboard.\x1b[0m');
|
|
354
|
+
if (wired === 0) {
|
|
355
|
+
console.log('\x1b[33m⚠ Guard scripts written but no rc file was newly wired — check the entries above.\x1b[0m');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** `fullcourtdefense uninstall-shell-guard` on macOS/Linux — remove the guard. */
|
|
359
|
+
async function uninstallPosixShellGuardCommand() {
|
|
360
|
+
if (!isPosixPlatform()) {
|
|
361
|
+
console.log('Nothing to remove on this OS.');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
let removed = 0;
|
|
365
|
+
for (const target of resolveRcTargets()) {
|
|
366
|
+
if (removeSnippetFromRc(target.rcPath)) {
|
|
367
|
+
removed++;
|
|
368
|
+
console.log(`Removed guard from ${target.shell} rc (${target.rcPath}).`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
fs.unlinkSync(GUARD_ZSH_PATH);
|
|
373
|
+
}
|
|
374
|
+
catch { /* not present */ }
|
|
375
|
+
try {
|
|
376
|
+
fs.unlinkSync(GUARD_BASH_PATH);
|
|
377
|
+
}
|
|
378
|
+
catch { /* not present */ }
|
|
379
|
+
try {
|
|
380
|
+
fs.unlinkSync(GUARD_JS_PATH);
|
|
381
|
+
}
|
|
382
|
+
catch { /* not present */ }
|
|
383
|
+
console.log(removed > 0 ? 'Shell guard uninstalled. Open shells keep the guard until closed.' : 'Shell guard was not installed in any rc file.');
|
|
384
|
+
}
|
|
385
|
+
/** Refresh the shared rules JSON when the POSIX guard is installed. Never throws. */
|
|
386
|
+
function refreshPosixShellGuardRules() {
|
|
387
|
+
try {
|
|
388
|
+
if (!isPosixShellGuardInstalled())
|
|
389
|
+
return;
|
|
390
|
+
(0, shellGuard_1.writeShellGuardRules)();
|
|
391
|
+
}
|
|
392
|
+
catch { /* best-effort */ }
|
|
393
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
/** Blast-severity levels (shellfirm-style). Drives per-severity console policy. */
|
|
2
|
+
export type ShellGuardSeverity = 'critical' | 'high' | 'medium' | 'low';
|
|
1
3
|
export interface ShellGuardRule {
|
|
2
4
|
id: string;
|
|
3
5
|
category: string;
|
|
4
|
-
|
|
6
|
+
severity: ShellGuardSeverity;
|
|
7
|
+
/** Regex compiled with IgnoreCase in BOTH .NET (PowerShell) and JS (cmd/posix). */
|
|
5
8
|
pattern: string;
|
|
6
9
|
reason: string;
|
|
7
10
|
source: 'builtin' | 'custom';
|
|
@@ -13,6 +16,23 @@ interface ShellGuardRulesFile {
|
|
|
13
16
|
}
|
|
14
17
|
/** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
|
|
15
18
|
export declare function writeShellGuardRules(): ShellGuardRulesFile;
|
|
19
|
+
/** The active ruleset in memory (builtins minus dashboard-disabled + org custom). */
|
|
20
|
+
export declare function activeShellGuardRules(): ShellGuardRule[];
|
|
21
|
+
export interface ShellGuardMatch {
|
|
22
|
+
rule: ShellGuardRule;
|
|
23
|
+
/** The command segment that matched (chained commands are split on ; & |). */
|
|
24
|
+
segment: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Evaluate a typed command line WITHOUT executing it — the dry-run classifier
|
|
28
|
+
* behind `shell-guard-check`. Uses the exact same rules + JS regex engine as the
|
|
29
|
+
* live cmd/posix checkers (equivalent to .NET for these patterns). Also splits
|
|
30
|
+
* chained commands (`a && del /s c:\`) and tests each segment, so combinations
|
|
31
|
+
* are caught even when the dangerous part is not first.
|
|
32
|
+
*
|
|
33
|
+
* Returns ALL matches (one per matched segment/rule), first-match-per-segment.
|
|
34
|
+
*/
|
|
35
|
+
export declare function evaluateShellCommand(line: string, rules?: ShellGuardRule[]): ShellGuardMatch[];
|
|
16
36
|
/**
|
|
17
37
|
* Opportunistic rules/mode refresh — called from the telemetry flush cycle so a
|
|
18
38
|
* console mode flip or new custom rule reaches interactive shells within ~30s.
|
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.writeShellGuardRules = writeShellGuardRules;
|
|
37
|
+
exports.activeShellGuardRules = activeShellGuardRules;
|
|
38
|
+
exports.evaluateShellCommand = evaluateShellCommand;
|
|
37
39
|
exports.refreshShellGuardRules = refreshShellGuardRules;
|
|
38
40
|
exports.getShellGuardStatus = getShellGuardStatus;
|
|
39
41
|
exports.isShellGuardInstalled = isShellGuardInstalled;
|
|
@@ -67,7 +69,9 @@ const path = __importStar(require("path"));
|
|
|
67
69
|
*
|
|
68
70
|
* Scope honesty: this covers INTERACTIVE PowerShell (PSReadLine hosts, incl.
|
|
69
71
|
* IDE-integrated terminals). Non-interactive scripts are covered by ScriptBlock
|
|
70
|
-
* Logging (windowsAudit.ts). Native cmd.exe is covered separately (cmdGuard.ts)
|
|
72
|
+
* Logging (windowsAudit.ts). Native cmd.exe is covered separately (cmdGuard.ts),
|
|
73
|
+
* and macOS/Linux bash+zsh by posixShellGuard.ts — all three share the ruleset
|
|
74
|
+
* emitted by writeShellGuardRules() below.
|
|
71
75
|
*/
|
|
72
76
|
const GUARD_RULES_PATH = path.join(os.homedir(), '.fullcourtdefense-shell-guard.json');
|
|
73
77
|
const GUARD_PS1_PATH = path.join(os.homedir(), '.fullcourtdefense-shell-guard.ps1');
|
|
@@ -76,82 +80,168 @@ const RUNTIME_CACHE_PATH = path.join(os.homedir(), '.fullcourtdefense-runtime.js
|
|
|
76
80
|
const SNAPSHOT_CACHE_DIR = path.join(os.homedir(), '.fullcourtdefense');
|
|
77
81
|
const MARKER_START = '# >>> fullcourtdefense shell guard >>>';
|
|
78
82
|
const MARKER_END = '# <<< fullcourtdefense shell guard <<<';
|
|
83
|
+
/** Confine every lookahead to a single command segment (never span `;` `&` `|`). */
|
|
84
|
+
const SEG = String.raw `[^;&|]*`;
|
|
85
|
+
function compileSpec(spec) {
|
|
86
|
+
let pattern = String.raw `\b(?:${spec.command.join('|')})\b`;
|
|
87
|
+
for (const flag of spec.flags || [])
|
|
88
|
+
pattern += `(?=${SEG}\\s(?:${flag}))`;
|
|
89
|
+
for (const body of spec.extra || [])
|
|
90
|
+
pattern += `(?=${SEG}${body})`;
|
|
91
|
+
for (const n of spec.neg || [])
|
|
92
|
+
pattern += `(?!${SEG}(?:${n}))`;
|
|
93
|
+
if (spec.target && spec.target.length)
|
|
94
|
+
pattern += `(?=${SEG}\\s(?:${spec.target.join('|')}))`;
|
|
95
|
+
return { id: spec.id, category: spec.category, severity: spec.severity, reason: spec.reason, source: 'builtin', pattern };
|
|
96
|
+
}
|
|
97
|
+
// Reusable target fragments.
|
|
98
|
+
const T_UNIX_ROOT = String.raw `["']?/["']?(?:\s|$|[;&|])|\*(?:\s|$|[;&|])`;
|
|
99
|
+
const T_WIN_DRIVE = String.raw `["']?[a-z]:(?:[\\/](?:\*(?:\.\*)?)?)?["']?\s*(?:$|[;&|])`;
|
|
100
|
+
const T_WIN_SYSDIR = String.raw `["']?[a-z]:[\\/](?:windows|program files(?: \(x86\))?|programdata|users)\b`;
|
|
79
101
|
/**
|
|
80
|
-
* Built-in dangerous-command rules
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
102
|
+
* Built-in dangerous-command rules. Flag/target-bearing rules are compiled from
|
|
103
|
+
* specs (order-independent by construction); the rest are literal regexes. Item
|
|
104
|
+
* ids match the deterministic guard so dashboard events line up across IDE hooks
|
|
105
|
+
* and terminal blocks. Every rule carries a severity for console policy.
|
|
84
106
|
*/
|
|
85
107
|
const BUILTIN_RULES = [
|
|
86
|
-
// Destructive filesystem
|
|
87
|
-
{ id: 'rm_rf_root', category: 'destructive_command', reason: 'recursive
|
|
88
|
-
|
|
89
|
-
{ id: '
|
|
90
|
-
|
|
91
|
-
{ id: '
|
|
92
|
-
|
|
93
|
-
{ id: '
|
|
94
|
-
|
|
95
|
-
{ id: '
|
|
108
|
+
// Destructive filesystem — spec-compiled (order-independent, optional flags not required).
|
|
109
|
+
compileSpec({ id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of filesystem root',
|
|
110
|
+
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [T_UNIX_ROOT] }),
|
|
111
|
+
compileSpec({ id: 'rm_rf_home', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of the home directory',
|
|
112
|
+
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [String.raw `["']?(?:~|\$HOME)(?:/\S*)?["']?(?:\s|$|[;&|])`] }),
|
|
113
|
+
compileSpec({ id: 'windows_drive_delete', category: 'destructive_command', severity: 'critical', reason: 'recursive Windows drive delete',
|
|
114
|
+
command: ['del', 'erase'], flags: [String.raw `/s\b`], target: [T_WIN_DRIVE] }),
|
|
115
|
+
compileSpec({ id: 'windows_drive_rmdir', category: 'destructive_command', severity: 'critical', reason: 'recursive Windows drive remove directory',
|
|
116
|
+
command: ['rd', 'rmdir'], flags: [String.raw `/s\b`], target: [String.raw `["']?[a-z]:[\\/]?["']?\s*(?:$|[;&|])`] }),
|
|
117
|
+
compileSpec({ id: 'windows_system_dir_delete', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of a Windows system directory',
|
|
118
|
+
command: ['del', 'erase', 'rd', 'rmdir'], flags: [String.raw `/s\b`], target: [T_WIN_SYSDIR] }),
|
|
119
|
+
compileSpec({ id: 'remove_item_drive', category: 'destructive_command', severity: 'critical', reason: 'recursive Remove-Item on a drive root',
|
|
120
|
+
command: ['remove-item', 'ri'], flags: [String.raw `-recurse\w*`], target: [String.raw `["']?[a-z]:[\\/]?["']?(?:\s|$|[;&|])`] }),
|
|
121
|
+
compileSpec({ id: 'chmod_root_777', category: 'destructive_command', severity: 'high', reason: 'recursive world-writable permission change on root',
|
|
122
|
+
command: ['chmod'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`, String.raw `[0-7]?777`], target: [T_UNIX_ROOT] }),
|
|
123
|
+
compileSpec({ id: 'chown_root_recursive', category: 'destructive_command', severity: 'high', reason: 'recursive ownership change on the filesystem root',
|
|
124
|
+
command: ['chown'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [T_UNIX_ROOT] }),
|
|
125
|
+
compileSpec({ id: 'dd_disk_overwrite', category: 'destructive_command', severity: 'critical', reason: 'raw disk overwrite command',
|
|
126
|
+
command: ['dd'], target: [String.raw `of=/dev/(?:sd|xvd|hd|nvme|disk)\S*`] }),
|
|
127
|
+
{ id: 'disk_device_overwrite', category: 'destructive_command', severity: 'critical', reason: 'redirecting output over a raw disk device', source: 'builtin',
|
|
128
|
+
pattern: String.raw `>\s*/dev/(?:sd|nvme|hd|disk|xvd|vd)[a-z0-9]*` },
|
|
129
|
+
{ id: 'format_drive', category: 'destructive_command', severity: 'critical', reason: 'drive format command', source: 'builtin',
|
|
96
130
|
pattern: String.raw `(?<![-\w])(?:format(?:\.com)?\s+[a-z]:(?:\s|$)|format-volume\b(?=.*-driveletter))` },
|
|
97
|
-
{ id: '
|
|
98
|
-
pattern: String.raw `:\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:` },
|
|
99
|
-
{ id: 'mkfs_device', category: 'destructive_command', reason: 'filesystem format command', source: 'builtin',
|
|
131
|
+
{ id: 'mkfs_device', category: 'destructive_command', severity: 'critical', reason: 'filesystem format command', source: 'builtin',
|
|
100
132
|
pattern: String.raw `\bmkfs(?:\.[a-z0-9]+)?\s+/dev/` },
|
|
101
|
-
{ id: '
|
|
102
|
-
pattern: String.raw `\
|
|
103
|
-
{ id: '
|
|
104
|
-
pattern: String.raw
|
|
105
|
-
{ id: 'vssadmin_delete_shadows', category: 'destructive_command', reason: 'shadow copy deletion (ransomware pattern)', source: 'builtin',
|
|
133
|
+
{ id: 'macos_erase_disk', category: 'destructive_command', severity: 'critical', reason: 'erasing a macOS disk/volume', source: 'builtin',
|
|
134
|
+
pattern: String.raw `\bdiskutil\s+(?:erasedisk|erasevolume|reformat|zerodisk|secureerase)\b` },
|
|
135
|
+
{ id: 'fork_bomb', category: 'destructive_command', severity: 'high', reason: 'fork bomb', source: 'builtin',
|
|
136
|
+
pattern: String.raw `:\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:` },
|
|
137
|
+
{ id: 'vssadmin_delete_shadows', category: 'destructive_command', severity: 'critical', reason: 'shadow copy deletion (ransomware pattern)', source: 'builtin',
|
|
106
138
|
pattern: String.raw `\bvssadmin\b(?=.*\bdelete\b)(?=.*\bshadows\b)` },
|
|
107
|
-
{ id: '
|
|
139
|
+
{ id: 'wbadmin_delete_backup', category: 'destructive_command', severity: 'critical', reason: 'Windows backup catalog deletion (ransomware pattern)', source: 'builtin',
|
|
140
|
+
pattern: String.raw `\bwbadmin\b(?=.*\bdelete\b)(?=.*\b(?:catalog|systemstatebackup|backup)\b)` },
|
|
141
|
+
{ id: 'bcdedit_recovery_disable', category: 'security_tampering', severity: 'critical', reason: 'disabling Windows recovery/boot repair (ransomware pattern)', source: 'builtin',
|
|
142
|
+
pattern: String.raw `\bbcdedit\b(?=.*(?:\brecoveryenabled\b\s+no\b|\bbootstatuspolicy\b\s+ignoreallfailures\b))` },
|
|
143
|
+
{ id: 'cipher_wipe_disk', category: 'destructive_command', severity: 'high', reason: 'wiping deleted data from a drive (cipher /w)', source: 'builtin',
|
|
144
|
+
pattern: String.raw `\bcipher(?:\.exe)?\b(?=.*\s/w(?::|\b))` },
|
|
145
|
+
{ id: 'defender_disable', category: 'security_tampering', severity: 'high', reason: 'disabling Windows Defender real-time protection', source: 'builtin',
|
|
108
146
|
pattern: String.raw `\bset-mppreference\b(?=.*-disablerealtimemonitoring)(?=.*(?:\$true|\s1\b))` },
|
|
109
147
|
// Reverse shells / droppers
|
|
110
|
-
{ id: 'bash_dev_tcp', category: 'reverse_shell', reason: 'bash reverse shell', source: 'builtin',
|
|
148
|
+
{ id: 'bash_dev_tcp', category: 'reverse_shell', severity: 'critical', reason: 'bash reverse shell', source: 'builtin',
|
|
111
149
|
pattern: String.raw `\bbash\s+-i\b.*(?:/dev/tcp/)` },
|
|
112
|
-
{ id: 'netcat_exec', category: 'reverse_shell', reason: 'netcat reverse shell', source: 'builtin',
|
|
150
|
+
{ id: 'netcat_exec', category: 'reverse_shell', severity: 'critical', reason: 'netcat reverse shell', source: 'builtin',
|
|
113
151
|
pattern: String.raw `\b(?:nc|ncat|netcat)\b.*\s-e\s+(?:/bin/)?(?:sh|bash|cmd(?:\.exe)?|powershell(?:\.exe)?)\b` },
|
|
114
|
-
{ id: 'socat_exec', category: 'reverse_shell', reason: 'socat reverse shell', source: 'builtin',
|
|
152
|
+
{ id: 'socat_exec', category: 'reverse_shell', severity: 'critical', reason: 'socat reverse shell', source: 'builtin',
|
|
115
153
|
pattern: String.raw `\bsocat\b.*\bexec:(?:/bin/)?(?:sh|bash)\b` },
|
|
116
|
-
{ id: 'curl_pipe_shell', category: 'reverse_shell', reason: 'remote script piped to shell', source: 'builtin',
|
|
154
|
+
{ id: 'curl_pipe_shell', category: 'reverse_shell', severity: 'high', reason: 'remote script piped to shell', source: 'builtin',
|
|
117
155
|
pattern: String.raw `\b(?:curl|wget)\b[^|;&]{0,300}\|\s*(?:sudo\s+)?(?:sh|bash)\b` },
|
|
118
|
-
{ id: '
|
|
156
|
+
{ id: 'remote_pipe_interpreter', category: 'reverse_shell', severity: 'high', reason: 'remote script piped to an interpreter', source: 'builtin',
|
|
157
|
+
pattern: String.raw `\b(?:curl|wget)\b[^|;&]{0,300}\|\s*(?:sudo\s+)?(?:python[0-9.]*|perl|ruby|node|php)\b` },
|
|
158
|
+
{ id: 'powershell_download_exec', category: 'reverse_shell', severity: 'high', reason: 'download-and-execute (IEX + web download)', source: 'builtin',
|
|
119
159
|
pattern: String.raw `(?=.*\b(?:iex\b|invoke-expression\b))(?=.*(?:downloadstring|net\.webclient|invoke-webrequest\b|\biwr\b|invoke-restmethod\b|\birm\b))` },
|
|
120
|
-
{ id: 'python_socket_subprocess', category: 'reverse_shell', reason: 'python reverse shell', source: 'builtin',
|
|
160
|
+
{ id: 'python_socket_subprocess', category: 'reverse_shell', severity: 'high', reason: 'python reverse shell', source: 'builtin',
|
|
121
161
|
pattern: String.raw `\bpython(?:3)?\s+-c\b(?=.*socket)(?=.*subprocess)` },
|
|
162
|
+
{ id: 'perl_reverse_shell', category: 'reverse_shell', severity: 'high', reason: 'perl reverse shell', source: 'builtin',
|
|
163
|
+
pattern: String.raw `\bperl\s+-e\b(?=.*socket)(?=.*(?:exec|system|/bin/(?:sh|bash)))` },
|
|
164
|
+
{ id: 'php_reverse_shell', category: 'reverse_shell', severity: 'high', reason: 'php reverse shell', source: 'builtin',
|
|
165
|
+
pattern: String.raw `\bphp\s+-r\b(?=.*fsockopen)` },
|
|
122
166
|
// Cloud metadata / credential exfiltration
|
|
123
|
-
{ id: 'aws_gcp_metadata_ip', category: 'metadata_ssrf', reason: 'request to cloud metadata endpoint', source: 'builtin',
|
|
167
|
+
{ id: 'aws_gcp_metadata_ip', category: 'metadata_ssrf', severity: 'high', reason: 'request to cloud metadata endpoint', source: 'builtin',
|
|
124
168
|
pattern: String.raw `(?=.*\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b)(?=.*(?:169\.254\.169\.254|metadata\.google\.internal|metadata\.azure\.com|100\.100\.100\.200))` },
|
|
125
|
-
{ id: 'credential_exfiltration', category: 'secret_exfiltration', reason: 'credential file sent to the network', source: 'builtin',
|
|
169
|
+
{ id: 'credential_exfiltration', category: 'secret_exfiltration', severity: 'critical', reason: 'credential file sent to the network', source: 'builtin',
|
|
126
170
|
pattern: String.raw `(?=.*\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|scp|nc|ncat)\b)(?=.*(?:\.ssh[\\/]id_(?:rsa|ed25519|ecdsa|dsa)|\.aws[\\/]credentials|application_default_credentials\.json|\.npmrc|\.netrc|\.kube[\\/]config))` },
|
|
127
|
-
// Infra destroy
|
|
128
|
-
{ id: 'terraform_destroy_auto', category: 'infra_destroy', reason: 'terraform destroy with auto-approve',
|
|
129
|
-
|
|
130
|
-
{ id: 'pulumi_destroy_yes', category: 'infra_destroy', reason: 'pulumi destroy with auto-approve',
|
|
131
|
-
|
|
132
|
-
{ id: '
|
|
133
|
-
|
|
134
|
-
{ id: '
|
|
135
|
-
|
|
136
|
-
{ id: '
|
|
171
|
+
// Infra destroy — spec-compiled where flags matter (order-independent).
|
|
172
|
+
compileSpec({ id: 'terraform_destroy_auto', category: 'infra_destroy', severity: 'high', reason: 'terraform destroy with auto-approve',
|
|
173
|
+
command: ['terraform'], extra: [String.raw `\bdestroy\b`], flags: [String.raw `--auto-approve`] }),
|
|
174
|
+
compileSpec({ id: 'pulumi_destroy_yes', category: 'infra_destroy', severity: 'high', reason: 'pulumi destroy with auto-approve',
|
|
175
|
+
command: ['pulumi'], extra: [String.raw `\bdestroy\b`], flags: [String.raw `--yes|-y\b`] }),
|
|
176
|
+
compileSpec({ id: 'kubectl_delete_all_all', category: 'infra_destroy', severity: 'high', reason: 'broad kubectl delete all',
|
|
177
|
+
command: ['kubectl'], extra: [String.raw `\bdelete\b`, String.raw `\ball\b`], flags: [String.raw `--all`] }),
|
|
178
|
+
compileSpec({ id: 'aws_s3_recursive_rm', category: 'infra_destroy', severity: 'high', reason: 'recursive S3 deletion',
|
|
179
|
+
command: ['aws'], extra: [String.raw `\bs3\s+rm\b`], flags: [String.raw `--recursive`], target: [String.raw `s3://\S+`] }),
|
|
180
|
+
compileSpec({ id: 'aws_iam_admin_policy', category: 'infra_destroy', severity: 'high', reason: 'IAM administrator privilege grant',
|
|
181
|
+
command: ['aws'], extra: [String.raw `\biam\s+attach-user-policy\b`], flags: [String.raw `AdministratorAccess`] }),
|
|
182
|
+
compileSpec({ id: 'az_group_delete_yes', category: 'infra_destroy', severity: 'high', reason: 'Azure resource group deletion',
|
|
183
|
+
command: ['az'], extra: [String.raw `\bgroup\s+delete\b`], flags: [String.raw `--yes|-y\b`] }),
|
|
184
|
+
compileSpec({ id: 'az_vm_delete', category: 'infra_destroy', severity: 'high', reason: 'Azure VM deletion',
|
|
185
|
+
command: ['az'], extra: [String.raw `\bvm\s+delete\b`], flags: [String.raw `--yes|-y\b`] }),
|
|
186
|
+
compileSpec({ id: 'docker_prune_all', category: 'infra_destroy', severity: 'medium', reason: 'destructive docker prune (removes all images/volumes)',
|
|
187
|
+
command: ['docker'], extra: [String.raw `\b(?:system|volume|image)\s+prune\b`], flags: [String.raw `-a\b|--all|--volumes`] }),
|
|
188
|
+
compileSpec({ id: 'kubectl_delete_pvc_all', category: 'infra_destroy', severity: 'high', reason: 'kubectl delete all persistent volume claims',
|
|
189
|
+
command: ['kubectl'], extra: [String.raw `\bdelete\s+(?:pvc|persistentvolumeclaims?)\b`], flags: [String.raw `--all`] }),
|
|
190
|
+
{ id: 'kubectl_delete_namespace', category: 'infra_destroy', severity: 'high', reason: 'destructive kubectl namespace delete', source: 'builtin',
|
|
191
|
+
pattern: String.raw `\bkubectl\s+delete\s+(?:namespace|ns)\b` },
|
|
192
|
+
{ id: 'gcloud_project_delete', category: 'infra_destroy', severity: 'high', reason: 'GCP project deletion', source: 'builtin',
|
|
137
193
|
pattern: String.raw `\bgcloud\s+projects\s+delete\b` },
|
|
138
|
-
{ id: 'gcloud_sql_delete', category: 'infra_destroy', reason: 'GCP SQL instance deletion', source: 'builtin',
|
|
194
|
+
{ id: 'gcloud_sql_delete', category: 'infra_destroy', severity: 'high', reason: 'GCP SQL instance deletion', source: 'builtin',
|
|
139
195
|
pattern: String.raw `\bgcloud\s+sql\s+instances\s+delete\b` },
|
|
140
|
-
{ id: '
|
|
141
|
-
pattern: String.raw `\
|
|
142
|
-
{ id: 'aws_cloudformation_delete_stack', category: 'infra_destroy', reason: 'CloudFormation stack deletion', source: 'builtin',
|
|
196
|
+
{ id: 'gcloud_gke_delete', category: 'infra_destroy', severity: 'high', reason: 'GKE cluster deletion', source: 'builtin',
|
|
197
|
+
pattern: String.raw `\bgcloud\s+container\s+clusters\s+delete\b` },
|
|
198
|
+
{ id: 'aws_cloudformation_delete_stack', category: 'infra_destroy', severity: 'high', reason: 'CloudFormation stack deletion', source: 'builtin',
|
|
143
199
|
pattern: String.raw `\baws\s+cloudformation\s+delete-stack\b` },
|
|
144
|
-
{ id: '
|
|
145
|
-
pattern: String.raw `\baws\s+iam\s+attach-user-policy\b(?=.*AdministratorAccess)` },
|
|
146
|
-
{ id: 'aws_iam_access_key', category: 'infra_destroy', reason: 'IAM access key creation', source: 'builtin',
|
|
200
|
+
{ id: 'aws_iam_access_key', category: 'infra_destroy', severity: 'medium', reason: 'IAM access key creation', source: 'builtin',
|
|
147
201
|
pattern: String.raw `\baws\s+iam\s+create-access-key\b` },
|
|
148
|
-
{ id: '
|
|
149
|
-
pattern: String.raw `\
|
|
202
|
+
{ id: 'aws_ec2_terminate', category: 'infra_destroy', severity: 'high', reason: 'EC2 instance termination', source: 'builtin',
|
|
203
|
+
pattern: String.raw `\baws\s+ec2\s+terminate-instances\b` },
|
|
204
|
+
{ id: 'aws_dynamodb_delete_table', category: 'infra_destroy', severity: 'high', reason: 'DynamoDB table deletion', source: 'builtin',
|
|
205
|
+
pattern: String.raw `\baws\s+dynamodb\s+delete-table\b` },
|
|
206
|
+
{ id: 'aws_rds_delete', category: 'infra_destroy', severity: 'high', reason: 'RDS instance deletion', source: 'builtin',
|
|
207
|
+
pattern: String.raw `\baws\s+rds\s+delete-db-instance\b` },
|
|
208
|
+
// Version-control footguns — imported from shellfirm / Sigma (git group).
|
|
209
|
+
compileSpec({ id: 'git_force_push', category: 'vcs_danger', severity: 'high', reason: 'git force push (overwrites remote history)',
|
|
210
|
+
command: ['git'], extra: [String.raw `\bpush\b`], flags: [String.raw `--force|-f\b`], neg: [String.raw `\s--force-with-lease`] }),
|
|
211
|
+
compileSpec({ id: 'git_reset_hard', category: 'vcs_danger', severity: 'medium', reason: 'git reset --hard (discards local changes)',
|
|
212
|
+
command: ['git'], extra: [String.raw `\breset\b`], flags: [String.raw `--hard`] }),
|
|
213
|
+
compileSpec({ id: 'git_clean_force', category: 'vcs_danger', severity: 'medium', reason: 'git clean -fd (deletes untracked files)',
|
|
214
|
+
command: ['git'], extra: [String.raw `\bclean\b`], flags: [String.raw `-[a-z]*f`, String.raw `-[a-z]*d`] }),
|
|
215
|
+
{ id: 'git_branch_delete_force', category: 'vcs_danger', severity: 'low', reason: 'git force-delete branch', source: 'builtin',
|
|
216
|
+
pattern: String.raw `\bgit\s+branch\b(?=[^;&|]*\s-D\b)` },
|
|
217
|
+
// Anti-forensics / tamper
|
|
218
|
+
{ id: 'clear_shell_history', category: 'anti_forensics', severity: 'medium', reason: 'clearing shell history', source: 'builtin',
|
|
219
|
+
pattern: String.raw `\bhistory\s+-c\b|\brm\b[^|;&]{0,80}\.(?:bash|zsh)_history\b` },
|
|
220
|
+
{ id: 'windows_clear_eventlog', category: 'anti_forensics', severity: 'high', reason: 'clearing Windows event logs', source: 'builtin',
|
|
221
|
+
pattern: String.raw `\b(?:wevtutil\s+cl|clear-eventlog)\b` },
|
|
222
|
+
// Security-control tampering
|
|
223
|
+
{ id: 'disable_firewall_windows', category: 'security_tampering', severity: 'high', reason: 'disabling the Windows firewall', source: 'builtin',
|
|
224
|
+
pattern: String.raw `\bnetsh\s+advfirewall\s+set\b(?=.*\bstate\s+off)` },
|
|
225
|
+
{ id: 'disable_firewall_unix', category: 'security_tampering', severity: 'high', reason: 'disabling the host firewall', source: 'builtin',
|
|
226
|
+
pattern: String.raw `\bufw\s+disable\b|\biptables\s+-F\b|\bsystemctl\s+(?:stop|disable)\s+firewalld\b` },
|
|
227
|
+
{ id: 'macos_disable_gatekeeper', category: 'security_tampering', severity: 'high', reason: 'disabling macOS Gatekeeper', source: 'builtin',
|
|
228
|
+
pattern: String.raw `\bspctl\s+--master-disable\b` },
|
|
229
|
+
{ id: 'macos_disable_sip', category: 'security_tampering', severity: 'high', reason: 'disabling macOS System Integrity Protection', source: 'builtin',
|
|
230
|
+
pattern: String.raw `\bcsrutil\s+disable\b` },
|
|
231
|
+
// Persistence / backdoor
|
|
232
|
+
{ id: 'crontab_from_stdin', category: 'persistence', severity: 'medium', reason: 'installing a crontab from stdin', source: 'builtin',
|
|
233
|
+
pattern: String.raw `\|\s*crontab\b` },
|
|
234
|
+
{ id: 'authorized_keys_backdoor', category: 'persistence', severity: 'high', reason: 'appending an SSH authorized key', source: 'builtin',
|
|
235
|
+
pattern: String.raw `>>?\s*(?:~|\$HOME|/root|/home/\S+)?/?\.ssh/authorized_keys\b` },
|
|
150
236
|
// Destructive SQL typed straight into a terminal client
|
|
151
|
-
{ id: 'drop_database', category: 'destructive_sql', reason: 'DROP DATABASE', source: 'builtin',
|
|
237
|
+
{ id: 'drop_database', category: 'destructive_sql', severity: 'high', reason: 'DROP DATABASE', source: 'builtin',
|
|
152
238
|
pattern: String.raw `\bdrop\s+database\b` },
|
|
153
|
-
{ id: 'drop_schema', category: 'destructive_sql', reason: 'DROP SCHEMA', source: 'builtin',
|
|
239
|
+
{ id: 'drop_schema', category: 'destructive_sql', severity: 'high', reason: 'DROP SCHEMA', source: 'builtin',
|
|
154
240
|
pattern: String.raw `\bdrop\s+schema\b` },
|
|
241
|
+
{ id: 'truncate_table', category: 'destructive_sql', severity: 'medium', reason: 'TRUNCATE TABLE', source: 'builtin',
|
|
242
|
+
pattern: String.raw `\btruncate\s+table\b` },
|
|
243
|
+
{ id: 'drop_table', category: 'destructive_sql', severity: 'medium', reason: 'DROP TABLE', source: 'builtin',
|
|
244
|
+
pattern: String.raw `\bdrop\s+table\b` },
|
|
155
245
|
];
|
|
156
246
|
function escapeDotNetRegex(literal) {
|
|
157
247
|
return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -211,9 +301,11 @@ function cachedCustomRules() {
|
|
|
211
301
|
for (const block of blocks.slice(0, 100)) {
|
|
212
302
|
if (!block || typeof block.pattern !== 'string' || !block.pattern.trim())
|
|
213
303
|
continue;
|
|
304
|
+
const sev = String(block.severity || '').toLowerCase();
|
|
214
305
|
rules.push({
|
|
215
306
|
id: String(block.id || block.pattern).slice(0, 120),
|
|
216
307
|
category: String(block.categoryId || 'custom'),
|
|
308
|
+
severity: (sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low') ? sev : 'high',
|
|
217
309
|
// Snapshot custom blocks are substring matches — escape into a literal regex.
|
|
218
310
|
pattern: escapeDotNetRegex(block.pattern.trim()),
|
|
219
311
|
reason: typeof block.explanation === 'string' && block.explanation ? block.explanation : `matched custom safety pattern "${block.pattern.trim()}"`,
|
|
@@ -246,6 +338,52 @@ function writeShellGuardRules() {
|
|
|
246
338
|
fs.writeFileSync(GUARD_RULES_PATH, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
247
339
|
return data;
|
|
248
340
|
}
|
|
341
|
+
/** The active ruleset in memory (builtins minus dashboard-disabled + org custom). */
|
|
342
|
+
function activeShellGuardRules() {
|
|
343
|
+
const disabled = cachedDisabledItemIds();
|
|
344
|
+
return [...BUILTIN_RULES.filter(rule => !disabled.has(rule.id)), ...cachedCustomRules()];
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Evaluate a typed command line WITHOUT executing it — the dry-run classifier
|
|
348
|
+
* behind `shell-guard-check`. Uses the exact same rules + JS regex engine as the
|
|
349
|
+
* live cmd/posix checkers (equivalent to .NET for these patterns). Also splits
|
|
350
|
+
* chained commands (`a && del /s c:\`) and tests each segment, so combinations
|
|
351
|
+
* are caught even when the dangerous part is not first.
|
|
352
|
+
*
|
|
353
|
+
* Returns ALL matches (one per matched segment/rule), first-match-per-segment.
|
|
354
|
+
*/
|
|
355
|
+
function evaluateShellCommand(line, rules = activeShellGuardRules()) {
|
|
356
|
+
const compiled = rules.map(r => {
|
|
357
|
+
try {
|
|
358
|
+
return { rule: r, re: new RegExp(r.pattern, 'i') };
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}).filter((x) => !!x);
|
|
364
|
+
// Whole line first (some rules intentionally span a pipe, e.g. curl | sh),
|
|
365
|
+
// then each &&/||/;/newline segment (catches "cleanup && del /s c:\").
|
|
366
|
+
const candidates = [line, ...line.split(/&&|\|\||[;&\n]|\|/).map(s => s.trim()).filter(Boolean)];
|
|
367
|
+
const seen = new Set();
|
|
368
|
+
const matches = [];
|
|
369
|
+
for (const candidate of candidates) {
|
|
370
|
+
const norm = candidate.replace(/\s+/g, ' ').trim();
|
|
371
|
+
for (const { rule, re } of compiled) {
|
|
372
|
+
try {
|
|
373
|
+
if (re.test(norm) || re.test(candidate)) {
|
|
374
|
+
const key = `${rule.id}|${candidate}`;
|
|
375
|
+
if (seen.has(key))
|
|
376
|
+
continue;
|
|
377
|
+
seen.add(key);
|
|
378
|
+
matches.push({ rule, segment: candidate });
|
|
379
|
+
break; // first rule per segment is enough
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch { /* skip bad regex */ }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return matches;
|
|
386
|
+
}
|
|
249
387
|
/**
|
|
250
388
|
* Opportunistic rules/mode refresh — called from the telemetry flush cycle so a
|
|
251
389
|
* console mode flip or new custom rule reaches interactive shells within ~30s.
|
|
@@ -286,6 +424,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
286
424
|
` [pscustomobject]@{`,
|
|
287
425
|
` Id = [string]$_.id`,
|
|
288
426
|
` Category = [string]$_.category`,
|
|
427
|
+
` Severity = [string]$_.severity`,
|
|
289
428
|
` Reason = [string]$_.reason`,
|
|
290
429
|
` Source = [string]$_.source`,
|
|
291
430
|
` Regex = [regex]::new([string]$_.pattern, 'IgnoreCase')`,
|
|
@@ -321,6 +460,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
321
460
|
` reason = ('Shell guard: ' + $Rule.Reason)`,
|
|
322
461
|
` ruleId = $Rule.Id`,
|
|
323
462
|
` category = $Rule.Category`,
|
|
463
|
+
` severity = $Rule.Severity`,
|
|
324
464
|
` source = $Rule.Source`,
|
|
325
465
|
` evidence = $evidence`,
|
|
326
466
|
` occurredAt = (Get-Date).ToUniversalTime().ToString('o')`,
|
|
@@ -349,14 +489,14 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
349
489
|
` if ($null -ne $hit) {`,
|
|
350
490
|
` if ($global:FcdGuardMode -eq 'monitor') {`,
|
|
351
491
|
` Write-Host ''`,
|
|
352
|
-
` Write-Host ('[FullCourtDefense] monitor: would block
|
|
492
|
+
` Write-Host ('[FullCourtDefense] monitor: would block [' + $hit.Severity + '] ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
|
|
353
493
|
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'allow'`,
|
|
354
494
|
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
355
495
|
` return`,
|
|
356
496
|
` }`,
|
|
357
497
|
` [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine()`,
|
|
358
498
|
` Write-Host ''`,
|
|
359
|
-
` Write-Host ('[FullCourtDefense] BLOCKED: ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Red`,
|
|
499
|
+
` Write-Host ('[FullCourtDefense] BLOCKED [' + $hit.Severity + ']: ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Red`,
|
|
360
500
|
` Write-Host 'This command was not executed. Reported to your security dashboard.' -ForegroundColor DarkGray`,
|
|
361
501
|
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'block'`,
|
|
362
502
|
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
package/dist/index.js
CHANGED
|
@@ -53,6 +53,7 @@ const autoProtect_1 = require("./commands/autoProtect");
|
|
|
53
53
|
const windowsAudit_1 = require("./commands/windowsAudit");
|
|
54
54
|
const shellGuard_1 = require("./commands/shellGuard");
|
|
55
55
|
const cmdGuard_1 = require("./commands/cmdGuard");
|
|
56
|
+
const posixShellGuard_1 = require("./commands/posixShellGuard");
|
|
56
57
|
const fs = __importStar(require("fs"));
|
|
57
58
|
const path = __importStar(require("path"));
|
|
58
59
|
function readCliVersion() {
|
|
@@ -68,6 +69,7 @@ function readCliVersion() {
|
|
|
68
69
|
const VERSION = readCliVersion();
|
|
69
70
|
function parseArgs(argv) {
|
|
70
71
|
const flags = {};
|
|
72
|
+
const positional = [];
|
|
71
73
|
let command = '';
|
|
72
74
|
for (let i = 0; i < argv.length; i++) {
|
|
73
75
|
const arg = argv[i];
|
|
@@ -75,6 +77,10 @@ function parseArgs(argv) {
|
|
|
75
77
|
command = arg;
|
|
76
78
|
continue;
|
|
77
79
|
}
|
|
80
|
+
if (command && !arg.startsWith('-')) {
|
|
81
|
+
positional.push(arg);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
78
84
|
if (arg.startsWith('--')) {
|
|
79
85
|
const key = arg.slice(2);
|
|
80
86
|
const eqIdx = key.indexOf('=');
|
|
@@ -104,7 +110,7 @@ function parseArgs(argv) {
|
|
|
104
110
|
}
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
|
-
return { command, flags };
|
|
113
|
+
return { command, flags, positional };
|
|
108
114
|
}
|
|
109
115
|
function printHelp() {
|
|
110
116
|
console.log(`
|
|
@@ -128,11 +134,14 @@ function printHelp() {
|
|
|
128
134
|
install Alias for install-all.
|
|
129
135
|
windows-audit Show Windows PowerShell audit coverage (ScriptBlock Logging +
|
|
130
136
|
Transcription). Use --enable to turn it on (one admin prompt).
|
|
131
|
-
install-shell-guard Block dangerous commands typed in
|
|
132
|
-
|
|
133
|
-
shell-guard-status shows coverage.
|
|
137
|
+
install-shell-guard Block dangerous commands typed in a terminal (real-time,
|
|
138
|
+
offline rule cache): PowerShell on Windows, bash + zsh on macOS/Linux.
|
|
139
|
+
uninstall-shell-guard removes it; shell-guard-status shows coverage.
|
|
134
140
|
install-cmd-guard Block dangerous commands typed in cmd.exe (Command Prompt).
|
|
135
141
|
uninstall-cmd-guard removes it; cmd-guard-status shows coverage.
|
|
142
|
+
shell-guard-check Dry-run: test whether a command (or a file/stdin list of
|
|
143
|
+
commands) would be blocked — nothing is executed. Splits chained
|
|
144
|
+
commands (a && del /s c:\). --file <path>, --json; exits 1 if any block.
|
|
136
145
|
scan Runs the scan. Use --local for inside-organization scans.
|
|
137
146
|
discover Finds MCP servers, local secrets, agent rules/skills, and machine
|
|
138
147
|
blast radius on this laptop. Use --surface all --upload for AI Fleet.
|
|
@@ -349,7 +358,7 @@ function printHelp() {
|
|
|
349
358
|
`);
|
|
350
359
|
}
|
|
351
360
|
async function main() {
|
|
352
|
-
const { command, flags } = parseArgs(process.argv.slice(2));
|
|
361
|
+
const { command, flags, positional } = parseArgs(process.argv.slice(2));
|
|
353
362
|
const buildInstallArgs = () => ({
|
|
354
363
|
clients: flags.clients,
|
|
355
364
|
cursorProject: flags['cursor-project'],
|
|
@@ -605,6 +614,7 @@ async function main() {
|
|
|
605
614
|
windowsAudit: flags['windows-audit'],
|
|
606
615
|
shellGuard: flags['shell-guard'],
|
|
607
616
|
cmdGuard: flags['cmd-guard'],
|
|
617
|
+
posixGuard: flags['posix-guard'],
|
|
608
618
|
};
|
|
609
619
|
await (0, installAll_1.installAllCommand)(args, config);
|
|
610
620
|
break;
|
|
@@ -615,28 +625,101 @@ async function main() {
|
|
|
615
625
|
break;
|
|
616
626
|
}
|
|
617
627
|
case 'install-shell-guard': {
|
|
618
|
-
|
|
628
|
+
if (process.platform === 'win32') {
|
|
629
|
+
await (0, shellGuard_1.installShellGuardCommand)({ profiles: flags.profiles });
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
await (0, posixShellGuard_1.installPosixShellGuardCommand)({ profiles: flags.profiles });
|
|
633
|
+
}
|
|
619
634
|
break;
|
|
620
635
|
}
|
|
621
636
|
case 'uninstall-shell-guard': {
|
|
622
|
-
|
|
637
|
+
if (process.platform === 'win32') {
|
|
638
|
+
await (0, shellGuard_1.uninstallShellGuardCommand)();
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
await (0, posixShellGuard_1.uninstallPosixShellGuardCommand)();
|
|
642
|
+
}
|
|
623
643
|
break;
|
|
624
644
|
}
|
|
625
645
|
case 'shell-guard-status': {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
console.log(
|
|
646
|
+
if (process.platform === 'win32') {
|
|
647
|
+
const status = (0, shellGuard_1.getShellGuardStatus)();
|
|
648
|
+
console.log(`\nInteractive PowerShell shell guard: ${status.installed ? '\x1b[32minstalled\x1b[0m' : '\x1b[31mnot installed\x1b[0m'}`);
|
|
649
|
+
for (const profile of status.profiles) {
|
|
650
|
+
console.log(` ${profile.engine}: ${profile.installed ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'} \x1b[2m${profile.profilePath}\x1b[0m`);
|
|
651
|
+
}
|
|
652
|
+
if (status.rulesPresent)
|
|
653
|
+
console.log(` Rules: ${status.ruleCount} (mode: ${status.mode || 'block'})`);
|
|
654
|
+
console.log('');
|
|
629
655
|
break;
|
|
630
656
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
657
|
+
const status = (0, posixShellGuard_1.getPosixShellGuardStatus)();
|
|
658
|
+
console.log(`\nInteractive shell guard (bash + zsh): ${status.installed ? '\x1b[32minstalled\x1b[0m' : '\x1b[31mnot installed\x1b[0m'}`);
|
|
659
|
+
for (const rc of status.rcFiles) {
|
|
660
|
+
console.log(` ${rc.shell}: ${rc.installed ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'} \x1b[2m${rc.rcPath}\x1b[0m`);
|
|
634
661
|
}
|
|
635
662
|
if (status.rulesPresent)
|
|
636
663
|
console.log(` Rules: ${status.ruleCount} (mode: ${status.mode || 'block'})`);
|
|
637
664
|
console.log('');
|
|
638
665
|
break;
|
|
639
666
|
}
|
|
667
|
+
case 'shell-guard-check': {
|
|
668
|
+
// Dry-run classifier — evaluate command(s) against the guard rules WITHOUT
|
|
669
|
+
// executing anything. Positional args are one command; --file reads one
|
|
670
|
+
// command per line; no args reads stdin. Exit 1 if ANY command is blocked
|
|
671
|
+
// (so it can gate CI). --json for machine output.
|
|
672
|
+
const asJson = flags.json === 'true' || flags.json === '';
|
|
673
|
+
const lines = [];
|
|
674
|
+
if (flags.file) {
|
|
675
|
+
try {
|
|
676
|
+
lines.push(...fs.readFileSync(flags.file, 'utf8').split(/\r?\n/));
|
|
677
|
+
}
|
|
678
|
+
catch (e) {
|
|
679
|
+
console.error(`Cannot read --file: ${e instanceof Error ? e.message : String(e)}`);
|
|
680
|
+
process.exit(2);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
else if (positional.length > 0) {
|
|
684
|
+
lines.push(positional.join(' '));
|
|
685
|
+
}
|
|
686
|
+
else if (!process.stdin.isTTY) {
|
|
687
|
+
lines.push(...fs.readFileSync(0, 'utf8').split(/\r?\n/));
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
console.log('Usage: fullcourtdefense shell-guard-check "<command>" | --file cmds.txt | echo "<cmd>" | fullcourtdefense shell-guard-check');
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
const cmds = lines.map(l => l.trim()).filter(Boolean);
|
|
694
|
+
const rules = (0, shellGuard_1.activeShellGuardRules)();
|
|
695
|
+
let blocked = 0;
|
|
696
|
+
const results = cmds.map(cmd => {
|
|
697
|
+
const matches = (0, shellGuard_1.evaluateShellCommand)(cmd, rules);
|
|
698
|
+
if (matches.length)
|
|
699
|
+
blocked++;
|
|
700
|
+
return { command: cmd, blocked: matches.length > 0, matches: matches.map(m => ({ ruleId: m.rule.id, severity: m.rule.severity, category: m.rule.category, reason: m.rule.reason, segment: m.segment })) };
|
|
701
|
+
});
|
|
702
|
+
if (asJson) {
|
|
703
|
+
console.log(JSON.stringify({ total: cmds.length, blocked, ruleCount: rules.length, results }, null, 2));
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
console.log(`\nEvaluated ${cmds.length} command(s) against ${rules.length} rules (dry-run — nothing executed):\n`);
|
|
707
|
+
for (const r of results) {
|
|
708
|
+
if (r.blocked) {
|
|
709
|
+
const m = r.matches[0];
|
|
710
|
+
console.log(` \x1b[31mBLOCK\x1b[0m [\x1b[1m${m.severity}\x1b[0m] ${r.command}`);
|
|
711
|
+
console.log(` \x1b[2m${m.reason} (rule ${m.ruleId})${r.matches.length > 1 ? ` +${r.matches.length - 1} more` : ''}\x1b[0m`);
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
console.log(` \x1b[32mALLOW\x1b[0m ${r.command}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
console.log(`\n${blocked} blocked, ${cmds.length - blocked} allowed.`);
|
|
718
|
+
}
|
|
719
|
+
if (blocked > 0)
|
|
720
|
+
process.exit(1);
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
640
723
|
case 'install-cmd-guard': {
|
|
641
724
|
await (0, cmdGuard_1.installCmdGuardCommand)();
|
|
642
725
|
break;
|
package/dist/telemetry.d.ts
CHANGED
package/dist/telemetry.js
CHANGED
|
@@ -45,6 +45,7 @@ const machineIdentity_1 = require("./machineIdentity");
|
|
|
45
45
|
const windowsAudit_1 = require("./commands/windowsAudit");
|
|
46
46
|
const shellGuard_1 = require("./commands/shellGuard");
|
|
47
47
|
const cmdGuard_1 = require("./commands/cmdGuard");
|
|
48
|
+
const posixShellGuard_1 = require("./commands/posixShellGuard");
|
|
48
49
|
/**
|
|
49
50
|
* Local-first telemetry: every enforcement decision is appended to an on-disk
|
|
50
51
|
* spool (instant, offline-safe) and flushed to the backend in batches. Critical
|
|
@@ -72,6 +73,7 @@ function spoolEvent(event) {
|
|
|
72
73
|
category: event.category,
|
|
73
74
|
categoryId: event.categoryId,
|
|
74
75
|
itemId: event.itemId,
|
|
76
|
+
severity: event.severity,
|
|
75
77
|
source: event.source,
|
|
76
78
|
evidence: event.evidence,
|
|
77
79
|
explanation: event.explanation,
|
|
@@ -115,6 +117,7 @@ async function flushSpool(input) {
|
|
|
115
117
|
// + new custom rules reach open shells within ~30s).
|
|
116
118
|
(0, shellGuard_1.refreshShellGuardRules)();
|
|
117
119
|
(0, cmdGuard_1.refreshCmdGuardRules)();
|
|
120
|
+
(0, posixShellGuard_1.refreshPosixShellGuardRules)();
|
|
118
121
|
const all = readSpool();
|
|
119
122
|
const batch = all.slice(0, MAX_BATCH);
|
|
120
123
|
const overflow = all.slice(MAX_BATCH);
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"test:shell-audit": "npm run build && node scripts/test-shell-audit.js",
|
|
20
20
|
"test:shell-guard": "npm run build && node scripts/test-shell-guard.js",
|
|
21
21
|
"test:cmd-guard": "npm run build && node scripts/test-cmd-guard.js",
|
|
22
|
+
"test:posix-guard": "npm run build && node scripts/test-posix-guard.js",
|
|
22
23
|
"test:ping-e2e": "npm run build && node scripts/test-ping-e2e.js",
|
|
23
24
|
"prepublishOnly": "npm run build"
|
|
24
25
|
},
|