mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
fix(block-no-verify): split scanner and close quoting bypasses
Merge buildCommentMask into buildScanBoundaries so one pass owns comment mask and scan bounds. Extract shell-scan primitives to scripts/hooks/lib/shell-scan.js; keep policy in the hook. Use a sticky heredoc regex instead of copying the remaining input on every <<. Replace the short-circuiting chain test with clean-leading sequences that exercise checkCommand advance. Add quoted-executable non-leakage coverage for the scanEnd=length path. Treat $'…' ANSI-C quoting as a quoted executable word. Join unquoted backslash-newline for the flag scan and heredoc-line bound. Co-Authored-By: grok (fleet lane) <noreply@fleet.local> Co-authored-by: groy75 <groy75@users.noreply.github.com>
This commit is contained in:
co-authored by
grok
groy75
parent
b3a2d3586b
commit
5a878131de
@@ -15,26 +15,11 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const { tokenizeShellWords, findCommandSegmentEnd, buildScanBoundaries, getScanBoundary, findGitSubcommand, findGit, assembleShellWordContaining } = 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
|
||||
@@ -79,133 +53,12 @@ const COMMIT_OPTIONS_WITH_INLINE_VALUE = [
|
||||
// not another flag.
|
||||
const COMMIT_SHORT_OPTIONS_WITH_VALUE = new Set(['m', 'F', 'C', 'c', 't']);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the end of a shell command segment without scanning beyond `limit`.
|
||||
* Return true when a commit option consumes the following token as its value.
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function findCommandSegmentEnd(input, start, limit = input.length) {
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = start; i < limit; 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 limit;
|
||||
}
|
||||
|
||||
function commitOptionConsumesNextValue(value) {
|
||||
if (isCommitNoVerifyShortFlag(value)) {
|
||||
return false;
|
||||
@@ -219,6 +72,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;
|
||||
@@ -232,6 +91,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;
|
||||
@@ -242,7 +107,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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -250,429 +115,33 @@ 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) {
|
||||
return value === '-n' || /^-n[a-zA-Z]/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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);
|
||||
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, 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.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {Int32Array} boundaries
|
||||
* @param {Uint8Array} comments
|
||||
* @param {number} [start=0]
|
||||
* @returns {{command: string, offset: number, gitStart: number, gitEnd: number, commandStart: number, scanEnd: number}|null}
|
||||
*/
|
||||
function detectGitCommand(input, boundaries, comments, start = 0) {
|
||||
while (start < input.length) {
|
||||
const git = findGit(input, start);
|
||||
if (!git) return null;
|
||||
if (!git) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (comments[git.idx]) {
|
||||
start = git.end;
|
||||
@@ -682,11 +151,8 @@ function detectGitCommand(input, boundaries, comments, start = 0) {
|
||||
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');
|
||||
const assembledWord = quotedExecutable ? assembleShellWordContaining(input, git.idx) : null;
|
||||
const assembledExecutable = assembledWord && (assembledWord.value === 'git' || assembledWord.value === 'git.exe');
|
||||
|
||||
if (quotedExecutable && !assembledExecutable) {
|
||||
start = git.end;
|
||||
@@ -704,7 +170,7 @@ function detectGitCommand(input, boundaries, comments, start = 0) {
|
||||
gitStart: git.idx,
|
||||
gitEnd,
|
||||
commandStart: subcommand.start,
|
||||
scanEnd,
|
||||
scanEnd
|
||||
};
|
||||
}
|
||||
|
||||
@@ -718,6 +184,12 @@ function detectGitCommand(input, boundaries, comments, start = 0) {
|
||||
* 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.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {string} command
|
||||
* @param {number} offset
|
||||
* @param {number} scanEnd
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasNoVerifyFlag(input, command, offset, scanEnd) {
|
||||
const segmentEnd = findCommandSegmentEnd(input, offset, scanEnd);
|
||||
@@ -747,7 +219,9 @@ function hasNoVerifyFlag(input, command, offset, scanEnd) {
|
||||
}
|
||||
}
|
||||
|
||||
if (value === '--no-verify') return true;
|
||||
if (value === '--no-verify') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For commit, -n is shorthand for --no-verify.
|
||||
if (command === 'commit' && isCommitNoVerifyShortFlag(value)) {
|
||||
@@ -760,6 +234,10 @@ function hasNoVerifyFlag(input, command, offset, scanEnd) {
|
||||
|
||||
/**
|
||||
* Check if the input contains a -c core.hooksPath= override.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {{gitEnd: number, commandStart: number}} detected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasHooksPathOverride(input, detected) {
|
||||
const tokens = tokenizeShellWords(input, detected.gitEnd, detected.commandStart);
|
||||
@@ -790,29 +268,33 @@ function hasHooksPathOverride(input, detected) {
|
||||
|
||||
/**
|
||||
* Check a command string for git hook bypass attempts.
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {{blocked: boolean, reason?: string}}
|
||||
*/
|
||||
function checkCommand(input) {
|
||||
const boundaries = buildScanBoundaries(input);
|
||||
const comments = buildCommentMask(input);
|
||||
const { boundaries, comments } = buildScanBoundaries(input);
|
||||
let start = 0;
|
||||
|
||||
while (start < input.length) {
|
||||
const detected = detectGitCommand(input, boundaries, comments, start);
|
||||
if (!detected) return { blocked: false };
|
||||
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.`,
|
||||
reason: `BLOCKED: Overriding core.hooksPath is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`
|
||||
};
|
||||
}
|
||||
|
||||
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.`,
|
||||
reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -824,22 +306,33 @@ function checkCommand(input) {
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -850,6 +343,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);
|
||||
@@ -858,7 +354,7 @@ function run(rawInput) {
|
||||
if (result.blocked) {
|
||||
return {
|
||||
exitCode: 2,
|
||||
stderr: result.reason,
|
||||
stderr: result.reason
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shell-scanning primitives shared by hook-bypass matchers.
|
||||
*
|
||||
* These helpers locate Git executables and bound each candidate's flag scan
|
||||
* to its enclosing quote, heredoc body line, or command segment. They keep
|
||||
* every `git` token — quoted data may later be executed by a shell.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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;&|$`(<{!"\']/.~\\';
|
||||
|
||||
/**
|
||||
* Sticky heredoc opener. `lastIndex` must be set to the candidate `<<` before exec.
|
||||
*/
|
||||
const HEREDOC_START = /<<(-?)[ \t]*(?:'([^']+)'|"([^"]+)"|([^ \t\r\n;|&()<>]+))/y;
|
||||
|
||||
/**
|
||||
* Return the last index of an unquoted backslash-newline continuation at `i`,
|
||||
* or -1 when `input[i]` is not a line continuation.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {number} i
|
||||
* @returns {number}
|
||||
*/
|
||||
function lineContinuationEnd(input, i) {
|
||||
if (input.charAt(i) !== '\\') return -1;
|
||||
if (input.charAt(i + 1) === '\n') return i + 1;
|
||||
if (input.charAt(i + 1) === '\r' && input.charAt(i + 2) === '\n') return i + 2;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when `input[i]` starts an ANSI-C quoted word (`$'...'`).
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {number} i
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAnsiCQuoteStart(input, i) {
|
||||
return input.charAt(i) === '$' && input.charAt(i + 1) === "'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a slice of `input` into shell words, respecting quotes, escapes,
|
||||
* ANSI-C quoting, and backslash-newline continuations.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {number} [start=0]
|
||||
* @param {number} [end=input.length]
|
||||
* @returns {{value: string, start: number, end: number}[]}
|
||||
*/
|
||||
function tokenizeShellWords(input, start = 0, end = input.length) {
|
||||
const tokens = [];
|
||||
let value = '';
|
||||
let tokenStart = null;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
/** Mark the current word's raw start the first time a character is consumed. */
|
||||
function beginToken(index) {
|
||||
if (tokenStart === null) tokenStart = index;
|
||||
}
|
||||
|
||||
/** Push the current word and reset the assembler. */
|
||||
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 === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
|
||||
beginToken(i);
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
beginToken(i);
|
||||
value += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAnsiCQuoteStart(input, i)) {
|
||||
beginToken(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
beginToken(i);
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
|
||||
beginToken(i);
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
pushToken(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
beginToken(i);
|
||||
value += char;
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
value += '\\';
|
||||
}
|
||||
pushToken(end);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the end of a shell command segment without scanning beyond `limit`.
|
||||
* Unquoted or double-quoted backslash-newline pairs join the next physical line.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {number} start
|
||||
* @param {number} [limit=input.length]
|
||||
* @returns {number}
|
||||
*/
|
||||
function findCommandSegmentEnd(input, start, limit = input.length) {
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = start; i < limit; i++) {
|
||||
const char = input.charAt(i);
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (quote === '"' && char === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ';' || char === '|' || char === '&' || char === '\n') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one character of the comment-mask state machine.
|
||||
* Newlines stay unmarked and reset the comment/escape flags, matching the
|
||||
* historical `buildCommentMask` bit pattern.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {Uint8Array} comments
|
||||
* @param {{afterCommentMarker: boolean, quote: string|null, escaped: boolean}} state
|
||||
* @param {number} i
|
||||
*/
|
||||
function applyCommentMaskChar(input, comments, state, i) {
|
||||
const char = input.charAt(i);
|
||||
if (char === '\n') {
|
||||
state.afterCommentMarker = false;
|
||||
state.escaped = false;
|
||||
return;
|
||||
}
|
||||
|
||||
comments[i] = state.afterCommentMarker ? 1 : 0;
|
||||
if (state.afterCommentMarker) return;
|
||||
if (state.escaped) {
|
||||
state.escaped = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.quote) {
|
||||
if (state.quote === '"' && char === '\\') state.escaped = true;
|
||||
else if (char === state.quote) state.quote = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
state.escaped = true;
|
||||
return;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
state.quote = char;
|
||||
return;
|
||||
}
|
||||
if (char === '#' && (i === 0 || /[\s;&|()]/.test(input.charAt(i - 1)))) {
|
||||
const previous = i > 0 ? input.charAt(i - 1) : '';
|
||||
if (previous !== '$' && previous !== '\\') state.afterCommentMarker = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclusive end of a heredoc body line starting at `start`, joining further
|
||||
* physical lines only while an unquoted backslash-newline continuation remains.
|
||||
* A trailing CR is stripped like the original single-line bound.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {number} start
|
||||
* @returns {number}
|
||||
*/
|
||||
function heredocContinuedBound(input, start) {
|
||||
let quote = null;
|
||||
|
||||
for (let i = start; i < input.length; i++) {
|
||||
const char = input.charAt(i);
|
||||
|
||||
if (quote) {
|
||||
if (char === '\n') {
|
||||
return input.charAt(i - 1) === '\r' ? i - 1 : i;
|
||||
}
|
||||
if (quote === '"' && char === '\\') {
|
||||
i++;
|
||||
} else if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\n') {
|
||||
return input.charAt(i - 1) === '\r' ? i - 1 : i;
|
||||
}
|
||||
}
|
||||
|
||||
return input.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the comment mask and the maximum scan endpoint for characters
|
||||
* inside outer quotes and heredoc body lines. One pass tracks quote, escape,
|
||||
* comment, and heredoc state so the mask and boundary array cannot drift.
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {{boundaries: Int32Array, comments: Uint8Array}}
|
||||
*/
|
||||
function buildScanBoundaries(input) {
|
||||
const comments = new Uint8Array(input.length);
|
||||
const boundaries = new Int32Array(input.length);
|
||||
boundaries.fill(-1);
|
||||
|
||||
const commentState = { afterCommentMarker: false, quote: null, escaped: false };
|
||||
const pendingHeredocs = [];
|
||||
let quote = null;
|
||||
let quoteStart = -1;
|
||||
let escaped = false;
|
||||
let comment = false;
|
||||
|
||||
/** Fill the comment mask for a closed index range, preserving visit order. */
|
||||
function fillCommentMask(from, lastInclusive) {
|
||||
const last = Math.min(lastInclusive, input.length - 1);
|
||||
for (let j = from; j <= last; j++) {
|
||||
applyCommentMaskChar(input, comments, commentState, j);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
fillCommentMask(i, physicalEnd === input.length ? input.length - 1 : physicalEnd);
|
||||
|
||||
if (comparableLine === heredoc.delimiter) {
|
||||
pendingHeredocs.shift();
|
||||
} else {
|
||||
const bound = heredocContinuedBound(input, i);
|
||||
boundaries.fill(bound, i, bound);
|
||||
}
|
||||
|
||||
i = physicalEnd === input.length ? input.length : physicalEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
applyCommentMaskChar(input, comments, commentState, i);
|
||||
|
||||
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) !== '<') {
|
||||
HEREDOC_START.lastIndex = i;
|
||||
const heredocMatch = HEREDOC_START.exec(input);
|
||||
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, comments };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the enclosing quote or heredoc-line endpoint for a candidate.
|
||||
*
|
||||
* @param {Int32Array} boundaries
|
||||
* @param {number} idx
|
||||
* @param {number} fallback
|
||||
* @returns {number}
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @returns {{terminal: boolean, command: string|null, start: number}|null}
|
||||
*/
|
||||
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 === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
escaped = true;
|
||||
} else {
|
||||
if (tokenStart === -1) {
|
||||
tokenStart = i;
|
||||
}
|
||||
value += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAnsiCQuoteStart(input, i)) {
|
||||
if (tokenStart === -1) {
|
||||
tokenStart = i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
if (tokenStart === -1) {
|
||||
tokenStart = i;
|
||||
}
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {number} start
|
||||
* @returns {{idx: number, len: number, end: number}|null}
|
||||
*/
|
||||
function findRawGit(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, 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.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @returns {{idx: number, len: number, end: number}|null}
|
||||
*/
|
||||
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 === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
escaped = true;
|
||||
} else {
|
||||
value += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAnsiCQuoteStart(input, i)) {
|
||||
if (tokenStart === -1) {
|
||||
tokenStart = i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
if (tokenStart === -1) {
|
||||
tokenStart = i;
|
||||
}
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {number} start
|
||||
* @returns {{idx: number, len: number, end: number}|null}
|
||||
*/
|
||||
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,
|
||||
* ANSI-C, and escaped fragments, and return its raw endpoint.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {number} idx
|
||||
* @returns {{value: string, end: number}}
|
||||
*/
|
||||
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 === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
escaped = true;
|
||||
} else {
|
||||
value += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAnsiCQuoteStart(input, i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
const continued = lineContinuationEnd(input, i);
|
||||
if (continued !== -1) {
|
||||
i = continued;
|
||||
continue;
|
||||
}
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char) || char === ';' || char === '|' || char === '&') {
|
||||
wordEnd = i;
|
||||
break;
|
||||
}
|
||||
|
||||
value += char;
|
||||
}
|
||||
|
||||
return { value: value.toLowerCase(), end: wordEnd };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GIT_COMMANDS_WITH_NO_VERIFY,
|
||||
tokenizeShellWords,
|
||||
findCommandSegmentEnd,
|
||||
buildScanBoundaries,
|
||||
getScanBoundary,
|
||||
findGitSubcommand,
|
||||
findRawGit,
|
||||
findAssembledGit,
|
||||
findGit,
|
||||
assembleShellWordContaining
|
||||
};
|
||||
@@ -243,7 +243,14 @@ const executingPayloads = [
|
||||
['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'],
|
||||
['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) {
|
||||
@@ -270,6 +277,12 @@ const nonLeakingPayloads = [
|
||||
['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) {
|
||||
|
||||
Reference in New Issue
Block a user