Files
msd-core/src/pattern.cts
Tom Boucher 895d9df96d 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>
2026-08-14 14:34:36 -04:00

136 lines
7.0 KiB
TypeScript

/**
* pattern.cts — the pattern-construction seam (ADR-3212 §1, epic #3212 Phase 1,
* #3412).
*
* Source in src/pattern.cts, compiled to gsd-core/bin/lib/pattern.cjs
* (gitignored), per the repo's ADR-457 build-at-publish convention.
*
* Sole owner of building a `RegExp` from a runtime value. `escapeRegex`
* delegates to the built-in `RegExp.escape` (ES2026 / Node 24+) when present,
* falling back to an in-file metacharacter escape below Node 24 (#3498) —
* still the one owner: no module outside this seam escapes a value for regex
* use (ADR §1).
*
* Counts, corrected during implementation (design doc "Ground truth" #1;
* .gsd/phase/chore-3412-pattern-seam/40-design.md): the ADR's census counted
* ~12 *named* helper functions. The shape-based lint guard
* (eslint-rules/no-adhoc-regex-escape.cjs), which matches the escape-class
* SHAPE wherever it appears rather than named-function bodies, found 27
* additional inline copies that census-by-name could not see — **~39 escape
* sites total**. Call sites follow the same pattern: 17 were surveyed
* directly, but `src/phase-id.cts`'s `escapeRegex` turned out to have 8
* external production importers the pre-implementation survey missed, plus
* the 27 guard-found inline sites also call into the seam — **~44 call
* sites total**. The lesson worth keeping: a named-function census
* structurally cannot see an inline `.replace(...)` copy or an
* externally-imported symbol; only a shape-based guard (or a direct
* importer graph query) does.
*
* `escapeRegex` is the PRIMARY export: the large majority of call sites
* build a *source string* (alternation via `.map(escapeRegex).join('|')`, or
* template/concat interpolation into a larger pattern) rather than a
* standalone literal match. `literalPattern` is the minority convenience
* wrapper for the remaining "match this value literally" shape — not the
* dominant one (design doc "Ground truth" #2).
*
* Behavior-preserving for MATCH RESULTS, not for pattern TEXT: `RegExp.escape`
* hex-escapes the leading character of nearly every non-empty input (and `-`,
* space, and control characters throughout), so the escaped source string
* differs from the twelve deleted copies' output for almost every value.
* Match behavior against that source is unaffected — verified by the
* 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
// `npm run build` on Node 22. Prefer the built-in when present; otherwise use
// the local metachar escape — still inside this file, so the #3212 sole-owner
// invariant (and lint-no-adhoc-regex-escape's scope) is preserved. Captured at
// module load so a runtime mutation of RegExp.escape cannot flip the path
// mid-process.
const escapeBuiltin: ((value: string) => string) | undefined =
typeof RegExp.escape === 'function'
? RegExp.escape.bind(RegExp)
: undefined;
const escapeMetachars = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export function escapeRegex(value: string): string {
return (escapeBuiltin ?? escapeMetachars)(value);
}
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' };
}
}