fix(redact): env.value trims the value, keeps a prefix value's longer match, hides the preview and skips PUBLIC names

/ship Step 9.4 review fixes for the GSTA-104 env-value scan:

- a value whose environment copy ends in a newline (a key read from a file)
  is searched for trimmed, so its leaked form still matches (the length
  check already trimmed; the search used the raw value);
- when one credential value is a prefix of another, the dedup key carries the
  span length, so the longer value is redacted whole in either order instead
  of leaving its tail in the text and dropping its finding;
- the env.value preview is a fixed mask instead of the first characters of a
  live credential;
- framework-public names (NEXT_PUBLIC_*_KEY, *_PUBLIC_KEY) are not scanned,
  since those values are committed on purpose;
- the header and the processEnv docstring state the name rule (KEY, TOKEN,
  SECRET, PASSWORD, PASSWD, any case) and that process.env is the engine's
  one ambient input.

Step 7 coverage extensions and a regression test for each fix in
test/redact-env-value.test.ts; docs/capy-install.md names the full rule.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 04:19:17 +00:00
1 parent c33c796623
commit 58f456acb1
3 files changed
+84 -13

No files matched your search

+4 -3
View File
@@ -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=<dir>:$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
+21 -10
View File
@@ -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<Finding, Ori
while ((normOffset = normalized.indexOf(value, from)) !== -1) {
from = normOffset + value.length;
const origOffset = map[Math.min(normOffset, map.length - 1)] ?? 0;
const key = `env.value:${origOffset}`;
// Keyed on the span, not the offset alone: when one credential's value is
// a prefix of another's, both findings exist and their spans coalesce.
const key = `env.value:${origOffset}:${value.length}`;
if (seen.has(key)) continue;
seen.add(key);
starts ??= lineStarts(input);
@@ -491,7 +496,8 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map<Finding, Ori
description: `Value of the ${name} environment variable`,
line,
col,
preview: maskPreview(value),
// A live credential: the name already says what leaked; show no bytes.
preview: "********",
autoRedactable: false,
repoVisibility,
};
@@ -513,18 +519,23 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map<Finding, Ori
}
const CREDENTIAL_ENV_NAME = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD)$/i;
/** Framework-public keys (NEXT_PUBLIC_*_KEY, VITE_PUBLIC_*, *_PUBLIC_KEY) are committed on purpose. */
const PUBLIC_ENV_NAME = /(^|_)PUBLIC(_|$)/i;
const MIN_ENV_VALUE_LENGTH = 8;
/**
* The (name, value) pairs of `env` worth scanning for by value: credential-named
* variables whose value is long enough to be a credential and is not a
* placeholder. A short or placeholder value would match ordinary prose.
* variables whose trimmed value is long enough to be a credential and is not a
* placeholder. A short or placeholder value would match ordinary prose. The
* value is trimmed, so a key read from a file with a trailing newline still
* matches its leaked form.
*/
export function credentialEnvValues(env: Record<string, string | undefined>): 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 });
}
+59
View File
@@ -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: <REDACTED-env.value> 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("<REDACTED-env.value>");
});
// /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: <REDACTED-env.value> 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";