From 098e2837d18be9acf4ffe19bb3d18d287dfe34c4 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 21:26:03 +0000 Subject: [PATCH] fix(redact): a name-shaped literal inside a call is not a secret (GST-1064) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gst-1064-secrets-call-literal.md | 2 ++ lib/redact-patterns.ts | 12 ++++++++-- test/pregate-secrets-source-context.test.ts | 9 ++++++++ test/redact-context-exemptions.test.ts | 23 ++++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 changelog.d/gst-1064-secrets-call-literal.md diff --git a/changelog.d/gst-1064-secrets-call-literal.md b/changelog.d/gst-1064-secrets-call-literal.md new file mode 100644 index 000000000..b43df3d5c --- /dev/null +++ b/changelog.d/gst-1064-secrets-call-literal.md @@ -0,0 +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")` and a `getenv` default like `"change-me-later"`. A quoted argument made only of lowercase words joined by `_`, `.` or `-` now counts as a name. A real key in quotes, in a `.env` line, or passed to a call (`decrypt("")`) still reports, and the entropy bar is unchanged. diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 1d7ef39b9..9f1e8be67 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -627,10 +627,18 @@ 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. */ +/** + * True when `text` holds a quoted, non-placeholder, high-entropy literal. + * GST-1064: lowercase letter words joined by `_`, `.` or `-` name a file, dict + * key or default (`"key.json"`, `"access_token"`, `"change-me-later"`); eight + * distinct letters already reach 3.0 bits, so entropy alone cannot tell them + * from a secret. A literal with a digit, an uppercase letter or one unbroken + * run still counts. + */ +const NAME_SHAPED_LITERAL = /^[a-z]+(?:[_.-][a-z]+)+$/; function carriesSecretLiteral(text: string): boolean { for (const [, literal] of text.matchAll(/["']([^\s'"]{8,})["']/g)) - if (!isPlaceholderSpan(literal) && shannonEntropy(literal) >= 3.0) return true; + if (!isPlaceholderSpan(literal) && !NAME_SHAPED_LITERAL.test(literal) && shannonEntropy(literal) >= 3.0) return true; return false; } diff --git a/test/pregate-secrets-source-context.test.ts b/test/pregate-secrets-source-context.test.ts index 27777514c..6db4f99c9 100644 --- a/test/pregate-secrets-source-context.test.ts +++ b/test/pregate-secrets-source-context.test.ts @@ -54,6 +54,15 @@ describe('pregate secrets: source context and diff integrity', () => { expect([...parseAddedLines(diff)]).toEqual([['a.ts', ['one', '++two']], ['b.env', ['three']]]); }); + test('a Python call that reads key.json is code; the same key name holding a literal key still fails (GST-1064)', () => { + expect(repo({ 'attach.py': 'import json\nkey = json.loads(files.get("key.json", ""))\n' }).secrets().status).toBe('pass'); + const fake = 'Zx9Q' + 'w8Er7Ty6Ui5Op4As3Df2'; + const r = repo({ 'attach.py': `key = json.loads(files.get("key.json")) or "${fake}"\n`, '.env': `KEY=${fake}\n` }).secrets(); + expect(r.status).toBe('fail'); + expect(r.lines.join('\n')).toContain('attach.py'); + expect(r.lines.join('\n')).toContain('.env'); + }); + test('a TS object property that reads a credential at runtime is code, not a secret (#3211)', () => { const f = repo({ 'app.ts': 'const cfg = {\n token: credentials.token,\n apiKey: settings.apiKey,\n};\n' }); expect(f.secrets().status).toBe('pass'); diff --git a/test/redact-context-exemptions.test.ts b/test/redact-context-exemptions.test.ts index 39dd75a6c..8a82b76bc 100644 --- a/test/redact-context-exemptions.test.ts +++ b/test/redact-context-exemptions.test.ts @@ -106,6 +106,29 @@ describe("env.kv: a function call assigned to a credential name is code (#2899)" ); }); +describe("env.kv: a name-shaped literal inside a call is a file or dict key, not a secret (GST-1064)", () => { + const FAKE_KEY = "Zx9Qw8Er7Ty6Ui5Op4As3Df2"; + const FAKE_STRIPE_TAIL = "4eC39HqLyjWDarjtT1zdp7dc"; + table( + "env.kv", + "MEDIUM", + [ + String.raw`key = json.loads(files.get("key.json", ""))`, + String.raw`token = resp.json().get("access_token")`, + String.raw` session = store.load('session.cookie')`, + String.raw`password = os.getenv("DB_PASSWORD", "change-me-later")`, + ], + [ + String.raw`key = "${FAKE_KEY}"`, + String.raw`KEY=${FAKE_KEY}`, + String.raw`key = json.loads("sk_live_${FAKE_STRIPE_TAIL}")`, + String.raw`token = resp.json().get("access_token", "${FAKE_KEY}")`, + String.raw`password = os.getenv("DB_PASSWORD", "hunter2.Zx9Qw8")`, + String.raw`key = json.loads(files.get("key.json")) or "${FAKE_KEY}"`, + ], + ); +}); + describe("internal.hostname: per-machine config filenames are not hosts (#2962, #2740)", () => { table( "internal.hostname",