Skip to content

Commit aaa897c

Browse files
committed
fix(secret-guard): expand shell variables before secret matching
Tokens like $HOME/.env or ${CFG}/settings.json skipped every shell filter through a blanket includes("$") exemption, so secret-guard never prompted for them. Expand $VAR, ${VAR}, ${VAR:-default}, and ~ against process.env only (never shells out) at the top of isSensitiveShellToken; unexpandable references fail closed to a prompt while single-quoted command substitution stays literal. cmd keeps $ literal so ADS paths such as .flaskenv::$DATA still match.
1 parent 83c2aed commit aaa897c

2 files changed

Lines changed: 160 additions & 21 deletions

File tree

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, test, expect } from "bun:test";
1+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
22
import { mkdir, mkdtemp, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
@@ -10,6 +10,7 @@ import { loadProjectApprovals } from "../permission/store.js";
1010
import {
1111
secretGuardPlugin,
1212
isSensitivePath,
13+
isSensitiveShellToken,
1314
commandReferencesSensitivePath,
1415
} from "./secret-guard-plugin.js";
1516

@@ -314,6 +315,53 @@ describe("commandReferencesSensitivePath", () => {
314315
}
315316
});
316317

318+
describe("commandReferencesSensitivePath shell-variable expansion (CL-8999)", () => {
319+
const CFG_VALUE = "/tmp/cl-8999-cfg/.corbits";
320+
let savedCFG: string | undefined;
321+
let savedUnknown: string | undefined;
322+
323+
beforeEach(() => {
324+
savedCFG = process.env.CFG;
325+
savedUnknown = process.env.UNKNOWN_X;
326+
process.env.CFG = CFG_VALUE;
327+
delete process.env.UNKNOWN_X;
328+
});
329+
330+
afterEach(() => {
331+
if (savedCFG === undefined) delete process.env.CFG;
332+
else process.env.CFG = savedCFG;
333+
if (savedUnknown === undefined) delete process.env.UNKNOWN_X;
334+
else process.env.UNKNOWN_X = savedUnknown;
335+
});
336+
337+
const expandedSensitive = [
338+
"cat $HOME/.env",
339+
"cat ${HOME}/.env",
340+
'cat "$HOME/.env"',
341+
"cat ${CFG}/settings.json",
342+
"cat $CFG/settings.json",
343+
"cat $UNKNOWN_X/.env",
344+
];
345+
for (const c of expandedSensitive) {
346+
test(`flags: ${c}`, () =>
347+
expect(commandReferencesSensitivePath(c)).toBeDefined());
348+
}
349+
350+
test("flags an unexpandable variable reference fail-closed", () => {
351+
expect(isSensitiveShellToken("${BROKEN")).toBe(true);
352+
});
353+
354+
test("flags a variable-expanded token directly", () => {
355+
expect(isSensitiveShellToken("$CFG/settings.json")).toBe(true);
356+
});
357+
358+
const expandedBenign = ["cat $HOME/README.md", "cat Makefile"];
359+
for (const c of expandedBenign) {
360+
test(`allows: ${c}`, () =>
361+
expect(commandReferencesSensitivePath(c)).toBeUndefined());
362+
}
363+
});
364+
317365
describe("secretGuardPlugin run_shell", () => {
318366
// Shell commands that mention a secret path are no longer hard-denied here —
319367
// they require operator approval at the permission gate. The plugin only

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

Lines changed: 111 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ export function createExtraDeniedPathMatcher(
203203
// Path-keyed tools stay hard-denied below.
204204
//
205205
// RESIDUAL THREAT MODEL: shell detection is best-effort. Token matching defeats
206-
// quoting/escaping and the common env-assignment and redirection forms, but not
206+
// quoting/escaping, the common env-assignment and redirection forms, and direct
207+
// variable references — `$VAR`, `${VAR}`, `${VAR:-default}`, and `~` expand
208+
// against process.env before matching, so `cat $HOME/.env` prompts — but not
207209
// dynamic construction of a path the matcher never sees as one token — e.g.
208210
// indirection through an unrelated variable (`F=.en; cat ${F}v`), character-by-
209211
// character assembly (`printf`), or reading via an interpreter that builds the
@@ -218,20 +220,18 @@ export function createExtraDeniedPathMatcher(
218220
export const PURE_DIRECTORY_LISTING_PROGRAMS = new Set(["ls", "tree"]);
219221

220222
// Worth spending a realpath on: shaped like a path the shell could open
221-
// (a slash, an extension dot, or absolute), not a flag, variable, or fd
223+
// (a slash, an extension dot, or absolute), not a flag, glob, or fd
222224
// number — those can never name a file the shell opens, so they skip the
223-
// stat and the hot auto-allow path stays syscall-free for them. Globs are
224-
// skipped here for a different reason: the matcher only sees the unexpanded
225-
// pattern, so `cat *.txt` cannot resolve without running the shell — but a
226-
// glob CAN expand into a symlink at runtime, which stays a stated residual
227-
// (see the threat model below), not something this filter disproves.
225+
// stat and the hot auto-allow path stays syscall-free for them. Shell
226+
// variables reach here already expanded (see expandShellToken), so there is
227+
// no `$` exemption: an unexpandable token fails closed before this filter.
228+
// Globs are skipped here for a different reason: the matcher only sees the
229+
// unexpanded pattern, so `cat *.txt` cannot resolve without running the
230+
// 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.
228233
function isPathLikeShellToken(token: string): boolean {
229-
if (
230-
token.startsWith("-") ||
231-
token.includes("$") ||
232-
token.includes("*") ||
233-
token.includes("`")
234-
)
234+
if (token.startsWith("-") || token.includes("*") || token.includes("`"))
235235
return false;
236236
return (
237237
isAbsolute(token) ||
@@ -252,16 +252,105 @@ export function expandHome(token: string): string {
252252
return token;
253253
}
254254

255+
// Expand a shell token's `~` and `$` references against process.env only —
256+
// never shells out. Handles `$VAR`, `${VAR}`, `${VAR:-default}` /
257+
// `${VAR-default}`, and a single layer of surrounding quotes; `\$` is a
258+
// literal dollar and unset variables expand to empty. A `$` followed by any
259+
// other character (or at end of token) is a literal dollar, matching shell
260+
// behavior for `$.`, `$"`, and friends. A backtick or `$(` the tokenizer left
261+
// whole comes from single quotes, where the shell never substitutes — it is
262+
// matched as literal text. Returns expandable=false only when the token
263+
// cannot be resolved statically: a malformed `${…}` or an unsupported
264+
// operator (`:=`, `:?`, `:+`, `#`, `%`, `/`). Callers fail closed on
265+
// expandable=false: the shell would compute the value at runtime, so the
266+
// matcher must assume the worst.
267+
export interface ExpandedShellToken {
268+
expanded: string;
269+
expandable: boolean;
270+
}
271+
272+
const SHELL_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*/;
273+
const SHELL_BRACED_VAR = /^([A-Za-z_][A-Za-z0-9_]*)(:-(.*)|-(.*)|)$/s;
274+
275+
export function expandShellToken(
276+
token: string,
277+
dialect: ShellDialect = "posix",
278+
): ExpandedShellToken {
279+
let text = token;
280+
if (
281+
text.length >= 2 &&
282+
((text.startsWith('"') && text.endsWith('"')) ||
283+
(text.startsWith("'") && text.endsWith("'")))
284+
) {
285+
text = text.slice(1, -1);
286+
}
287+
if (text.includes("`") || text.includes("$(")) {
288+
return { expanded: text, expandable: true };
289+
}
290+
if (text === "~") text = homedir();
291+
else if (text.startsWith("~/")) text = joinPath(homedir(), text.slice(2));
292+
// In cmd `$` is literal (`type .flaskenv::$DATA` names the default ADS
293+
// stream — there is no `$VAR` expansion, only `%VAR%`), so expanding would
294+
// corrupt the token before matching. Only posix-style dialects expand.
295+
if (dialect === "cmd") return { expanded: text, expandable: true };
296+
let expanded = "";
297+
for (let i = 0; i < text.length;) {
298+
const char = text[i] ?? "";
299+
if (char === "\\" && text[i + 1] === "$") {
300+
expanded += "$";
301+
i += 2;
302+
continue;
303+
}
304+
if (char !== "$") {
305+
expanded += char;
306+
i += 1;
307+
continue;
308+
}
309+
const rest = text.slice(i + 1);
310+
if (rest.startsWith("{")) {
311+
const close = text.indexOf("}", i + 2);
312+
if (close === -1) return { expanded: token, expandable: false };
313+
const match = SHELL_BRACED_VAR.exec(text.slice(i + 2, close));
314+
if (match === null) return { expanded: token, expandable: false };
315+
const value = process.env[match[1] ?? ""];
316+
const fallback = match[3] ?? match[4];
317+
if (fallback === undefined) {
318+
expanded += value ?? "";
319+
} else if (
320+
value === undefined ||
321+
(match[3] !== undefined && value === "")
322+
) {
323+
const inner = expandShellToken(fallback, dialect);
324+
if (!inner.expandable) return { expanded: token, expandable: false };
325+
expanded += inner.expanded;
326+
} else {
327+
expanded += value;
328+
}
329+
i = close + 1;
330+
continue;
331+
}
332+
const name = SHELL_VAR_NAME.exec(rest)?.[0];
333+
if (name !== undefined) {
334+
expanded += process.env[name] ?? "";
335+
i += 1 + name.length;
336+
continue;
337+
}
338+
expanded += "$";
339+
i += 1;
340+
}
341+
return { expanded, expandable: true };
342+
}
343+
255344
// A bare token the shell could open as a cwd-relative file: not a flag,
256-
// variable, glob, or command substitution — same exclusions as the path-like
345+
// glob, or command substitution — same exclusions as the path-like
257346
// filter, minus the dot/slash shape requirement, so extensionless names
258347
// (`notes`, or `notes` split out of `--file=notes` / `cat -n notes`) still
259-
// get an existence probe below.
348+
// get an existence probe below. Shell variables reach here already expanded,
349+
// so there is no `$` exemption (see expandShellToken).
260350
function isBareProbeCandidate(token: string): boolean {
261351
return (
262352
token.length > 0 &&
263353
!token.startsWith("-") &&
264-
!token.includes("$") &&
265354
!token.includes("*") &&
266355
!token.includes("`")
267356
);
@@ -277,11 +366,12 @@ function isBareProbeCandidate(token: string): boolean {
277366
// tokens first pay a single lstat existence probe against the cwd-resolved
278367
// path — a miss (the common `cat Makefile` case) costs exactly that one
279368
// lstat and skips the resolve, a hit (file or symlink, dangling included)
280-
// pays the realpath and matches on the target. Flags, variables, globs, and
369+
// pays the realpath and matches on the target. Flags, globs, and
281370
// backticks never probe, so the worst case per command is one lstat per bare
282371
// token plus one realpath per existing entry. Relative tokens resolve
283-
// against cwd first because the helper takes absolute paths; `~` expands to
284-
// the home directory before resolving for the same reason. That cwd is the
372+
// against cwd first because the helper takes absolute paths; `~` and
373+
// `$VAR`/`${VAR}` expand against process.env before resolving (see
374+
// expandShellToken) for the same reason. That cwd is the
285375
// session/process cwd, not a `cd` prefix inside the command —
286376
// `cd sub && cat notes.txt` resolves `notes.txt` against the session cwd
287377
// (absent) rather than cwd/sub (present). The chain still fails closed
@@ -303,7 +393,8 @@ export function isSensitiveShellToken(
303393
isExtraDenied: (value: string) => boolean = () => false,
304394
dialect: ShellDialect = nativeShellDialect(process.platform),
305395
): boolean {
306-
const expanded = expandHome(token);
396+
const { expanded, expandable } = expandShellToken(token, dialect);
397+
if (!expandable) return true;
307398
if (isSensitivePath(expanded, dialect)) return true;
308399
if (isExtraDenied(expanded)) return true;
309400
if (!resolveSymlinks) {

0 commit comments

Comments
 (0)