fix(#2128): bound the phase-tag clause to {0,200} — kill quadratic ReDoS
The canonical OPTIONAL_PHASE_TAG_SOURCE tag clause `(?:\s*\([^)\n]*\))?` (and its
inlined literal mirrors across 11 modules) had an UNBOUNDED body, making the
optional-group + /g header scan quadratic on adversarial ROADMAP.md/STATE.md — a
long run of `(` after a header ran ~18.8s at 1.7MB. Bound the body to {0,200} in
the constant AND every mirror in lockstep (the #1729 "both forms change together"
contract), so the scan is linear: the same 1.7MB input now resolves in ~9ms
(measured), while real tags (a handful of chars) still match and a 201-char tag
is rejected. Added a #2128 boundary regression to the #1729 suite.
Pre-existing (byte-identical before/after the Phase 4 migrations); folded in at
maintainer direction rather than deferred.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -713,8 +713,8 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?:
|
||||
// (section header, roadmap bullet, or on-disk directory) is counted:
|
||||
|
||||
// 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
||||
// 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
|
||||
// The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
|
||||
// or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
|
||||
@@ -811,8 +811,8 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
let maxPhase = 0;
|
||||
if (config.phase_naming !== 'custom') {
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(content)) !== null) {
|
||||
const num = parseInt(m[1], 10);
|
||||
@@ -1195,7 +1195,7 @@ function updateRoadmapAfterPhaseRemoval(
|
||||
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
|
||||
// is re-emitted verbatim — a tagged later phase still gets renumbered.
|
||||
content = content.replace(
|
||||
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]*\))?\s*:)/gi,
|
||||
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]{0,200}\))?\s*:)/gi,
|
||||
(_match, prefix: string, num: string, suffix: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`,
|
||||
);
|
||||
@@ -1704,11 +1704,11 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
// phase. Allow optional `**`/`__` emphasis after the marker and stop
|
||||
// the name capture at emphasis so bold names slug cleanly; the number
|
||||
// capture is unchanged.
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` after the number tolerates a pre-colon
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` after the number tolerates a pre-colon
|
||||
// ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
|
||||
// `### Phase N (Cluster B): X` resolves. Captures are unchanged.
|
||||
const phasePattern = new RegExp(
|
||||
`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n*]+)`,
|
||||
`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`,
|
||||
'gi'
|
||||
);
|
||||
let pm: RegExpExecArray | null;
|
||||
@@ -1747,7 +1747,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
try {
|
||||
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
|
||||
const cbPattern = new RegExp(
|
||||
`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n*]+)`,
|
||||
`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`,
|
||||
'gi'
|
||||
);
|
||||
let cbm: RegExpExecArray | null;
|
||||
|
||||
Reference in New Issue
Block a user