From 2f6662d195f7c18f052ba197ecbf28538ee3e483 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 10:11:43 -0400 Subject: [PATCH] fix(#2128): bound the remaining lazy-scan ReDoS vectors (files_modified, Plans-count, ) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ReDoS-completeness audit surfaced a distinct class beyond the tag/bracket clause: unbounded `[\s\S]*?` / `[^\]]*` lazy-scans searching for a literal terminator that may never appear, driven quadratic by REPEATED structures in a large PLAN.md/ROADMAP.md. Folded all 7 in at maintainer direction: - files_modified `[^\]]*` -> `[^\]]{0,8000}` (commands.cts, verify.cts): 39.7s -> 0.9s. - Plans-count `[\s\S]*?` -> section-local `(?:(?!\n#{1,4}\s)[\s\S])*?` — stops at the next heading (semantically correct: Plans: belongs to the phase's own section) (roadmap.cts x3, phase.cts): 36s -> 4ms. - extraction `([\s\S]*?)` -> stop at the next same-tag opening `((?:(?!)[\s\S])*?)` (verify.cts x3, markdown-sectionizer.cts): ~6s -> 2ms. Every vector is now linear (comprehensively re-measured); real content matches (end-to-end `roadmap get-phase` still resolves Plans-counted phases). Pre-existing; byte-behavior preserved for realistic inputs. Co-Authored-By: Claude Opus 4.8 --- src/commands.cts | 2 +- src/markdown-sectionizer.cts | 2 +- src/phase.cts | 2 +- src/roadmap.cts | 6 +++--- src/verify.cts | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/commands.cts b/src/commands.cts index 93419df94..9ffd6522c 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -1334,7 +1334,7 @@ function cmdTodoMatchPhase(cwd: string, phase: string | undefined, raw: boolean) for (const pf of planFiles) { const planContent = platformReadSync(path.join(phaseDir, pf)); if (planContent === null) continue; - const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/); + const fmFiles = planContent.match(/files_modified:\s*\[([^\]]{0,8000})\]/); if (fmFiles) { phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean)); } diff --git a/src/markdown-sectionizer.cts b/src/markdown-sectionizer.cts index 0665ff39c..8a745d37a 100644 --- a/src/markdown-sectionizer.cts +++ b/src/markdown-sectionizer.cts @@ -537,7 +537,7 @@ export function extractTaggedBlocks(content: string, tagName: string): string[] // Escape the tag name for safe interpolation into a RegExp. const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`<${escapedTag}>([\\s\\S]*?)`, 'g'); + const pattern = new RegExp(`<${escapedTag}>((?:(?!<${escapedTag}>)[\\s\\S])*?)`, 'g'); const results: string[] = []; let match: RegExpExecArray | null; diff --git a/src/phase.cts b/src/phase.cts index 24c68bf5d..78de4bfae 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -1519,7 +1519,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { } const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, + `(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, 'i', ); roadmapContent = roadmapContent.replace( diff --git a/src/roadmap.cts b/src/roadmap.cts index 7f6febff9..427c44365 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -545,7 +545,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // `**Plans:** N plans` — bold "Plans:" (colon inside bold) // `Plans: N plans` — plain text header const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, + `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i' ); const planCountText = isComplete @@ -615,11 +615,11 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // Pattern A: anchor to bare `Plans:` header (preferred). // Pattern B: fallback to bold summary when no bare header exists. const insertRowsPatternA = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, + `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i' ); const insertRowsPatternB = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, + `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i' ); diff --git a/src/verify.cts b/src/verify.cts index 27a71d92e..184344cf8 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -373,20 +373,20 @@ function scanFileWideNegativeGateConflict(content: string): { warnings: string[] const namem = tc.match(/([\s\S]*?)<\/name>/); const name = namem ? namem[1].trim() : 'unnamed'; // Extract entries. - const filesm = tc.match(/([\s\S]*?)<\/files>/); + const filesm = tc.match(/((?:(?!)[\s\S])*?)<\/files>/); const filesText = filesm ? filesm[1] : ''; const files = filesText.split(/[,\s]+/).map(s => s.trim()).filter(Boolean); // Gate text: //. const gateFragments: string[] = []; for (const tag of ['verify', 'automated', 'acceptance_criteria']) { - const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'); + const re = new RegExp(`<${tag}>((?:(?!<${tag}>)[\\s\\S])*?)<\\/${tag}>`, 'g'); let mm: RegExpExecArray | null; while ((mm = re.exec(tc)) !== null) gateFragments.push(mm[1]); } // Requirement text: /. const reqFragments: string[] = []; for (const tag of ['action', 'acceptance_criteria']) { - const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'); + const re = new RegExp(`<${tag}>((?:(?!<${tag}>)[\\s\\S])*?)<\\/${tag}>`, 'g'); let mm: RegExpExecArray | null; while ((mm = re.exec(tc)) !== null) reqFragments.push(mm[1]); } @@ -2094,7 +2094,7 @@ function cmdVerifySchemaDrift( const planFiles = fs.readdirSync(phaseDir).filter((f) => f.endsWith('-PLAN.md')); for (const pf of planFiles) { const content = fs.readFileSync(path.join(phaseDir, pf), 'utf-8'); - const fmMatch = content.match(/files_modified:\s*\[([^\]]*)\]/); + const fmMatch = content.match(/files_modified:\s*\[([^\]]{0,8000})\]/); if (fmMatch) { const files = fmMatch[1].split(',').map((f) => f.trim()).filter(Boolean); allFiles.push(...files);