Files
ECC/scripts/hooks/lib/shell-scan.js
T
L4XB b8ce049abb fix(hooks): block a bypass that a brace group or subshell pipes into a shell
pipelineSources() read the literal output of the commands that feed a shell, but a brace group or subshell producer has no words of its own, so nothing it printed reached the check. { echo 'git commit --no-verify -m x'; } | sh and (echo 'git push --no-verify'; printf x) | bash passed the guard, while echo 'git commit --no-verify -m x' | sh is blocked. shell-scan now keeps the source text of a closed scope, and pipelineSources() reads the commands in it that write to the group's output, as it already does for a plain producer.
2026-10-23 07:19:32 +02:00

543 lines
21 KiB
JavaScript

'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;
}
// Skip to the end of a comment. The returned index is the newline or EOF.
function skipComment(input, start, budget) {
let i = start;
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
return i;
}
function skipDelimiterBlanks(input, start, budget) {
let i = start;
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;
}
return i;
}
// A backslash in the delimiter word. Returns the index of the last consumed
// character; a continuation is removed without starting the word.
function delimiterEscape(input, i, word) {
const continued = continuationEnd(input, i);
if (continued !== i) return continued - 1;
word.began = true; word.quoted = true;
const next = input[i + 1];
if (next === undefined) { word.value += '\\'; return i; }
if (word.quote === '"' && !'"$`\\'.includes(next)) word.value += '\\';
word.value += next;
return i + 1;
}
function readDelimiterWord(input, start, budget) {
const word = { value: '', quote: null, began: false, quoted: false, end: start };
let i = start;
for (; i < input.length; i++) {
budget.spend();
const c = input[i];
if (word.quote === "'") {
if (c === "'") word.quote = null;
else word.value += c;
continue;
}
if (c === '\\') { i = delimiterEscape(input, i, word); continue; }
if (word.quote === '"') {
if (c === '"') word.quote = null;
else word.value += c;
continue;
}
if (c === "'" || c === '"') { word.quote = c; word.began = true; word.quoted = true; continue; }
if (/[\s;|&()<>]/.test(c) || (!word.began && c === '#')) break;
word.began = true; word.value += c;
}
word.end = i;
return word;
}
// 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] === '-' ? '<<-' : '<<';
const word = readDelimiterWord(input, skipDelimiterBlanks(input, start + operator.length, budget), budget);
return word.began ? { operator, word: { value: word.value, quoted: word.quoted }, end: word.end } : 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 };
}
// --- $(...) region state ---
// Each helper below takes the innermost frame and the index of the current
// character. Those that consume input return the index of the last consumed
// character, or undefined when the character was not theirs to handle.
function regionFrame() {
return { quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] };
}
function endRegionWord(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;
}
// Inside '...' or $'...': only the closing quote matters, and an ANSI-C escape
// protects the next character.
function regionQuotedEnd(state, input, i) {
const c = input[i];
if (state.quote === "$'" && c === '\\' && i + 1 < input.length) return i + 1;
if (c === "'") state.quote = null;
return i;
}
// Escapes and nested substitutions apply inside double quotes as well.
function regionSubstitution(state, stack, input, i, budget) {
const c = input[i];
if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; return i + 1; }
if (c === '$' && input[i + 1] === '(') {
state.word += '$()'; state.quotedWord = true; stack.push(regionFrame()); return i + 1;
}
if (c === '`') {
const region = legacyRegion(input, i, budget);
state.word += '`'; state.quotedWord = true; return region.end - 1;
}
return undefined;
}
function openRegionQuote(state, input, i) {
const c = input[i];
if (c === '$' && input[i + 1] === "'") {
state.quote = "$'"; state.word += "$'"; state.quotedWord = true; return i + 1;
}
if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; return i; }
return undefined;
}
// A here-string or heredoc operator ends the current word. A heredoc
// delimiter queues its body for the next newline.
function regionHeredocOperator(state, input, i, budget) {
if (input[i] !== '<') return undefined;
if (input.startsWith('<<<', i)) { endRegionWord(state); return i + 2; }
if (input[i + 1] === '<' && input[i + 2] !== '<') {
endRegionWord(state);
const delimiter = heredocDelimiter(input, i, budget);
if (delimiter) { state.heredocs.push(delimiter); return delimiter.end - 1; }
}
return undefined;
}
function regionHeredocBodies(state, input, i, budget) {
endRegionWord(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;
return next - 1;
}
// A case pattern's closing ')' is not the end of $(...). Only literal
// keyword positions affect this state; quoted or echo operands do not.
function casePhaseStep(state, input, i) {
const c = input[i];
const phase = state.cases[state.cases.length - 1];
if (c === ')' && phase === 'pattern') {
state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; return i;
}
if (c === '(' && phase === 'pattern') return i;
if (c === ';' && input[i + 1] === ';' && phase === 'body') {
state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; return i + 1;
}
return undefined;
}
// Tracks parenthesis depth; true once the outermost region has closed.
function closeRegionParen(stack, state, c) {
if (/[;|&\n]/.test(c)) state.commandStart = true;
if (c === '(') state.depth++;
if (c !== ')') return false;
state.depth--;
if (state.depth !== 0) return false;
stack.pop();
return stack.length === 0;
}
// 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 stack = [regionFrame()];
for (let i = from; i < input.length; i++) {
budget.spend();
const state = stack[stack.length - 1];
const c = input[i];
if (state.quote === "'" || state.quote === "$'") { i = regionQuotedEnd(state, input, i); continue; }
const substituted = regionSubstitution(state, stack, input, i, budget);
if (substituted !== undefined) { i = substituted; continue; }
if (state.quote === '"') {
if (c === '"') state.quote = null;
continue;
}
const opened = openRegionQuote(state, input, i);
if (opened !== undefined) { i = opened; continue; }
if (c === '#' && /[\s;|&()]/.test(input[i - 1] || ' ')) {
// Stop before the newline so pending heredoc bodies are still processed.
i = skipComment(input, i, budget) - 1;
continue;
}
const redirected = regionHeredocOperator(state, input, i, budget);
if (redirected !== undefined) { i = redirected; continue; }
if (c === '\n' && state.heredocs.length) { i = regionHeredocBodies(state, input, i, budget); continue; }
if (/[\s;|&()]/.test(c)) endRegionWord(state);
else state.word += c;
const stepped = casePhaseStep(state, input, i);
if (stepped !== undefined) { i = stepped; continue; }
if (closeRegionParen(stack, state, c)) {
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;
}
// --- Top-level scan state ---
// scan.i is the read position; every helper advances it past what it consumed.
const COMPOUND_KEYWORDS = ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function'];
function newCommand(scan, pipeFrom) {
return { words: [], redirects: [], pipeFrom, nested: [], scope: scan.scope };
}
function createScan(input, budget) {
const rootScope = { parent: null, isolated: false, conditional: false };
const scan = {
input, budget, commands: [], nested: [], pendingHeredocs: [], rootScope,
scope: rootScope, current: null, word: null, quote: null, pendingRedirect: null, i: 0
};
scan.current = newCommand(scan, null);
return scan;
}
function beginWord(scan) {
if (!scan.word) scan.word = { value: '', start: scan.i, end: scan.i, quoted: false, literal: true, dynamic: false };
}
function flushWord(scan) {
const { word, current } = scan;
if (!word) return;
word.end = scan.i;
word.raw = scan.input.slice(word.start, scan.i);
if (scan.pendingRedirect) {
const redirect = { operator: scan.pendingRedirect, word, body: '' };
current.redirects.push(redirect);
if (scan.pendingRedirect === '<<' || scan.pendingRedirect === '<<-') scan.pendingHeredocs.push({ redirect, owner: current });
scan.pendingRedirect = null;
} else current.words.push(word);
scan.word = null;
}
function flushCommand(scan, pipe = false, background = false) {
flushWord(scan);
const previous = scan.current;
previous.pipeTo = pipe;
previous.background = background;
if (previous.closedScope && (pipe || background)) {
previous.closedScope.isolated = true;
previous.closedScope.pipelineLast = false;
}
const first = previous.words[0];
if (first && !first.quoted && COMPOUND_KEYWORDS.includes(first.value)) scan.scope.conditional = true;
if (previous.words.length || previous.redirects.length) scan.commands.push(previous);
scan.current = newCommand(scan, pipe ? previous : null);
scan.pendingRedirect = null;
}
function consumeHeredocs(scan) {
for (const { redirect, owner } of scan.pendingHeredocs) {
const region = heredocBody(scan.input, scan.i, redirect, scan.budget);
redirect.body = region.body;
scan.i = region.end;
if (!redirect.word.quoted) {
const regions = scanExpansions(redirect.body, scan.budget);
for (const text of regions) { scan.budget.spend(); scan.nested.push(text); owner.nested.push(text); }
}
}
scan.pendingHeredocs.length = 0;
}
// Inside '...' or $'...'.
function scanQuotedChar(scan) {
const c = scan.input[scan.i];
if (scan.quote === "$'" && c === '\\' && scan.i + 1 < scan.input.length) {
const next = scan.input[scan.i + 1];
// Preserve boundaries without claiming general ANSI-C escape expansion.
scan.word.value += next === "'" || next === '\\' ? next : c + next;
scan.i += 2;
return;
}
if (c === "'") scan.quote = null;
else scan.word.value += c;
scan.i++;
}
function scanDoubleQuotedChar(scan, c) {
if (c === '"') scan.quote = null;
else scan.word.value += c;
scan.i++;
}
function scanEscape(scan) {
beginWord(scan); scan.word.quoted = true;
const next = scan.input[scan.i + 1];
if (next === undefined) { scan.word.value += '\\'; scan.i++; return; }
if (scan.quote === '"' && !'"$`\\'.includes(next)) scan.word.value += '\\';
scan.word.value += next; scan.i += 2;
}
function scanSubstitution(scan) {
beginWord(scan); scan.word.literal = false; scan.word.dynamic = true;
const region = executionRegion(scan.input, scan.i, scan.budget);
scan.nested.push(region.text); scan.current.nested.push(region.text);
scan.word.value += '\u0000'; scan.i = region.end;
}
// Parameter expansions remain opaque values. Escaped/single/ANSI-C
// quoted dollars have already been consumed as data before this point.
function markParameterExpansion(scan, c) {
if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(scan.input[scan.i + 1] || '')) {
beginWord(scan); scan.word.dynamic = true;
}
}
function openQuote(scan, c) {
if (c === '$' && scan.input[scan.i + 1] === "'") {
// ANSI-C escaped quotes do not close the word; its contents never expand.
// Numeric/control escapes and generated names remain outside this grammar.
beginWord(scan); scan.word.quoted = true; scan.quote = "$'"; scan.i += 2;
return true;
}
if (c === '"' || c === "'") { beginWord(scan); scan.word.quoted = true; scan.quote = c; scan.i++; return true; }
return false;
}
function isBraceKeyword(scan, c) {
return (c === '{' || c === '}') && !scan.word && scan.current.words.length === 0 && /[\s;&|]/.test(scan.input[scan.i + 1] || ' ');
}
function markFunctionDefinition(scan) {
flushWord(scan);
// A function definition does not execute its body. Function grammar
// is unsupported: keep pre-definition alternatives instead of using
// flattened body mutations to certify a later command as safe.
const words = scan.current.words;
if (words.length === 1 && !words[0].quoted && /^[A-Za-z_][A-Za-z0-9_]*$/.test(words[0].value)) scan.scope.conditional = true;
}
function openScope(scan, c, incomingPipe) {
scan.scope = { parent: scan.scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe, bodyStart: scan.i + 1 };
scan.current.scope = scan.scope;
}
function closeScope(scan) {
const closedScope = scan.scope;
// The source text between the braces or parentheses, for a reader of the
// group's output.
closedScope.body = scan.input.slice(closedScope.bodyStart, scan.i);
scan.scope = closedScope.parent;
scan.current.scope = scan.scope;
scan.current.closedScope = closedScope;
// Ordered metadata only; there is no executable argv in this event.
scan.commands.push({ ...newCommand(scan, null), scopeExit: closedScope });
}
function scanOperator(scan, c, braceKeyword) {
const doubled = (c === '&' || c === '|') && scan.input[scan.i + 1] === c;
const pipe = c === '|' && !doubled;
const background = c === '&' && !doubled;
if (doubled) scan.scope.conditional = true;
const incomingPipe = Boolean(scan.current.pipeFrom);
if (c === '(') markFunctionDefinition(scan);
flushCommand(scan, pipe, background);
if (c === '(' || (braceKeyword && c === '{')) openScope(scan, c, incomingPipe);
else if ((c === ')' || (braceKeyword && c === '}')) && scan.scope.parent) closeScope(scan);
scan.i += doubled ? 2 : 1;
if (c === '\n') consumeHeredocs(scan);
}
// An immediately adjacent numeric word is a descriptor, not an argv word.
function dropDescriptorWord(scan) {
if (scan.word && /^\d+$/.test(scan.word.value)) scan.word = null;
}
function queueHeredoc(scan, delimiter) {
dropDescriptorWord(scan);
flushWord(scan);
const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' };
scan.current.redirects.push(redirect);
scan.pendingHeredocs.push({ redirect, owner: scan.current });
scan.i = delimiter.end;
scan.pendingRedirect = null;
}
function redirectOperator(input, i, c) {
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 += '&';
return operator;
}
function scanRedirect(scan, c) {
const { input } = scan;
if (c === '<' && input[scan.i + 1] === '<' && input[scan.i + 2] !== '<') {
const delimiter = heredocDelimiter(input, scan.i, scan.budget);
if (delimiter) { queueHeredoc(scan, delimiter); return; }
}
dropDescriptorWord(scan);
flushWord(scan);
scan.pendingRedirect = redirectOperator(input, scan.i, c);
scan.i += scan.pendingRedirect.length;
}
function scanShell(input, budget) {
const scan = createScan(input, budget);
while (scan.i < input.length) {
budget.spend();
if (scan.quote === "'" || scan.quote === "$'") { scanQuotedChar(scan); continue; }
const continued = continuationEnd(input, scan.i);
if (continued !== scan.i) { scan.i = continued; continue; }
const c = input[scan.i];
if (c === '\\') { scanEscape(scan); continue; }
if (hasExpansion(input, scan.i, scan.quote === null)) { scanSubstitution(scan); continue; }
markParameterExpansion(scan, c);
if (scan.quote === '"') { scanDoubleQuotedChar(scan, c); continue; }
if (openQuote(scan, c)) continue;
if (c === '#' && !scan.word) { scan.i = skipComment(input, scan.i, budget); continue; }
const braceKeyword = isBraceKeyword(scan, c);
if (braceKeyword || '\n;&|()'.includes(c)) { scanOperator(scan, c, braceKeyword); continue; }
if (c === '<' || c === '>') { scanRedirect(scan, c); continue; }
if (/\s/.test(c)) { flushWord(scan); scan.i++; continue; }
beginWord(scan); scan.word.value += c; scan.i++;
}
flushCommand(scan);
return { commands: scan.commands, nested: scan.nested, rootScope: scan.rootScope };
}
module.exports = { createBudget, scanShell, scanExpansions };