mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 19:44:48 +01:00
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:
1 parent
9a475d5de3
commit
726fe107d2
3 files changed
+43
-12
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user