`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>
136 lines
7.0 KiB
TypeScript
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' };
|
|
}
|
|
}
|