Skip to content

Commit e528d7e

Browse files
Stop auto-allowing misparsed nested interpreter peels (#673)
* Stop auto-allowing misparsed nested interpreter peels * Harden nested bash auto-shell peels
1 parent 5249e6f commit e528d7e

2 files changed

Lines changed: 113 additions & 3 deletions

File tree

src/permission/classify-security.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,3 +790,55 @@ describe("CL-6697 — quoted dangerous flags and program names still deny/ask",
790790
expect(autoShellRuleForCall(shellCall(`git commit -m "some text"`))).toBeUndefined();
791791
});
792792
});
793+
794+
describe("CL-6988 — nested / escaped interpreter peels do not auto-allow", () => {
795+
test("a single-level bash -c redirect still denies (quote fix preserved)", () => {
796+
expect(autoShellRuleForCall(shellCall(`bash -c 'echo hi > "out.txt"'`))?.name).toBe(
797+
"file-mutation",
798+
);
799+
expect(autoShellRuleForCall(shellCall(`bash -c "echo hi > out.txt"`))?.name).toBe(
800+
"file-mutation",
801+
);
802+
});
803+
804+
test("a double-nested alternating-quote bash -c redirect still denies", () => {
805+
expect(autoShellRuleForCall(shellCall(`bash -c "bash -c 'echo hi > out.txt'"`))?.name).toBe(
806+
"file-mutation",
807+
);
808+
});
809+
810+
test("bash -O/-o option values before -c do not hide dependency installs", () => {
811+
expect(autoShellRuleForCall(shellCall(`bash -O extglob -c 'npm install left-pad'`))?.name).toBe(
812+
"dependency-install",
813+
);
814+
expect(
815+
autoShellRuleForCall(shellCall(`bash -o pipefail -c 'npm install left-pad'`))?.name,
816+
).toBe("dependency-install");
817+
});
818+
819+
test("bash -c positional argv execution does not auto-allow dependency installs", () => {
820+
const cmd = `bash -c '$0 $1 $2' npm install left-pad`;
821+
expect(isAutoAllowedShellCall(shellCall(cmd))).toBe(false);
822+
expect(autoShellRuleForCall(shellCall(cmd))?.name).toBe("opaque-wrapper");
823+
});
824+
825+
test("an escaped triple-nested bash -c redirect does not auto-allow", () => {
826+
// tokenize() has no backslash-escape support, so peeling
827+
// `bash -c "bash -c \"bash -c '…>…'\""` used to degrade to subjects like
828+
// `bash -c \bash` / `\bash` and auto-allow. Misparsed nested-interpreter
829+
// payloads must ask (opaque-wrapper) rather than accept the degraded leaf.
830+
const escaped = `bash -c "bash -c \\"bash -c 'echo hi > out.txt'\\""`;
831+
expect(isAutoAllowedShellCall(shellCall(escaped))).toBe(false);
832+
expect(autoShellRuleForCall(shellCall(escaped))?.name).toBe("opaque-wrapper");
833+
expect(autoShellRuleForCall(shellCall(escaped))?.effect).toBe("ask");
834+
});
835+
836+
test("quote-broken deep nesting that degrades to a bare interpreter asks", () => {
837+
// Alternating quotes collide by depth 4 and peel used to land on bare `bash`.
838+
const deep = String.raw`bash -c "bash -c 'bash -c \"bash -c 'echo hi > out.txt'\"'"`;
839+
expect(isAutoAllowedShellCall(shellCall(deep))).toBe(false);
840+
const rule = autoShellRuleForCall(shellCall(deep));
841+
expect(rule).toBeDefined();
842+
expect(rule?.effect === "ask" || rule?.effect === "deny").toBe(true);
843+
});
844+
});

src/shell/run-shell-authz.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,13 @@ const RM_WRAPPER = /^(sudo|command|env|exec|builtin|time|nice|nohup)$/;
269269
const RECURSIVE_FLAG = /^(--recursive|-[A-Za-z]*[rR][A-Za-z]*)$/;
270270

271271
// Interpreters whose `-c` / `--command` payload is an independent shell subject.
272-
const SHELL_INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
272+
// Exported so tests and callers share one explicit list with the peeler.
273+
export const SHELL_INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
273274
// Transparent prefixes that sit in front of a real program without changing it.
274275
const PREFIX_WRAPPERS = new Set(["command", "env", "builtin", "time", "nice", "nohup", "timeout"]);
275-
const MAX_PEEL_DEPTH = 4;
276+
// Max recursive peel depth for nested wrappers. Exported so the depth cap is a
277+
// named policy knob tests can assert against, not a magic number.
278+
export const MAX_PEEL_DEPTH = 4;
276279

