mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-28 20:45:11 +02:00
Merge commit 'de480c65fdbbdf8b0b6ffce0f16c0968791c5fbd' into affaan/integrate-reviewed-followups-20260928
This commit is contained in:
+271
-372
@@ -15,26 +15,11 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const { createBudget, scanShell } = require('./lib/shell-scan');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
|
||||
/**
|
||||
* Git commands that support the --no-verify flag.
|
||||
*/
|
||||
const GIT_COMMANDS_WITH_NO_VERIFY = [
|
||||
'commit',
|
||||
'push',
|
||||
'merge',
|
||||
'cherry-pick',
|
||||
'rebase',
|
||||
'am',
|
||||
];
|
||||
|
||||
/**
|
||||
* Characters that can appear immediately before 'git' in a command string.
|
||||
*/
|
||||
const VALID_BEFORE_GIT = ' \t\n\r;&|$`(<{!"\']/.~\\';
|
||||
|
||||
// Git config section and variable names are case-insensitive
|
||||
// (subsection names are case-sensitive but core.hooksPath has none),
|
||||
// so we normalize the candidate token to lowercase before matching.
|
||||
@@ -56,21 +41,10 @@ const COMMIT_OPTIONS_WITH_VALUE = new Set([
|
||||
'--template',
|
||||
'--fixup',
|
||||
'--squash',
|
||||
'--pathspec-from-file',
|
||||
'--pathspec-from-file'
|
||||
]);
|
||||
|
||||
const COMMIT_OPTIONS_WITH_INLINE_VALUE = [
|
||||
'--message=',
|
||||
'--file=',
|
||||
'--reuse-message=',
|
||||
'--reedit-message=',
|
||||
'--author=',
|
||||
'--date=',
|
||||
'--template=',
|
||||
'--fixup=',
|
||||
'--squash=',
|
||||
'--pathspec-from-file=',
|
||||
];
|
||||
const COMMIT_OPTIONS_WITH_INLINE_VALUE = ['--message=', '--file=', '--reuse-message=', '--reedit-message=', '--author=', '--date=', '--template=', '--fixup=', '--squash=', '--pathspec-from-file='];
|
||||
|
||||
// Short options that take a value. When seen as part of a combined
|
||||
// short-option token (e.g. -tn), git's parser treats the rest of the
|
||||
@@ -83,130 +57,12 @@ const COMMIT_SHORT_OPTIONS_WITH_VALUE = new Set(['m', 'F', 'C', 'c', 't']);
|
||||
// after them is not the -n flag: `git commit -uno` means --untracked-files=no.
|
||||
const COMMIT_SHORT_OPTIONS_WITH_OPTIONAL_VALUE = new Set(['u', 'S']);
|
||||
|
||||
function tokenizeShellWords(input, start = 0, end = input.length) {
|
||||
const tokens = [];
|
||||
let value = '';
|
||||
let tokenStart = null;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
function beginToken(index) {
|
||||
if (tokenStart === null) {
|
||||
tokenStart = index;
|
||||
}
|
||||
}
|
||||
|
||||
function pushToken(index) {
|
||||
if (tokenStart === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
tokens.push({
|
||||
value,
|
||||
start: tokenStart,
|
||||
end: index,
|
||||
});
|
||||
value = '';
|
||||
tokenStart = null;
|
||||
}
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const char = input.charAt(i);
|
||||
|
||||
if (escaped) {
|
||||
beginToken(i - 1);
|
||||
value += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === '"' && char === '\\') {
|
||||
beginToken(i);
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
beginToken(i);
|
||||
value += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
beginToken(i);
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
beginToken(i);
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
pushToken(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
beginToken(i);
|
||||
value += char;
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
value += '\\';
|
||||
}
|
||||
pushToken(end);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function findCommandSegmentEnd(input, start) {
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = start; i < input.length; i++) {
|
||||
const char = input.charAt(i);
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (quote === '"' && char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ';' || char === '|' || char === '&' || char === '\n') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return input.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when a commit option consumes the following token as its value.
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function commitOptionConsumesNextValue(value) {
|
||||
if (isCommitNoVerifyShortFlag(value)) {
|
||||
return false;
|
||||
@@ -220,6 +76,12 @@ function commitOptionConsumesNextValue(value) {
|
||||
return Boolean(shortValueOption && shortValueOption.consumesNextValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when a commit option already carries its value in the same token.
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function commitOptionContainsInlineValue(value) {
|
||||
if (isCommitNoVerifyShortFlag(value)) {
|
||||
return false;
|
||||
@@ -233,6 +95,12 @@ function commitOptionContainsInlineValue(value) {
|
||||
return Boolean(shortValueOption && shortValueOption.containsInlineValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a combined short-option token that includes a value-taking option.
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {{consumesNextValue: boolean, containsInlineValue: boolean}|null}
|
||||
*/
|
||||
function getCommitShortValueOption(value) {
|
||||
if (!value.startsWith('-') || value.startsWith('--') || value === '-') {
|
||||
return null;
|
||||
@@ -243,7 +111,7 @@ function getCommitShortValueOption(value) {
|
||||
if (COMMIT_SHORT_OPTIONS_WITH_VALUE.has(options.charAt(i))) {
|
||||
return {
|
||||
consumesNextValue: i === options.length - 1,
|
||||
containsInlineValue: i < options.length - 1,
|
||||
containsInlineValue: i < options.length - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -251,6 +119,12 @@ function getCommitShortValueOption(value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when a token is commit's `-n` / `--no-verify` short form.
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isCommitNoVerifyShortFlag(value) {
|
||||
if (!value.startsWith('-') || value.startsWith('--') || value === '-') {
|
||||
return false;
|
||||
@@ -274,125 +148,6 @@ function isCommitNoVerifyShortFlag(value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a position in the input is inside a shell comment.
|
||||
*/
|
||||
function isInComment(input, idx) {
|
||||
const lineStart = input.lastIndexOf('\n', idx - 1) + 1;
|
||||
const before = input.slice(lineStart, idx);
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
if (before.charAt(i) === '#') {
|
||||
const prev = i > 0 ? before.charAt(i - 1) : '';
|
||||
if (prev !== '$' && prev !== '\\') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next 'git' token in the input starting from a position.
|
||||
*/
|
||||
function findGit(input, start) {
|
||||
let pos = start;
|
||||
while (pos < input.length) {
|
||||
const idx = input.indexOf('git', pos);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const isExe = input.slice(idx + 3, idx + 7).toLowerCase() === '.exe';
|
||||
const len = isExe ? 7 : 3;
|
||||
const after = input[idx + len] || ' ';
|
||||
if (!/[\s"']/.test(after)) {
|
||||
pos = idx + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = idx > 0 ? input[idx - 1] : ' ';
|
||||
if (VALID_BEFORE_GIT.includes(before)) return { idx, len };
|
||||
pos = idx + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which git subcommand (commit, push, etc.) is being invoked.
|
||||
* Returns { command, offset } where offset is the position right after the
|
||||
* subcommand keyword, so callers can scope flag checks to only that portion.
|
||||
*/
|
||||
function detectGitCommand(input, start = 0) {
|
||||
while (start < input.length) {
|
||||
const git = findGit(input, start);
|
||||
if (!git) return null;
|
||||
|
||||
if (isInComment(input, git.idx)) {
|
||||
start = git.idx + git.len;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the first matching subcommand token after "git".
|
||||
// We pick the one closest to "git" so that argument values like
|
||||
// "git push origin commit" don't misclassify "commit" as the subcommand.
|
||||
let bestCmd = null;
|
||||
let bestIdx = Infinity;
|
||||
|
||||
for (const cmd of GIT_COMMANDS_WITH_NO_VERIFY) {
|
||||
let searchPos = git.idx + git.len;
|
||||
while (searchPos < input.length) {
|
||||
const cmdIdx = input.indexOf(cmd, searchPos);
|
||||
if (cmdIdx === -1) break;
|
||||
|
||||
const before = cmdIdx > 0 ? input[cmdIdx - 1] : ' ';
|
||||
const after = input[cmdIdx + cmd.length] || ' ';
|
||||
if (!/\s/.test(before)) { searchPos = cmdIdx + 1; continue; }
|
||||
if (!/[\s;&#|>)\]}"']/.test(after) && after !== '') { searchPos = cmdIdx + 1; continue; }
|
||||
if (/[;|]/.test(input.slice(git.idx + git.len, cmdIdx))) break;
|
||||
if (isInComment(input, cmdIdx)) { searchPos = cmdIdx + 1; continue; }
|
||||
|
||||
// Verify this token is the first non-flag word after "git" — i.e. the
|
||||
// actual subcommand, not an argument value to a different subcommand.
|
||||
const gap = input.slice(git.idx + git.len, cmdIdx);
|
||||
const tokens = gap.trim().split(/\s+/).filter(Boolean);
|
||||
// Every token before the candidate must be a flag or a flag argument.
|
||||
// Git global flags like -c take a value argument (e.g. -c key=value).
|
||||
let onlyFlagsAndArgs = true;
|
||||
let expectFlagArg = false;
|
||||
for (const t of tokens) {
|
||||
if (expectFlagArg) { expectFlagArg = false; continue; }
|
||||
if (t.startsWith('-')) {
|
||||
// -c is a git global flag that takes the next token as its argument
|
||||
if (t === '-c' || t === '-C' || t === '--work-tree' || t === '--git-dir' ||
|
||||
t === '--namespace' || t === '--super-prefix') {
|
||||
expectFlagArg = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
onlyFlagsAndArgs = false;
|
||||
break;
|
||||
}
|
||||
if (!onlyFlagsAndArgs) { searchPos = cmdIdx + 1; continue; }
|
||||
|
||||
if (cmdIdx < bestIdx) {
|
||||
bestIdx = cmdIdx;
|
||||
bestCmd = cmd;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCmd) {
|
||||
return {
|
||||
command: bestCmd,
|
||||
offset: bestIdx + bestCmd.length,
|
||||
gitStart: git.idx,
|
||||
gitEnd: git.idx + git.len,
|
||||
commandStart: bestIdx,
|
||||
};
|
||||
}
|
||||
|
||||
start = git.idx + git.len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* git's option parser accepts any unambiguous prefix of a long option, so
|
||||
* `--no-veri` and `--no-verif` run as --no-verify. Shorter prefixes such as
|
||||
@@ -403,131 +158,272 @@ function isNoVerifyLongFlag(value) {
|
||||
return value.length >= '--no-v'.length && '--no-verify'.startsWith(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input contains a --no-verify flag for a specific git command.
|
||||
* Only inspects the portion of the input starting at `offset` (the position
|
||||
* right after the detected subcommand keyword) so that flags belonging to
|
||||
* earlier commands in a chain are not falsely matched.
|
||||
*/
|
||||
function hasNoVerifyFlag(input, command, offset) {
|
||||
const segmentEnd = findCommandSegmentEnd(input, offset);
|
||||
const tokens = tokenizeShellWords(input, offset, segmentEnd);
|
||||
let skipNext = false;
|
||||
const PROTECTED_GIT_COMMANDS = new Set(['commit', 'push', 'merge', 'cherry-pick', 'rebase', 'am']);
|
||||
const GIT_GLOBAL_VALUES = new Set(['-c', '-C', '--work-tree', '--git-dir', '--namespace', '--super-prefix']);
|
||||
const SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh']);
|
||||
const DATA_COMMANDS = new Set(['echo', 'printf', 'cat', 'grep', 'head', 'tail', 'wc', 'sort', 'uniq', ':', 'true', 'false']);
|
||||
const CONTROL_WORDS = new Set(['!', 'if', 'then', 'elif', 'while', 'until', 'do', 'else']);
|
||||
|
||||
for (const token of tokens) {
|
||||
const value = token.value;
|
||||
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === '--') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (command === 'commit') {
|
||||
if (commitOptionConsumesNextValue(value)) {
|
||||
skipNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (commitOptionContainsInlineValue(value)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNoVerifyLongFlag(value)) return true;
|
||||
|
||||
// For commit, -n is shorthand for --no-verify.
|
||||
if (command === 'commit' && isCommitNoVerifyShortFlag(value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
function basename(value) {
|
||||
return value.replace(/\\/g, '/').split('/').pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input contains a -c core.hooksPath= override.
|
||||
*/
|
||||
function hasHooksPathOverride(input, detected) {
|
||||
const tokens = tokenizeShellWords(input, detected.gitEnd, detected.commandStart);
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const value = tokens[i].value;
|
||||
// Git config section + variable names are case-insensitive, so a
|
||||
// bypass attempt like `core.HOOKSPATH=...` or `core.hookspath=...`
|
||||
// must compare against the lowercased token.
|
||||
const lowered = value.toLowerCase();
|
||||
|
||||
function checkGitWords(words, budget, start = 0) {
|
||||
let index = start + 1;
|
||||
let override = false;
|
||||
for (; index < words.length; index++) {
|
||||
const value = words[index].value;
|
||||
budget.spend(value.length + 1);
|
||||
if (!value.startsWith('-')) break;
|
||||
if (value === '--') { index++; break; }
|
||||
if (value === '-c') {
|
||||
const next = tokens[i + 1] && tokens[i + 1].value;
|
||||
if (typeof next === 'string' && next.toLowerCase().startsWith(GIT_CONFIG_KEY_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
const setting = words[index + 1]?.value || '';
|
||||
budget.spend(setting.length + 1);
|
||||
override ||= setting.toLowerCase().startsWith(GIT_CONFIG_KEY_PREFIX);
|
||||
} else if (value.toLowerCase().startsWith(`-c${GIT_CONFIG_KEY_PREFIX}`)) override = true;
|
||||
if (GIT_GLOBAL_VALUES.has(value)) index++;
|
||||
}
|
||||
const command = words[index]?.value;
|
||||
budget.spend((command?.length || 0) + 1);
|
||||
if (!PROTECTED_GIT_COMMANDS.has(command)) return null;
|
||||
if (override) return `BLOCKED: Overriding core.hooksPath is not allowed with git ${command}. Git hooks must not be bypassed.`;
|
||||
let skipNext = false;
|
||||
for (index++; index < words.length; index++) {
|
||||
const value = words[index].value;
|
||||
budget.spend(value.length + 1);
|
||||
if (skipNext) { skipNext = false; continue; }
|
||||
if (value === '--') break;
|
||||
if (command === 'commit') {
|
||||
if (commitOptionConsumesNextValue(value)) { skipNext = true; continue; }
|
||||
if (commitOptionContainsInlineValue(value)) continue;
|
||||
}
|
||||
|
||||
if (lowered.startsWith(`-c${GIT_CONFIG_KEY_PREFIX}`)) {
|
||||
return true;
|
||||
if (isNoVerifyLongFlag(value) || (command === 'commit' && isCommitNoVerifyShortFlag(value))) {
|
||||
return `BLOCKED: --no-verify flag is not allowed with git ${command}. Git hooks must not be bypassed.`;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a command string for git hook bypass attempts.
|
||||
*/
|
||||
function checkCommand(input) {
|
||||
let start = 0;
|
||||
|
||||
while (start < input.length) {
|
||||
const detected = detectGitCommand(input, start);
|
||||
if (!detected) return { blocked: false };
|
||||
|
||||
const { command: gitCommand, offset } = detected;
|
||||
|
||||
if (hasHooksPathOverride(input, detected)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: `BLOCKED: Overriding core.hooksPath is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasNoVerifyFlag(input, gitCommand, offset)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
|
||||
};
|
||||
}
|
||||
|
||||
start = findCommandSegmentEnd(input, offset) + 1;
|
||||
// Only explicit option grammars remove wrapper operands. Unknown launchers are
|
||||
// opaque/conservative, never guessed from a name found among data arguments.
|
||||
function executableWords(words, budget) {
|
||||
function suffix(start) {
|
||||
budget.spend(words.length - start);
|
||||
return words.slice(start);
|
||||
}
|
||||
let i = 0;
|
||||
let assignments = true;
|
||||
let environmentAssignments = false;
|
||||
while (i < words.length) {
|
||||
const token = words[i];
|
||||
budget.spend(token.value.length + token.raw.length + 1);
|
||||
if (assignments && /^[A-Za-z_][A-Za-z0-9_]*=/.test(environmentAssignments ? token.value : token.raw)) { i++; continue; }
|
||||
if (!token.quoted && CONTROL_WORDS.has(token.value)) { i++; continue; }
|
||||
const name = basename(token.value);
|
||||
if (name === 'command') {
|
||||
i++;
|
||||
while (words[i]?.value.startsWith('-')) {
|
||||
const flag = words[i++].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') break;
|
||||
if (/^-[pvV]+$/.test(flag) && /[vV]/.test(flag)) return [];
|
||||
if (!/^-p+$/.test(flag)) return suffix(i - 1);
|
||||
}
|
||||
assignments = false; continue;
|
||||
}
|
||||
if (name === 'exec') {
|
||||
i++;
|
||||
while (words[i]?.value.startsWith('-')) {
|
||||
const flag = words[i++].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') break;
|
||||
if (flag === '-a') i++;
|
||||
else if (!/^-([cl]*a.+|[cl]+)$/.test(flag)) return suffix(i - 1);
|
||||
}
|
||||
assignments = false; continue;
|
||||
}
|
||||
if (name === 'env' || name === 'sudo' || name === 'doas') {
|
||||
const env = name === 'env';
|
||||
const values = env
|
||||
? new Set(['-u', '--unset', '-C', '--chdir'])
|
||||
: new Set(['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '-T', '-R', '-D']);
|
||||
const flags = env ? new Set(['-i', '--ignore-environment', '-0', '--null']) : new Set(['-n', '-E', '-H', '-S', '-k', '-K', '-b']);
|
||||
i++;
|
||||
while (words[i]?.value.startsWith('-')) {
|
||||
const flag = words[i].value;
|
||||
budget.spend(flag.length + 1);
|
||||
if (flag === '--') { i++; break; }
|
||||
if (values.has(flag)) i += 2;
|
||||
else if (flags.has(flag) || [...values].some(value => value.startsWith('--') ? flag.startsWith(`${value}=`) : flag.startsWith(value) && flag.length > value.length)) i++;
|
||||
else return suffix(i - 1); // Includes opaque env -S / sudo shell modes.
|
||||
}
|
||||
assignments = true; environmentAssignments = true; continue;
|
||||
}
|
||||
return suffix(i);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function shellRole(words, budget, shell) {
|
||||
let i = 1;
|
||||
let stdin = false;
|
||||
let code = false;
|
||||
while (i < words.length) {
|
||||
const option = words[i].value;
|
||||
budget.spend(option.length + 1);
|
||||
if (option === '--' || option === '-') { i++; break; }
|
||||
if (!/^[+-]/.test(option)) break;
|
||||
if (option === '--rcfile' || option === '--init-file') { i += 2; continue; }
|
||||
if (option.startsWith('--')) {
|
||||
if (!['--noprofile', '--norc', '--posix', '--restricted', '--verbose', '--login'].includes(option)) return { kind: 'opaque', stdin: true };
|
||||
i++; continue;
|
||||
}
|
||||
// Bash accepts either sign and consumes a separate operand for each o/O
|
||||
// even inside a cluster. The command string follows ALL option processing,
|
||||
// not necessarily the argv word immediately after the first c flag.
|
||||
let next = i + 1;
|
||||
for (let j = 1; j < option.length; j++) {
|
||||
budget.spend();
|
||||
const flag = option[j];
|
||||
// Named-option arity is unproved for sh/dash/ksh: keep the invocation
|
||||
// opaque instead of consuming a code flag as a guessed option operand.
|
||||
if ((flag === 'o' || flag === 'O') && shell !== 'bash' && shell !== 'zsh') return { kind: 'opaque', stdin: true };
|
||||
if (flag === 'c') code = true;
|
||||
else if (flag === 's') stdin = true;
|
||||
else if (shell === 'zsh' && flag === 'o') {
|
||||
// zsh consumes the rest of this argv word as the option name, or one
|
||||
// separate word if no suffix exists, then ends this option cluster.
|
||||
if (j + 1 === option.length && next < words.length) next++;
|
||||
break;
|
||||
} else if (shell === 'zsh' && (flag === 'O' || flag === 'b')) {
|
||||
// These are not Bash's operand grammar; unmodeled zsh modes stay opaque.
|
||||
return { kind: 'opaque', stdin: true };
|
||||
} else if (flag === 'o' || flag === 'O') { if (next < words.length) next++; }
|
||||
else if (!'abefhiklmnprtuvxBCEHPTD'.includes(flag)) return { kind: 'opaque', stdin: true };
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
if (code) return { kind: 'shell', code: words[i]?.value, stdin: false };
|
||||
// A script filename and its positional arguments are not shell source text.
|
||||
return { kind: 'shell', stdin: stdin || i === words.length };
|
||||
}
|
||||
|
||||
function commandRole(words, budget) {
|
||||
if (!words.length) return { kind: 'data' };
|
||||
budget.spend(words[0].value.length + 1);
|
||||
const name = basename(words[0].value);
|
||||
if (name === 'git' || name === 'git.exe') return { kind: 'git' };
|
||||
if (SHELLS.has(name)) return shellRole(words, budget, name);
|
||||
if (name === 'eval') {
|
||||
for (const word of words) budget.spend(word.value.length + 3);
|
||||
return { kind: 'shell', code: words.slice(words[1]?.value === '--' ? 2 : 1).map(word => word.value).join(' '), stdin: false };
|
||||
}
|
||||
if (DATA_COMMANDS.has(name)) return { kind: 'data' };
|
||||
return { kind: 'opaque', stdin: true };
|
||||
}
|
||||
|
||||
// Literal producers only. Unmodeled transformations remain conservative rather
|
||||
// than executing a formatter, interpreter, shell or user-supplied command.
|
||||
function pipelineSources(command, budget) {
|
||||
const sources = [];
|
||||
for (let current = command; current; current = current.pipeFrom) {
|
||||
budget.spend(current.words.length + 1);
|
||||
const words = executableWords(current.words, budget);
|
||||
for (const word of words) budget.spend(word.value.length + 3);
|
||||
const name = basename(words[0]?.value || '');
|
||||
if (name === 'echo') sources.push(words.slice(1).filter(word => !/^-[neE]+$/.test(word.value)).map(word => word.value).join(' '));
|
||||
if (name === 'printf') {
|
||||
const format = words[1]?.value || '';
|
||||
if (format !== '-v') sources.push((format === '%s' || format === '%s\\n') ? words.slice(2).map(word => word.value).join('\n') : words.slice(1).map(word => word.value).join(' '));
|
||||
}
|
||||
for (const redirect of current.redirects) {
|
||||
if (redirect.operator === '<<<') sources.push(redirect.word.value);
|
||||
else if (redirect.operator === '<<' || redirect.operator === '<<-') sources.push(redirect.body);
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function checkCommand(input) {
|
||||
const budget = createBudget(input.length);
|
||||
const pending = [{ text: input, opaque: false }];
|
||||
function enqueue(text, opaque = false) {
|
||||
if (!text) return;
|
||||
budget.spend(text.length + 1);
|
||||
pending.push({ text, opaque });
|
||||
}
|
||||
function inspectOpaque(words, text) {
|
||||
for (let index = 0; index < words.length; index++) {
|
||||
const word = words[index];
|
||||
budget.spend(word.value.length + 1);
|
||||
if (['git', 'git.exe'].includes(basename(word.value))) {
|
||||
const reason = checkGitWords(words, budget, index);
|
||||
if (reason) return reason;
|
||||
}
|
||||
if (word.value !== text && /git/.test(word.value) && /[\s'"()]/.test(word.value)) enqueue(word.value, true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
while (pending.length) {
|
||||
const task = pending.pop();
|
||||
const scan = scanShell(task.text, budget);
|
||||
for (const text of scan.nested) enqueue(text);
|
||||
for (const command of scan.commands) {
|
||||
const words = executableWords(command.words, budget);
|
||||
const role = commandRole(words, budget);
|
||||
const reason = task.opaque || role.kind === 'opaque'
|
||||
? inspectOpaque(command.words, task.text)
|
||||
: role.kind === 'git' ? checkGitWords(words, budget) : null;
|
||||
if (reason) return { blocked: true, reason };
|
||||
if (role.code) enqueue(role.code);
|
||||
if (role.stdin) {
|
||||
for (const redirect of command.redirects) {
|
||||
if (redirect.operator === '<<<') enqueue(redirect.word.value, role.kind === 'opaque');
|
||||
else if (redirect.operator === '<<' || redirect.operator === '<<-') enqueue(redirect.body, role.kind === 'opaque');
|
||||
}
|
||||
if (command.pipeFrom) {
|
||||
for (const source of pipelineSources(command.pipeFrom, budget)) enqueue(source, role.kind === 'opaque');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof RangeError)) throw error;
|
||||
return { blocked: true, reason: 'BLOCKED: Shell analysis work budget exceeded; hook-bypass safety could not be established.' };
|
||||
}
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the command string from hook input (JSON or plain text).
|
||||
*
|
||||
* @param {string} rawInput
|
||||
* @returns {string}
|
||||
*/
|
||||
function extractCommand(rawInput) {
|
||||
const trimmed = rawInput.trim();
|
||||
if (!trimmed.startsWith('{')) return trimmed;
|
||||
if (!trimmed.startsWith('{')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== 'object' || parsed === null) return trimmed;
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Claude Code format: { tool_input: { command: "..." } }
|
||||
const cmd = parsed.tool_input?.command;
|
||||
if (typeof cmd === 'string') return cmd;
|
||||
if (typeof cmd === 'string') {
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Generic JSON formats
|
||||
for (const key of ['command', 'cmd', 'input', 'shell', 'script']) {
|
||||
if (typeof parsed[key] === 'string') return parsed[key];
|
||||
if (typeof parsed[key] === 'string') {
|
||||
return parsed[key];
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
@@ -538,6 +434,9 @@ function extractCommand(rawInput) {
|
||||
|
||||
/**
|
||||
* Exportable run() for in-process execution via run-with-flags.js.
|
||||
*
|
||||
* @param {string} rawInput
|
||||
* @returns {{exitCode: number, stderr?: string}}
|
||||
*/
|
||||
function run(rawInput) {
|
||||
const command = extractCommand(rawInput);
|
||||
@@ -546,7 +445,7 @@ function run(rawInput) {
|
||||
if (result.blocked) {
|
||||
return {
|
||||
exitCode: 2,
|
||||
stderr: result.reason,
|
||||
stderr: result.reason
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
'use strict';
|
||||
|
||||
// Finite literal shell scanner for block-no-verify. This is not an interpreter:
|
||||
// aliases, generated programs, expansion results and foreign languages remain
|
||||
// opaque. Every scan/queued region spends one shared input-proportional budget.
|
||||
function createBudget(length) {
|
||||
let remaining = 24 * (length + 1) + 4096;
|
||||
return { spend(amount = 1) {
|
||||
remaining -= amount;
|
||||
if (remaining < 0) throw new RangeError('Shell scan work budget exceeded');
|
||||
} };
|
||||
}
|
||||
|
||||
function continuationEnd(input, index) {
|
||||
if (input[index] !== '\\') return index;
|
||||
if (input[index + 1] === '\n') return index + 2;
|
||||
if (input[index + 1] === '\r' && input[index + 2] === '\n') return index + 3;
|
||||
return index;
|
||||
}
|
||||
|
||||
// Delimiters undergo quote removal, not command expansion. Keeping this small
|
||||
// reader shared with substitution matching prevents body punctuation becoming
|
||||
// shell syntax while locating the enclosing execution region.
|
||||
function heredocDelimiter(input, start, budget) {
|
||||
if (!input.startsWith('<<', start) || input[start + 2] === '<') return null;
|
||||
const operator = input[start + 2] === '-' ? '<<-' : '<<';
|
||||
let i = start + operator.length;
|
||||
while (i < input.length) {
|
||||
budget.spend();
|
||||
if (input[i] === ' ' || input[i] === '\t' || input[i] === '\r') { i++; continue; }
|
||||
const continued = continuationEnd(input, i);
|
||||
if (continued === i) break;
|
||||
i = continued;
|
||||
}
|
||||
let value = '';
|
||||
let quote = null;
|
||||
let began = false;
|
||||
let quoted = false;
|
||||
for (; i < input.length; i++) {
|
||||
budget.spend();
|
||||
const c = input[i];
|
||||
if (quote === "'") {
|
||||
if (c === "'") quote = null;
|
||||
else value += c;
|
||||
continue;
|
||||
}
|
||||
if (c === '\\') {
|
||||
const continued = continuationEnd(input, i);
|
||||
if (continued !== i) { i = continued - 1; continue; }
|
||||
began = true; quoted = true;
|
||||
const next = input[i + 1];
|
||||
if (next === undefined) { value += c; continue; }
|
||||
if (quote === '"' && !'"$`\\'.includes(next)) value += '\\';
|
||||
value += next; i++; continue;
|
||||
}
|
||||
if (quote === '"') {
|
||||
if (c === '"') quote = null;
|
||||
else value += c;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"') { quote = c; began = true; quoted = true; continue; }
|
||||
if (/[\s;|&()<>]/.test(c) || (!began && c === '#')) break;
|
||||
began = true; value += c;
|
||||
}
|
||||
return began ? { operator, word: { value, quoted }, end: i } : null;
|
||||
}
|
||||
|
||||
function heredocLine(input, start, joinContinuations, budget) {
|
||||
const parts = [];
|
||||
let i = start;
|
||||
let fragment = start;
|
||||
while (i < input.length && input[i] !== '\n') {
|
||||
budget.spend();
|
||||
if (joinContinuations && input[i] === '\\') {
|
||||
const continued = continuationEnd(input, i);
|
||||
if (continued !== i) {
|
||||
budget.spend(i - fragment + 1);
|
||||
parts.push(input.slice(fragment, i));
|
||||
i = continued; fragment = i; continue;
|
||||
}
|
||||
// An escaped backslash cannot itself quote the following newline. Keep
|
||||
// the pair unchanged; only the unpaired final slash joins physical lines.
|
||||
if (i + 1 < input.length) { i += 2; continue; }
|
||||
}
|
||||
i++;
|
||||
}
|
||||
budget.spend(3 * (i - start + 1));
|
||||
parts.push(input.slice(fragment, i));
|
||||
const newline = i < input.length;
|
||||
return { text: parts.join(''), newline, end: newline ? i + 1 : i };
|
||||
}
|
||||
|
||||
function heredocBody(input, start, redirect, budget) {
|
||||
const lines = [];
|
||||
let length = 0;
|
||||
let i = start;
|
||||
while (i < input.length) {
|
||||
// Bash removes unquoted backslash-newline before testing the ending
|
||||
// delimiter. Quoted delimiters retain physical lines. The original end
|
||||
// offset is kept separately so the next actual command is never swallowed.
|
||||
const line = heredocLine(input, i, !redirect.word.quoted, budget);
|
||||
budget.spend(2 * (line.text.length + 1));
|
||||
const text = redirect.operator === '<<-' ? line.text.replace(/^\t+/, '') : line.text;
|
||||
i = line.end;
|
||||
if (text.replace(/\r$/, '') === redirect.word.value) break;
|
||||
lines.push(text);
|
||||
length += text.length;
|
||||
if (line.newline) { lines.push('\n'); length++; }
|
||||
}
|
||||
budget.spend(length + 1);
|
||||
return { body: lines.join(''), end: i };
|
||||
}
|
||||
|
||||
function legacyRegion(input, start, budget) {
|
||||
const decoded = [];
|
||||
let i = start + 1;
|
||||
for (; i < input.length; i++) {
|
||||
budget.spend();
|
||||
const c = input[i];
|
||||
if (c === '`') break;
|
||||
if (c === '\\' && '$`\\\n'.includes(input[i + 1] || '\u0000')) {
|
||||
// One old-style substitution layer only. Escapes outside an executable
|
||||
// backtick region are still handled by the outer lexer as literal data.
|
||||
const next = input[++i];
|
||||
if (next !== '\n') decoded.push(next);
|
||||
} else decoded.push(c);
|
||||
}
|
||||
budget.spend(decoded.length + 1);
|
||||
return { text: decoded.join(''), end: i < input.length ? i + 1 : i };
|
||||
}
|
||||
|
||||
// Locate a nested execution region without evaluating any supplied text.
|
||||
// Balanced substitutions have independent quote state; malformed regions extend
|
||||
// to EOF and remain conservatively inspectable instead of silently disappearing.
|
||||
function executionRegion(input, start, budget) {
|
||||
if (input[start] === '`') return legacyRegion(input, start, budget);
|
||||
const from = start + 2;
|
||||
const frame = () => ({ quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] });
|
||||
const stack = [frame()];
|
||||
function endWord(state) {
|
||||
if (!state.word) return;
|
||||
const phase = state.cases[state.cases.length - 1];
|
||||
if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop();
|
||||
else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject');
|
||||
else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in';
|
||||
else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern';
|
||||
state.commandStart = false;
|
||||
state.word = ''; state.quotedWord = false;
|
||||
}
|
||||
for (let i = from; i < input.length; i++) {
|
||||
budget.spend();
|
||||
const state = stack[stack.length - 1];
|
||||
const c = input[i];
|
||||
if (state.quote === "'") {
|
||||
if (c === "'") state.quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; i++; continue; }
|
||||
if (c === '$' && input[i + 1] === '(') {
|
||||
state.word += '$()'; state.quotedWord = true; stack.push(frame()); i++; continue;
|
||||
}
|
||||
if (c === '`') {
|
||||
const region = legacyRegion(input, i, budget);
|
||||
state.word += '`'; state.quotedWord = true; i = region.end - 1; continue;
|
||||
}
|
||||
if (state.quote === '"') {
|
||||
if (c === '"') state.quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; continue; }
|
||||
if (c === '#' && /[\s;|&()]/.test(input[i - 1] || ' ')) {
|
||||
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
|
||||
i--; // Process the newline, including any pending heredoc bodies.
|
||||
continue;
|
||||
}
|
||||
if (c === '<' && input.startsWith('<<<', i)) { endWord(state); i += 2; continue; }
|
||||
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
|
||||
endWord(state);
|
||||
const delimiter = heredocDelimiter(input, i, budget);
|
||||
if (delimiter) { state.heredocs.push(delimiter); i = delimiter.end - 1; continue; }
|
||||
}
|
||||
if (c === '\n' && state.heredocs.length) {
|
||||
endWord(state);
|
||||
let next = i + 1;
|
||||
for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end;
|
||||
state.heredocs.length = 0;
|
||||
state.commandStart = true; i = next - 1; continue;
|
||||
}
|
||||
if (/[\s;|&()]/.test(c)) endWord(state);
|
||||
else state.word += c;
|
||||
const phase = state.cases[state.cases.length - 1];
|
||||
// A case pattern's closing ')' is not the end of $(...). Only literal
|
||||
// keyword positions affect this state; quoted or echo operands do not.
|
||||
if (c === ')' && phase === 'pattern') {
|
||||
state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; continue;
|
||||
}
|
||||
if (c === '(' && phase === 'pattern') continue;
|
||||
if (c === ';' && input[i + 1] === ';' && phase === 'body') {
|
||||
state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; i++; continue;
|
||||
}
|
||||
if (/[;|&\n]/.test(c)) state.commandStart = true;
|
||||
if (c === '(') state.depth++;
|
||||
if (c === ')') {
|
||||
state.depth--;
|
||||
if (state.depth === 0) {
|
||||
stack.pop();
|
||||
if (stack.length === 0) {
|
||||
budget.spend(i - from + 1);
|
||||
return { text: input.slice(from, i), end: i + 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
budget.spend(input.length - from + 1);
|
||||
return { text: input.slice(from), end: input.length };
|
||||
}
|
||||
|
||||
function hasExpansion(input, index, processSubstitution = false) {
|
||||
return input[index] === '`' || (input[index] === '$' && input[index + 1] === '(') ||
|
||||
(processSubstitution && '<>'.includes(input[index]) && input[index + 1] === '(');
|
||||
}
|
||||
|
||||
// Unquoted heredoc bodies expand even inside quote characters in the body.
|
||||
// Backslash still protects $, ` and backslash; quoted delimiters skip this pass.
|
||||
function scanExpansions(input, budget) {
|
||||
const nested = [];
|
||||
for (let i = 0; i < input.length;) {
|
||||
budget.spend();
|
||||
if (input[i] === '\\' && /[$`\\\r\n]/.test(input[i + 1] || '')) {
|
||||
const continued = continuationEnd(input, i);
|
||||
i = continued !== i ? continued : i + 2;
|
||||
continue;
|
||||
}
|
||||
if (hasExpansion(input, i)) {
|
||||
const region = executionRegion(input, i, budget);
|
||||
nested.push(region.text); i = region.end;
|
||||
} else i++;
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
|
||||
function scanShell(input, budget) {
|
||||
const commands = [];
|
||||
const nested = [];
|
||||
const pendingHeredocs = [];
|
||||
let current = { words: [], redirects: [], pipeFrom: null };
|
||||
let word = null;
|
||||
let quote = null;
|
||||
let pendingRedirect = null;
|
||||
let i = 0;
|
||||
function begin() {
|
||||
if (!word) word = { value: '', start: i, end: i, quoted: false, literal: true };
|
||||
}
|
||||
function flushWord() {
|
||||
if (!word) return;
|
||||
word.end = i;
|
||||
word.raw = input.slice(word.start, i);
|
||||
if (pendingRedirect) {
|
||||
const redirect = { operator: pendingRedirect, word, body: '' };
|
||||
current.redirects.push(redirect);
|
||||
if (pendingRedirect === '<<' || pendingRedirect === '<<-') pendingHeredocs.push(redirect);
|
||||
pendingRedirect = null;
|
||||
} else current.words.push(word);
|
||||
word = null;
|
||||
}
|
||||
function flushCommand(pipe = false) {
|
||||
flushWord();
|
||||
const previous = current;
|
||||
if (previous.words.length || previous.redirects.length) commands.push(previous);
|
||||
current = { words: [], redirects: [], pipeFrom: pipe ? previous : null };
|
||||
pendingRedirect = null;
|
||||
}
|
||||
function consumeHeredocs() {
|
||||
for (const redirect of pendingHeredocs) {
|
||||
const region = heredocBody(input, i, redirect, budget);
|
||||
redirect.body = region.body;
|
||||
i = region.end;
|
||||
if (!redirect.word.quoted) nested.push(...scanExpansions(redirect.body, budget));
|
||||
}
|
||||
pendingHeredocs.length = 0;
|
||||
}
|
||||
while (i < input.length) {
|
||||
budget.spend();
|
||||
const c = input[i];
|
||||
if (quote === "'") {
|
||||
if (c === "'") quote = null;
|
||||
else word.value += c;
|
||||
i++; continue;
|
||||
}
|
||||
const continued = continuationEnd(input, i);
|
||||
if (continued !== i) { i = continued; continue; }
|
||||
if (c === '\\') {
|
||||
begin(); word.quoted = true;
|
||||
const next = input[i + 1];
|
||||
if (next === undefined) { word.value += c; i++; continue; }
|
||||
if (quote === '"' && !'"$`\\'.includes(next)) word.value += '\\';
|
||||
word.value += next; i += 2; continue;
|
||||
}
|
||||
if (hasExpansion(input, i, quote === null)) {
|
||||
begin(); word.literal = false;
|
||||
const region = executionRegion(input, i, budget);
|
||||
nested.push(region.text); word.value += '\u0000'; i = region.end; continue;
|
||||
}
|
||||
if (quote === '"') {
|
||||
if (c === '"') quote = null;
|
||||
else word.value += c;
|
||||
i++; continue;
|
||||
}
|
||||
if (c === '$' && input[i + 1] === "'") {
|
||||
// Literal ANSI-C words without escape interpretation; escaped/generated
|
||||
// names are not claimed to be a complete expansion implementation.
|
||||
begin(); word.quoted = true; quote = "'"; i += 2; continue;
|
||||
}
|
||||
if (c === '"' || c === "'") { begin(); word.quoted = true; quote = c; i++; continue; }
|
||||
if (c === '#' && !word) {
|
||||
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
|
||||
continue;
|
||||
}
|
||||
const braceKeyword = (c === '{' || c === '}') && !word && current.words.length === 0 && /[\s;&|]/.test(input[i + 1] || ' ');
|
||||
if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || braceKeyword) {
|
||||
const pipe = c === '|' && input[i + 1] !== '|';
|
||||
flushCommand(pipe);
|
||||
i += (c === '&' || c === '|') && input[i + 1] === c ? 2 : 1;
|
||||
if (c === '\n') consumeHeredocs();
|
||||
continue;
|
||||
}
|
||||
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
|
||||
const delimiter = heredocDelimiter(input, i, budget);
|
||||
if (delimiter) {
|
||||
if (word && /^\d+$/.test(word.value)) word = null;
|
||||
flushWord();
|
||||
const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' };
|
||||
current.redirects.push(redirect); pendingHeredocs.push(redirect);
|
||||
i = delimiter.end; pendingRedirect = null; continue;
|
||||
}
|
||||
}
|
||||
if (c === '<' || c === '>') {
|
||||
// An immediately adjacent numeric word is a descriptor, not an argv word.
|
||||
if (word && /^\d+$/.test(word.value)) word = null;
|
||||
flushWord();
|
||||
let operator = c;
|
||||
if (input[i + 1] === c) operator += c;
|
||||
if (operator === '<<' && input[i + 2] === '<') operator = '<<<';
|
||||
else if (operator === '<<' && input[i + 2] === '-') operator = '<<-';
|
||||
else if (input[i + 1] === '&') operator += '&';
|
||||
pendingRedirect = operator; i += operator.length; continue;
|
||||
}
|
||||
if (/\s/.test(c)) { flushWord(); i++; continue; }
|
||||
begin(); word.value += c; i++;
|
||||
}
|
||||
flushCommand();
|
||||
return { commands, nested };
|
||||
}
|
||||
|
||||
module.exports = { createBudget, scanShell, scanExpansions };
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const vm = require('vm');
|
||||
const hook = require('../../scripts/hooks/block-no-verify');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
|
||||
@@ -20,25 +24,10 @@ function test(name, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
function runHook(input, env = {}) {
|
||||
function runHook(input) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [runner, 'pre:bash:block-no-verify', 'scripts/hooks/block-no-verify.js', 'minimal,standard,strict'], {
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
const result = hook.run(rawInput);
|
||||
return { code: result.exitCode, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
@@ -219,6 +208,103 @@ if (test('still allows -tn (n is the -t template path, not a flag)', () => {
|
||||
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
||||
// --- Quoted/heredoc candidates: preserve blocking, prevent flag leakage ---
|
||||
|
||||
const executingPayloads = [
|
||||
// Quoted heredoc delimiter disables shell expansion but Python still consumes code; unsupported interpreter language remains conservative on a literal bypass phrase.
|
||||
['hyphenated Python heredoc delimiter', 'python3 - <<\'PY-SCRIPT\'\nprint("git commit -n")\nPY-SCRIPT\nbash -n x.sh'],
|
||||
['block double-quoted git executable', '"git" commit -n -m x'],
|
||||
['block single-quoted git executable', "'git' commit -n -m x"],
|
||||
['block git executable assembled with empty single quotes', "g''it commit -n -m x"],
|
||||
['block git executable assembled with empty double quotes', 'g""it commit --no-verify -m x'],
|
||||
['block git executable assembled from quoted prefix', "'g'it commit -n -m x"],
|
||||
['block git executable assembled from quoted middle', "g'i't commit -n -m x"],
|
||||
['block git executable assembled with an escape', 'g\\it commit -n -m x'],
|
||||
['block double-quoted git plus exe suffix', '"git".exe commit -n -m x'],
|
||||
['block single-quoted git plus exe suffix', "'git'.exe commit -n -m x"],
|
||||
['block hooksPath after double-quoted git plus exe suffix', '"git".exe -c core.hooksPath=/tmp/no commit -m x'],
|
||||
['block hooksPath after single-quoted git plus exe suffix', "'git'.exe -c core.hooksPath=/tmp/no commit -m x"],
|
||||
['block double-quoted git with quote-assembled exe suffix', '"git".e""xe commit -n -m x'],
|
||||
['block single-quoted git with quote-assembled exe suffix', "'git'.e''xe commit -n -m x"],
|
||||
['block quoted git with escaped exe suffix', '"git".\\exe commit -n -m x'],
|
||||
['block hooksPath after quote-assembled exe suffix', '"git".e""xe -c core.hooksPath=/tmp/no commit -m x'],
|
||||
['quoted hash does not hide a later commit bypass', 'echo "#"; git commit -n -m x'],
|
||||
['hash text in quotes does not hide a later commit bypass', 'echo "not # a comment" && git commit --no-verify -m x'],
|
||||
['word-internal hash does not hide a later commit bypass', 'echo foo#bar; git commit -n -m x'],
|
||||
['word-internal hash does not hide a later push bypass', 'printf %s foo#bar && git push --no-verify'],
|
||||
['pipe echo data to bash', "echo 'git commit -n -m x' | bash"],
|
||||
['pipe printf data to sh', "printf '%s\\n' 'git commit --no-verify -m x' | sh"],
|
||||
['execute data through xargs and bash -c', "printf '%s\\n' 'git commit -n -m x' | xargs -I CMD bash -c CMD"],
|
||||
['execute command substitution text through bash', "echo '$(git commit -n -m x)' | bash"],
|
||||
['execute bash here-string', "bash <<< 'git commit -n -m x'"],
|
||||
['execute sh here-string', "sh -s <<< 'git commit --no-verify -m x'"],
|
||||
['block backtick command substitution', 'echo "`git commit -n -m x`"'],
|
||||
['block substitution after quoted parenthesis', 'echo "$(printf \')\'; git commit -n -m x)"'],
|
||||
['block substitution after case parenthesis', 'echo "$(case x in x) :;; esac; git commit -n -m x)"'],
|
||||
['block bash --noprofile -c', "bash --noprofile -c 'git commit -n -m x'"],
|
||||
['block bash -O extglob -c', "bash -O extglob -c 'git commit -n -m x'"],
|
||||
['block bash -o pipefail -c', "bash -o pipefail -c 'git commit -n -m x'"],
|
||||
['block bash -c after option terminator', "bash -c -- 'git commit -n -m x'"],
|
||||
['block sh -c after option terminator', "sh -c -- 'git commit --no-verify -m x'"],
|
||||
['block heredoc piped to bash', 'cat <<EOF | bash\ngit commit -n -m x\nEOF'],
|
||||
['block heredoc piped to sudo bash', 'cat <<EOF | sudo bash\ngit commit --no-verify -m x\nEOF'],
|
||||
['block heredoc on leading redirection', '<<EOF bash\ngit commit -n -m x\nEOF'],
|
||||
['block executable command after CRLF heredoc', 'python3 - <<\'PY\'\r\nprint("git commit -n")\r\nPY\r\ngit commit -n -m x'],
|
||||
['block bash -c double-quoted payload', 'bash -c "git commit -n -m x"'],
|
||||
['block eval payload', 'eval "git commit --no-verify -m x"'],
|
||||
['block bash heredoc payload', 'bash <<EOF\ngit commit -n -m x\nEOF'],
|
||||
['block second command in a chain', 'git commit -m ok; git commit --no-verify -m x'],
|
||||
['block third command in a chain', 'git add -A && git commit -m ok && git push --no-verify'],
|
||||
['block combined short flag after a clean command', 'git commit -m ok; git commit -am x -n'],
|
||||
['block bypass in a whitespace-separated command sequence', 'git commit -m ok git push --no-verify'],
|
||||
['block ANSI-C quoted git executable', "$'git' commit -n -m x"],
|
||||
['block ANSI-C quoted git with long flag', "$'git' commit --no-verify -m x"],
|
||||
['block line-continued commit bypass', 'git commit \\\n--no-verify -m x'],
|
||||
['block heredoc line-continued commit bypass', 'cat <<EOF | bash\ngit commit \\\n--no-verify -m x\nEOF'],
|
||||
];
|
||||
|
||||
for (const [name, command] of executingPayloads) {
|
||||
if (test(name, () => {
|
||||
const r = runHook({ tool_input: { command } });
|
||||
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
const nonLeakingPayloads = [
|
||||
// Inside double quotes a backslash before dot remains literal, so decoded executable is git\.exe, not git.exe.
|
||||
['allows the distinct executable with a literal escaped dot', '"git""\\.exe" commit -n -m x'],
|
||||
// A complete echo operand is data; accepted role repair intentionally corrects the authored broad-blocking expectation.
|
||||
['allows quoted echo data (corrected author expectation)', 'echo "git commit -n"'],
|
||||
['python heredoc string with later bash -n', 'python3 - <<\'PY\'\nold="git add -A\\nif ! git diff --cached --quiet; then\\n git commit -q -m \\"vault sync"\nPY\nbash -n vault-sync.sh'],
|
||||
['assignment string with later bash -n', 'old="git commit -q -m x"; bash -n x.sh'],
|
||||
['plain commit followed by later-line bash -n', 'git commit -m x\nbash -n s.sh'],
|
||||
['plain commit followed by grep -n', 'git commit -m x; grep -n foo f.txt'],
|
||||
['JSON string followed by sed -n', 'printf \'%s\' \'{"cmd":"git commit -q -m \\"x\\""}\' | node x.js; sed -n 1p f'],
|
||||
['non-shell heredoc after bash argument', "bash -c 'cat' <<EOF\ngit commit -q -m x\nEOF\nbash -n y.sh"],
|
||||
['separate commits do not inherit bash -n', 'git commit -m x ; git commit --no-edit ; bash -n x.sh ; git commit -tn'],
|
||||
['double-quoted literal does not inherit grep -n', 'echo "git commit -q -m x"; grep -n needle file'],
|
||||
['single-quoted push literal does not inherit later flag', "note='git push'; printf '%s\\n' --no-verify"],
|
||||
['Python heredoc line does not inherit sed flag', 'python3 <<EOF\nprint("git commit -q -m x")\nEOF\nsed --no-verify file'],
|
||||
['assignment literal does not inherit grep long flag', "payload='git commit -m x'; grep --no-verify file"],
|
||||
['printf literal does not inherit bash -n', 'printf \'%s\' "git commit -m x"; bash -n script.sh'],
|
||||
['assignment literal does not inherit quoted echo -n data', 'old="git commit -q"; echo " -n"'],
|
||||
['push literal does not inherit quoted printf long flag data', "payload='git push'; printf ' --no-verify'"],
|
||||
['printf literal does not inherit later quoted echo -n data', 'printf "%s" "git commit -q"; echo " -n"'],
|
||||
['quoted git executable does not inherit later grep -n', '"git" status; grep -n needle file'],
|
||||
['assembled git executable does not inherit later bash -n', "g''it status && bash -n script.sh"],
|
||||
['quoted git executable does not inherit quoted echo -n data', "'git' status; echo \" -n\""],
|
||||
['quoted git commit does not inherit later bash -n', '"git" commit -m x; bash -n y.sh'],
|
||||
['ANSI-C quoted status does not inherit later grep -n', "$'git' status; grep -n needle file"],
|
||||
['heredoc python string line ending in backslash does not inherit later bash -n', 'python3 - <<\'PY\'\nprint("git commit -q \\\n")\nPY\nbash -n x.sh'],
|
||||
];
|
||||
|
||||
for (const [name, command] of nonLeakingPayloads) {
|
||||
if (test(name, () => {
|
||||
const r = runHook({ tool_input: { command } });
|
||||
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
// --- Optional stuck values (-u, -S) and long-option prefixes ---
|
||||
|
||||
if (test('allows -uno (n is the -u untracked-files mode, not a flag)', () => {
|
||||
@@ -251,6 +337,401 @@ if (test('allows --no-verbose (not a prefix of --no-verify)', () => {
|
||||
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
||||
// Finite literal role regressions: supplied command strings are never executed.
|
||||
for (const command of [
|
||||
"git commit -m \"$(git push --no-verify)\"",
|
||||
"git commit -m \"$(git -c core.hooksPath=/dev/null push)\"",
|
||||
"git commit --message=\"$(git push --no-veri)\"",
|
||||
"git commit -m \"`git push --no-verify`\"",
|
||||
"echo '#'; git push --no-verify",
|
||||
"'bash' -c 'git push --no-verify'",
|
||||
"printf '%s' 'git commit --no-verify'; git push --no-verify",
|
||||
"printf '%s' 'git push --no-verify' | sh",
|
||||
"printf '%s' 'git push --no-verify' | sudo -u root bash",
|
||||
"git commit -m \"$(printf '%s' 'git push --no-verify' | sh)\"",
|
||||
"echo $(echo $(git push --no-verify))",
|
||||
"cat <(git push --no-verify)",
|
||||
"cat <<EOF\n'$(git push --no-verify)'\nEOF",
|
||||
"cat <<EOF\n`git push --no-verify`\nEOF",
|
||||
"bash <<'EOF'\ngit push --no-verify\nEOF",
|
||||
"cat <<'EOF' | bash\ngit push --no-verify\nEOF",
|
||||
"cat <<-EOF | sh\n\tgit push --no-verify\n\tEOF",
|
||||
"cat <<A <<B\nsafe\nA\n$(git push --no-verify)\nB",
|
||||
"A=$(git push --no-verify) echo safe",
|
||||
"A=x command -- git push --no-verify",
|
||||
"command -p git commit --no-verif",
|
||||
"exec -a git /usr/bin/git commit --no-verify",
|
||||
"env -u HOME git push --no-verify",
|
||||
"sudo -u git git push --no-verify",
|
||||
"eval 'git' 'push' '--no-verify'",
|
||||
"bash -lc 'git push --no-verify' 'data'",
|
||||
"sh -c -- 'git commit --no-verify' arg0",
|
||||
"(git commit -m safe; git push --no-verify)",
|
||||
"if true; then git push --no-verify; fi",
|
||||
"echo safe # git commit -m safe\ngit push --no-verify",
|
||||
"g\\\nit push --no-verify",
|
||||
"git com''mit --no-verify",
|
||||
"git commit --no-veri # actual option",
|
||||
"git push '--no-verify'",
|
||||
"custom-wrapper 'git push --no-verify'",
|
||||
"python -c 'os.system(\"git push --no-verify\")'",
|
||||
"python3 <<'PY'\nprint(\"git commit -n\")\nPY"
|
||||
]) {
|
||||
if (test(`literal denied: ${JSON.stringify(command)}`, () => {
|
||||
const result = runHook({ tool_input: { command } });
|
||||
assert.strictEqual(result.code, 2, result.stderr);
|
||||
assert.deepStrictEqual(runHook({ tool_input: { command } }), result, 'Second call must not inherit lexical state');
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
for (const command of [
|
||||
"printf '%s' 'git commit --no-verify'",
|
||||
"printf '%s' eval 'git commit --no-verify'",
|
||||
"echo 'git commit' --no-verify",
|
||||
"bash -c 'echo ok' 'git push --no-verify'",
|
||||
"'bash' -c 'printf %s safe' 'git push --no-verify'",
|
||||
"git commit -m --no-verify",
|
||||
"git commit -Skeyn -m x",
|
||||
"git commit -- --no-verify",
|
||||
"git push '--no-verify;literal'",
|
||||
"git push '--no-verify)literal'",
|
||||
"git commit -m '$(git push --no-verify)'",
|
||||
"printf '%s' '$(git push --no-verify)'",
|
||||
"echo \"\\$(git push --no-verify)\"",
|
||||
"echo \"\\`git push --no-verify\\`\"",
|
||||
"cat <<'EOF'\n$(git push --no-verify)\nEOF",
|
||||
"cat <<\\EOF\ngit push --no-verify\nEOF",
|
||||
"cat <<EOF\ngit push --no-verify\nEOF",
|
||||
"cat <<EOF\n\\$(git push --no-verify)\nEOF",
|
||||
"cat <<'EOF'\ntext\nEOF\ngit commit -m safe",
|
||||
"echo 'git commit --no-verify' | cat",
|
||||
"printf '%s' 'git push --no-verify' | grep git",
|
||||
"cat <<'EOF' | cat\ngit push --no-verify\nEOF",
|
||||
"bash -c cat <<'EOF'\ngit push --no-verify\nEOF",
|
||||
"echo '# git push --no-verify'",
|
||||
"echo safe # git push --no-verify",
|
||||
"git commit -m safe # --no-verify",
|
||||
"payload='git push --no-verify'",
|
||||
"command -v git push --no-verify",
|
||||
"command -pV git commit --no-verify",
|
||||
"exec -a git echo 'push --no-verify'",
|
||||
"env -u git echo 'git push --no-verify'",
|
||||
"sudo -u git echo 'git push --no-verify'",
|
||||
"git commit -m safe > --no-verify",
|
||||
"git commit -m safe 2> --no-verify",
|
||||
"printf '%s' 'git push --no-verify'; git push origin main",
|
||||
"git -C '/tmp/git push --no-verify' status",
|
||||
"echo 'git commit --no-verify' > log",
|
||||
"bash script.sh 'git push --no-verify'"
|
||||
]) {
|
||||
if (test(`literal allowed: ${JSON.stringify(command)}`, () => {
|
||||
const result = runHook({ tool_input: { command } });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.deepStrictEqual(runHook({ tool_input: { command } }), result, 'Second call must not inherit lexical state');
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
if (test('bounded wide quoted data remains data', () => {
|
||||
assert.strictEqual(runHook(`printf '%s' ${"'git push --no-verify' ".repeat(2000)}`).code, 0);
|
||||
})) passed++; else failed++;
|
||||
if (test('deep nested substitutions fail closed within the work budget', () => {
|
||||
assert.strictEqual(runHook('echo ' + '$('.repeat(120) + 'git push --no-verify' + ')'.repeat(120)).code, 2);
|
||||
})) passed++; else failed++;
|
||||
|
||||
for (const command of [
|
||||
'echo note{git push --no-verify}',
|
||||
'printf %s note{git push --no-verify}',
|
||||
'echo "$(printf %s case)"',
|
||||
"echo 'case x in x) git push --no-verify;; esac'",
|
||||
]) {
|
||||
if (test(`literal role control: ${JSON.stringify(command)}`, () => {
|
||||
assert.strictEqual(runHook(command).code, 0);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
if (test('moderate nested execution identifies the actual Git bypass', () => {
|
||||
const result = runHook('echo ' + '$('.repeat(8) + 'git push --no-verify' + ')'.repeat(8));
|
||||
assert.strictEqual(result.code, 2);
|
||||
assert.match(result.stderr, /git push/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('escaped backtick inside substitution does not hide a later command', () => {
|
||||
const result = runHook('echo "`printf %s \\`; git push --no-verify`"');
|
||||
assert.strictEqual(result.code, 2);
|
||||
assert.match(result.stderr, /git push/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
||||
// Review regressions: literal option roles and nested execution boundaries.
|
||||
for (const [expected, commands] of [
|
||||
[2, [
|
||||
"bash +x -c 'git push --no-verify'",
|
||||
"bash +o posix -c 'git push --no-verify'",
|
||||
"bash +o errexit -c 'git push --no-verify'",
|
||||
"bash +O extglob -c 'git commit -n'",
|
||||
"bash +xo posix -c 'git push --no-verify'",
|
||||
"bash +oO posix extglob -c 'git push --no-verify'",
|
||||
"bash -c +x 'git push --no-verify'",
|
||||
"bash -co posix 'git push --no-verify'",
|
||||
"bash +c 'git push --no-verify'",
|
||||
"bash +x -c -- 'git push --no-verify'",
|
||||
"bash +x -c - 'git push --no-verify'",
|
||||
"bash +unknown -c 'git push --no-verify'",
|
||||
"echo \"$(cat <<'EOF'\n)\nEOF\ngit push --no-verify\n)\"",
|
||||
"echo \"$(cat <<EOF\n)\nEOF\ngit push --no-verify\n)\"",
|
||||
"echo \"$(cat <<')'\ntext\n)\ngit push --no-verify\n)\"",
|
||||
"echo \"$(cat <<E'OF'\n)\nEOF\ngit push --no-verify\n)\"",
|
||||
"echo \"$(cat <<\\EOF\n)\nEOF\ngit push --no-verify\n)\"",
|
||||
"echo \"$(cat <<-EOF\n\t)\n\tEOF\ngit push --no-verify\n)\"",
|
||||
"echo \"$(cat <<A <<'B'\n)\nA\n)\nB\ngit push --no-verify\n)\"",
|
||||
"echo \"$(cat <<'EOF' # delimiter is pending\n)\nEOF\ngit push --no-verify\n)\"",
|
||||
"echo \"$(echo $(cat <<'EOF'\n)\nEOF\ngit push --no-verify\n))\"",
|
||||
"echo \"$(cat <<EOF\n)\n$(git push --no-verify)\nEOF\n)\"",
|
||||
"echo \"`echo \\`git push --no-verify\\``\"",
|
||||
"echo \"`echo \\$(git push --no-verify)`\"",
|
||||
"echo \"`git push --no-verify`\"",
|
||||
"echo \"$(echo $(git push --no-verify))\"",
|
||||
]],
|
||||
[0, [
|
||||
"bash +x -c 'echo safe' 'git push --no-verify'",
|
||||
"bash +o posix -c 'echo safe' 'git push --no-verify'",
|
||||
"bash +O extglob -c 'echo safe' 'git push --no-verify'",
|
||||
"bash +oO posix extglob -c 'echo safe' 'git push --no-verify'",
|
||||
"bash -co posix 'echo safe' 'git push --no-verify'",
|
||||
"bash -c +x 'echo safe' 'git push --no-verify'",
|
||||
"bash +x script.sh 'git push --no-verify'",
|
||||
"bash -- +x -c 'git push --no-verify'",
|
||||
"echo \"$(cat <<'EOF'\n)\ngit push --no-verify\nEOF\n)\"",
|
||||
"echo \"$(cat <<EOF\n)\ngit push --no-verify\nEOF\n)\"",
|
||||
"echo \"$(cat <<')'\ngit push --no-verify\n)\n)\"",
|
||||
"echo \"$(cat <<E'OF'\n)\n$(git push --no-verify)\nEOF\n)\"",
|
||||
"echo \"$(cat <<-EOF\n\t)\n\tgit push --no-verify\n\tEOF\n)\"",
|
||||
"echo \"$(cat <<A <<'B'\n)\nA\ngit push --no-verify\n)\nB\n)\"",
|
||||
"echo \"\\`git push --no-verify\\`\"",
|
||||
"echo '`echo \\`git push --no-verify\\``'",
|
||||
"echo \"`echo 'git push --no-verify'`\"",
|
||||
"printf '%s' 'git push --no-verify'",
|
||||
]],
|
||||
]) {
|
||||
for (const command of commands) {
|
||||
if (test(`review boundary ${expected}: ${JSON.stringify(command)}`, () => {
|
||||
const result = runHook(command);
|
||||
assert.strictEqual(result.code, expected, result.stderr);
|
||||
if (expected === 2) assert.match(result.stderr, /git (push|commit)/, 'The literal bypass, not budget exhaustion, must be identified');
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Nearby delimiter roles use the same lexer and must not become heredocs.
|
||||
for (const [expected, command] of [
|
||||
[2, "echo \"$(cat <<\\\n EOF\n)\nEOF\ngit push --no-verify\n)\""],
|
||||
[0, "echo \"$(cat <<\\\n EOF\n)\ngit push --no-verify\nEOF\n)\""],
|
||||
[2, "echo \"$(cat <<<EOF\ngit push --no-verify\n)\""],
|
||||
[0, "echo \"$(cat <<<EOF\nprintf '%s' 'git push --no-verify'\n)\""],
|
||||
[2, "echo \"$(cat <<\"EOF\"\n)\nEOF\ngit push --no-verify\n)\""],
|
||||
[0, "echo \"$(cat <<\"EOF\"\n)\n$(git push --no-verify)\nEOF\n)\""],
|
||||
[2, "echo \"$(cat <<''\n)\n\ngit push --no-verify\n)\""],
|
||||
[0, "echo \"$(cat <<''\n)\ngit push --no-verify\n\n)\""],
|
||||
]) {
|
||||
if (test(`delimiter role ${expected}: ${JSON.stringify(command)}`, () => {
|
||||
const result = runHook(command);
|
||||
assert.strictEqual(result.code, expected, result.stderr);
|
||||
if (expected === 2) assert.match(result.stderr, /git push/);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
// Private VM instrumentation loads the exact source without changing the host
|
||||
// globals, module cache, production API or executing any supplied command.
|
||||
function countedClassification(command, quota) {
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(`
|
||||
globalThis.copiedElements = 0;
|
||||
const originalSlice = Array.prototype.slice;
|
||||
Array.prototype.slice = function(start = 0, end = this.length) {
|
||||
const a = start < 0 ? Math.max(0, this.length + start) : Math.min(this.length, start);
|
||||
const b = end < 0 ? Math.max(0, this.length + end) : Math.min(this.length, end);
|
||||
globalThis.copiedElements += Math.max(0, b - a);
|
||||
return originalSlice.call(this, start, end);
|
||||
};
|
||||
`, context);
|
||||
const lexer = { exports: {} };
|
||||
const hookModule = { exports: {} };
|
||||
const hooks = path.join(__dirname, '../../scripts/hooks');
|
||||
const load = (file, module, require) => vm.compileFunction(fs.readFileSync(file, 'utf8').replace(/^#![^\n]*\n/, ''), ['module', 'require'], { parsingContext: context, filename: file })(module, require);
|
||||
load(path.join(hooks, 'lib/shell-scan.js'), lexer, () => { throw new Error('Unexpected scanner dependency'); });
|
||||
let spent = 0;
|
||||
let valueReads = 0;
|
||||
const instrumentedLexer = {
|
||||
...lexer.exports,
|
||||
createBudget(length) {
|
||||
const budget = lexer.exports.createBudget(length);
|
||||
return { spend(amount = 1) {
|
||||
spent += amount;
|
||||
// Throw in the module's own realm so its fail-closed catch is exercised.
|
||||
if (quota !== undefined && spent > quota) vm.runInContext('throw new RangeError("Test work quota exceeded")', context);
|
||||
budget.spend(amount);
|
||||
} };
|
||||
},
|
||||
scanShell(text, budget) {
|
||||
const scan = lexer.exports.scanShell(text, budget);
|
||||
for (const command of scan.commands) {
|
||||
for (const word of command.words) {
|
||||
const value = word.value;
|
||||
Object.defineProperty(word, 'value', { get() { valueReads++; return value; } });
|
||||
}
|
||||
}
|
||||
return scan;
|
||||
},
|
||||
};
|
||||
load(path.join(hooks, 'block-no-verify.js'), hookModule, name => {
|
||||
assert.strictEqual(name, './lib/shell-scan');
|
||||
return instrumentedLexer;
|
||||
});
|
||||
return { result: hookModule.exports.run(command), copiedElements: context.copiedElements, spent, valueReads };
|
||||
}
|
||||
for (const n of [64, 128]) {
|
||||
if (test(`opaque Git candidates avoid quadratic suffix copies at ${n}`, () => {
|
||||
const command = 'unknown ' + 'git '.repeat(n);
|
||||
const result = countedClassification(command);
|
||||
assert.strictEqual(result.result.exitCode, 0);
|
||||
assert.ok(result.copiedElements <= 2 * (n + 1), JSON.stringify(result));
|
||||
assert.ok(result.valueReads <= 12 * (n + 1), JSON.stringify(result));
|
||||
})) passed++; else failed++;
|
||||
if (test(`repeated global-option traversal spends the shared quota at ${n}`, () => {
|
||||
const result = countedClassification('unknown git ' + '-c git '.repeat(n), 3000);
|
||||
assert.strictEqual(result.result.exitCode, 2, JSON.stringify(result));
|
||||
assert.match(result.result.stderr, /work budget/);
|
||||
assert.ok(result.spent >= 3000 && result.spent < 3100, JSON.stringify(result));
|
||||
assert.ok(result.valueReads < 6000, JSON.stringify(result));
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
|
||||
// Unquoted heredoc ending delimiters use logical lines; quoted ones do not.
|
||||
for (const quoted of [false, true]) {
|
||||
for (const stripTabs of [false, true]) {
|
||||
for (const nested of [false, true]) {
|
||||
for (const backslashes of [1, 2, 3, 4]) {
|
||||
const delimiter = quoted ? "'EOF'" : 'EOF';
|
||||
const tab = stripTabs ? '\t' : '';
|
||||
let command = `cat <<${stripTabs ? '-' : ''}${delimiter}\n${tab}EO${'\\'.repeat(backslashes)}\nF\ngit push --no-verify\n${tab}EOF\n`;
|
||||
if (nested) command = `echo "$( ${command})"`;
|
||||
const expected = !quoted && backslashes === 1 ? 2 : 0;
|
||||
if (test(`heredoc logical ending quoted=${quoted} tabs=${stripTabs} nested=${nested} escapes=${backslashes}`, () => {
|
||||
const result = runHook(command);
|
||||
assert.strictEqual(result.code, expected, result.stderr);
|
||||
if (expected === 2) assert.match(result.stderr, /git push/);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [expected, command] of [
|
||||
[2, 'cat <<EOF\nE\\\nO\\\nF\ngit push --no-verify\n'],
|
||||
[0, "cat <<'EOF'\nE\\\nO\\\nF\ngit push --no-verify\nEOF\n"],
|
||||
[0, 'cat <<-EOF\n\tEO\\\n\tF\ngit push --no-verify\n\tEOF\n'],
|
||||
[2, 'cat <<EOF\nhello\nEOF\ngit push --no-verify\n'],
|
||||
[2, 'cat <<EOF\n$\\\n(git push --no-verify)\nEOF\n'],
|
||||
[0, "cat <<'EOF'\n$\\\n(git push --no-verify)\nEOF\n"],
|
||||
[0, 'cat <<EOF\n\\$\\\n(git push --no-verify)\nEOF\n'],
|
||||
[2, "echo \"$(cat <<EOF\nEO\\\nF\ngit push --no-verify\n)\""],
|
||||
[0, "echo \"$(cat <<'EOF'\nEO\\\nF\n)\ngit push --no-verify\nEOF\n)\""],
|
||||
]) {
|
||||
if (test(`joined heredoc role ${expected}: ${JSON.stringify(command)}`, () => {
|
||||
const result = runHook(command);
|
||||
assert.strictEqual(result.code, expected, result.stderr);
|
||||
if (expected === 2) assert.match(result.stderr, /git push/);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
for (const option of ['-oerrexit', '+oerrexit', '-xoerrexit', '+xoerrexit', '-o errexit', '+o errexit', '-coerrexit']) {
|
||||
for (const [expected, code, tail] of [
|
||||
[2, 'git push --no-verify', ''],
|
||||
[0, 'echo safe', " 'git push --no-verify'"],
|
||||
]) {
|
||||
const command = `zsh ${option} -c '${code}'${tail}`;
|
||||
if (test(`zsh named option role ${expected}: ${command}`, () => {
|
||||
const result = runHook(command);
|
||||
assert.strictEqual(result.code, expected, result.stderr);
|
||||
if (expected === 2) assert.match(result.stderr, /git push/);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
}
|
||||
for (const [expected, command] of [
|
||||
[2, "zsh -coerrexit 'git push --no-verify'"],
|
||||
[0, "zsh -coerrexit 'echo safe' 'git push --no-verify'"],
|
||||
[0, "zsh -oerrexit script.sh 'git push --no-verify'"],
|
||||
[0, "zsh +oerrexit -- script.sh 'git push --no-verify'"],
|
||||
[2, "bash +o errexit -c 'git push --no-verify'"],
|
||||
[0, "bash +o errexit -c 'echo safe' 'git push --no-verify'"],
|
||||
]) {
|
||||
if (test(`shell-specific option control ${expected}: ${command}`, () => {
|
||||
assert.strictEqual(runHook(command).code, expected);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
|
||||
// Named option arity is only modeled for Bash and the scoped zsh o grammar.
|
||||
// For other literal shell names these are opaque, not guessed script operands.
|
||||
for (const shell of ['sh', 'dash', 'ksh']) {
|
||||
for (const option of ['-oerrexit', '+oerrexit', '-o errexit', '+o errexit', '-Oextglob', '+Oextglob', '-O extglob', '+O extglob']) {
|
||||
for (const [expected, payload] of [[2, 'git push --no-verify'], [0, 'echo safe']]) {
|
||||
const command = `${shell} ${option} -c '${payload}'`;
|
||||
if (test(`opaque shell named option ${expected}: ${command}`, () => {
|
||||
const result = runHook(command);
|
||||
assert.strictEqual(result.code, expected, result.stderr);
|
||||
if (expected === 2) assert.match(result.stderr, /git push/);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
}
|
||||
for (const [expected, tail] of [
|
||||
[2, "-oerrexit -c 'echo safe' 'git push --no-verify'"],
|
||||
[2, "-O extglob script.sh 'git push --no-verify'"],
|
||||
[0, "-c 'echo safe' 'git push --no-verify'"],
|
||||
[2, "-c 'git push --no-verify'"],
|
||||
[0, "script.sh 'git push --no-verify'"],
|
||||
[2, "-s <<'EOF'\ngit push --no-verify\nEOF"],
|
||||
[0, "-s <<'EOF'\necho safe\nEOF"],
|
||||
]) {
|
||||
if (test(`opaque versus supported ${shell}: ${JSON.stringify(tail)}`, () => {
|
||||
// The first two are intentionally conservative refusals, including
|
||||
// potentially inert positional data; no execution semantics are claimed.
|
||||
const result = runHook(`${shell} ${tail}`);
|
||||
assert.strictEqual(result.code, expected, result.stderr);
|
||||
if (expected === 2) assert.match(result.stderr, /git push/);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
}
|
||||
|
||||
const pureOnly = process.argv.includes('--pure-only');
|
||||
if (pureOnly) console.log('Pure classifier mode: 3 bounded Node routing checks omitted.');
|
||||
else {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-no-verify-'));
|
||||
try {
|
||||
for (const [name, input, disabled, code, direct] of [
|
||||
['raw stdin passes through direct hook', 'git status', false, 0, true],
|
||||
['JSON bypass blocks through runner', JSON.stringify({ tool_input: { command: 'git push --no-verify' } }), false, 2],
|
||||
['disabled hook is silent', 'git push --no-verify', true, 0],
|
||||
]) {
|
||||
if (test(name, () => {
|
||||
const args = direct ? [path.join(__dirname, '../../scripts/hooks/block-no-verify.js')] : [runner, 'pre:bash:block-no-verify', 'scripts/hooks/block-no-verify.js', 'minimal,standard,strict'];
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
input, encoding: 'utf8', timeout: 3000,
|
||||
env: { PATH: path.dirname(process.execPath), HOME: home, USERPROFILE: home, TMPDIR: home, TMP: home, TEMP: home,
|
||||
ECC_HOOK_PROFILE: 'standard', ECC_HOOK_CONFIG: path.join(home, 'absent.json'),
|
||||
ECC_DISABLED_HOOKS: disabled ? 'pre:bash:block-no-verify' : '' },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
assert.ifError(result.error);
|
||||
assert.strictEqual(result.status, code, result.stderr);
|
||||
if (code === 0) assert.strictEqual(result.stdout, direct ? input : '');
|
||||
if (disabled) assert.strictEqual(result.stderr, '');
|
||||
if (code === 2) assert.match(result.stderr, /BLOCKED/);
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('─'.repeat(50));
|
||||
console.log(`Passed: ${passed} Failed: ${failed}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user