@@ -10,35 +10,63 @@ import { parseStrictSemver } from "../src/lib/strict-semver";
1010 *
1111 * The length ceiling did not help. It only chose where on the curve the input landed.
1212 */
13+ /**
14+ * Every timing assertion here measures the BEST of several runs, not a single one.
15+ *
16+ * A first call carries one-time cost the parse itself does not: regex compilation, JIT
17+ * warm-up, and whatever the shared CI runner was doing during that millisecond. On a
18+ * loaded macOS runner that noise reached 53.77ms against a 50ms budget and failed a
19+ * suite whose subject is three orders of magnitude away from the regression it guards
20+ * (522ms). A gate that fires on runner weather rather than on the defect teaches
21+ * everyone to re-run it, which is how a real ReDoS regression would get waved through.
22+ *
23+ * The minimum is the right statistic for this question. Superlinear backtracking is a
24+ * property of the pattern, so it reproduces on EVERY iteration; scheduler noise does
25+ * not. If the exponential path returns, no run is fast.
26+ *
27+ * That claim was measured rather than assumed. Running the semver.org prerelease
28+ * pattern this module replaced against the same inputs, three runs each:
29+ *
30+ * reps=20 len=68 17.6ms 17.4ms 17.4ms
31+ * reps=30 len=98 545.4ms 521.2ms 500.0ms
32+ * reps=39 len=125 492.3ms 493.5ms 491.3ms
33+ * reps=45 len=128 495.3ms 507.9ms 527.8ms
34+ *
35+ * The blowup is on every run, not the first, so a best-of-N below 50ms still fails
36+ * loudly if it comes back. The spread across runs is under 10%, which is what a
37+ * deterministic cost looks like next to the 4ms of scheduler jitter that broke the
38+ * single-sample form.
39+ */
40+ function fastestParseMs ( input : string , runs = 5 ) : number {
41+ let best = Infinity ;
42+ for ( let i = 0 ; i < runs ; i ++ ) {
43+ const started = performance . now ( ) ;
44+ parseStrictSemver ( input ) ;
45+ const elapsed = performance . now ( ) - started ;
46+ if ( elapsed < best ) best = elapsed ;
47+ }
48+ return best ;
49+ }
50+
1351describe ( "parseStrictSemver ReDoS resistance" , ( ) => {
1452 test ( "the flagged attack shape stays linear at the length ceiling" , ( ) => {
1553 // "0.0.0-0." followed by repetitions of "--." is the input CodeQL named.
1654 const attack = ( "0.0.0-0." + "--." . repeat ( 45 ) ) . slice ( 0 , 128 ) ;
1755 expect ( attack . length ) . toBe ( 128 ) ;
1856
19- const started = performance . now ( ) ;
2057 expect ( parseStrictSemver ( attack ) ) . toBeNull ( ) ;
21- const elapsed = performance . now ( ) - started ;
2258
2359 // The vulnerable pattern took ~522ms for this input. Anything in that region means the
2460 // superlinear path is back; a linear parse lands three orders of magnitude below it.
25- expect ( elapsed ) . toBeLessThan ( 50 ) ;
61+ expect ( fastestParseMs ( attack ) ) . toBeLessThan ( 50 ) ;
2662 } ) ;
2763
2864 test ( "cost does not grow with the number of repetitions" , ( ) => {
29- const measure = ( reps : number ) : number => {
30- const input = ( "0.0.0-0." + "--." . repeat ( reps ) ) . slice ( 0 , 128 ) ;
31- const started = performance . now ( ) ;
32- parseStrictSemver ( input ) ;
33- return performance . now ( ) - started ;
34- } ;
65+ const inputFor = ( reps : number ) : string => ( "0.0.0-0." + "--." . repeat ( reps ) ) . slice ( 0 , 128 ) ;
3566
3667 // Under the old pattern, going from 20 to 39 repetitions moved 16ms to 524ms.
37- measure ( 20 ) ;
38- const short = measure ( 20 ) ;
39- const long = measure ( 39 ) ;
40- expect ( short ) . toBeLessThan ( 50 ) ;
41- expect ( long ) . toBeLessThan ( 50 ) ;
68+ expect ( fastestParseMs ( inputFor ( 20 ) ) ) . toBeLessThan ( 50 ) ;
69+ expect ( fastestParseMs ( inputFor ( 39 ) ) ) . toBeLessThan ( 50 ) ;
4270 } ) ;
4371
4472 test ( "the length guard still rejects before any matching work" , ( ) => {
0 commit comments