diff --git a/tests/strict-semver.test.ts b/tests/strict-semver.test.ts index 934dabc048..e47d511f2b 100644 --- a/tests/strict-semver.test.ts +++ b/tests/strict-semver.test.ts @@ -10,35 +10,63 @@ import { parseStrictSemver } from "../src/lib/strict-semver"; * * The length ceiling did not help. It only chose where on the curve the input landed. */ +/** + * Every timing assertion here measures the BEST of several runs, not a single one. + * + * A first call carries one-time cost the parse itself does not: regex compilation, JIT + * warm-up, and whatever the shared CI runner was doing during that millisecond. On a + * loaded macOS runner that noise reached 53.77ms against a 50ms budget and failed a + * suite whose subject is three orders of magnitude away from the regression it guards + * (522ms). A gate that fires on runner weather rather than on the defect teaches + * everyone to re-run it, which is how a real ReDoS regression would get waved through. + * + * The minimum is the right statistic for this question. Superlinear backtracking is a + * property of the pattern, so it reproduces on EVERY iteration; scheduler noise does + * not. If the exponential path returns, no run is fast. + * + * That claim was measured rather than assumed. Running the semver.org prerelease + * pattern this module replaced against the same inputs, three runs each: + * + * reps=20 len=68 17.6ms 17.4ms 17.4ms + * reps=30 len=98 545.4ms 521.2ms 500.0ms + * reps=39 len=125 492.3ms 493.5ms 491.3ms + * reps=45 len=128 495.3ms 507.9ms 527.8ms + * + * The blowup is on every run, not the first, so a best-of-N below 50ms still fails + * loudly if it comes back. The spread across runs is under 10%, which is what a + * deterministic cost looks like next to the 4ms of scheduler jitter that broke the + * single-sample form. + */ +function fastestParseMs(input: string, runs = 5): number { + let best = Infinity; + for (let i = 0; i < runs; i++) { + const started = performance.now(); + parseStrictSemver(input); + const elapsed = performance.now() - started; + if (elapsed < best) best = elapsed; + } + return best; +} + describe("parseStrictSemver ReDoS resistance", () => { test("the flagged attack shape stays linear at the length ceiling", () => { // "0.0.0-0." followed by repetitions of "--." is the input CodeQL named. const attack = ("0.0.0-0." + "--.".repeat(45)).slice(0, 128); expect(attack.length).toBe(128); - const started = performance.now(); expect(parseStrictSemver(attack)).toBeNull(); - const elapsed = performance.now() - started; // The vulnerable pattern took ~522ms for this input. Anything in that region means the // superlinear path is back; a linear parse lands three orders of magnitude below it. - expect(elapsed).toBeLessThan(50); + expect(fastestParseMs(attack)).toBeLessThan(50); }); test("cost does not grow with the number of repetitions", () => { - const measure = (reps: number): number => { - const input = ("0.0.0-0." + "--.".repeat(reps)).slice(0, 128); - const started = performance.now(); - parseStrictSemver(input); - return performance.now() - started; - }; + const inputFor = (reps: number): string => ("0.0.0-0." + "--.".repeat(reps)).slice(0, 128); // Under the old pattern, going from 20 to 39 repetitions moved 16ms to 524ms. - measure(20); - const short = measure(20); - const long = measure(39); - expect(short).toBeLessThan(50); - expect(long).toBeLessThan(50); + expect(fastestParseMs(inputFor(20))).toBeLessThan(50); + expect(fastestParseMs(inputFor(39))).toBeLessThan(50); }); test("the length guard still rejects before any matching work", () => {