From 726fe107d220e68db49f1810660d1f8c32d76a4c Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 04:37:49 +0000 Subject: [PATCH] fix(redact): env.value admits *_PASS, *_PAT, *_APIKEY and *_PWD, and skips path values and gstack's lock token /ship Step 11 adversarial findings. The name rule also covers DB_PASS, GITHUB_PAT, *_APIKEY and *_PWD (the shell's own PWD and OLDPWD stay out). A credential-named variable whose value is a filesystem path (SSL_KEY=/etc/ssl/ private/x.key) names where the credential lives, so the path is no finding, and GSTACK_BRAIN_LOCK_TOKEN (pid-random-epoch, written to the lock owner file) is not a secret. Without these, an ordinary mention of that path or token in a PR body, receipt or CSO report was a HIGH fail-closed blocker. The adversarial pass also recommended an entropy floor so dev defaults such as DB_PASSWORD=postgres stop matching prose; declined here on purpose, because the scan's promise is to catch a leaked value whatever its shape, and a weak real password is still a credential. Recorded as an open decision in the ship report. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/capy-install.md | 9 ++++++--- lib/redact-engine.ts | 25 ++++++++++++++++--------- test/redact-env-value.test.ts | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/docs/capy-install.md b/docs/capy-install.md index 1477f21e7..8ad45c445 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -344,9 +344,12 @@ 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`, -`*_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. +`*_PASSWORD`, `*_PASSWD`, `*_PASS`, `*_PAT`, `*_APIKEY` or `*_PWD` variable +in the writer's environment (any case; names carrying a `PUBLIC` word are +framework-public and exempt, gstack's own `GSTACK_BRAIN_LOCK_TOKEN` is not a +secret, and a value that is a filesystem path names where the credential lives +rather than the credential) (`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 702bd2be5..d3e39ece9 100644 --- a/lib/redact-engine.ts +++ b/lib/redact-engine.ts @@ -79,8 +79,9 @@ export interface ScanOptions { sourcePath?: string; /** * 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 + * `_`-separated word is KEY, TOKEN, SECRET, PASSWORD, PASSWD, PASS, PAT or + * APIKEY, or that ends in _PWD, any case, carrying no PUBLIC word; see + * `credentialEnvValues`) 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. @@ -518,25 +519,31 @@ function scanInternal(input: string, opts: ScanOptions, spans?: Map): Array<{ name: string; value: string }> { const out: Array<{ name: string; value: string }> = []; for (const [name, raw] of Object.entries(env)) { - if (!CREDENTIAL_ENV_NAME.test(name) || PUBLIC_ENV_NAME.test(name)) continue; + if (!CREDENTIAL_ENV_NAME.test(name) || PUBLIC_ENV_NAME.test(name) || NON_SECRET_ENV_NAMES.has(name)) continue; const value = raw?.trim() ?? ""; if (value.length < MIN_ENV_VALUE_LENGTH) continue; - if (isPlaceholderSpan(value)) continue; + if (isPlaceholderSpan(value) || PATH_VALUE.test(value)) continue; out.push({ name, value }); } return out; diff --git a/test/redact-env-value.test.ts b/test/redact-env-value.test.ts index a27ab7c48..7282f7ecb 100644 --- a/test/redact-env-value.test.ts +++ b/test/redact-env-value.test.ts @@ -23,6 +23,10 @@ const dbPasswdValue = "passwd-passwd-passwd"; const lowerCaseValue = "lower-case-name-value"; const monkeyValue = "banana-banana-banana"; const tokenizerValue = "tokenizer-value-here"; +const sslKeyPath = "/etc/ssl/private/x.key"; +const winKeyPath = "C:\\Users\\me\\key.pem"; +const homeKeyPath = "~/.ssh/id_key"; +const lockOwner = ["12345", "6789", "17"].join("-"); const env = { ANTHROPIC_API_KEY: anthropicValue, @@ -139,6 +143,23 @@ describe("env-value scan", () => { expect(credentialEnvValues({ PUBLICITY_KEY: monkeyValue }).map(e => e.name)).toEqual(["PUBLICITY_KEY"]); }); + // /ship Step 11 adversarial fixes: (1) the name rule also admits *_PASS, *_PAT, *_APIKEY and *_PWD, + // while the shell's own PWD and OLDPWD stay out; (2) a credential-named variable whose value is a + // filesystem path (SSL_KEY=/etc/ssl/private/x.key) names where the credential lives, so the path + // is not a finding; (3) gstack's own GSTACK_BRAIN_LOCK_TOKEN (pid-random-epoch, written to the lock + // owner file) is not a secret. fails_when: PWD is admitted (every mention of the cwd becomes HIGH), + // the path rule goes, or the non-secret set loses the lock token. + test("more credential suffixes, path values and gstack's own lock token", () => { + const names = credentialEnvValues({ + DB_PASS: dbValue, GITHUB_PAT: ghValue, STRIPE_APIKEY: lowerCaseValue, MYSQL_PWD: dbPasswdValue, + PWD: "/workspace/gstack", OLDPWD: "/workspace/other", + SSL_KEY: sslKeyPath, WIN_KEY: winKeyPath, HOME_KEY: homeKeyPath, + GSTACK_BRAIN_LOCK_TOKEN: lockOwner, + }).map(e => e.name).sort(); + expect(names).toEqual(["DB_PASS", "GITHUB_PAT", "MYSQL_PWD", "STRIPE_APIKEY"]); + expect(credentialEnvValues({ SLASH_KEY: "/abc12345-no-second-slash" }).map(e => e.name)).toEqual(["SLASH_KEY"]); + }); + test("the default environment is process.env", () => { const name = "GSTA104_TEST_TOKEN"; process.env[name] = "v4lue-only-this-test-knows";