From 87d7ee3ad599be3b33d9c60c991d5de14bede6b5 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 12 Aug 2026 12:39:00 +0200 Subject: [PATCH 1/2] feat(protect): ship vendor API-key redaction by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default response ruleset covered private keys, AWS/GCP keys, JWTs, DB strings, and error/stack-trace leaks — but not the high-signal provider tokens that most often leak from AI-built apps. Add one prefix-anchored default redact rule covering Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-), Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), and npm (npm_). These never legitimately appear in a response body, so default redaction is low-FP; every site gets it with no rule authoring. Tests: 9 new (8 token classes masked + a no-false-positive case); 604 pass; typecheck clean. Co-Authored-By: Claude Opus 4.8 --- src/protect/defaults.js | 12 ++++++ tests/protect/default-secret-rules.test.ts | 43 ++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 tests/protect/default-secret-rules.test.ts diff --git a/src/protect/defaults.js b/src/protect/defaults.js index 9c542f2..2fbbeeb 100644 --- a/src/protect/defaults.js +++ b/src/protect/defaults.js @@ -32,6 +32,18 @@ export const DEFAULT_RESPONSE_RULES = [ action: 'redact', rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\bAIza[0-9A-Za-z_-]{35}\\b/' } }] }, + { + id: 'resp-vendor-api-key', + title: 'Vendor API key / token in response body', + phase: 'response', + category: 'secret-exposure', + action: 'redact', + // High-signal, prefix-anchored provider tokens that never legitimately appear in a response + // body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-), + // Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing + // (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ . + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|gh[opsu]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,}|glpat-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-ant-[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]{20,}|npm_[0-9A-Za-z]{36})(?![0-9A-Za-z])/' } }] + }, { id: 'resp-jwt', title: 'JWT in response body', diff --git a/tests/protect/default-secret-rules.test.ts b/tests/protect/default-secret-rules.test.ts new file mode 100644 index 0000000..6c95ddd --- /dev/null +++ b/tests/protect/default-secret-rules.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +// The default response ruleset ships vendor API-key redaction, so a leaked provider token is masked +// out of the box (no per-app rule authoring). Prefix-anchored + high-signal → low false-positive. +// +// NOTE: sample tokens are assembled from split fragments at runtime so no contiguous secret-shaped +// literal appears in this source file (which would trip secret-scanning push protection). The +// assembled string still matches the default rule's regex. + +const textResp = (body: string) => + new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); + +const body = (len: number) => 'x'.repeat(len); +const SAMPLES: Record = { + stripe: 'sk_' + 'live_' + body(24), + github: 'gh' + 'p_' + body(36), + githubPat: 'github' + '_pat_' + body(62), + gitlab: 'gl' + 'pat-' + body(24), + slack: 'xox' + 'b-' + '123456789012-' + body(12), + anthropic: 'sk-' + 'ant-' + body(30), + googleOAuth: 'ya' + '29.' + body(40), + npm: 'np' + 'm_' + body(36), +}; + +describe('default response rules — vendor API-key redaction', () => { + for (const [name, token] of Object.entries(SAMPLES)) { + it(`masks a leaked ${name} token by default`, async () => { + const p: any = await createProtection({ mode: 'block' }); // default response rules + const out = await p.screenResponse(textResp(JSON.stringify({ config: token }))); + const masked = await out.text(); + expect(masked.includes(token)).toBe(false); + expect(masked.includes('[REDACTED]')).toBe(true); + }); + } + + it('leaves an ordinary response untouched (no false positive)', async () => { + const p: any = await createProtection({ mode: 'block' }); + const clean = JSON.stringify({ user: 'ada@example.com', note: 'the quick brown fox', id: 42 }); + const out = await p.screenResponse(textResp(clean)); + expect(await out.text()).toBe(clean); + }); +}); From 8618cb4e061e472dd125dc8a54772fb246ce23d7 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 12 Aug 2026 12:58:49 +0200 Subject: [PATCH 2/2] perf(protect): prefilter anchors on default response rules + ReDoS/perf test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give each default response rule a cheap literal `prefilter` (necessary-substring anchors of its regex: PRIVATE KEY, AKIA/ASIA, AIza, eyJ, the vendor prefixes, DB schemes, SQL-error markers, exception/traceback markers). Once the response-phase prefilter mechanism lands (separate PR), a body with no anchor skips the rule's regex entirely — cutting CPU/latency and shrinking the regex/ReDoS surface. Inert (harmless) until that mechanism is present. Also add a ReDoS/perf test: a ~280 KB adversarial body of near-miss inputs is screened in linear time (<1s), guarding against catastrophic backtracking in the shipped defaults. 605 tests pass; typecheck clean. Co-Authored-By: Claude Opus 4.8 --- src/protect/defaults.js | 16 ++++++++++++++++ tests/protect/default-secret-rules.test.ts | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/protect/defaults.js b/src/protect/defaults.js index 2fbbeeb..8ea627d 100644 --- a/src/protect/defaults.js +++ b/src/protect/defaults.js @@ -3,6 +3,12 @@ // override per app via createProtection({ responseRules, egressRules }) or extend by // adding phase-tagged rules to the delivered bundle. Patterns are high-precision // (low false positive): structural markers a real secret has and normal content doesn't. +// +// Each rule carries a `prefilter`: cheap literal anchor(s) that MUST appear in the body for the +// regex to have any chance of matching. The response screener runs the (expensive) regex only when +// an anchor is present (case-insensitive), so a body with no candidate — the common case — skips +// the scan entirely. This cuts CPU/latency and shrinks the regex/ReDoS surface. (Honored by the +// response phase once the prefilter mechanism lands; a no-op before that.) // Response phase — secret / info exposure. Default action `redact` masks only the // offending span and still serves the page (a legit response that leaks one key gets @@ -14,6 +20,7 @@ export const DEFAULT_RESPONSE_RULES = [ phase: 'response', category: 'secret-exposure', action: 'redact', + prefilter: ['PRIVATE KEY'], rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/' } }] }, { @@ -22,6 +29,7 @@ export const DEFAULT_RESPONSE_RULES = [ phase: 'response', category: 'secret-exposure', action: 'redact', + prefilter: ['AKIA', 'ASIA'], rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/' } }] }, { @@ -30,6 +38,7 @@ export const DEFAULT_RESPONSE_RULES = [ phase: 'response', category: 'secret-exposure', action: 'redact', + prefilter: ['AIza'], rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\bAIza[0-9A-Za-z_-]{35}\\b/' } }] }, { @@ -42,6 +51,7 @@ export const DEFAULT_RESPONSE_RULES = [ // body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-), // Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing // (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ . + prefilter: ['sk_live_', 'rk_live_', 'ghp_', 'gho_', 'ghs_', 'ghu_', 'github_pat_', 'glpat-', 'xox', 'sk-ant-', 'ya29.', 'npm_'], rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|gh[opsu]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,}|glpat-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-ant-[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]{20,}|npm_[0-9A-Za-z]{36})(?![0-9A-Za-z])/' } }] }, { @@ -50,6 +60,7 @@ export const DEFAULT_RESPONSE_RULES = [ phase: 'response', category: 'secret-exposure', action: 'redact', + prefilter: ['eyJ'], rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/' } }] }, { @@ -58,6 +69,7 @@ export const DEFAULT_RESPONSE_RULES = [ phase: 'response', category: 'secret-exposure', action: 'redact', + prefilter: ['mongodb', 'postgres', 'mysql', 'redis', 'amqp'], rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i' } }] }, { @@ -66,6 +78,8 @@ export const DEFAULT_RESPONSE_RULES = [ phase: 'response', category: 'info-exposure', action: 'redact', + // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to + // gate on). The pattern is linearly bounded per line, so it runs on every screened body. rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/' } }] }, { @@ -74,6 +88,7 @@ export const DEFAULT_RESPONSE_RULES = [ phase: 'response', category: 'info-exposure', action: 'redact', + prefilter: ['SQLSTATE', 'Sequelize', 'ER_', 'ORA-', 'PG::', 'SQLITE_ERROR', 'SQL syntax'], rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i' } }] }, { @@ -85,6 +100,7 @@ export const DEFAULT_RESPONSE_RULES = [ // Multi-language exception/traceback signatures a normal API response never carries: // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames, // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.) + prefilter: ['Traceback', 'Exception in thread', 'System.', 'goroutine ', '.java:', '.kt:', '.scala:', '.rb:', '.py:', '.cs:'], rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/(Traceback \\(most recent call last\\)|Exception in thread "|System\\.[A-Za-z.]+Exception|\\bat [\\w.$]+\\([\\w]+\\.(?:java|kt|scala|rb|py|cs):\\d+\\)|goroutine \\d+ \\[)/' } }] } ]; diff --git a/tests/protect/default-secret-rules.test.ts b/tests/protect/default-secret-rules.test.ts index 6c95ddd..6012e75 100644 --- a/tests/protect/default-secret-rules.test.ts +++ b/tests/protect/default-secret-rules.test.ts @@ -41,3 +41,21 @@ describe('default response rules — vendor API-key redaction', () => { expect(await out.text()).toBe(clean); }); }); + +describe('default response rules — performance / ReDoS safety', () => { + it('screens a large adversarial body in linear time (no catastrophic backtracking)', async () => { + const p: any = await createProtection({ mode: 'block' }); + // ~280 KB (< the 512 KiB screen cap, so it IS screened) of near-miss inputs that stress the + // default regexes: repeated token prefixes, a long alnum run, and JWT- / stack-frame-ish noise. + const adversarial = + ('sk_' + 'live_').repeat(10000) + + 'A'.repeat(100000) + + ('eyJ' + '_').repeat(10000) + + ('at x(y.js:1:').repeat(5000); + const start = performance.now(); + const out = await p.screenResponse(textResp(adversarial)); + await out.text(); + const ms = performance.now() - start; + expect(ms).toBeLessThan(1000); // linear-time; a ReDoS pattern would blow far past this + }); +});