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.
This commit is contained in:
L4XB committed 2026-10-23 07:19:32 +02:00
1 parent 90606c8315
commit b8ce049abb
3 files changed
+15 -1

No files matched your search

+7
View File
@@ -820,6 +820,13 @@ function commandRole(words, budget) {
function pipelineSources(command, budget) {
const sources = [];
for (let current = command; current; current = current.pipeFrom) {
// A brace group or subshell writes what its own commands print into the
// pipe: `{ echo 'git push --no-verify'; } | sh` feeds sh like `echo ... | sh`.
if (current.closedScope) {
for (const inner of scanShell(current.closedScope.body, budget).commands) {
if (!inner.pipeTo) sources.push(...pipelineSources(inner, budget));
}
}
budget.spend(current.words.length + 1);
const { words } = executableWords(current.words, budget);
for (const word of words) budget.spend(word.value.length + 3);
+4 -1
View File
@@ -449,12 +449,15 @@ function markFunctionDefinition(scan) {
}
function openScope(scan, c, incomingPipe) {
scan.scope = { parent: scan.scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: 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;
+4
View File
@@ -235,6 +235,10 @@ const executingPayloads = [
['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"],
['pipe brace group data to sh', "{ echo 'git commit --no-verify -m x'; } | sh"],
['pipe subshell data to bash', "(echo 'git push --no-verify'; printf x) | bash"],
['pipe nested group data to sh', "{ (printf '%s\\n' 'git commit -n -m x'); } | sh"],
['pipe group pipeline data to sh', "{ echo 'git push --no-verify' | grep git; } | 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'"],