mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
Merge reviewed contributor PR #2829 into backlog batch
Source-PR: https://github.com/affaan-m/ECC/pull/2829
Source-Head: d811349224
Local integration checkpoint; aggregate review and hosted acceptance pending.
This commit is contained in:
@@ -53,11 +53,15 @@ const ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT =
|
||||
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
|
||||
const ECC_ENABLE_VALUES = new Set(['1', 'true', 'on', 'enabled', 'enable', 'yes']);
|
||||
|
||||
// SQL-keyword + dd patterns stay as a single regex — they are stable
|
||||
// phrases without shell-flag ordering concerns. Quoted strings are
|
||||
// stripped before this regex runs so a commit message mentioning
|
||||
// "drop table" no longer triggers a false positive.
|
||||
const DESTRUCTIVE_SQL_DD = /\b(drop\s+table|delete\s+from|truncate|dd\s+if=)\b/i;
|
||||
// SQL keywords remain a phrase check. Quoted strings are stripped before
|
||||
// this regex runs so a commit message mentioning "drop table" stays passive.
|
||||
// `dd if=` used to be a fourth arm here. Matching it as text could not work:
|
||||
// the arm ended in `=`, so the shared trailing \b required the NEXT character
|
||||
// to be a word character and `dd if=/dev/zero` slipped through while
|
||||
// `echo dd if=x` — which runs no dd at all — was gated. The boundary decided
|
||||
// the verdict instead of the command position, so dd moved to isDestructiveDd()
|
||||
// alongside the other token-based detectors (#2642).
|
||||
const DESTRUCTIVE_SQL = /\b(drop\s+table|delete\s+from|truncate)\b/i;
|
||||
|
||||
// Operator-supplied additional destructive patterns. Lazily compiled from
|
||||
// `GATEGUARD_BASH_EXTRA_DESTRUCTIVE` (regex source) on first use, then
|
||||
@@ -319,14 +323,28 @@ function quoteAwareSegments(input) {
|
||||
words = [];
|
||||
};
|
||||
|
||||
for (const ch of String(input || '')) {
|
||||
const source = String(input || '');
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (escaped) {
|
||||
current += ch;
|
||||
hasWord = true;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
if (ch === '\\' && quote !== "'") {
|
||||
const next = source[i + 1];
|
||||
// Single quotes preserve every backslash; double quotes only escape
|
||||
// shell-special characters. env -S must receive those literal bytes.
|
||||
if (quote === '"' && next && !['$', '`', '"', '\\', '\n'].includes(next)) {
|
||||
current += ch;
|
||||
hasWord = true;
|
||||
continue;
|
||||
}
|
||||
if (next === '\n') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
escaped = true;
|
||||
hasWord = true;
|
||||
continue;
|
||||
@@ -421,65 +439,253 @@ const SUDO_VALUE_FLAGS = new Set([
|
||||
'--command-timeout',
|
||||
]);
|
||||
|
||||
const DOAS_VALUE_FLAGS = new Set(['-u', '-C']);
|
||||
const EXEC_VALUE_FLAGS = new Set(['-a']);
|
||||
const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir', '-a', '--argv0', '-S', '--split-string']);
|
||||
const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
||||
|
||||
/**
|
||||
* Advance past `sudo`/`doas`/`env` wrappers including their flags and
|
||||
* `VAR=value` assignments, so `sudo -u postgres psql ...` and
|
||||
* `env PGUSER=postgres psql ...` still resolve to the real command.
|
||||
* Split one literal env -S argument into argv, never into shell programs.
|
||||
* Operators, substitutions and variable spellings stay literal text; no host
|
||||
* environment is read. A dynamic executable name therefore remains opaque.
|
||||
* Unterminated quotes, unknown escapes and invalid quoted \c return null.
|
||||
* This is bounded literal parsing, not GNU env variable interpolation.
|
||||
*
|
||||
* @param {string} source
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
function splitEnvWords(source) {
|
||||
const words = [];
|
||||
let word = '';
|
||||
let hasWord = false;
|
||||
let quote = null;
|
||||
const flush = () => {
|
||||
if (hasWord) words.push(word);
|
||||
word = '';
|
||||
hasWord = false;
|
||||
};
|
||||
const escapes = { f: '\f', n: '\n', r: '\r', t: '\t', v: '\v' };
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '\\' && quote !== "'") {
|
||||
const next = source[++i];
|
||||
if (next === undefined) return null;
|
||||
if (next === 'c') {
|
||||
if (quote) return null;
|
||||
flush();
|
||||
return words;
|
||||
}
|
||||
if (next === '_') {
|
||||
if (quote) {
|
||||
word += ' ';
|
||||
hasWord = true;
|
||||
} else flush();
|
||||
continue;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(escapes, next)) word += escapes[next];
|
||||
else if (['#', '$', '"', "'", '\\'].includes(next)) word += next;
|
||||
else return null;
|
||||
hasWord = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
else word += ch;
|
||||
hasWord = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
hasWord = true;
|
||||
} else if (ch === '#' && !hasWord) {
|
||||
break;
|
||||
} else if (/\s/.test(ch)) {
|
||||
flush();
|
||||
} else {
|
||||
word += ch;
|
||||
hasWord = true;
|
||||
}
|
||||
}
|
||||
if (quote) return null;
|
||||
flush();
|
||||
return words;
|
||||
}
|
||||
|
||||
/** Locate a value-taking flag, including the tail of a short-option cluster. */
|
||||
function wrapperValueOption(arg, valueFlags) {
|
||||
if (arg.startsWith('--')) {
|
||||
const separator = arg.indexOf('=');
|
||||
const name = separator === -1 ? arg : arg.slice(0, separator);
|
||||
return valueFlags.has(name)
|
||||
? { name, value: separator === -1 ? undefined : arg.slice(separator + 1) }
|
||||
: null;
|
||||
}
|
||||
if (!arg.startsWith('-')) return null;
|
||||
for (let i = 1; i < arg.length; i += 1) {
|
||||
const name = `-${arg[i]}`;
|
||||
if (valueFlags.has(name)) {
|
||||
return { name, value: i + 1 < arg.length ? arg.slice(i + 1) : undefined };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Explicit external-launcher argv grammars for dd and shell-wrapper discovery.
|
||||
// Unknown flags do not justify guessing which later argument executes.
|
||||
const DD_LAUNCHER_OPTIONS = {
|
||||
xargs: {
|
||||
values: new Set(['-a', '--arg-file', '-d', '--delimiter', '-E', '-I', '-J', '-L', '-n', '--max-args', '-P', '--max-procs', '-s', '--max-chars', '--process-slot-var']),
|
||||
optional: new Set(['-e', '--eof', '-i', '--replace', '-l', '--max-lines']),
|
||||
flags: new Set(['-0', '--null', '-r', '--no-run-if-empty', '-t', '--verbose', '-p', '--interactive', '-x', '--exit', '-o', '--open-tty', '--show-limits'])
|
||||
},
|
||||
timeout: {
|
||||
values: new Set(['-k', '--kill-after', '-s', '--signal']),
|
||||
optional: new Set(),
|
||||
flags: new Set(['-v', '--verbose', '--foreground', '--preserve-status'])
|
||||
},
|
||||
nice: { values: new Set(['-n', '--adjustment']), optional: new Set(), flags: new Set() },
|
||||
nohup: { values: new Set(), optional: new Set(), flags: new Set() }
|
||||
};
|
||||
|
||||
/** Return the command position after one explicitly supported launcher's options. */
|
||||
function ddLauncherCommandIndex(argv, index, name) {
|
||||
const { values, optional, flags } = DD_LAUNCHER_OPTIONS[name];
|
||||
index += 1;
|
||||
while (index < argv.length) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (!arg.startsWith('-') || arg === '-') break;
|
||||
// nice retains the historical -N / --N priority spellings.
|
||||
if (name === 'nice' && /^--?\d+$/.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--')) {
|
||||
const separator = arg.indexOf('=');
|
||||
const flag = separator === -1 ? arg : arg.slice(0, separator);
|
||||
if (values.has(flag)) index += separator === -1 ? 2 : 1;
|
||||
else if (optional.has(flag) || (separator === -1 && flags.has(flag))) index += 1;
|
||||
else return argv.length;
|
||||
continue;
|
||||
}
|
||||
let consumesNext = false;
|
||||
for (let offset = 1; offset < arg.length; offset += 1) {
|
||||
const flag = `-${arg[offset]}`;
|
||||
if (values.has(flag)) {
|
||||
consumesNext = offset + 1 === arg.length;
|
||||
break;
|
||||
}
|
||||
if (optional.has(flag)) break;
|
||||
if (!flags.has(flag)) return argv.length;
|
||||
}
|
||||
index += consumesNext ? 2 : 1;
|
||||
}
|
||||
// timeout's duration is data, followed by exactly one executable position.
|
||||
return name === 'timeout' ? index + 1 : index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve leading assignments, shell prefixes and sudo/doas/env into command
|
||||
* argv. Wrapper-specific option values never become executable names. Every
|
||||
* wrapper/split consumes source bytes, so the input-size budget bounds nested
|
||||
* expansion without rejecting a valid long chain at an arbitrary depth.
|
||||
*
|
||||
* @param {string[]} tokens dequoted tokens for one segment
|
||||
* @returns {number} index of the real command token
|
||||
* @param {boolean} [allowShellBuiltins] false for external argv (e.g. find -exec)
|
||||
* @param {boolean} [allowDdLaunchers] opt-in; other shared callers retain their grammar
|
||||
* @returns {string[]} normalized argv, or [] when no literal command resolves
|
||||
*/
|
||||
function unwrapLeadWrappers(tokens) {
|
||||
function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers = false) {
|
||||
let argv = tokens.slice();
|
||||
let index = 0;
|
||||
for (let guard = 0; guard < 4; guard += 1) {
|
||||
if (index >= tokens.length) return index;
|
||||
const base = commandBasename(tokens[index]);
|
||||
if (base === 'sudo' || base === 'doas') {
|
||||
let allowAssignments = true;
|
||||
let budget = tokens.reduce((size, token) => size + token.length + 1, 1);
|
||||
while (index < argv.length && budget-- > 0) {
|
||||
while (allowAssignments && index < argv.length && SHELL_ASSIGNMENT.test(argv[index])) index += 1;
|
||||
if (index >= argv.length) return [];
|
||||
const base = commandBasename(argv[index]);
|
||||
if (allowShellBuiltins && base === 'command') {
|
||||
index += 1;
|
||||
while (index < tokens.length) {
|
||||
const flag = tokens[index];
|
||||
while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') {
|
||||
const flag = argv[index++];
|
||||
if (flag === '--') break;
|
||||
// -v/-V (including -pv) only describe names; no command executes.
|
||||
if (!/^-[pVv]+$/.test(flag) || /[vV]/.test(flag)) return [];
|
||||
}
|
||||
allowAssignments = false;
|
||||
continue;
|
||||
}
|
||||
if (allowShellBuiltins && base === 'exec') {
|
||||
index += 1;
|
||||
while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') {
|
||||
const flag = argv[index];
|
||||
if (flag === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
const option = wrapperValueOption(flag, EXEC_VALUE_FLAGS);
|
||||
const flagLetters = option ? flag.slice(1, flag.indexOf('a')) : flag.slice(1);
|
||||
if (!/^[cl]*$/.test(flagLetters)) return [];
|
||||
index += option && option.value === undefined ? 2 : 1;
|
||||
}
|
||||
// exec replaces the shell with an external executable; its argument
|
||||
// 'command' is not the shell's builtin, and A=1 is not an assignment.
|
||||
allowShellBuiltins = false;
|
||||
allowAssignments = false;
|
||||
continue;
|
||||
}
|
||||
if (allowDdLaunchers && Object.prototype.hasOwnProperty.call(DD_LAUNCHER_OPTIONS, base)) {
|
||||
index = ddLauncherCommandIndex(argv, index, base);
|
||||
allowShellBuiltins = false;
|
||||
allowAssignments = false;
|
||||
continue;
|
||||
}
|
||||
if (base === 'sudo' || base === 'doas') {
|
||||
allowShellBuiltins = false;
|
||||
allowAssignments = true;
|
||||
const valueFlags = base === 'sudo' ? SUDO_VALUE_FLAGS : DOAS_VALUE_FLAGS;
|
||||
index += 1;
|
||||
while (index < argv.length) {
|
||||
const flag = argv[index];
|
||||
if (flag === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (flag === '-' || !flag.startsWith('-')) break;
|
||||
if (SUDO_VALUE_FLAGS.has(flag)) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (/^--[^=]+=.*$/.test(flag)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
const option = wrapperValueOption(flag, valueFlags);
|
||||
index += option && option.value === undefined ? 2 : 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (base === 'env') {
|
||||
allowShellBuiltins = false;
|
||||
allowAssignments = true;
|
||||
index += 1;
|
||||
while (index < tokens.length) {
|
||||
const arg = tokens[index];
|
||||
if (arg === '--' || arg === '-' || arg === '-i' || arg === '--ignore-environment') {
|
||||
while (index < argv.length) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
const option = wrapperValueOption(arg, ENV_VALUE_FLAGS);
|
||||
if (option && (option.name === '-S' || option.name === '--split-string')) {
|
||||
const separate = option.value === undefined;
|
||||
const source = separate ? argv[index + 1] : option.value;
|
||||
if (source === undefined || budget-- <= 0) return [];
|
||||
const expanded = splitEnvWords(source);
|
||||
if (!expanded) return [];
|
||||
argv = [...expanded, ...argv.slice(index + (separate ? 2 : 1))];
|
||||
index = 0;
|
||||
continue;
|
||||
}
|
||||
if (arg === '-u' || arg === '--unset') {
|
||||
index += 2;
|
||||
if (option) {
|
||||
index += option.value === undefined ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '-C' || arg === '--chdir') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (/^--unset=.*$/.test(arg) || /^--chdir=.*$/.test(arg) || /^--argv0=.*$/.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('-') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
|
||||
if (arg.startsWith('-') || SHELL_ASSIGNMENT.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -487,9 +693,9 @@ function unwrapLeadWrappers(tokens) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
return argv.slice(index);
|
||||
}
|
||||
return index;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,10 +708,9 @@ function unwrapLeadWrappers(tokens) {
|
||||
*/
|
||||
function isDestructiveSqlClient(tokens) {
|
||||
if (!tokens || tokens.length === 0) return false;
|
||||
const start = unwrapLeadWrappers(tokens);
|
||||
if (start >= tokens.length) return false;
|
||||
if (!SQL_CLIENT_COMMANDS.has(commandBasename(tokens[start]))) return false;
|
||||
return DESTRUCTIVE_SQL_DD.test(stripSqlLiterals(tokens.slice(start).join(' ')));
|
||||
const argv = unwrapLeadWrappers(tokens);
|
||||
if (!SQL_CLIENT_COMMANDS.has(commandBasename(argv[0]))) return false;
|
||||
return DESTRUCTIVE_SQL.test(stripSqlLiterals(argv.join(' ')));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -519,18 +724,23 @@ function isDestructiveSqlClient(tokens) {
|
||||
*/
|
||||
function isDestructiveQuoteAware(raw, depth = 0) {
|
||||
if (depth > 4) return false;
|
||||
for (const tokens of quoteAwareSegments(raw)) {
|
||||
if (tokens.length === 0) continue;
|
||||
if (isDestructiveRm(tokens)) return true;
|
||||
if (isDestructiveGit(tokens)) return true;
|
||||
if (isDestructiveSqlClient(tokens)) return true;
|
||||
if (isDestructiveFindExec(tokens.join(' '))) return true;
|
||||
const wi = unwrapLeadWrappers(tokens);
|
||||
const base = wi < tokens.length ? commandBasename(tokens[wi]) : '';
|
||||
if (SHELL_WRAPPERS.has(base)) {
|
||||
const ci = tokens.indexOf('-c', wi);
|
||||
if (ci !== -1 && tokens[ci + 1] && isDestructiveQuoteAware(tokens[ci + 1], depth + 1)) {
|
||||
return true;
|
||||
// The outer command was preprocessed already; shell -c introduces a new
|
||||
// program whose literal heredoc data must also stay outside execution.
|
||||
const executable = depth === 0 ? raw : stripHeredocBodies(raw);
|
||||
for (const body of collectExecutableBodies(executable)) {
|
||||
for (const tokens of quoteAwareSegments(body)) {
|
||||
if (tokens.length === 0) continue;
|
||||
if (isDestructiveRm(tokens)) return true;
|
||||
if (isDestructiveGit(tokens)) return true;
|
||||
if (isDestructiveDd(tokens)) return true;
|
||||
if (isDestructiveSqlClient(tokens)) return true;
|
||||
if (isDestructiveFindExec(tokens)) return true;
|
||||
const argv = unwrapLeadWrappers(tokens, true, true);
|
||||
if (SHELL_WRAPPERS.has(commandBasename(argv[0]))) {
|
||||
const ci = argv.indexOf('-c', 1);
|
||||
if (ci !== -1 && argv[ci + 1] && isDestructiveQuoteAware(argv[ci + 1], depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -552,6 +762,27 @@ function commandBasename(token) {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a `dd` invocation carrying an `if=` or `of=` operand.
|
||||
* Keep the existing input-file gate and include output-only writes from stdin.
|
||||
*
|
||||
* Token-based rather than a regex arm because the verdict has to depend on
|
||||
* `dd` being the command, not on `dd if=` appearing anywhere in the line:
|
||||
* `echo dd if=/dev/zero` executes nothing. dd operands are order-free, so
|
||||
* `dd of=/dev/sda if=/dev/zero` counts too — a text pattern anchored on
|
||||
* `dd\s+if=` missed that spelling entirely.
|
||||
*
|
||||
* Leading `sudo` / `doas` / `env`, their flags, and `VAR=value` assignment
|
||||
* prefixes are skipped so `sudo dd if=/dev/zero` stays the dd invocation it is.
|
||||
*
|
||||
* @param {string[]} tokens
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDestructiveDd(tokens, allowShellBuiltins = true) {
|
||||
const argv = unwrapLeadWrappers(tokens, allowShellBuiltins, true);
|
||||
return commandBasename(argv[0]) === 'dd' && argv.slice(1).some(operand => /^(?:if|of)=/i.test(operand));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect `rm` invocations that recursively force-delete files. Handles
|
||||
* combined (`-rf`, `-fr`, `-Rf`) and split (`-r -f`) flag forms.
|
||||
@@ -878,87 +1109,79 @@ function collectExecutableBodies(raw) {
|
||||
return bodies;
|
||||
}
|
||||
|
||||
// Find predicates consume their arguments even when a value spells '-exec'.
|
||||
const FIND_VALUE_PREDICATES = new Set([
|
||||
'-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex',
|
||||
'-type', '-xtype', '-maxdepth', '-mindepth', '-mtime', '-mmin', '-atime', '-amin',
|
||||
'-ctime', '-cmin', '-newer', '-anewer', '-cnewer', '-used', '-uid', '-gid', '-user',
|
||||
'-group', '-perm', '-size', '-inum', '-links', '-fstype', '-context', '-lname', '-ilname',
|
||||
'-printf', '-fprint', '-fprint0', '-fls', '-samefile', '-files0-from', '-regextype'
|
||||
]);
|
||||
const FIND_EXEC_ACTIONS = new Set(['-exec', '-execdir', '-ok', '-okdir']);
|
||||
|
||||
/**
|
||||
* Detect destructive commands inside `find ... -exec` invocations.
|
||||
* Handles `-exec rm {} \;`, `-exec rm -rf {} \;`, `-exec rmdir {} \;`,
|
||||
* `-exec unlink {} \;`, `-exec git reset --hard {} \;`.
|
||||
* Inspect each find executable action without mistaking its argv for another
|
||||
* action. -ok/-okdir still run the command after their own confirmation, so
|
||||
* they retain the explicit destructive gate. A + terminates exec/execdir only
|
||||
* after {}; elsewhere it remains an ordinary argument.
|
||||
*
|
||||
* @param {string} command
|
||||
* @param {string | string[]} command raw segment or already dequoted argv
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDestructiveFindExec(command) {
|
||||
const raw = String(command || '');
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const quoteAware = Array.isArray(command);
|
||||
const tokens = quoteAware ? command : tokenize(String(command || '').trim());
|
||||
if (commandBasename(tokens[0]) !== 'find') return false;
|
||||
|
||||
// Tokenize the whole command line
|
||||
const tokens = tokenize(trimmed);
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must start with `find`
|
||||
if (commandBasename(tokens[0]) !== 'find') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the `-exec` token
|
||||
const execIndex = tokens.indexOf('-exec');
|
||||
if (execIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Collect tokens after `-exec` until we hit a terminator (`;`, `\;`, or `+`)
|
||||
const execTokens = [];
|
||||
for (let i = execIndex + 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (token === ';' || token === '\\;' || token === '+') {
|
||||
break;
|
||||
for (let index = 1; index < tokens.length; index += 1) {
|
||||
const action = tokens[index];
|
||||
if (action === '-fprintf') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
execTokens.push(token);
|
||||
}
|
||||
|
||||
if (execTokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseCmd = commandBasename(execTokens[0]);
|
||||
|
||||
// Directly destructive commands inside -exec
|
||||
if (baseCmd === 'rmdir' || baseCmd === 'unlink') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `rm` with any flags (including none) inside -exec is destructive
|
||||
if (baseCmd === 'rm') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `git reset --hard` inside -exec
|
||||
if (baseCmd === 'git') {
|
||||
const sub = findGitSubcommand(execTokens);
|
||||
if (sub && sub.command === 'reset' && sub.rest.includes('--hard')) {
|
||||
return true;
|
||||
if (FIND_VALUE_PREDICATES.has(action) || /^-newer[a-zA-Z]{2}$/.test(action)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!FIND_EXEC_ACTIONS.has(action)) continue;
|
||||
const execTokens = [];
|
||||
for (index += 1; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (token === ';' || token === '\\;' || (
|
||||
token === '+' && (action === '-exec' || action === '-execdir') &&
|
||||
execTokens[execTokens.length - 1] === '{}'
|
||||
)) break;
|
||||
execTokens.push(token);
|
||||
}
|
||||
if (execTokens.length === 0) continue;
|
||||
// The legacy raw fallback can split quoted prose into apparent actions.
|
||||
// Preserve its old rm/Git coverage, but classify dd only from real argv.
|
||||
if (quoteAware && isDestructiveDd(execTokens, false)) return true;
|
||||
const baseCmd = commandBasename(execTokens[0]);
|
||||
// Preserve main's existing rm/rmdir/unlink and git-reset handling.
|
||||
if (baseCmd === 'rm' || baseCmd === 'rmdir' || baseCmd === 'unlink') return true;
|
||||
if (baseCmd === 'git') {
|
||||
const sub = findGitSubcommand(execTokens);
|
||||
if (sub && sub.command === 'reset' && sub.rest.includes('--hard')) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDestructiveBash(command) {
|
||||
// The SQL/dd phrases live in command bodies, not as flag-bearing
|
||||
// arguments, so we still match them by regex — but on the input
|
||||
// SQL phrases live in command bodies, not as flag-bearing
|
||||
// arguments, so we still match them by regex - but on the input
|
||||
// after quoting AND subshell delimiters are normalized so phrases
|
||||
// inside `$(...)` or backticks are also caught.
|
||||
const raw = String(command || '');
|
||||
// Keep main's heredoc stripping: a phrase inside a heredoc body is data, not a
|
||||
// command. dd is no longer part of this regex — see DESTRUCTIVE_SQL.
|
||||
const executable = stripHeredocBodies(raw);
|
||||
const flattened = explodeSubshells(stripQuotedStrings(executable));
|
||||
if (DESTRUCTIVE_SQL_DD.test(flattened)) return true;
|
||||
if (DESTRUCTIVE_SQL.test(flattened)) return true;
|
||||
|
||||
// Operator-supplied additional destructive patterns. Same scope as the
|
||||
// built-in SQL/dd regex: matched against the quote-stripped, subshell-
|
||||
// built-in SQL regex: matched against the quote-stripped, subshell-
|
||||
// exploded command so a phrase inside `$(...)` or backticks is caught.
|
||||
const extra = getExtraDestructiveRegex();
|
||||
if (extra && extra.test(flattened)) return true;
|
||||
@@ -982,7 +1205,7 @@ function isDestructiveBash(command) {
|
||||
const segments = bodies.flatMap(splitCommandSegments);
|
||||
for (const segment of segments) {
|
||||
const stripped = stripQuotedStrings(segment);
|
||||
if (DESTRUCTIVE_SQL_DD.test(stripped)) return true;
|
||||
if (DESTRUCTIVE_SQL.test(stripped)) return true;
|
||||
if (extra && extra.test(stripped)) return true;
|
||||
const tokens = tokenize(segment);
|
||||
if (isDestructiveRm(tokens)) return true;
|
||||
|
||||
@@ -155,6 +155,342 @@ function loadDirectHook(env = {}) {
|
||||
return require(hookScript);
|
||||
}
|
||||
|
||||
|
||||
// Fast, pure classification matrix. These strings are input data, never shell commands.
|
||||
function runDdRegressionTests() {
|
||||
const environment = {
|
||||
GATEGUARD_STATE_DIR: stateDir,
|
||||
CLAUDE_SESSION_ID: TEST_SESSION_ID,
|
||||
GATEGUARD_DISABLED: '',
|
||||
ECC_GATEGUARD: 'on',
|
||||
GATEGUARD_BASH_EXTRA_DESTRUCTIVE: '',
|
||||
GATEGUARD_BASH_ROUTINE_DISABLED: '1',
|
||||
GATEGUARD_EXEMPT_GLOBS: '',
|
||||
ECC_HOOKS_ENABLED: 'true',
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
ECC_DISABLED_HOOKS: '',
|
||||
ECC_DRY_RUN: '0',
|
||||
ECC_HOOK_CONFIG: path.join(stateDir, 'no-managed-config.json'),
|
||||
CLAUDE_PLUGIN_ROOT: path.resolve(__dirname, '../..'),
|
||||
ECC_PLUGIN_ROOT: path.resolve(__dirname, '../..')
|
||||
};
|
||||
const original = Object.fromEntries(Object.keys(environment).map(key => [key, process.env[key]]));
|
||||
Object.assign(process.env, environment);
|
||||
let hook;
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const check = (name, fn) => {
|
||||
if (test(name, fn)) passed++;
|
||||
else failed++;
|
||||
};
|
||||
const destructive = [
|
||||
'dd if=/dev/zero of=/dev/sda',
|
||||
'dd of=/dev/sda bs=1M',
|
||||
'cat /dev/zero | dd of=/dev/sda',
|
||||
'sudo dd of=/dev/sda < /dev/zero',
|
||||
'dd bs=1M of="./output"',
|
||||
"timeout 60 bash -c 'dd if=/dev/zero of=/dev/sda'",
|
||||
"nohup sh -c 'dd if=input'",
|
||||
"nice -n 5 sh -c 'dd if=input'",
|
||||
"xargs sh -c 'dd if=input'",
|
||||
"timeout 2 sh -c 'dd of=output'",
|
||||
"nohup sh -c 'echo $(dd of=output)'",
|
||||
"nice -n 5 sh -c 'cat <<EOF\n$(dd of=output)\nEOF'",
|
||||
'find . -exec dd of=output \\;',
|
||||
'dd if=./image of=./out',
|
||||
'dd of=./out bs=1M if="./image"',
|
||||
"'/bin/dd' if=input of=output",
|
||||
'sudo -u root dd if=input',
|
||||
'command dd if=input of=output',
|
||||
'xargs dd if=input of=output',
|
||||
'xargs -- dd if=input',
|
||||
'xargs -0 -I{} dd if=input',
|
||||
'xargs -n 2 -P4 dd if=input',
|
||||
'xargs -a input --delimiter=, dd if=input',
|
||||
'xargs --max-args 2 dd if=input',
|
||||
'xargs -e dd if=input',
|
||||
'xargs -i dd if=input',
|
||||
'xargs --replace dd if=input',
|
||||
'xargs -J{} dd if=input',
|
||||
'timeout 2 dd if=input of=output',
|
||||
'timeout -k 1 -s TERM 2s dd if=input',
|
||||
'timeout --kill-after=1 --signal=TERM --foreground 2 dd if=input',
|
||||
'timeout -- 2 dd if=input',
|
||||
'nice dd if=input of=output',
|
||||
'nice -n 5 dd if=input',
|
||||
'nice --adjustment=-5 dd if=input',
|
||||
'nice -5 dd if=input',
|
||||
'nice --5 dd if=input',
|
||||
'nohup dd if=input of=output',
|
||||
'nohup -- dd if=input',
|
||||
'command nice -n 2 timeout 3 env A=1 dd if=input',
|
||||
'command -p dd if=input',
|
||||
'command -- dd if=input',
|
||||
'exec dd if=input of=output',
|
||||
'exec -a ddname dd if=input',
|
||||
'exec -addname dd if=input',
|
||||
'exec -cl dd if=input',
|
||||
'exec -- dd if=input',
|
||||
'A=1 command -p exec -a ddname dd if=input',
|
||||
'sudo --user=root dd if=input',
|
||||
'sudo -uroot dd if=input',
|
||||
'sudo -nu root dd if=input',
|
||||
'sudo -nuroot dd if=input',
|
||||
'doas -nu root dd if=input',
|
||||
'sudo -g staff dd if=input',
|
||||
'sudo --group staff dd if=input',
|
||||
'sudo -C 3 dd if=input',
|
||||
'sudo -D /tmp dd if=input',
|
||||
'sudo -- dd if=input',
|
||||
'doas -u root dd if=input',
|
||||
'doas -C /tmp/doas.conf dd if=input',
|
||||
'env -u FOO dd if=input',
|
||||
'env -uFOO dd if=input',
|
||||
'env -C /tmp dd if=input',
|
||||
'env -C/tmp dd if=input',
|
||||
'env --argv0 ddname dd if=input',
|
||||
'env -a ddname dd if=input',
|
||||
'env -addname dd if=input',
|
||||
'env --unset=FOO --chdir=/tmp --argv0=ddname dd if=input',
|
||||
'A=1 env B=2 sudo -u root dd if=input',
|
||||
'sudo A=1 dd if=input',
|
||||
"env -S 'dd if=input of=output'",
|
||||
"env --split-string='dd if=input'",
|
||||
'env -Sdd if=input',
|
||||
"env -iS 'dd if=input'",
|
||||
'env -iuFOO dd if=input',
|
||||
"env -S 'sudo -u root dd' if=input",
|
||||
"env -S 'sh -c \"dd if=input\"'",
|
||||
"env -S 'env -S \"dd if=input\"'",
|
||||
"env -S 'dd\\_if=input'",
|
||||
"env -S 'dd if=input # trailing comment'",
|
||||
String.raw`env -S 'dd "if=input\_file"'`,
|
||||
String.raw`env -S 'dd "if=input\"quote"'`,
|
||||
'echo $(dd if=input of=output)',
|
||||
'echo "$(dd if=./input)"',
|
||||
'echo `dd if=input`',
|
||||
'(dd if=input)',
|
||||
'{ dd if=input; }',
|
||||
'echo $({ (dd if=input); })',
|
||||
"sh -c 'echo $(dd if=input)'",
|
||||
"sh -c 'echo `dd if=input`'",
|
||||
"sh -c '(dd if=input)'",
|
||||
"sh -c 'cat <<EOF\n$(dd if=input)\nEOF'",
|
||||
"sh -c 'sh <<EOF\ndd if=input\nEOF'",
|
||||
'find . -exec dd if=input of=output \\;',
|
||||
'printf note; find . -exec dd if=input \\;',
|
||||
'echo "note; passive text"; find . -exec dd if=input \\;',
|
||||
"sh -c 'printf note; find . -exec dd if=input \\;'",
|
||||
'find . -exec sudo -u root dd if=input \\;',
|
||||
'find . -exec echo {} \\; -exec dd if=input of=output \\;',
|
||||
'find . -exec echo {} + -exec dd if=input \\;',
|
||||
'find . -exec echo + -exec dd if=argument \\; -exec dd if=actual \\;',
|
||||
'find . -execdir dd if=input \\;',
|
||||
'find . -ok dd if=input \\;',
|
||||
'find . -okdir dd if=input \\;',
|
||||
'find . -name -exec -exec dd if=input \\;',
|
||||
"find . -printf '-exec' -exec dd if=input \\;",
|
||||
"find . -fprintf '-exec' '-exec' -exec dd if=input \\;",
|
||||
['cat <<EOF', '$(dd if=input)', 'EOF'].join('\n'),
|
||||
['sh <<EOF', 'dd if=input', 'EOF'].join('\n'),
|
||||
["cat <<'EOF'", 'dd if=input', 'EOF', 'dd if=after'].join('\n'),
|
||||
'sudo -u postgres psql -c "drop table users"',
|
||||
"env -S 'sudo -u postgres psql' -c 'drop table users'",
|
||||
"sh -c 'psql -c \"drop table users\"'",
|
||||
'git push --force origin main',
|
||||
'git push --force-with-lease origin main',
|
||||
`${'env '.repeat(40)}dd if=input`,
|
||||
'git reset --hard',
|
||||
'git stash clear',
|
||||
'git restore tracked.txt',
|
||||
"find . -exec 'rm' {} \\;"
|
||||
];
|
||||
const passive = [
|
||||
'echo dd if=input',
|
||||
'echo dd of=/dev/sda',
|
||||
'grep dd of=output file',
|
||||
"printf '%s' 'dd of=output'",
|
||||
'dd count=0',
|
||||
"timeout 2 echo 'sh -c dd of=output'",
|
||||
"timeout 2 sh -c 'echo \"dd of=output\"'",
|
||||
"nohup sh -c 'cat <<EOF\ndd of=output\nEOF'",
|
||||
"nice -n 5 echo 'dd of=output'",
|
||||
"xargs echo 'sh -c dd of=output'",
|
||||
'echo "note; find . -exec dd of=output \\;"',
|
||||
'command -v dd',
|
||||
'command -v dd if=input',
|
||||
'command -V dd if=input',
|
||||
'command -V dd',
|
||||
'command -pv dd',
|
||||
'command -pV dd if=input',
|
||||
'command echo dd if=input',
|
||||
'xargs echo dd if=input',
|
||||
'xargs -I dd echo if=input',
|
||||
'xargs -d dd echo if=input',
|
||||
'xargs --arg-file dd echo if=input',
|
||||
'xargs -edd echo if=input',
|
||||
'xargs --replace=dd echo if=input',
|
||||
'xargs --help dd if=input',
|
||||
'timeout 2 echo dd if=input',
|
||||
'timeout -s dd 2 echo if=input',
|
||||
'timeout --help dd if=input',
|
||||
'nice -n 2 echo dd if=input',
|
||||
'nice --version dd if=input',
|
||||
'nohup echo dd if=input',
|
||||
'nohup --help dd if=input',
|
||||
'xargs command dd if=input',
|
||||
'timeout 2 command dd if=input',
|
||||
'exec -a dd echo if=input',
|
||||
'exec -add echo if=input',
|
||||
'exec echo dd if=input',
|
||||
'command A=1 dd if=input',
|
||||
"echo 'command dd if=input'",
|
||||
"echo 'exec dd if=input'",
|
||||
'sudo command dd if=input',
|
||||
'env command dd if=input',
|
||||
'exec command dd if=input',
|
||||
'grep dd if=/dev/zero file',
|
||||
"printf '%s' 'dd if=input'",
|
||||
'echo add if=1',
|
||||
'echo truncated',
|
||||
'sudo -u dd echo if=input',
|
||||
'sudo --user=dd echo if=input',
|
||||
'sudo -udd echo if=input',
|
||||
'sudo -nu dd echo if=input',
|
||||
'doas -nu dd echo if=input',
|
||||
'env -iu dd echo if=input',
|
||||
'doas -u dd echo if=input',
|
||||
'env -u dd echo if=input',
|
||||
'env -C dd echo if=input',
|
||||
'env --argv0 dd echo if=input',
|
||||
'env -a dd echo if=input',
|
||||
'env -- -u dd if=input',
|
||||
'A=dd echo if=input',
|
||||
'echo sudo -u root dd if=input',
|
||||
"env -S 'echo dd if=input'",
|
||||
String.raw`env -S 'echo "dd if=input\"quote"'`,
|
||||
"env -S 'echo ok; dd if=input'",
|
||||
"env -S 'echo ok | dd if=input'",
|
||||
"env -S 'echo ok & dd if=input'",
|
||||
"env -S 'echo $(dd if=input)'",
|
||||
"env -S 'echo `dd if=input`'",
|
||||
"env -S 'echo # dd if=input'",
|
||||
"env -S 'echo\\c dd if=input'",
|
||||
"env -S 'echo \\$(dd if=input)'",
|
||||
"echo 'env -S dd if=input'",
|
||||
"env -S ''",
|
||||
'env -S',
|
||||
"env -S '\"dd if=input'",
|
||||
"env -S 'dd if=input\\q'",
|
||||
"env -S '${UNREAD_HOST_COMMAND} if=input'",
|
||||
"echo '$(dd if=input)'",
|
||||
"echo '`dd if=input`'",
|
||||
"echo '(dd if=input)'",
|
||||
"echo '{ dd if=input; }'",
|
||||
"sh -c 'echo \"dd if=input\"'",
|
||||
"sh -c 'cat <<EOF\ndd if=input\nEOF'",
|
||||
["cat <<'EOF'", 'dd if=input; $(dd if=input)', 'EOF'].join('\n'),
|
||||
['cat <<EOF', 'dd if=input', 'EOF'].join('\n'),
|
||||
['cat <<EOF', '\\$(dd if=input)', 'EOF'].join('\n'),
|
||||
'psql -c "SELECT \'drop table\' FROM audit_log"',
|
||||
'echo "drop table users"',
|
||||
'git commit -m "drop table users"',
|
||||
'git push --force-with-lease origin feature-branch',
|
||||
'echo "note; find . -exec dd if=input \\;"',
|
||||
"printf '%s' 'note; find . -exec dd if=input \\;'",
|
||||
'echo "note | find . -exec dd if=input \\;"',
|
||||
'echo "note & find . -exec dd if=input \\;"',
|
||||
"find . -name 'x -exec dd if=input' -print",
|
||||
'find . -exec echo -exec dd if=input \\;',
|
||||
'find . -exec echo + -exec dd if=input \\;',
|
||||
"find . -exec echo '-exec dd if=input' \\;",
|
||||
'find . -execdir echo dd if=input \\;',
|
||||
'find . -ok echo -exec dd if=input \\;',
|
||||
'find . -okdir echo dd if=input \\;',
|
||||
'find . -exec command dd if=input \\;',
|
||||
'git status',
|
||||
'git diff --stat',
|
||||
'git restore --staged tracked.txt'
|
||||
];
|
||||
try {
|
||||
hook = loadDirectHook();
|
||||
for (const command of destructive) {
|
||||
check(`dd/preservation destructive: ${JSON.stringify(command)}`, () => {
|
||||
assert.deepStrictEqual(hook.classifyDestructiveCommand('Bash', command), [
|
||||
'gateguard.bash-compatible-destructive'
|
||||
]);
|
||||
});
|
||||
}
|
||||
for (const command of passive) {
|
||||
check(`dd/preservation passive: ${JSON.stringify(command)}`, () => {
|
||||
assert.deepStrictEqual(hook.classifyDestructiveCommand('Bash', command), []);
|
||||
});
|
||||
}
|
||||
for (const [command, denied] of [
|
||||
['sudo -u root dd if=input', true],
|
||||
["sh -c 'echo $(dd if=input)'", true],
|
||||
['sudo -u dd echo if=input', false],
|
||||
["env -S 'echo ok; dd if=input'", false],
|
||||
['find . -exec echo {} \\; -exec dd if=input \\;', true],
|
||||
['command -pv dd', false],
|
||||
["timeout 2 sh -c 'dd if=input'", true],
|
||||
['cat /dev/zero | dd of=/dev/sda', true]
|
||||
]) {
|
||||
check(`dd hook-input contract: ${command}`, () => {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const input = { tool_name: 'Bash', tool_input: { command } };
|
||||
const result = spawnSync(process.execPath, [runner, 'pre:bash:gateguard-fact-force',
|
||||
'scripts/hooks/gateguard-fact-force.js', 'standard,strict'], {
|
||||
input: JSON.stringify(input), encoding: 'utf8', timeout: 3000,
|
||||
env: { ...process.env, ...environment }, stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
assert.ifError(result.error);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const output = JSON.parse(result.stdout);
|
||||
if (denied) {
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.match(output.hookSpecificOutput.permissionDecisionReason, /Destructive/);
|
||||
} else {
|
||||
assert.deepStrictEqual(output, input, 'allow must be actual JSON pass-through, not silence');
|
||||
}
|
||||
});
|
||||
}
|
||||
check('main batch warning and invisible-path sanitizer stay intact', () => {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const result = hook.run({ tool_name: 'Write', tool_input: { file_path: '/src/a\u0091b\u200bc.js' } });
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
const output = JSON.parse(result.stdout).hookSpecificOutput;
|
||||
assert.strictEqual(output.permissionDecision, 'deny');
|
||||
assert.match(output.permissionDecisionReason, /parallel batch/);
|
||||
assert.ok(!output.permissionDecisionReason.includes('\u0091'));
|
||||
assert.ok(!output.permissionDecisionReason.includes('\u200b'));
|
||||
assert.match(output.permissionDecisionReason, /c\.js/);
|
||||
});
|
||||
check('disabled hook remains silent through the routing wrapper', () => {
|
||||
const result = spawnSync(process.execPath, [runner, 'pre:bash:gateguard-fact-force',
|
||||
'scripts/hooks/gateguard-fact-force.js', 'standard,strict'], {
|
||||
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'dd if=input' } }),
|
||||
encoding: 'utf8', timeout: 3000,
|
||||
env: { ...process.env, ...environment, ECC_DISABLED_HOOKS: 'pre:bash:gateguard-fact-force' },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
assert.ifError(result.error);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
});
|
||||
return { passed, failed };
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
for (const [key, value] of Object.entries(original)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
delete require.cache[require.resolve(hookScript)];
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing gateguard-fact-force ===\n');
|
||||
|
||||
@@ -281,6 +617,91 @@ function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// --- Test 4b: dd targets that do not start with a word character ---
|
||||
/**
|
||||
* #2642: DESTRUCTIVE_SQL_DD carried one trailing \b across every alternation
|
||||
* arm. `dd\s+if=` ends in `=`, so that \b demanded the NEXT character be a
|
||||
* word character: `dd if=x` was denied while the disk-wipe spelling
|
||||
* `dd if=/dev/zero of=/dev/sda` and the relative `dd if=./img` were allowed.
|
||||
* These run through the real hook, since the report is specifically that the
|
||||
* published hook lets the slash-prefixed form through.
|
||||
*/
|
||||
for (const command of [
|
||||
'dd if=/dev/zero of=/dev/sda',
|
||||
'dd if=./disk.img of=/dev/sdb',
|
||||
'dd if="/dev/zero" of=/dev/sda',
|
||||
// Wrapped invocations must still resolve to the dd command word.
|
||||
'sudo dd if=/dev/zero of=/dev/sda',
|
||||
// dd operands are order-free; a text pattern anchored on `dd if=` missed
|
||||
// both the reversed and the intervening-option spellings.
|
||||
'dd of=/dev/sda if=/dev/zero',
|
||||
'dd bs=1M if=/dev/zero of=/dev/sda'
|
||||
]) {
|
||||
clearState();
|
||||
if (
|
||||
test(`denies dd whose input path is not word-initial: ${command}`, () => {
|
||||
const result = runBashHook({ tool_name: 'Bash', tool_input: { command } });
|
||||
assert.strictEqual(result.code, 0, `hook should exit successfully for ${command}`);
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'hook should produce JSON output');
|
||||
assert.ok(output.hookSpecificOutput, 'hook should return a permission decision');
|
||||
assert.strictEqual(
|
||||
output.hookSpecificOutput.permissionDecision,
|
||||
'deny',
|
||||
`${command} must be gated as destructive`
|
||||
);
|
||||
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Destructive'));
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
// --- Test 4c: widening the dd arm must not gate ordinary commands ---
|
||||
/**
|
||||
* SQL keywords retain their word boundaries; dd is checked only at command
|
||||
* position. `truncated`, `add if=` and prose mentioning dd stay passive.
|
||||
*/
|
||||
for (const command of [
|
||||
'echo add if=1',
|
||||
'echo truncated output',
|
||||
'git status',
|
||||
// `dd if=` as another command's argument runs no dd at all. The old text
|
||||
// match gated these; the command-word check is what keeps them out.
|
||||
'echo dd if=/dev/zero',
|
||||
'grep dd if=/dev/zero file',
|
||||
'echo dd if=x'
|
||||
]) {
|
||||
clearState();
|
||||
if (
|
||||
test(`does not gate as destructive: ${command}`, () => {
|
||||
// Prime the session so the separate first-command routine gate cannot
|
||||
// be mistaken for a destructive denial.
|
||||
runBashHook({ tool_name: 'Bash', tool_input: { command: 'printf ready' } });
|
||||
const result = runBashHook({ tool_name: 'Bash', tool_input: { command } });
|
||||
// Assert the hook actually answered before reading the decision: a
|
||||
// crashed or silent hook makes parseOutput return null, and a bare
|
||||
// `if (output)` would let this case pass without testing anything.
|
||||
assert.strictEqual(result.code, 0, `hook should exit 0 for ${command}`);
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, `hook should produce JSON output for ${command}`);
|
||||
const decision = output.hookSpecificOutput;
|
||||
if (decision) {
|
||||
const reason = decision.permissionDecisionReason || '';
|
||||
assert.ok(
|
||||
decision.permissionDecision !== 'deny' || !reason.includes('Destructive'),
|
||||
`${command} must not be gated as destructive`
|
||||
);
|
||||
} else {
|
||||
// Pass-through echoes the input back unchanged.
|
||||
assert.strictEqual(output.tool_name, 'Bash', 'pass-through should preserve input');
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
// --- Test 5: denies first routine Bash, allows second ---
|
||||
clearState();
|
||||
if (
|
||||
@@ -3397,8 +3818,17 @@ function runTests() {
|
||||
failed++;
|
||||
}
|
||||
|
||||
const ddResults = runDdRegressionTests();
|
||||
passed += ddResults.passed;
|
||||
failed += ddResults.failed;
|
||||
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
if (process.argv.includes('--dd-only')) {
|
||||
const { passed, failed } = runDdRegressionTests();
|
||||
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
} else {
|
||||
runTests();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user