277280
// xargs flags that consume the following token as a value.
278281
const XARGS_VALUE_FLAGS = new Set([
@@ -371,6 +374,43 @@ function rejoinTokens(tokens: string[]): string | null {
371374
return quoted.join(" ");
372375
}
373376

377+
// True when a token is a safe `$0`/`$1` positional after `bash -c 'script'` —
378+
// a plain word with no shell syntax. Anything else after the -c payload is
379+
// treated as evidence the quoted body was split by a tokenizer that does not
380+
// honor backslash-escapes (classic `bash -c "…\"…"` degradation).
381+
function isSafeShellPositional(token: string): boolean {
382+
if (token.startsWith("-") && token !== "-") return false;
383+
if (token.includes("\\")) return false;
384+
if (/[><|&;`$]/.test(token)) return false;
385+
return SAFE_REJOIN_TOKEN.test(token);
386+
}
387+
388+
// `\bash` / `\sh` — tokenize artifact from peeling through an escaped quote.
389+
function isBackslashInterpreterToken(token: string): boolean {
390+
const base = programBasename(token);
391+
return base.startsWith("\\") && SHELL_INTERPRETERS.has(base.slice(1));
392+
}
393+
394+
function shellPayloadReferencesPositional(payload: string): boolean {
395+
return /\$(?:[0-9@*#]|\{(?:[0-9]+|[@*#])\})/.test(payload);
396+
}
397+
398+
function nestedInterpreterPayloadOpaque(payload: string, rest: readonly string[]): boolean {
399+
if (isBackslashInterpreterToken(payload)) return true;
400+
const first = tokenize(payload)[0];
401+
if (first !== undefined && isBackslashInterpreterToken(first)) return true;
402+
// The payload can execute trailing argv through $0/$1/... substitution, so
403+
// the payload alone is not a faithful subject for dependency-install policy.
404+
if (rest.length > 0 && shellPayloadReferencesPositional(payload)) return true;
405+
// Trailing tokens after the -c payload: allow only plain positionals.
406+
// `-c`, redirects, backslashes, or flags mean the quoted body was split and
407+
// the truncated payload must not be trusted on its own under auto.
408+
if (rest.length > 0 && !rest.every(isSafeShellPositional)) return true;
409+
return false;
410+
}
411+
412+
const SHELL_SEPARATE_VALUE_FLAGS = new Set(["-O", "-o"]);
413+
374414
function peelShellDashC(tokens: string[], start: number): PeelOutcome {
375415
let i = start;
376416
while (i < tokens.length) {
@@ -382,18 +422,28 @@ function peelShellDashC(tokens: string[], start: number): PeelOutcome {
382422
if (t === "-c" || t === "--command") {
383423
const payload = tokens[i + 1];
384424
if (payload === undefined || isOpaquePayload(payload)) return { kind: "opaque" };
425+
const rest = tokens.slice(i + 2);
426+
if (nestedInterpreterPayloadOpaque(payload, rest)) return { kind: "opaque" };
385427
return { kind: "inner", command: payload };
386428
}
387429
if (t.startsWith("--command=")) {
388430
const payload = t.slice("--command=".length);
389431
if (isOpaquePayload(payload)) return { kind: "opaque" };
432+
// Glued `--command=` has no separate rest tokens; still reject `\bash`.
433+
if (nestedInterpreterPayloadOpaque(payload, [])) return { kind: "opaque" };
390434
return { kind: "inner", command: payload };
391435
}
436+
if (SHELL_SEPARATE_VALUE_FLAGS.has(t)) {
437+
i += 2;
438+
continue;
439+
}
392440
// Clustered short flags that include `c` (`-lc`, `-ic`, …): `c` takes the
393441
// next token as the command string, matching bash/sh/zsh.
394442
if (/^-[A-Za-z]*c[A-Za-z]*$/.test(t)) {
395443
const payload = tokens[i + 1];
396444
if (payload === undefined || isOpaquePayload(payload)) return { kind: "opaque" };
445+
const rest = tokens.slice(i + 2);
446+
if (nestedInterpreterPayloadOpaque(payload, rest)) return { kind: "opaque" };
397447
return { kind: "inner", command: payload };
398448
}
399449
if (t.startsWith("-") && t !== "-") {
@@ -808,7 +858,15 @@ export function expandShellSubjects(command: string, maxDepth = MAX_PEEL_DEPTH):
808858
seen.add(stripped);
809859
subjects.push(stripped);
810860
}
811-
if (depth >= maxDepth) return;
861+
if (depth >= maxDepth) {
862+
// Depth exhausted while this subject may still be a nested interpreter
863+
// wrapper. Mark opaque so auto cannot accept a leaf we never fully peeled.
864+
for (const segment of splitChainedCommand(trimmed)) {
865+
const peeled = peelOnce(segment);
866+
if (peeled.kind === "inner" || peeled.kind === "opaque") opaque = true;
867+
}
868+
return;
869+
}
812870

813871
for (const segment of splitChainedCommand(trimmed)) {
814872
const peeled = peelOnce(segment);

0 commit comments

Comments
 (0)