fix(#2128): bound the remaining lazy-scan ReDoS vectors (files_modified, Plans-count, <tag>)

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.
- <tag> extraction `([\s\S]*?)` -> stop at the next same-tag opening
  `((?:(?!<tag>)[\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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-10 10:11:43 -04:00
parent b321bc04f4
commit 2f6662d195
5 changed files with 10 additions and 10 deletions

View File

@@ -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));
}

View File

@@ -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]*?)</${escapedTag}>`, 'g');
const pattern = new RegExp(`<${escapedTag}>((?:(?!<${escapedTag}>)[\\s\\S])*?)</${escapedTag}>`, 'g');
const results: string[] = [];
let match: RegExpExecArray | null;

View File

@@ -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(

View File

@@ -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'
);

View File

@@ -373,20 +373,20 @@ function scanFileWideNegativeGateConflict(content: string): { warnings: string[]
const namem = tc.match(/<name>([\s\S]*?)<\/name>/);
const name = namem ? namem[1].trim() : 'unnamed';
// Extract <files> entries.
const filesm = tc.match(/<files>([\s\S]*?)<\/files>/);
const filesm = tc.match(/<files>((?:(?!<files>)[\s\S])*?)<\/files>/);
const filesText = filesm ? filesm[1] : '';
const files = filesText.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
// Gate text: <verify>/<automated>/<acceptance_criteria>.
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: <action>/<acceptance_criteria>.
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);