diff --git a/docs/capy-install.md b/docs/capy-install.md index 0adac0eb6..1477f21e7 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -343,9 +343,10 @@ back empty, the bare `export` printed the whole environment, and three API key values landed in its tool output. Never `export $(...)`, `env`, `printenv`, a bare `set` or a bare `export` in a tool fence (`test/launch-env-echo-ratchet.test.ts` keeps them out of every skill fence and shell helper), and the redaction -engine now refuses the VALUE of any `*_KEY`, `*_TOKEN`, `*_SECRET` or -`*_PASSWORD` variable in the writer's environment (`env.value`, HIGH), so a -leaked value cannot reach a PR body, receipt, report or Drive file. +engine now refuses the VALUE of any `*_KEY`, `*_TOKEN`, `*_SECRET`, +`*_PASSWORD` or `*_PASSWD` variable in the writer's environment (any case; +names carrying a `PUBLIC` word are framework-public and exempt) (`env.value`, +HIGH), so a leaked value cannot reach a PR body, receipt, report or Drive file. `PATH=:$PATH` and `GSTACK_LAUNCH_PATH` appear only when the certified Bun, or the project's resolved Node, is not first on PATH (older Capy images ship diff --git a/lib/redact-engine.ts b/lib/redact-engine.ts index 43f490f41..702bd2be5 100644 --- a/lib/redact-engine.ts +++ b/lib/redact-engine.ts @@ -1,7 +1,9 @@ /** * redact-engine — pure scanning + auto-redaction over the shared taxonomy. * - * No I/O. Deterministic. The CLI shim (`bin/gstack-redact`), the pre-push hook + * No I/O. Deterministic given its one ambient input: `scan()` reads the + * credential-named variables of `process.env` by value unless `processEnv` is + * passed (GSTA-104). The CLI shim (`bin/gstack-redact`), the pre-push hook * (`bin/gstack-redact-prepush`), and tests all import from here. * * Key behaviors (locked in /plan-eng-review + two Codex passes): @@ -76,8 +78,9 @@ export interface ScanOptions { */ sourcePath?: string; /** - * The environment whose credential-named variables (`*_KEY`, `*_TOKEN`, - * `*_SECRET`, `*_PASSWORD`) are scanned for BY VALUE: wherever one of those + * The environment whose credential-named variables (a name whose last + * `_`-separated word is KEY, TOKEN, SECRET, PASSWORD or PASSWD, any case, + * and that carries no PUBLIC word) are scanned for BY VALUE: wherever one of those * values appears in the text it is an `env.value` HIGH finding, whatever its * shape, so a value that reached the text (a dumped environment in a tool * output, GSTA-96) never reaches a PR body, receipt, report or Drive file. @@ -478,7 +481,9 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map): Array<{ name: string; value: string }> { const out: Array<{ name: string; value: string }> = []; - for (const [name, value] of Object.entries(env)) { - if (!CREDENTIAL_ENV_NAME.test(name)) continue; - if (!value || value.trim().length < MIN_ENV_VALUE_LENGTH) continue; + for (const [name, raw] of Object.entries(env)) { + if (!CREDENTIAL_ENV_NAME.test(name) || PUBLIC_ENV_NAME.test(name)) continue; + const value = raw?.trim() ?? ""; + if (value.length < MIN_ENV_VALUE_LENGTH) continue; if (isPlaceholderSpan(value)) continue; out.push({ name, value }); } diff --git a/test/redact-env-value.test.ts b/test/redact-env-value.test.ts index cf7dc1af0..a27ab7c48 100644 --- a/test/redact-env-value.test.ts +++ b/test/redact-env-value.test.ts @@ -18,6 +18,11 @@ const anthropicValue = "zq7Vm2pL9xR4tW8yB3nK6hJ1"; const ubicloudValue = "ubi-plain-looking-value-0042"; const ghValue = "f0a9c2e1d7b6"; const dbValue = "hunter2hunter2"; +const awsSecretValue = "s3cr3t-s3cr3t-s3cr3t"; +const dbPasswdValue = "passwd-passwd-passwd"; +const lowerCaseValue = "lower-case-name-value"; +const monkeyValue = "banana-banana-banana"; +const tokenizerValue = "tokenizer-value-here"; const env = { ANTHROPIC_API_KEY: anthropicValue, @@ -35,6 +40,36 @@ describe("env-value scan", () => { test("picks only credential-named variables with credential-sized, non-placeholder values", () => { const names = credentialEnvValues(env).map(e => e.name).sort(); expect(names).toEqual(["ANTHROPIC_API_KEY", "DB_PASSWORD", "GH_TOKEN", "UBICLOUD_API_KEY"]); + // /ship Step 7 value card. protects: the name filter is a suffix WORD (_KEY, _TOKEN, _SECRET, + // _PASSWORD, _PASSWD, any case), not a suffix string, and an unset or whitespace-padded short + // value is never scanned for. fails_when: the (^|_) anchor is dropped (MONKEY's value becomes a + // HIGH finding and blocks a clean PR body), SECRET/PASSWD/lowercase names stop being scanned (a + // leaked AWS_SECRET passes), or the trim guard goes (a padded 7-character value matches prose). + // why_existing_misses: the table above holds only *_KEY/*_TOKEN/*_PASSWORD upper-case names and + // no undefined or padded value. seam=none. + const edges = { + AWS_SECRET: awsSecretValue, DB_PASSWD: dbPasswdValue, api_key: lowerCaseValue, + MONKEY: monkeyValue, TOKENIZER: tokenizerValue, UNSET_KEY: undefined, PADDED_KEY: " abcdefg ", EIGHT_KEY: "12345678", + }; + expect(credentialEnvValues(edges).map(e => e.name).sort()).toEqual(["AWS_SECRET", "DB_PASSWD", "EIGHT_KEY", "api_key"]); + expect(scan(`monkeys: ${monkeyValue}; tokens: ${tokenizerValue}`, { processEnv: edges }).findings.filter(f => f.id === "env.value")).toEqual([]); + }); + + // /ship Step 7 value card. protects: the value is matched on the NORMALIZED text (the engine's + // documented first rule), so a credential split by a zero-width character or re-typed in + // fullwidth forms is still an env.value finding, and the span maps back to the original bytes so + // redaction removes the whole value, zero-width filler included. fails_when: the loop matches + // `input` instead of `normalized`, or the span end is taken from the normalized offset. + // why_existing_misses: every value above appears verbatim in ASCII. seam=none. + test("a value split by a zero-width character or written in fullwidth forms is still found, and redaction removes the original bytes", () => { + const split = `${anthropicValue.slice(0, 6)}\u200b${anthropicValue.slice(6)}`; + const text = `leak: ${split} end`; + expect(scan(text, { processEnv: env }).findings.map(f => [f.id, f.line, f.col])).toEqual([["env.value", 1, 7]]); + const redacted = redactFindingSpans(text, { processEnv: env }); + expect(redacted).toBe("leak: end"); + const fullwidth = anthropicValue.replace(/[0-9A-Za-z]/g, c => String.fromCharCode(c.charCodeAt(0) + 0xfee0)); + expect(fullwidth).not.toBe(anthropicValue); + expect(scan(`x ${fullwidth} y`, { processEnv: env }).findings.map(f => f.id)).toEqual(["env.value"]); }); test("a dumped environment line is HIGH by value, whatever the value's shape", () => { @@ -80,6 +115,30 @@ describe("env-value scan", () => { expect(redacted).toContain(""); }); + // /ship Step 9.4 review fixes, each pinned: (1) a value whose env copy carries a trailing newline + // (a key read from a file) is searched for TRIMMED, so its leaked form still matches; (2) when one + // value is a prefix of another, both are findings and the longer value is redacted whole, in either + // order (the dedup key carries the length, not the offset alone); (3) the preview never shows any + // byte of a live value; (4) a framework-public name (NEXT_PUBLIC_*_KEY, *_PUBLIC_KEY) is not + // scanned, since that value is committed on purpose. fails_when: the trim moves back to the + // length check only, the dedup key drops the length, maskPreview returns, or the PUBLIC exclusion goes. + test("a padded value, a prefix value, the preview and PUBLIC-named variables", () => { + const padded = { FILE_KEY: `${anthropicValue}\n` }; + expect(scan(`leak: ${anthropicValue} end`, { processEnv: padded }).findings.map(f => f.id)).toEqual(["env.value"]); + const short = anthropicValue.slice(0, 12); + const text = `leak: ${anthropicValue} end`; + for (const order of [{ A_KEY: short, B_KEY: anthropicValue }, { B_KEY: anthropicValue, A_KEY: short }]) { + expect(scan(text, { processEnv: order }).findings.map(f => f.description).sort()).toEqual([ + "Value of the A_KEY environment variable", "Value of the B_KEY environment variable", + ]); + expect(redactFindingSpans(text, { processEnv: order })).toBe("leak: end"); + } + const preview = scan(text, { processEnv: env }).findings.find(f => f.id === "env.value")?.preview ?? ""; + expect(preview).toBe("********"); + expect(scan(`pk: ${monkeyValue}`, { processEnv: { NEXT_PUBLIC_STRIPE_KEY: monkeyValue, APP_PUBLIC_KEY: monkeyValue } }).findings).toEqual([]); + expect(credentialEnvValues({ PUBLICITY_KEY: monkeyValue }).map(e => e.name)).toEqual(["PUBLICITY_KEY"]); + }); + test("the default environment is process.env", () => { const name = "GSTA104_TEST_TOKEN"; process.env[name] = "v4lue-only-this-test-knows";