diff --git a/changelog.d/gst-1064-secrets-call-literal.md b/changelog.d/gst-1064-secrets-call-literal.md index e64d6f09a..35c25132b 100644 --- a/changelog.d/gst-1064-secrets-call-literal.md +++ b/changelog.d/gst-1064-secrets-call-literal.md @@ -1,2 +1,2 @@ ### Fixed -- **The secrets check no longer reads a file name or dict key inside a call as a credential (GST-1064).** `key = json.loads(files.get("key.json", ""))` failed pregate with `PREGATE_SECRETS` (`env.kv`): eight distinct letters in `"key.json"` reach the entropy bar, so the call's argument looked like a secret. So did `token = resp.json().get("access_token")`. A quoted argument that is a lowercase file name or a snake_case key of two or three words now counts as a name. A real key in quotes, in a `.env` line, or passed to a call still reports, and so does a passphrase (`decrypt("prod-signing-key")`, a `getenv` default like `"correct-horse-battery-staple"`). The entropy bar is unchanged. +- **The secrets check no longer reads a file name or dict key inside a call as a credential (GST-1064).** `key = json.loads(files.get("key.json", ""))` failed pregate with `PREGATE_SECRETS` (`env.kv`): eight distinct letters in `"key.json"` reach the entropy bar, so the call's argument looked like a secret. So did `token = resp.json().get("access_token")`. A call's first argument that is a lowercase file name or a snake_case key of two or three words now counts as a name. A later argument or fallback is still a value: `os.getenv("SECRET_KEY", "orange_monkey_pass")` and `.get("access_token", "")` still report. So do a real key in quotes or in a `.env` line, and a passphrase passed to a call (`decrypt("prod-signing-key")`). The entropy bar is unchanged. diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index a2c2eea82..56d93ad09 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -627,19 +627,10 @@ function isBareEnvRead(span: string, match: RegExpExecArray): boolean { return !carriesSecretLiteral(match.input.slice(match.index + match[0].length).split("\n", 1)[0]); } -/** - * True when `text` holds a quoted, non-placeholder, high-entropy literal. - * GST-1064: a lowercase file name (`"key.json"`) or a snake_case key of two or - * three words (`"access_token"`) names what the call reads; eight distinct - * letters already reach 3.0 bits, so entropy alone cannot tell them from a - * secret. A passphrase still counts: kebab or dotted words - * (`"correct-horse-battery-staple"`), four or more snake_case words, a digit, - * an uppercase letter or one unbroken run. - */ -const NAME_SHAPED_LITERAL = /^(?:[a-z]+(?:_[a-z]+){1,2}|[a-z]+(?:[_-][a-z]+)*\.[a-z]{2,5})$/; +/** True when `text` holds a quoted, non-placeholder, high-entropy literal. */ function carriesSecretLiteral(text: string): boolean { for (const [, literal] of text.matchAll(/["']([^\s'"]{8,})["']/g)) - if (!isPlaceholderSpan(literal) && !NAME_SHAPED_LITERAL.test(literal) && shannonEntropy(literal) >= 3.0) return true; + if (!isPlaceholderSpan(literal) && shannonEntropy(literal) >= 3.0) return true; return false; } @@ -670,13 +661,25 @@ const SCHEMA_FIELD_VALUE = /^[a-z][a-z0-9]*(?:[_.-][a-z][a-z0-9]*)+$/; function isSchemaFieldIdentifier(span: string, match: RegExpExecArray): boolean { return SCHEMA_FIELD_NAME.test(match[0]) && SCHEMA_FIELD_VALUE.test(span); } +/** + * GST-1064: a call's first argument that is a lowercase file name + * (`files.get("key.json")`) or a snake_case key of two or three words + * (`resp.json().get("access_token")`) names what the call reads. Eight distinct + * letters already reach 3.0 bits, so entropy alone cannot tell it from a + * secret. Only that first position is a lookup: a later argument or fallback + * (`os.getenv("X", "orange_monkey_pass")`, `.get("k") or "…"`) is a value and + * is still checked, and so is a first argument with a digit, an uppercase + * letter, kebab words or four or more snake_case words. + */ +const LOOKUP_ARGUMENT = + /\([ \t]*(["'])(?:[a-z]+(?:_[a-z]+){1,2}|[a-z]+(?:[_-][a-z]+)*\.(?:json|ya?ml|toml|ini|cfg|conf|env|pem|crt|cer|key|txt|csv|xml|cookie))\1/g; function isCallExpression(span: string, match: RegExpExecArray): boolean { if (!CALL_SHAPED_VALUE.test(span)) return false; const { start } = spanBounds(match); if (match.input[start - 1] === '"' || match.input[start - 1] === "'") return false; const call = match.input.slice(start + span.indexOf("(")).split("\n", 1)[0]; if (!call.includes(")") && call.trim() !== "(") return false; - return !carriesSecretLiteral(call); + return !carriesSecretLiteral(call.replace(LOOKUP_ARGUMENT, "(")); } /** diff --git a/test/redact-context-exemptions.test.ts b/test/redact-context-exemptions.test.ts index 3ccbd5148..74fc2a300 100644 --- a/test/redact-context-exemptions.test.ts +++ b/test/redact-context-exemptions.test.ts @@ -129,6 +129,11 @@ describe("env.kv: a name-shaped literal inside a call is a file or dict key, not String.raw`api_key = decrypt("prod-signing-key")`, String.raw`token = decrypt("correct_horse_battery_staple")`, String.raw`key = decrypt("qwhzxkvbnmplor")`, + String.raw`SECRET_KEY = os.getenv("SECRET_KEY", "orange_monkey_pass")`, + String.raw`password = os.environ.get("DB_PASSWORD", "purple_walrus")`, + String.raw`token = resp.json().get("access_token", "orange_monkey_pass")`, + String.raw`key = derive_secret("tigers.horse")`, + String.raw`password = os.getenv("DB_PASSWORD", "qwhzxkvbnmplor.txt")`, ], ); });