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
4 changes: 2 additions & 2 deletions examples/protect/demo-rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
"title": "XML external entity (XXE) in request body",
"category": "xxe",
"rule_v2": [
{ "parameter": "raw", "match": { "type": "regex", "value": "/<!ENTITY|<!DOCTYPE[^>]*SYSTEM/i" } }
{ "parameter": "raw", "match": { "type": "regex", "value": "/<!ENTITY|<!DOCTYPE[^>]{0,256}SYSTEM/i" } }
],
"_demo": {
"desc": "External DOCTYPE/ENTITY referencing a local file",
Expand Down Expand Up @@ -119,7 +119,7 @@
"category": "pii-exposure",
"action": "redact",
"rule_v2": [
{ "parameter": "response.body", "match": { "type": "regex", "value": "/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+){0,8}\\.[A-Za-z]{2,}/" } }
{ "parameter": "response.body", "match": { "type": "regex", "value": "/(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\\.[A-Za-z0-9-]{1,63}){0,8}\\.[A-Za-z]{2,}/" } }
],
"_demo": {
"desc": "An endpoint returns a user's email; the address is masked",
Expand Down
12 changes: 10 additions & 2 deletions scripts/regex-cost.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -309,13 +309,21 @@ function buildDeclared(candidate, bytes) {
*
* A candidate that does not finish inside `timeoutMs` is outside the budget by construction — the
* deadline is the budget, and the pattern is still running when it expires.
*
* `derive: false` measures the declared candidate alone. It is for a pattern whose derived candidate
* MATCHES it — the derivation reads a pattern as one sequence, so a top-level alternation or a repeated
* group can lead it to build an input the pattern accepts — which would otherwise invalidate a screening
* the declared candidate can carry. It requires a declared candidate.
*/
export async function screenPatternCost(pattern, { candidate, bytes = 512 * 1024, budgetMs = 250 } = {}) {
export async function screenPatternCost(pattern, { candidate, bytes = 512 * 1024, budgetMs = 250, derive = true } = {}) {
const inputs = [];
const declared = buildDeclared(candidate, bytes);
if (declared !== null) inputs.push({ source: 'declared', input: declared });
if (!derive && declared === null) {
return { pattern, screened: false, reason: 'derive: false needs a declared candidate' };
}

const derived = deriveCandidate(pattern, bytes);
const derived = derive ? deriveCandidate(pattern, bytes) : null;
if (derived !== null) inputs.push({ source: 'derived', input: derived });

if (inputs.length === 0) {
Expand Down
4 changes: 2 additions & 2 deletions src/protect/templates/demo-rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@
"parameter": "raw",
"match": {
"type": "regex",
"value": "/<!ENTITY|<!DOCTYPE[^>]*SYSTEM/i"
"value": "/<!ENTITY|<!DOCTYPE[^>]{0,256}SYSTEM/i"
}
}
]
Expand Down Expand Up @@ -195,7 +195,7 @@
"parameter": "response.body",
"match": {
"type": "regex",
"value": "/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+){0,8}\\.[A-Za-z]{2,}/"
"value": "/(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\\.[A-Za-z0-9-]{1,63}){0,8}\\.[A-Za-z]{2,}/"
}
}
]
Expand Down
82 changes: 76 additions & 6 deletions tests/protect/regex-cost.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { DEFAULT_EGRESS_RULES, DEFAULT_RESPONSE_RULES } from '../../src/protect/defaults.js';
import { LIMITS } from '../../src/protect/rules/contract.js';
import {
Expand All @@ -11,8 +12,10 @@ import {
/**
* What a compiled pattern costs to REJECT a body, at a size it may really be run at.
*
* Regression coverage for the rules this package compiles in, and nothing wider. It does not screen a
* rule delivered from the rules service, and does not stand between an authored rule and being served.
* Regression coverage for the rules this package ships — the ones it compiles in and the rule sets the
* installer scaffolds into an app (`rules.json`, and `demo-rules.json` for `protect --demo`) — and nothing
* wider. It does not screen a rule delivered from the rules service, and does not stand between an
* authored rule and being served.
*
* `safeRegExp()` refuses exponential shapes and adjacent unbounded atoms. The measurement remains a
* corpus gate for more subtle polynomial interactions that a structural check cannot classify without
Expand Down Expand Up @@ -63,9 +66,46 @@ const WORST_CASE: Record<string, WorstCase> = {
'resp-stack-trace::rule_v2[0].match': { lead: ' at f (', fill: 'a:1:1' },
'resp-sql-error::rule_v2[0].match': 'SQLSTATE[',
'resp-exception-trace::rule_v2[0].match': 'at a.b(c.java:1',

// The scaffolded starter and demo sets. Each fill is a near-miss of one alternative, repeated.
'ps-fallback-ssrf-internal::rule_v2[0].match': 'localhos ',
'ps-fallback-xss::rule_v2[0].match': 'onerror ',
'demo-sqli::rule_v2[0].match': 'union or 1 ',
'demo-xss::rule_v2[0].match': 'onerror ',
'demo-command-injection::rule_v2[0].match': 'a',
'demo-nosql-injection::rule_v2[0].match': '"$ne" ',
// Declarations that never reach the keyword, each one a new place for the pattern to start.
'demo-xxe::rule_v2[0].match': '<!DOCTYPE a ',
'demo-ssrf-request::rule_v2[0].match': 'localhos ',
// A local part, then dot-separated one-letter labels: never a two-letter final label.
'demo-resp-pii-email::rule_v2[0].match': { lead: 'a@', fill: 'b.' },
// Twelve digits with separators, one short of the shortest match.
'demo-resp-credit-card::rule_v2[0].match': '1-1-1-1-1-1-1-1-1-1-1-1-a',
};

const compiled = [...DEFAULT_RESPONSE_RULES, ...DEFAULT_EGRESS_RULES] as any[];
/**
* Clauses whose DERIVED candidate matches the pattern, so it measures a match rather than a rejection.
* Their declared worst case carries the screening alone. Checked below: every entry's derived candidate
* really does match, so this cannot switch off a derivation that would have measured something.
*/
const DERIVED_MATCHES = new Set([
'ps-fallback-xss::rule_v2[0].match',
'demo-xss::rule_v2[0].match',
'demo-xxe::rule_v2[0].match',
'demo-resp-credit-card::rule_v2[0].match',
]);

const scaffolded = (name: string) =>
JSON.parse(readFileSync(new URL(`../../src/protect/templates/${name}`, import.meta.url), 'utf8')).firewall;

// Every rule the package ships. The example bundle is not read separately: `demo-rules.test.ts` holds it
// in lockstep with the scaffolded demo set.
const compiled = [
...DEFAULT_RESPONSE_RULES,
...DEFAULT_EGRESS_RULES,
...scaffolded('rules.json'),
...scaffolded('demo-rules.json'),
] as any[];
const keyOf = (clause: { ruleId: string | null; path: string }) => `${clause.ruleId}::${clause.path}`;
const clauses = compiled.flatMap((rule) => regexClausesOf(rule));

Expand Down Expand Up @@ -155,6 +195,23 @@ describe('the measurement itself', () => {
}
});

it('measures the declared candidate alone when derivation is switched off', async () => {
// The derived candidate for this pattern matches it, which would invalidate the screening.
const pattern = '/(?:\\d[ -]?){13,16}/';
const withDerived = await screenPatternCost(pattern, { candidate: '1-1-a', bytes: 4096, budgetMs: BUDGET_MS });
const declaredOnly = await screenPatternCost(pattern, { candidate: '1-1-a', bytes: 4096, budgetMs: BUDGET_MS, derive: false });

expect(withDerived.screened).toBe(false);
expect(declaredOnly.screened, declaredOnly.reason).toBe(true);
expect(declaredOnly.measurements.map((m: any) => m.source)).toEqual(['declared']);
}, 30_000);

it('refuses to switch off derivation without a declared candidate', async () => {
const result = await screenPatternCost('/abc/', { derive: false });
expect(result.screened).toBe(false);
expect(result.reason).toContain('declared candidate');
});

it('reports a pattern it cannot compile rather than passing it', async () => {
expect((await screenPatternCost('not a pattern')).screened).toBe(false);
expect((await screenPatternCost('/[/')).screened).toBe(false);
Expand Down Expand Up @@ -226,14 +283,15 @@ describe('finding the patterns a rule carries', () => {
});
});

describe('every regex the compiled rules carry, at the size it may be screened at', () => {
describe('every regex the shipped rules carry, at the size it may be screened at', () => {
// The budget is generous — orders above what a linear pattern spends here — so this fails on a shape
// change rather than on timing noise.
it.each(clauses.map((clause) => [keyOf(clause), clause] as const))('%s', async (key, clause) => {
const result = await screenPatternCost(clause.pattern, {
candidate: WORST_CASE[key],
bytes: CAP,
budgetMs: BUDGET_MS,
derive: !DERIVED_MATCHES.has(key),
});

expect(result.screened, result.reason).toBe(true);
Expand All @@ -243,7 +301,7 @@ describe('every regex the compiled rules carry, at the size it may be screened a
).toBe(true);
}, 30_000);

it('screens at least one regex from every compiled rule that carries any', () => {
it('screens at least one regex from every shipped rule that carries any', () => {
// The set, not just the members: a rule whose patterns all went missing from the walk would leave
// the cases above passing over a shorter list.
const carrying = compiled.filter((rule) => regexClausesOf(rule).length > 0).map((rule) => rule.id);
Expand All @@ -260,11 +318,23 @@ describe('every regex the compiled rules carry, at the size it may be screened a
expect(missing, 'these compiled regex clauses have no declared worst case').toEqual([]);
});

it('declares a worst case for nothing that is not compiled in', () => {
it('declares a worst case for nothing that is not shipped', () => {
// The other direction: a stale entry leaves a real clause uncovered while the count looks right.
const live = new Set(clauses.map(keyOf));
const stale = Object.keys(WORST_CASE).filter((key) => !live.has(key));

expect(stale, 'these worst cases name no compiled regex clause').toEqual([]);
});

it('screens without the derived candidate only where it matches', async () => {
const live = new Set(clauses.map(keyOf));
for (const key of DERIVED_MATCHES) {
expect(live.has(key), `${key} names no shipped regex clause`).toBe(true);
const clause = clauses.find((c) => keyOf(c) === key)!;
const derived = deriveCandidate(clause.pattern, 4096);
expect(derived, `${key} derives no candidate at all`).not.toBeNull();
const measured = (await measurePatternCost(clause.pattern, derived!, { timeoutMs: BUDGET_MS })) as any;
expect(measured.matched, `${key}'s derived candidate does not match, so it should be measured`).toBe(true);
}
}, 30_000);
});
Loading