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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 04:37:49 +00:00
1 parent 9a475d5de3
commit 726fe107d2
3 files changed
+43 -12

No files matched your search

+6 -3
View File
@@ -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=<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
+16 -9
View File
@@ -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<Finding, Ori
return { findings, counts, repoVisibility, oversize: false };
}
const CREDENTIAL_ENV_NAME = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD)$/i;
const CREDENTIAL_ENV_NAME = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PAT|APIKEY)$|_PWD$/i;
/** Framework-public keys (NEXT_PUBLIC_*_KEY, VITE_PUBLIC_*, *_PUBLIC_KEY) are committed on purpose. */
const PUBLIC_ENV_NAME = /(^|_)PUBLIC(_|$)/i;
/** gstack's own credential-named variables whose values are not secrets. */
const NON_SECRET_ENV_NAMES = new Set(["GSTACK_BRAIN_LOCK_TOKEN"]);
/** A credential-named variable holding a filesystem path names where the credential lives, not the credential. */
const PATH_VALUE = /^(~?\/[^\s]*\/[^\s]*|[A-Za-z]:\\[^\s]*)$/;
const MIN_ENV_VALUE_LENGTH = 8;
/**
* The (name, value) pairs of `env` worth scanning for by value: credential-named
* 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.
* variables (the last `_`-separated word is KEY, TOKEN, SECRET, PASSWORD, PASSWD,
* PASS, PAT or APIKEY, or the name ends in _PWD; names carrying a PUBLIC word and
* gstack's own non-secret tokens are exempt) whose trimmed value is long enough to
* be a credential, is not a placeholder and is not a filesystem path. 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, 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;
+21
View File
@@ -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";