diff --git a/src/protect/defaults.js b/src/protect/defaults.js index 9c542f2..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,14 +38,29 @@ 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/' } }] }, + { + 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 - / _ . + 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])/' } }] + }, { id: 'resp-jwt', title: 'JWT in response body', 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/' } }] }, { @@ -46,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' } }] }, { @@ -54,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+\\)/' } }] }, { @@ -62,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' } }] }, { @@ -73,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 new file mode 100644 index 0000000..6012e75 --- /dev/null +++ b/tests/protect/default-secret-rules.test.ts @@ -0,0 +1,61 @@ +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); + }); +}); + +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 + }); +});