Skip to content

Commit 5509622

Browse files
committed
fix(secret-guard): close ?/[] glob bypass, resolve :=/:+ over-prompt
1 parent aaa897c commit 5509622

2 files changed

Lines changed: 113 additions & 12 deletions

File tree

‎src/plugins/secret-guard-plugin.test.ts‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
isSensitivePath,
1313
isSensitiveShellToken,
1414
commandReferencesSensitivePath,
15+
expandShellToken,
1516
} from "./secret-guard-plugin.js";
1617

1718
const next = async (call: ToolCall): Promise<ToolResult> => ({
@@ -271,6 +272,11 @@ describe("commandReferencesSensitivePath", () => {
271272
// Relative-dot prefixes resolve to the same anchored match as a raw token.
272273
"cat ./.env",
273274
"cat ./secrets/.env",
275+
// `?`/`[…]` globs read a secret the matcher only sees as a pattern.
276+
"cat .en?",
277+
"cat .e?v",
278+
"cat .en[v]",
279+
"head -c 100 .en?",
274280
// Runtime env-file loaders — detected so the gate can ask, not hard-deny.
275281
"bun --env-file=../../.env.staging run bin/publish.ts",
276282
"bun --env-file=.env run -e 'console.log(1)'",
@@ -308,6 +314,10 @@ describe("commandReferencesSensitivePath", () => {
308314
"sed --f=.envrc input.txt",
309315
"grep --fil=.envrc needle",
310316
"bun test",
317+
// `*` stays an accepted residual: it cannot resolve without running the
318+
// shell, and prompting on it would fire on every benign `cat *`.
319+
"cat *",
320+
"cat *.txt",
311321
];
312322
for (const c of allowed) {
313323
test(`allows: ${c}`, () =>
@@ -319,19 +329,29 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", ()
319329
const CFG_VALUE = "/tmp/cl-8999-cfg/.corbits";
320330
let savedCFG: string | undefined;
321331
let savedUnknown: string | undefined;
332+
let savedPort: string | undefined;
333+
let savedEmpty: string | undefined;
322334

323335
beforeEach(() => {
324336
savedCFG = process.env.CFG;
325337
savedUnknown = process.env.UNKNOWN_X;
338+
savedPort = process.env.PORT;
339+
savedEmpty = process.env.EMPTY_X;
326340
process.env.CFG = CFG_VALUE;
327341
delete process.env.UNKNOWN_X;
342+
delete process.env.PORT;
343+
delete process.env.EMPTY_X;
328344
});
329345

330346
afterEach(() => {
331347
if (savedCFG === undefined) delete process.env.CFG;
332348
else process.env.CFG = savedCFG;
333349
if (savedUnknown === undefined) delete process.env.UNKNOWN_X;
334350
else process.env.UNKNOWN_X = savedUnknown;
351+
if (savedPort === undefined) delete process.env.PORT;
352+
else process.env.PORT = savedPort;
353+
if (savedEmpty === undefined) delete process.env.EMPTY_X;
354+
else process.env.EMPTY_X = savedEmpty;
335355
});
336356

337357
const expandedSensitive = [
@@ -341,6 +361,8 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", ()
341361
"cat ${CFG}/settings.json",
342362
"cat $CFG/settings.json",
343363
"cat $UNKNOWN_X/.env",
364+
"cat ${UNKNOWN_X:-$CFG/settings.json}",
365+
"cat ${UNKNOWN_X:=.env}",
344366
];
345367
for (const c of expandedSensitive) {
346368
test(`flags: ${c}`, () =>
@@ -355,7 +377,66 @@ describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", ()
355377
expect(isSensitiveShellToken("$CFG/settings.json")).toBe(true);
356378
});
357379

358-
const expandedBenign = ["cat $HOME/README.md", "cat Makefile"];
380+
test("resolves := without prompting when the default is benign", () => {
381+
expect(isSensitiveShellToken("${UNKNOWN_X:=fallback.txt}")).toBe(false);
382+
});
383+
384+
test("allows := / :+ port defaults without a prompt", () => {
385+
expect(
386+
commandReferencesSensitivePath("bun --port ${PORT:=3000} run x"),
387+
).toBeUndefined();
388+
process.env.PORT = "4000";
389+
expect(
390+
commandReferencesSensitivePath("bun --port ${PORT:=3000} run x"),
391+
).toBeUndefined();
392+
delete process.env.PORT;
393+
expect(
394+
commandReferencesSensitivePath("bun --port ${PORT:+3000} run x"),
395+
).toBeUndefined();
396+
});
397+
398+
test("expands := like :- for unset and empty variables", () => {
399+
expect(expandShellToken("${UNKNOWN_X:=dflt}")).toEqual({
400+
expanded: "dflt",
401+
expandable: true,
402+
});
403+
expect(expandShellToken("${CFG:=dflt}").expanded).toBe(CFG_VALUE);
404+
process.env.EMPTY_X = "";
405+
expect(expandShellToken("${EMPTY_X:=dflt}").expanded).toBe("dflt");
406+
});
407+
408+
test("expands :+ and + only when the variable is set", () => {
409+
expect(expandShellToken("${CFG:+alt}").expanded).toBe("alt");
410+
expect(expandShellToken("${CFG+alt}").expanded).toBe("alt");
411+
expect(expandShellToken("${UNKNOWN_X:+alt}")).toEqual({
412+
expanded: "",
413+
expandable: true,
414+
});
415+
expect(expandShellToken("${UNKNOWN_X+alt}").expanded).toBe("");
416+
process.env.EMPTY_X = "";
417+
expect(expandShellToken("${EMPTY_X:+alt}").expanded).toBe("");
418+
expect(expandShellToken("${EMPTY_X+alt}").expanded).toBe("alt");
419+
});
420+
421+
test("keeps :?, #, %, / and offsets fail-closed", () => {
422+
for (const token of [
423+
"${CFG:?must be set}",
424+
"${CFG#prefix}",
425+
"${CFG%post}",
426+
"${CFG/a/b}",
427+
"${CFG:1}",
428+
"${CFG:1:2}",
429+
"${UNKNOWN_X:-${BROKEN}",
430+
]) {
431+
expect(expandShellToken(token).expandable).toBe(false);
432+
}
433+
});
434+
435+
const expandedBenign = [
436+
"cat $HOME/README.md",
437+
"cat Makefile",
438+
"cat ${UNKNOWN_X:-prefix}",
439+
];
359440
for (const c of expandedBenign) {
360441
test(`allows: ${c}`, () =>
361442
expect(commandReferencesSensitivePath(c)).toBeUndefined());

‎src/plugins/secret-guard-plugin.ts‎

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -225,11 +225,13 @@ export const PURE_DIRECTORY_LISTING_PROGRAMS = new Set(["ls", "tree"]);
225225
// stat and the hot auto-allow path stays syscall-free for them. Shell
226226
// variables reach here already expanded (see expandShellToken), so there is
227227
// no `$` exemption: an unexpandable token fails closed before this filter.
228-
// Globs are skipped here for a different reason: the matcher only sees the
228+
// `*` globs are skipped here for a different reason: the matcher only sees the
229229
// unexpanded pattern, so `cat *.txt` cannot resolve without running the
230230
// shell — but a glob CAN expand into a symlink at runtime, which stays a
231-
// stated residual (see the threat model below), not something this filter
232-
// disproves.
231+
// stated residual (see the threat model above), not something this filter
232+
// disproves. `?` and `[…]` patterns are not skipped: `cat .en?` reads `.env`
233+
// while the matcher only ever sees the pattern, so they fail closed to a
234+
// prompt in isSensitiveShellToken instead of resolving here.
233235
function isPathLikeShellToken(token: string): boolean {
234236
if (token.startsWith("-") || token.includes("*") || token.includes("`"))
235237
return false;
@@ -254,14 +256,15 @@ export function expandHome(token: string): string {
254256

255257
// Expand a shell token's `~` and `$` references against process.env only —
256258
// never shells out. Handles `$VAR`, `${VAR}`, `${VAR:-default}` /
257-
// `${VAR-default}`, and a single layer of surrounding quotes; `\$` is a
259+
// `${VAR-default}`, `${VAR:=default}`, and `${VAR:+alt}` / `${VAR+alt}`,
260+
// plus a single layer of surrounding quotes; `\$` is a
258261
// literal dollar and unset variables expand to empty. A `$` followed by any
259262
// other character (or at end of token) is a literal dollar, matching shell
260263
// behavior for `$.`, `$"`, and friends. A backtick or `$(` the tokenizer left
261264
// whole comes from single quotes, where the shell never substitutes — it is
262265
// matched as literal text. Returns expandable=false only when the token
263266
// cannot be resolved statically: a malformed `${…}` or an unsupported
264-
// operator (`:=`, `:?`, `:+`, `#`, `%`, `/`). Callers fail closed on
267+
// operator (`:?`, `#`, `%`, `/`). Callers fail closed on
265268
// expandable=false: the shell would compute the value at runtime, so the
266269
// matcher must assume the worst.
267270
export interface ExpandedShellToken {
@@ -270,7 +273,8 @@ export interface ExpandedShellToken {
270273
}
271274

272275
const SHELL_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*/;
273-
const SHELL_BRACED_VAR = /^([A-Za-z_][A-Za-z0-9_]*)(:-(.*)|-(.*)|)$/s;
276+
const SHELL_BRACED_VAR =
277+
/^([A-Za-z_][A-Za-z0-9_]*)(:=(.*)|:-(.*)|-(.*)|:\+(.*)|\+(.*)|)$/s;
274278

275279
export function expandShellToken(
276280
token: string,
@@ -313,19 +317,29 @@ export function expandShellToken(
313317
const match = SHELL_BRACED_VAR.exec(text.slice(i + 2, close));
314318
if (match === null) return { expanded: token, expandable: false };
315319
const value = process.env[match[1] ?? ""];
316-
const fallback = match[3] ?? match[4];
317-
if (fallback === undefined) {
320+
const fallback = match[3] ?? match[4] ?? match[5];
321+
const alternate = match[6] ?? match[7];
322+
if (fallback === undefined && alternate === undefined) {
318323
expanded += value ?? "";
319324
} else if (
320-
value === undefined ||
321-
(match[3] !== undefined && value === "")
325+
fallback !== undefined &&
326+
(value === undefined || (match[5] === undefined && value === ""))
322327
) {
323328
const inner = expandShellToken(fallback, dialect);
324329
if (!inner.expandable) return { expanded: token, expandable: false };
325330
expanded += inner.expanded;
326-
} else {
331+
} else if (
332+
alternate !== undefined &&
333+
value !== undefined &&
334+
(match[6] === undefined || value !== "")
335+
) {
336+
const inner = expandShellToken(alternate, dialect);
337+
if (!inner.expandable) return { expanded: token, expandable: false };
338+
expanded += inner.expanded;
339+
} else if (fallback !== undefined) {
327340
expanded += value;
328341
}
342+
// Otherwise the alternate form expands to empty — append nothing.
329343
i = close + 1;
330344
continue;
331345
}
@@ -404,6 +418,12 @@ export function isSensitiveShellToken(
404418
);
405419
}
406420
if (dialect === "cmd" && /^\\\\[?.]\\/.test(expanded)) return false;
421+
// A `?` or `[` glob expands at runtime into whatever names match, so the
422+
// matcher only ever sees the pattern while the shell can open a secret
423+
// (`cat .en?` and `cat .en[v]` both read `.env`). Fail closed to a prompt —
424+
// the dual of the `*` exclusions in the filters above, which stay untouched.
425+
// After the cmd device-path exemption so `\\?\…` names keep working.
426+
if (expanded.includes("?") || expanded.includes("[")) return true;
407427
if (isPathLikeShellToken(expanded)) {
408428
if (isAbsolute(expanded)) {
409429
return (

0 commit comments

Comments
 (0)