mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
fix(block-no-verify): bound the flag scan to the enclosing quote/heredoc line
The scanner locked onto a `git commit` literal that sat inside a quoted string of an OUTER command (a python heredoc's string, a VAR="..." assignment, a printf JSON payload), then hasNoVerifyFlag() re-tokenized from that inner offset with a fresh quote state. When quote parity flipped, the "segment" ran past the enclosing string and picked up an unrelated later `bash -n` / `sed -n` / `grep -n` as `commit -n`. Two legitimate commands were blocked this way in one working session. The first attempt at this PR (d5c428c) fixed the false positive by SKIPPING `git` tokens judged to be in "data" context. That was the wrong lever: data becomes executable the moment it is piped to a shell (`echo '...' | bash`, `bash <<< '...'`, `cat <<EOF | bash`), and no regex list of shell invocations is complete (`bash --noprofile -c` slipped). Three independent reviewers (codex, Greptile, CodeRabbit) demonstrated executing bypasses; that commit is squashed away here and none of its behaviour survives. This version keeps every `git` candidate visible and keeps main's blocking surface intact. A single O(n) preprocessing pass (buildScanBoundaries) records, for each enclosed character, the closing quote or the current heredoc-body line end. Subcommand discovery and flag tokenization are capped at that boundary, so tokens after the enclosing data can never leak into a candidate's argument list. Also: comment detection is a single mask pass instead of a repeated prefix scan; heredoc parsing accepts quoted hyphenated delimiters and CRLF terminators and preserves the `<<` vs `<<-` tab distinction; quote-assembled words (`g''it`) are recognized. Verified (all payloads run through the hook as JSON on stdin): - red-list, 24 executing forms incl. every reviewer-reported bypass -> exit 2 on main AND here (CRLF heredoc trailer: 0 on main, 2 here); - green-list, 16 leak-class false positives -> exit 2 on main, 0 here; - tests/hooks/block-no-verify.test.js 84/84 (main's 25 retained verbatim, d5c428c's 8 removed, 59 table-driven red/green cases added); tests/hooks/cursor-block-no-verify.test.js 14/14; - 204,807-byte command with 8,905 non-command `git` literals: 8.5 ms. Co-Authored-By: Codex (GPT) <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EyuZY1LZNuShHiewExekLT
This commit is contained in:
co-authored by
Codex
Claude Fable 5.1
parent
4130457d67
commit
b3a2d3586b
@@ -162,11 +162,14 @@ function tokenizeShellWords(input, start = 0, end = input.length) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function findCommandSegmentEnd(input, start) {
|
||||
/**
|
||||
* Find the end of a shell command segment without scanning beyond `limit`.
|
||||
*/
|
||||
function findCommandSegmentEnd(input, start, limit = input.length) {
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = start; i < input.length; i++) {
|
||||
for (let i = start; i < limit; i++) {
|
||||
const char = input.charAt(i);
|
||||
|
||||
if (escaped) {
|
||||
@@ -200,7 +203,7 @@ function findCommandSegmentEnd(input, start) {
|
||||
}
|
||||
}
|
||||
|
||||
return input.length;
|
||||
return limit;
|
||||
}
|
||||
|
||||
function commitOptionConsumesNextValue(value) {
|
||||
@@ -252,24 +255,256 @@ function isCommitNoVerifyShortFlag(value) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a position in the input is inside a shell comment.
|
||||
* Precompute the positions that follow a comment marker on their current line.
|
||||
* This preserves the hook's existing comment heuristic without rescanning a
|
||||
* potentially long line for every `git` candidate.
|
||||
*/
|
||||
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;
|
||||
function buildCommentMask(input) {
|
||||
const comments = new Uint8Array(input.length);
|
||||
let afterCommentMarker = false;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input.charAt(i);
|
||||
if (char === '\n') {
|
||||
afterCommentMarker = false;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
comments[i] = afterCommentMarker ? 1 : 0;
|
||||
|
||||
if (afterCommentMarker) continue;
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (quote === '"' && char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '#' && (i === 0 || /[\s;&|()]/.test(input.charAt(i - 1)))) {
|
||||
const previous = i > 0 ? input.charAt(i - 1) : '';
|
||||
if (previous !== '$' && previous !== '\\') afterCommentMarker = true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next 'git' token in the input starting from a position.
|
||||
* Compute the maximum scan endpoint for characters inside outer quotes and
|
||||
* heredoc body lines. The hook deliberately keeps every `git` candidate: quoted
|
||||
* data may later be executed by a shell. Bounds only prevent a candidate's flag
|
||||
* scan from leaking into unrelated text after its enclosing quote or body line.
|
||||
*/
|
||||
function findGit(input, start) {
|
||||
function buildScanBoundaries(input) {
|
||||
const boundaries = new Int32Array(input.length);
|
||||
boundaries.fill(-1);
|
||||
|
||||
const pendingHeredocs = [];
|
||||
let quote = null;
|
||||
let quoteStart = -1;
|
||||
let escaped = false;
|
||||
let comment = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
if ((i === 0 || input.charAt(i - 1) === '\n') && pendingHeredocs.length > 0) {
|
||||
const lineEnd = input.indexOf('\n', i);
|
||||
const physicalEnd = lineEnd === -1 ? input.length : lineEnd;
|
||||
const contentEnd = input.charAt(physicalEnd - 1) === '\r' ? physicalEnd - 1 : physicalEnd;
|
||||
const heredoc = pendingHeredocs[0];
|
||||
const line = input.slice(i, contentEnd);
|
||||
const comparableLine = heredoc.stripTabs ? line.replace(/^\t+/, '') : line;
|
||||
|
||||
if (comparableLine === heredoc.delimiter) {
|
||||
pendingHeredocs.shift();
|
||||
} else {
|
||||
boundaries.fill(contentEnd, i, contentEnd);
|
||||
}
|
||||
|
||||
i = physicalEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
const char = input.charAt(i);
|
||||
|
||||
if (comment) {
|
||||
if (char === '\n') comment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (quote === '"' && char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
boundaries.fill(i, quoteStart + 1, i);
|
||||
quote = null;
|
||||
quoteStart = -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
quoteStart = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '#' && (i === 0 || /[\s;&|()]/.test(input.charAt(i - 1)))) {
|
||||
comment = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '<' && input.charAt(i + 1) === '<' && input.charAt(i + 2) !== '<') {
|
||||
const heredocMatch = /^<<(-?)[ \t]*(?:'([^']+)'|"([^"]+)"|([^ \t\r\n;|&()<>]+))/.exec(input.slice(i));
|
||||
if (heredocMatch) {
|
||||
pendingHeredocs.push({
|
||||
delimiter: heredocMatch[2] || heredocMatch[3] || heredocMatch[4],
|
||||
stripTabs: heredocMatch[1] === '-',
|
||||
});
|
||||
i += heredocMatch[0].length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (quote) boundaries.fill(input.length, quoteStart + 1);
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the enclosing quote or heredoc-line endpoint for a candidate.
|
||||
*/
|
||||
function getScanBoundary(boundaries, idx, fallback) {
|
||||
const boundary = boundaries[idx];
|
||||
return boundary >= 0 ? boundary : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the first non-global-option word after a `git` executable token.
|
||||
* Git chooses that word as its subcommand, so later words cannot change it.
|
||||
*/
|
||||
function findGitSubcommand(input, start, end) {
|
||||
let value = '';
|
||||
let tokenStart = -1;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
let expectOptionValue = false;
|
||||
|
||||
/**
|
||||
* Classify a completed word, returning a protected Git subcommand if found.
|
||||
*/
|
||||
function classifyWord() {
|
||||
if (tokenStart === -1) return null;
|
||||
|
||||
const completed = { value, start: tokenStart };
|
||||
value = '';
|
||||
tokenStart = -1;
|
||||
|
||||
if (expectOptionValue) {
|
||||
expectOptionValue = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (completed.value.startsWith('-')) {
|
||||
if (completed.value === '-c' || completed.value === '-C' ||
|
||||
completed.value === '--work-tree' || completed.value === '--git-dir' ||
|
||||
completed.value === '--namespace' || completed.value === '--super-prefix') {
|
||||
expectOptionValue = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
terminal: true,
|
||||
command: GIT_COMMANDS_WITH_NO_VERIFY.includes(completed.value) ? completed.value : null,
|
||||
start: completed.start,
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const char = input.charAt(i);
|
||||
|
||||
if (escaped) {
|
||||
if (tokenStart === -1) tokenStart = i - 1;
|
||||
value += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
} else if (quote === '"' && char === '\\') {
|
||||
escaped = true;
|
||||
} else {
|
||||
if (tokenStart === -1) tokenStart = i;
|
||||
value += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
if (tokenStart === -1) tokenStart = i;
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
if (tokenStart === -1) tokenStart = i;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char) || char === ';' || char === '|' || char === '&') {
|
||||
const completed = classifyWord();
|
||||
if (completed?.terminal) return completed;
|
||||
if (char === ';' || char === '|' || char === '&' || char === '\n') return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tokenStart === -1) tokenStart = i;
|
||||
value += char;
|
||||
}
|
||||
|
||||
return classifyWord();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Find the next contiguous raw `git` token starting from a position.
|
||||
*/
|
||||
function findRawGit(input, start) {
|
||||
let pos = start;
|
||||
while (pos < input.length) {
|
||||
const idx = input.indexOf('git', pos);
|
||||
@@ -284,88 +519,196 @@ function findGit(input, start) {
|
||||
}
|
||||
|
||||
const before = idx > 0 ? input[idx - 1] : ' ';
|
||||
if (VALID_BEFORE_GIT.includes(before)) return { idx, len };
|
||||
if (VALID_BEFORE_GIT.includes(before)) return { idx, len, end: idx + len };
|
||||
pos = idx + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a shell word assembled through quoting or escapes that evaluates to
|
||||
* `git` or `git.exe`. Only words before `end` need inspection because a raw
|
||||
* candidate at that position is already known to be earlier.
|
||||
*/
|
||||
function findAssembledGit(input, start, end) {
|
||||
let value = '';
|
||||
let tokenStart = -1;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
/**
|
||||
* Complete the current word and return it when it evaluates to Git.
|
||||
*/
|
||||
function completeWord(wordEnd) {
|
||||
if (tokenStart === -1) return null;
|
||||
const normalized = value.toLowerCase();
|
||||
const candidate = normalized === 'git' || normalized === 'git.exe'
|
||||
? { idx: tokenStart, len: wordEnd - tokenStart, end: wordEnd }
|
||||
: null;
|
||||
value = '';
|
||||
tokenStart = -1;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const char = input.charAt(i);
|
||||
|
||||
if (escaped) {
|
||||
value += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
} else if (quote === '"' && char === '\\') {
|
||||
escaped = true;
|
||||
} else {
|
||||
value += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
if (tokenStart === -1) tokenStart = i;
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
if (tokenStart === -1) tokenStart = i;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char) || char === ';' || char === '|' || char === '&') {
|
||||
const candidate = completeWord(i);
|
||||
if (candidate) return candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tokenStart === -1) tokenStart = i;
|
||||
value += char;
|
||||
}
|
||||
|
||||
return completeWord(end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next raw or shell-assembled Git executable token.
|
||||
*/
|
||||
function findGit(input, start) {
|
||||
const rawCandidate = findRawGit(input, start);
|
||||
const assembledCandidate = findAssembledGit(
|
||||
input,
|
||||
start,
|
||||
rawCandidate ? rawCandidate.idx : input.length
|
||||
);
|
||||
return assembledCandidate || rawCandidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the shell word containing `idx`, including adjacent quoted and
|
||||
* escaped fragments, and return its raw endpoint.
|
||||
*/
|
||||
function assembleShellWordContaining(input, idx) {
|
||||
let wordStart = idx;
|
||||
while (wordStart > 0 && !/[\s;&|]/.test(input.charAt(wordStart - 1))) {
|
||||
wordStart--;
|
||||
}
|
||||
|
||||
let value = '';
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
let wordEnd = input.length;
|
||||
|
||||
for (let i = wordStart; i < input.length; i++) {
|
||||
const char = input.charAt(i);
|
||||
|
||||
if (escaped) {
|
||||
value += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
} else if (quote === '"' && char === '\\') {
|
||||
escaped = true;
|
||||
} else {
|
||||
value += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char) || char === ';' || char === '|' || char === '&') {
|
||||
wordEnd = i;
|
||||
break;
|
||||
}
|
||||
|
||||
value += char;
|
||||
}
|
||||
|
||||
return { value: value.toLowerCase(), end: wordEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
function detectGitCommand(input, boundaries, comments, 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;
|
||||
if (comments[git.idx]) {
|
||||
start = git.end;
|
||||
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;
|
||||
const rawGitEnd = git.end;
|
||||
const enclosingEnd = getScanBoundary(boundaries, git.idx, input.length);
|
||||
const quotedExecutable = enclosingEnd === rawGitEnd;
|
||||
const assembledWord = quotedExecutable
|
||||
? assembleShellWordContaining(input, git.idx)
|
||||
: null;
|
||||
const assembledExecutable = assembledWord &&
|
||||
(assembledWord.value === 'git' || assembledWord.value === 'git.exe');
|
||||
|
||||
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 (quotedExecutable && !assembledExecutable) {
|
||||
start = git.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bestCmd) {
|
||||
const gitEnd = assembledExecutable ? assembledWord.end : rawGitEnd;
|
||||
const scanEnd = quotedExecutable ? input.length : enclosingEnd;
|
||||
|
||||
const subcommand = findGitSubcommand(input, gitEnd, scanEnd);
|
||||
if (subcommand?.command) {
|
||||
return {
|
||||
command: bestCmd,
|
||||
offset: bestIdx + bestCmd.length,
|
||||
command: subcommand.command,
|
||||
offset: subcommand.start + subcommand.command.length,
|
||||
gitStart: git.idx,
|
||||
gitEnd: git.idx + git.len,
|
||||
commandStart: bestIdx,
|
||||
gitEnd,
|
||||
commandStart: subcommand.start,
|
||||
scanEnd,
|
||||
};
|
||||
}
|
||||
|
||||
start = git.idx + git.len;
|
||||
start = git.end;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -376,8 +719,8 @@ function detectGitCommand(input, start = 0) {
|
||||
* 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);
|
||||
function hasNoVerifyFlag(input, command, offset, scanEnd) {
|
||||
const segmentEnd = findCommandSegmentEnd(input, offset, scanEnd);
|
||||
const tokens = tokenizeShellWords(input, offset, segmentEnd);
|
||||
let skipNext = false;
|
||||
|
||||
@@ -449,10 +792,12 @@ function hasHooksPathOverride(input, detected) {
|
||||
* Check a command string for git hook bypass attempts.
|
||||
*/
|
||||
function checkCommand(input) {
|
||||
const boundaries = buildScanBoundaries(input);
|
||||
const comments = buildCommentMask(input);
|
||||
let start = 0;
|
||||
|
||||
while (start < input.length) {
|
||||
const detected = detectGitCommand(input, start);
|
||||
const detected = detectGitCommand(input, boundaries, comments, start);
|
||||
if (!detected) return { blocked: false };
|
||||
|
||||
const { command: gitCommand, offset } = detected;
|
||||
@@ -464,14 +809,14 @@ function checkCommand(input) {
|
||||
};
|
||||
}
|
||||
|
||||
if (hasNoVerifyFlag(input, gitCommand, offset)) {
|
||||
if (hasNoVerifyFlag(input, gitCommand, offset, detected.scanEnd)) {
|
||||
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;
|
||||
start = findCommandSegmentEnd(input, offset, detected.scanEnd) + 1;
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
|
||||
@@ -197,6 +197,88 @@ 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 = [
|
||||
['retain broad blocking of a quoted git literal', 'echo "git commit -n"'],
|
||||
['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 adjacent quoted git and escaped exe suffix', '"git""\\.exe" 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 bypass in a whitespace-separated command sequence', 'git commit -n -m x git commit --no-verify -m x git commit -am x -n git push --no-verify'],
|
||||
];
|
||||
|
||||
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 = [
|
||||
['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'],
|
||||
['hyphenated Python heredoc delimiter', 'python3 - <<\'PY-SCRIPT\'\nprint("git commit -n")\nPY-SCRIPT\nbash -n x.sh'],
|
||||
['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"'],
|
||||
];
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
console.log('─'.repeat(50));
|
||||
console.log(`Passed: ${passed} Failed: ${failed}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user