fix(#3477): run untrusted key_links patterns on a linear-time engine (#3496)

`cmdVerifyKeyLinks` compiled `must_haves.key_links[].pattern` from plan frontmatter with `new RegExp()` and tested it against whole file contents, so a nested-quantifier pattern such as `(a+)+$` hung `verify-phase` indefinitely (CWE-1333). JavaScript has no regex-execution timeout.

Untrusted patterns now run on RE2 (re2js), whose match time is linear in input length — the class is closed by the engine, not by a heuristic screen. The screen lost in the ADR-0174 consolidation was deliberately NOT restored: it never worked, since `(a|a)*$`, `((a+))+$`, `(a+){2,}$` and `(a{1,3})+$` all evade it. A refused pattern's matcher returns false for every input, so it cannot report a match no matter what the caller does.

The engine is vendored at gsd-core/bin/lib/vendor/re2js.cjs because gsd-core/bin/** is copied into installed trees with no node_modules; runtime dependencies are unchanged. New ESLint rule local/no-external-require-in-bin enforces that invariant, which had been documented in a comment since the #3024/#2071 bug class and enforced nowhere.

Backreferences and look-around are unsupported by RE2 by construction — disclosed in a Changed changeset.

Closes #3477

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-14 14:34:36 -04:00
committed by GitHub
parent b946051a46
commit 895d9df96d
19 changed files with 9067 additions and 17 deletions

View File

@@ -24,7 +24,7 @@ const assert = require('node:assert/strict');
// other *.property.test.cjs file. seed: 42, overridable via GSD_FC_SEED.
const fc = require('./helpers/fast-check-setup.cjs');
const { escapeRegex, literalPattern } = require('../gsd-core/bin/lib/pattern.cjs');
const { escapeRegex, literalPattern, compileUserPattern, MAX_USER_PATTERN_LEN } = require('../gsd-core/bin/lib/pattern.cjs');
// ─── Section 1: escapeRegex — rows 1-10 ───────────────────────────────────
@@ -249,3 +249,164 @@ describe('escapeRegex without RegExp.escape (#3498 Node-22 fallback)', () => {
assert.strictEqual(r.exitCode, 0, `post-load capture failed: ${r.stdout}\n${r.stderr}`);
});
});
// ─── Section 5: compileUserPattern — RE2 linear-time engine (#3477) ────────
//
// re2js guarantees match time linear in input length — there is no
// backtracking engine to exploit, so the vulnerability class is closed by
// the engine rather than detected by a heuristic scan of the pattern text
// (the hand-rolled `hasRiskyBacktrackingGroup` scanner this seam used to run
// is deleted). Assertions here are BEHAVIORAL ONLY — never against
// `RegExp.source` or any RegExp-shaped property, because the result is no
// longer a RegExp at all.
describe('compileUserPattern', () => {
test('a plain safe pattern compiles and reports neutralized: null', () => {
const { test: matches, neutralized } = compileUserPattern('fetch.*api/feed');
assert.strictEqual(matches('do a fetch of the api/feed endpoint'), true);
assert.strictEqual(matches('completely unrelated text'), false);
assert.strictEqual(neutralized, null);
});
test('boundary: a pattern at MAX_USER_PATTERN_LEN - 1 compiles', () => {
const p = 'a'.repeat(MAX_USER_PATTERN_LEN - 1);
const { test: matches, neutralized } = compileUserPattern(p);
assert.strictEqual(matches(p), true);
assert.strictEqual(matches('b'.repeat(MAX_USER_PATTERN_LEN - 1)), false);
assert.strictEqual(neutralized, null);
});
test('boundary: a pattern at exactly MAX_USER_PATTERN_LEN compiles', () => {
const p = 'a'.repeat(MAX_USER_PATTERN_LEN);
const { test: matches, neutralized } = compileUserPattern(p);
assert.strictEqual(matches(p), true);
assert.strictEqual(matches('b'.repeat(MAX_USER_PATTERN_LEN)), false);
assert.strictEqual(neutralized, null);
});
test('boundary: a pattern at MAX_USER_PATTERN_LEN + 1 (513 chars) is refused, never matches', () => {
const p = 'a'.repeat(MAX_USER_PATTERN_LEN + 1);
assert.strictEqual(p.length, 513);
const { test: matches, neutralized } = compileUserPattern(p);
assert.strictEqual(matches(p), false);
assert.strictEqual(matches(p.slice(0, MAX_USER_PATTERN_LEN)), false);
assert.strictEqual(neutralized, 'too-long');
});
test('empty string input is refused, never matches', () => {
const { test: matches, neutralized } = compileUserPattern('');
assert.strictEqual(matches(''), false);
assert.strictEqual(matches('anything'), false);
assert.strictEqual(neutralized, 'empty');
});
test('non-string input is refused, never matches', () => {
for (const bad of [null, undefined, 42, {}, []]) {
const { test: matches, neutralized } = compileUserPattern(bad);
assert.strictEqual(matches(''), false);
assert.strictEqual(matches('anything'), false);
assert.strictEqual(neutralized, 'empty');
}
});
test('property: for any string input, compileUserPattern returns a well-shaped result and never throws', () => {
// maxLength: 600 straddles MAX_USER_PATTERN_LEN (512) so fast-check actually
// exercises the >512 'too-long' branch. A neutralized result must never be
// able to report a match against arbitrary input — the security invariant.
const validNeutralizations = new Set(['empty', 'too-long', 'unsupported', null]);
fc.assert(
fc.property(fc.string({ maxLength: 600 }), fc.string({ maxLength: 50 }), (s, probe) => {
let result;
assert.doesNotThrow(() => {
result = compileUserPattern(s);
});
assert.strictEqual(typeof result.test, 'function');
assert.ok(validNeutralizations.has(result.neutralized));
if (result.neutralized !== null) {
let matched;
assert.doesNotThrow(() => {
matched = result.test(probe);
});
assert.strictEqual(matched, false, `neutralized (${result.neutralized}) result must never match: s=${JSON.stringify(s)} probe=${JSON.stringify(probe)}`);
}
})
);
});
test('a known-valid regex round-trips through the >512-length property test unmangled', () => {
// Companion assertion for the property test above: a legitimate long-ish
// regex must still compile (neutralized: null), not just "didn't throw" —
// pins that the length straddle doesn't accidentally neutralize everything
// under 512 chars too.
const p = 'fetch\\(.*\\)\\.then\\(' + 'x'.repeat(400) + '\\)';
assert.ok(p.length < MAX_USER_PATTERN_LEN, 'sanity: fixture must stay under the length threshold');
const { test: matches, neutralized } = compileUserPattern(p);
assert.strictEqual(neutralized, null);
assert.strictEqual(matches('fetch(url).then(' + 'x'.repeat(400) + ')'), true);
assert.strictEqual(matches('unrelated text'), false);
});
});
// ─── Section 6: RE2 engine acceptance table (#3477 follow-up) ─────────────
//
// The prior hand-rolled screen either hung (patterns it missed, run live
// through the JS backtracking engine) or wrongly refused (patterns it
// flagged as risky-shaped that are actually linear) on the rows below. RE2
// closes both failure modes: every MUST_COMPILE row below is evaluated for
// real, in linear time, with a correct match verdict — no heuristic,
// no false refusal.
describe('compileUserPattern — RE2 engine acceptance table', () => {
// [pattern, subjectA, subjectB, expectedA, expectedB]. Every one of these
// patterns is plain JS-valid ERE syntax (no backreferences/look-around), so
// the expected/expectedB booleans are exactly what `new RegExp(p).test(...)`
// would report — computed offline rather than hand-assumed, since a
// `*`/`?`-quantified pattern with nothing required outside the optional
// part (e.g. `(a|a)*$`, `(abc)?`, `(abc)*`) trivially matches an unanchored
// `.test()` against ANY subject (zero-width match), which is correct
// JS-regex semantics, not a defect. The expectations are hardcoded rather
// than oracle-derived because running these patterns through the JS
// backtracking engine (`new RegExp(p).test(...)`) is the exact
// catastrophic-backtracking vulnerability this issue is about — the suite
// must never execute that engine against them, even with short subjects.
const MUST_COMPILE = [
['(a+)+$', 'aaaa', 'bbbb', true, false],
['(a|a)*$', 'aaaa', 'aaab', true, true],
['((a+))+$', 'aaaa', 'aaab', true, false],
['(a+){2,}$', 'aaaa', 'a', true, false],
['(a{1,3})+$', 'aaaaaa', 'bbbbbb', true, false],
['^(\\s*\\w+)+$', 'foo bar baz', 'foo, bar', true, false],
['fetch.*api/feed', 'do a fetch of the api/feed endpoint', 'completely unrelated text', true, false],
['prisma\\.message\\.(find|create)', 'call prisma.message.find(x)', 'call prisma.other.find(x)', true, false],
['(abc)?', 'abc', '', true, true],
['(abc)*', 'abcabc', 'xyz', true, true],
['^\\s*export\\s+function\\s+\\w+', ' export function foo() {}', 'const foo = 1', true, false],
];
const MUST_REFUSE = ['(\\w+)\\1', '(?!x)a'];
for (const [p, subjectA, subjectB, expectedA, expectedB] of MUST_COMPILE) {
test(`compiles and evaluates correctly: ${JSON.stringify(p)}`, () => {
const { test: matches, neutralized } = compileUserPattern(p);
assert.strictEqual(neutralized, null, `expected null for ${JSON.stringify(p)}, got ${JSON.stringify(neutralized)}`);
assert.strictEqual(
matches(subjectA),
expectedA,
`expected ${JSON.stringify(p)} against ${JSON.stringify(subjectA)} to match real regex semantics`
);
assert.strictEqual(
matches(subjectB),
expectedB,
`expected ${JSON.stringify(p)} against ${JSON.stringify(subjectB)} to match real regex semantics`
);
});
}
for (const p of MUST_REFUSE) {
test(`is refused (unsupported RE2 syntax): ${JSON.stringify(p)}`, () => {
const { test: matches, neutralized } = compileUserPattern(p);
assert.strictEqual(neutralized, 'unsupported', `expected 'unsupported' for ${JSON.stringify(p)}, got ${JSON.stringify(neutralized)}`);
assert.strictEqual(matches('anything'), false);
});
}
});