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";