Skip to content

Commit bb8e006

Browse files
committed
fix(secret-guard): narrow ?/[] to file operands, prompt .* globs
1 parent a7b5d28 commit bb8e006

2 files changed

Lines changed: 101 additions & 10 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,47 @@ describe("commandReferencesSensitivePath", () => {
325325
}
326326
});
327327

328+
describe("secret-guard glob narrowing (CL-8999)", () => {
329+
let savedUnknown: string | undefined;
330+
331+
beforeEach(() => {
332+
savedUnknown = process.env.UNKNOWN_X;
333+
delete process.env.UNKNOWN_X;
334+
});
335+
336+
afterEach(() => {
337+
if (savedUnknown === undefined) delete process.env.UNKNOWN_X;
338+
else process.env.UNKNOWN_X = savedUnknown;
339+
});
340+
// `?`/`[` fire only on file-operand-shaped tokens: URLs, regex operands,
341+
// and the `[` test builtin itself must not prompt.
342+
const allowed = [
343+
"curl https://api.example.com/search?q=term",
344+
"grep -E colou?r file.txt",
345+
"grep 'colou?r' file.txt",
346+
"grep [0-9] file.txt",
347+
"grep '[0-9]' file.txt",
348+
"[ -f Makefile ]",
349+
];
350+
for (const c of allowed) {
351+
test(`allows: ${c}`, () =>
352+
expect(commandReferencesSensitivePath(c)).toBeUndefined());
353+
}
354+
355+
// Dotfile-rooted `*` globs deterministically match `.env` in any realistic
356+
// cwd, so they prompt; bare `*` cannot match a leading dot and stays free.
357+
const blocked = ["cat .*", "cat .env*", "cat ${UNKNOWN_X:=.env*}"];
358+
for (const c of blocked) {
359+
test(`flags: ${c}`, () =>
360+
expect(commandReferencesSensitivePath(c)).toBeDefined());
361+
}
362+
363+
test("keeps bare * allowed", () => {
364+
expect(commandReferencesSensitivePath("cat *")).toBeUndefined();
365+
expect(commandReferencesSensitivePath("cat *.txt")).toBeUndefined();
366+
});
367+
});
368+
328369
describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", () => {
329370
const CFG_VALUE = "/tmp/cl-8999-cfg/.corbits";
330371
let savedCFG: string | undefined;

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

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,17 @@ export function createExtraDeniedPathMatcher(
209209
// dynamic construction of a path the matcher never sees as one token — e.g.
210210
// indirection through an unrelated variable (`F=.en; cat ${F}v`), character-by-
211211
// character assembly (`printf`), or reading via an interpreter that builds the
212-
// name at runtime. Unexpanded globs are the same class: `cat *` can open a
213-
// symlink the matcher only ever saw as `*`. Perfect shell sandboxing is out
214-
// of scope; the goal is to force a prompt for the trivial, single-token
215-
// references that make exfiltration easy. Tool-result secret scrub still redacts credential-shaped output.
212+
// name at runtime. Unexpanded globs are narrowed, not closed: `?`/`[` prompt
213+
// only on file-operand-shaped tokens — URLs (`…?q=…`), regex operands
214+
// (`grep -E colou?r`), and bare `[`/`]` test syntax are exempt, and a pattern
215+
// with no `.`, `/`, or `\` cannot match a dotfile secret anyway — while `*`
216+
// prompts only when dotfile-rooted (`.*`, `.env*`) or lexically sensitive
217+
// (`*.pem`). What stays allowed, and why: bare `*` / `*.txt` cannot match a
218+
// leading dot and would fire on every benign `cat *`; non-dotfile-rooted `*`
219+
// (`.config/*`) and dotless-secret `?`/`[` forms (`id_rs?`) are already
220+
// reachable through bare `*`, so closing them alone buys nothing. Perfect
221+
// shell sandboxing is out of scope; the goal is to force a prompt for the
222+
// trivial, single-token references that make exfiltration easy. Tool-result secret scrub still redacts credential-shaped output.
216223
// Programs that only print directory names / metadata — listing a name never
217224
// dumps file contents. Single owner for this set: the resolve-leg skip below
218225
// and classify.ts's pure-listing exemption both read it, so a new names-only
@@ -229,9 +236,13 @@ export const PURE_DIRECTORY_LISTING_PROGRAMS = new Set(["ls", "tree"]);
229236
// unexpanded pattern, so `cat *.txt` cannot resolve without running the
230237
// shell — but a glob CAN expand into a symlink at runtime, which stays a
231238
// 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.
239+
// disproves. The one exception lives in isSensitiveShellToken: dotfile-rooted
240+
// `*` patterns (`.*`, `.env*`) deterministically match `.env`, so they fail
241+
// closed to a prompt there. `?` and `[…]` patterns are likewise not skipped:
242+
// `cat .en?` reads `.env` while the matcher only ever sees the pattern, so
243+
// file-operand-shaped ones fail closed to a prompt in isSensitiveShellToken
244+
// instead of resolving here (URLs, regex operands, and bare `[`/`]` test
245+
// syntax are exempt — see that check).
235246
function isPathLikeShellToken(token: string): boolean {
236247
if (token.startsWith("-") || token.includes("*") || token.includes("`"))
237248
return false;
@@ -370,6 +381,16 @@ function isBareProbeCandidate(token: string): boolean {
370381
);
371382
}
372383

384+
// Final path segment starts with a literal dot and holds a `*`: `.*`,
385+
// `.env*`, `sub/.*`. Bare `*` / `*.txt` never match a leading dot under
386+
// default shell semantics, so they stay out — as does anything rooted outside
387+
// a dotfile name (`.config/*`).
388+
function isDotfileRootedGlob(token: string): boolean {
389+
if (!token.includes("*")) return false;
390+
const segment = token.split(/[/\\]/).at(-1) ?? token;
391+
return segment.startsWith(".") && segment.includes("*");
392+
}
393+
373394
// CL-7790: the ONE shell-token matcher both secret-guard call sites share —
374395
// commandReferencesSensitivePath below and classify.ts's per-arg sensitive
375396
// check. The cheap lexical denylist runs first so the hot auto-allow path
@@ -421,9 +442,38 @@ export function isSensitiveShellToken(
421442
// A `?` or `[` glob expands at runtime into whatever names match, so the
422443
// matcher only ever sees the pattern while the shell can open a secret
423444
// (`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;
445+
// but only for file-operand-shaped tokens. The unscoped rule fired on
446+
// non-file operands: query strings (`curl …/search?q=term`), regex operands
447+
// (`grep -E colou?r`, `grep [0-9]`), and the `[` test builtin itself
448+
// (`[ -f Makefile ]`). Three exemptions, each too narrow to reopen a
449+
// bypass: tokens containing `://` are URLs, never a local file the shell
450+
// opens (the lexical denylist above still catches `file://…/.env`); bare
451+
// `[`/`]`/`[[`/`]]` are test syntax, not globs; and a `?`/`[` pattern with
452+
// no `.`, `/`, or `\` cannot name a dotfile secret — `?`/`[…]` never match
453+
// a leading dot under default shell semantics, so the literal dot must be
454+
// present. Dotless secrets (`id_rsa`, `Cookies`) stay reachable through the
455+
// accepted bare-`*` residual below, so exempting their `?`/`[` forms adds
456+
// no new bypass. After the cmd device-path exemption so `\\?\…` names keep
457+
// working.
458+
if (
459+
(expanded.includes("?") || expanded.includes("[")) &&
460+
!expanded.includes("://") &&
461+
expanded !== "[" &&
462+
expanded !== "]" &&
463+
expanded !== "[[" &&
464+
expanded !== "]]" &&
465+
(expanded.includes(".") ||
466+
expanded.includes("/") ||
467+
expanded.includes("\\"))
468+
)
469+
return true;
470+
// Dotfile-rooted `*` globs (`.*`, `.env*`) deterministically match `.env`
471+
// in any realistic cwd, so they prompt — the carve-out from the `*`
472+
// exclusions in the filters above. Bare `*` / `*.txt` cannot match a
473+
// leading dot and stay allowed, as do `*` globs rooted outside a dotfile
474+
// name (`.config/*`). Runs post-expansion, so `${UNKNOWN_X:=.env*}`
475+
// prompts while `${UNKNOWN_X:=fallback.txt}` stays free.
476+
if (isDotfileRootedGlob(expanded)) return true;
427477
if (isPathLikeShellToken(expanded)) {
428478
if (isAbsolute(expanded)) {
429479
return (

0 commit comments

Comments
 (0)