Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/protect/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-----/' } }]
},
{
Expand All @@ -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/' } }]
},
{
Expand All @@ -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])/' } }]
},
Comment on lines +44 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should those be hardcoded?

{
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/' } }]
},
{
Expand All @@ -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' } }]
},
{
Expand All @@ -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+\\)/' } }]
},
{
Expand All @@ -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' } }]
},
{
Expand All @@ -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+ \\[)/' } }]
}
];
Expand Down
61 changes: 61 additions & 0 deletions tests/protect/default-secret-rules.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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
});
});
Loading