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

@@ -41,6 +41,12 @@
* migration-equivalence property sweep in tests/pattern.test.cjs (rows 15-17).
*/
// re2js is vendored, not an npm dependency at runtime: gsd-core/bin/** is
// copied into installed trees that have no node_modules, so this module must
// carry zero external requires (eslint-rules/no-external-require-in-bin.cjs
// enforces it). See gsd-core/bin/lib/vendor/README.md.
import { RE2JS } from './vendor/re2js.cjs';
// #3498: RegExp.escape is ES2026 (first shipped in Node 24). The gsd-test
// matrix still runs a linux-node22 lane, and the build itself consumes this
// module (scripts/gen-loop-host-contract.cjs), so a hard dependency breaks
@@ -64,3 +70,66 @@ export function escapeRegex(value: string): string {
export function literalPattern(value: string, flags?: string): RegExp {
return new RegExp(escapeRegex(value), flags);
}
/** Max length for a user-supplied regex pattern before it is refused (ReDoS/compile-cost mitigation). */
export const MAX_USER_PATTERN_LEN = 512;
/** Reason an untrusted pattern was refused and never compiled, or `null` if it compiled. */
export type UserPatternNeutralization = 'empty' | 'too-long' | 'unsupported';
export interface UserPatternResult {
/** Linear-time match via RE2. Returns false for every neutralized pattern, by construction. */
test(input: string): boolean;
/** null when the pattern compiled; otherwise why it was refused. */
neutralized: UserPatternNeutralization | null;
}
/** Always-false matcher shared by every neutralization path — a refused pattern must never be able to report a match. */
const NEVER_MATCH: UserPatternResult['test'] = () => false;
/**
* Compile an UNTRUSTED, user-supplied pattern (e.g. plan frontmatter) via RE2
* (re2js), whose matching is linear-time in input length by construction —
* there is no backtracking engine here to exploit, so the vulnerability class
* (catastrophic/exponential backtracking) is closed by the engine rather than
* detected by a heuristic scan of the pattern text.
*
* Never throws. A pattern that is empty, too long, or not valid RE2 syntax
* (backreferences and look-around are unsupported by RE2 — those are exactly
* the constructs that require backtracking) is REFUSED: `test()` always
* returns `false`, and `neutralized` reports why so callers can surface the
* refusal (#3477 follow-up: a neutralized pattern must not look like a plain
* "not found"). Restores the guards lost with
* `sdk/src/query/validate.ts:regexForKeyLinkPattern` (#3477); this revision
* (post-#3477-follow-up) replaces the hand-rolled backtracking-shape scanner
* with RE2's linear-time guarantee — a refused pattern is never re-attempted
* as a literal-escaped match, since guessing at a pattern we could not
* compile is what produced the prior false-pass regression.
*
* `pattern` is `unknown`, not `string`, because callers pull this straight off
* parsed plan frontmatter (untrusted YAML) — a non-string value must reach the
* `'empty'`/never-match branch rather than being force-cast by the caller.
*/
export function compileUserPattern(pattern: unknown): UserPatternResult {
if (typeof pattern !== 'string' || pattern.length === 0) {
return { test: NEVER_MATCH, neutralized: 'empty' };
}
if (pattern.length > MAX_USER_PATTERN_LEN) {
// The cap now bounds compile cost/memory, not backtracking (RE2 has none) —
// an over-long pattern is refused outright rather than truncated-and-compiled.
return { test: NEVER_MATCH, neutralized: 'too-long' };
}
try {
// translateRegExp accepts JS-flavored syntax (named groups, `/`-escaping,
// etc.) that RE2's own grammar doesn't, reducing spurious refusals of
// otherwise-safe, JS-authored patterns before compiling under RE2's
// linear-time engine.
const compiled = RE2JS.compile(RE2JS.translateRegExp(pattern));
return { test: (input: string) => compiled.test(input), neutralized: null };
} catch {
// Backreferences, look-around, or any other RE2-unsupported/malformed
// syntax. No literal-escape fallback: a pattern we could not compile is
// never guessed at — guessing produced the #3477 false-pass regression.
return { test: NEVER_MATCH, neutralized: 'unsupported' };
}
}