Conversation
✅ Validation PassedAll checks completed successfully:
This PR is ready for review. View detailsLast updated: 2026-02-26T09:41:03.596Z |
| const baseExists = dirs.some(d => d.startsWith(normalized + '-') || d === normalized); | ||
|
|
||
| // Find existing decimal phases for this base | ||
| const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`); |
Check failure
Code scanning / CodeQL
Regular expression injection High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 7 days ago
In general, to fix regex injection when embedding user input in a RegExp constructor, the untrusted value must be passed through a function that escapes all regex metacharacters (for example, the existing escapeRegex helper) before interpolation. That way the input is treated purely as literal text within the pattern.
For this specific case, the best fix with no functional change is:
- Keep using
normalizePhaseName(basePhase)for phase normalization. - Before using
normalizedinside the regex, create an escaped version withescapeRegex(normalized). - Use the escaped version in the template string passed to
new RegExp, i.e., changenew RegExp(`^${normalized}\\.(\\d+)`)to useescapedNormalizedinstead.
escapeRegex is already imported from ./core.cjs at the top of templates/get-shit-done/bin/lib/phase.cjs, so no new imports or dependencies are required. The change is localized to cmdPhaseNextDecimal in phase.cjs, around lines 113–115.
| @@ -111,7 +111,8 @@ | ||
| const baseExists = dirs.some(d => d.startsWith(normalized + '-') || d === normalized); | ||
|
|
||
| // Find existing decimal phases for this base | ||
| const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`); | ||
| const escapedNormalized = escapeRegex(normalized); | ||
| const decimalPattern = new RegExp(`^${escapedNormalized}\\.(\\d+)`); | ||
| const existingDecimals = []; | ||
|
|
||
| for (const dir of dirs) { |
| // Normalize input then strip leading zeros for flexible matching | ||
| const normalizedAfter = normalizePhaseName(afterPhase); | ||
| const unpadded = normalizedAfter.replace(/^0+/, ''); | ||
| const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); |
Check failure
Code scanning / CodeQL
Incomplete string escaping or encoding High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 7 days ago
In general, when building a regular expression from user input, that input must be passed through a function that escapes all regex metacharacters, not just some of them. Relying on a simple .replace('.', '\.') is error-prone because it does not handle other special characters such as \, *, +, ?, etc. The best fix here is to replace the manual escaping on line 376 with a call to the already-imported escapeRegex helper, which is presumably designed to escape arbitrary strings for safe use in regular expressions.
Concretely, in templates/get-shit-done/bin/lib/phase.cjs, at line 376 we should replace:
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');with:
const afterPhaseEscaped = escapeRegex(unpadded);This uses the existing escapeRegex function imported from ./core.cjs on line 7, so no new imports or additional helper functions are needed. The rest of the logic can remain unchanged: afterPhaseEscaped is interpolated into the RegExp constructor as before, but now it will be fully escaped, including backslashes, ensuring that even unusual user inputs cannot break the pattern.
| @@ -373,7 +373,7 @@ | ||
| // Normalize input then strip leading zeros for flexible matching | ||
| const normalizedAfter = normalizePhaseName(afterPhase); | ||
| const unpadded = normalizedAfter.replace(/^0+/, ''); | ||
| const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); | ||
| const afterPhaseEscaped = escapeRegex(unpadded); | ||
| const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i'); | ||
| if (!targetPattern.test(content)) { | ||
| error(`Phase ${afterPhase} not found in ROADMAP.md`); |
| const normalizedAfter = normalizePhaseName(afterPhase); | ||
| const unpadded = normalizedAfter.replace(/^0+/, ''); | ||
| const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); | ||
| const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i'); |
Check failure
Code scanning / CodeQL
Regular expression injection High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 7 days ago
General fix: Whenever user input is interpolated into a RegExp constructor, escape all regex metacharacters first using a dedicated escaping function (like the existing escapeRegex in core.cjs), and then build the pattern using that escaped version.
Best concrete fix here: in cmdPhaseInsert in templates/get-shit-done/bin/lib/phase.cjs, change how afterPhaseEscaped is computed. Instead of only escaping dots via .replace(/\./g, '\\.'), we should pass unpadded through escapeRegex, which already correctly escapes all regex metacharacters. The rest of the logic (normalization, removing leading zeros, building the targetPattern with 0*${...}) can remain untouched, because escapeRegex will return a safe literal representation of the phase number, including any dots. This preserves intended matching semantics while preventing a user from injecting their own regex fragments.
Concretely:
- In
cmdPhaseInsert:- Replace line 376:
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); - With:
const afterPhaseEscaped = escapeRegex(unpadded);
- Replace line 376:
- No new imports are necessary because
escapeRegexis already imported from./core.cjsat the top ofphase.cjs.
This change keeps functionality the same for legitimate inputs (numbers and dots) while robustly preventing regex injection.
| @@ -373,7 +373,7 @@ | ||
| // Normalize input then strip leading zeros for flexible matching | ||
| const normalizedAfter = normalizePhaseName(afterPhase); | ||
| const unpadded = normalizedAfter.replace(/^0+/, ''); | ||
| const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); | ||
| const afterPhaseEscaped = escapeRegex(unpadded); | ||
| const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i'); | ||
| if (!targetPattern.test(content)) { | ||
| error(`Phase ${afterPhase} not found in ROADMAP.md`); |
| try { | ||
| const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); | ||
| const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); | ||
| const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`); |
Check failure
Code scanning / CodeQL
Regular expression injection High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 7 days ago
In general, to fix regular expression injection you must ensure that any user-controlled string interpolated into a regex is first sanitized by escaping all regex metacharacters. This converts the user input into a literal substring within the regex rather than allowing it to alter the regex structure.
For this specific case, the best fix with minimal behavioral change is to escape normalizedBase before interpolating it into the RegExp at line 390 in templates/get-shit-done/bin/lib/phase.cjs. The project already defines and exports an escapeRegex function in core.cjs and imports it at the top of phase.cjs, so we should use it. Concretely:
- In
cmdPhaseInsert, after computingnormalizedBase = normalizePhaseName(afterPhase), add a new constantnormalizedBaseEscaped = escapeRegex(normalizedBase). - Use
normalizedBaseEscapedinstead ofnormalizedBasewhen constructingdecimalPattern:- Change
const decimalPattern = new RegExp(\^${normalizedBase}\.(\d+)`);toconst decimalPattern = new RegExp(`^${normalizedBaseEscaped}\.\(\d+)`);`.
- Change
- This preserves the existing semantics (matching directory names that start with that phase base followed by a dot and digits) while ensuring that if
normalizedBaseever contains any unexpected regex metacharacters, they are treated as literals and cannot alter the regex.
No new imports or dependencies are needed; escapeRegex is already imported from ./core.cjs.
| @@ -382,12 +382,13 @@ | ||
| // Calculate next decimal using existing logic | ||
| const phasesDir = path.join(cwd, '.planning', 'phases'); | ||
| const normalizedBase = normalizePhaseName(afterPhase); | ||
| const normalizedBaseEscaped = escapeRegex(normalizedBase); | ||
| let existingDecimals = []; | ||
|
|
||
| try { | ||
| const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); | ||
| const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); | ||
| const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`); | ||
| const decimalPattern = new RegExp(`^${normalizedBaseEscaped}\\.(\\d+)`); | ||
| for (const dir of dirs) { | ||
| const dm = dir.match(decimalPattern); | ||
| if (dm) existingDecimals.push(parseInt(dm[1], 10)); |
| const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run {{COMMAND_PREFIX}}plan-phase ${decimalPhase} to break down)\n`; | ||
|
|
||
| // Insert after the target phase section | ||
| const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i'); |
Check failure
Code scanning / CodeQL
Regular expression injection High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 7 days ago
In general, to fix regex injection you must ensure that any user-controlled portion interpolated into a regular expression is first passed through a function that escapes all regex metacharacters, or you must validate and reject any values that don’t match a strict allowed pattern.
In this codebase, core.cjs already defines escapeRegex, which correctly escapes regex metacharacters. The safest and least intrusive fix is therefore to apply escapeRegex to the phase portion before interpolating it into new RegExp(...). Currently, afterPhaseEscaped is derived via normalizedAfter.replace(/^0+/, '') followed by .replace(/\./g, '\\.'), which only escapes dots. We should instead build a pure data representation of the phase identifier, then run that through escapeRegex. Concretely:
- Keep the numeric/letter normalization logic (
normalizePhaseNameand stripping leading zeros) as-is to preserve existing behavior. - Replace the manual
.replace(/\./g, '\\.')with a call toescapeRegex, so that any character that has regex meaning is escaped, including.which was already being handled. - Use this new escaped string both where
targetPatternis built (line 377) and whereheaderPatternis built (line 410), ensuring both regex constructions are safe even ifafterPhasecontains unexpected characters. - Optionally, to tighten semantics further, we can also escape
normalizedBasewhen buildingdecimalPatternfor directory names, though those are created internally; still, it is cheap and safe to do.
No new methods or imports are required: escapeRegex is already exported by core.cjs and imported at the top of phase.cjs. All changes are confined to templates/get-shit-done/bin/lib/phase.cjs within the shown snippet.
| @@ -373,7 +373,7 @@ | ||
| // Normalize input then strip leading zeros for flexible matching | ||
| const normalizedAfter = normalizePhaseName(afterPhase); | ||
| const unpadded = normalizedAfter.replace(/^0+/, ''); | ||
| const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); | ||
| const afterPhaseEscaped = escapeRegex(unpadded); | ||
| const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i'); | ||
| if (!targetPattern.test(content)) { | ||
| error(`Phase ${afterPhase} not found in ROADMAP.md`); | ||
| @@ -382,12 +382,13 @@ | ||
| // Calculate next decimal using existing logic | ||
| const phasesDir = path.join(cwd, '.planning', 'phases'); | ||
| const normalizedBase = normalizePhaseName(afterPhase); | ||
| const normalizedBaseEscaped = escapeRegex(normalizedBase); | ||
| let existingDecimals = []; | ||
|
|
||
| try { | ||
| const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); | ||
| const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); | ||
| const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`); | ||
| const decimalPattern = new RegExp(`^${normalizedBaseEscaped}\\.(\\d+)`); | ||
| for (const dir of dirs) { | ||
| const dm = dir.match(decimalPattern); | ||
| if (dm) existingDecimals.push(parseInt(dm[1], 10)); |
| const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); | ||
|
|
||
| // Find sibling decimals with higher numbers | ||
| const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`); |
Check failure
Code scanning / CodeQL
Regular expression injection High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 7 days ago
General approach: when building a RegExp from any value influenced by user input (here, CLI arguments), ensure the interpolated segment cannot introduce regex meta-characters. This is typically done either by (a) strictly validating the value against an allowed-character whitelist (e.g., digits only) and/or (b) escaping it with a function like escapeRegex.
Best fix here: we already have an escapeRegex helper exported from core.cjs. While baseInt is currently digits-only, we can make the regex construction explicitly safe by escaping baseInt before interpolation. This will not change existing behavior (since digits are unchanged by escaping) but will prevent issues if normalizePhaseName is ever relaxed or changed. Concretely, in templates/get-shit-done/bin/lib/phase.cjs, in cmdPhaseRemove’s renumbering logic, change:
const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);to:
const safeBaseInt = escapeRegex(baseInt);
const decPattern = new RegExp(`^${safeBaseInt}\\.(\\d+)-(.+)$`);This uses the existing escapeRegex import on line 7, so no new imports are needed. No other functional changes are required.
| @@ -493,7 +493,8 @@ | ||
| const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); | ||
|
|
||
| // Find sibling decimals with higher numbers | ||
| const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`); | ||
| const safeBaseInt = escapeRegex(baseInt); | ||
| const decPattern = new RegExp(`^${safeBaseInt}\\.(\\d+)-(.+)$`); | ||
| const toRename = []; | ||
| for (const dir of dirs) { | ||
| const dm = dir.match(decPattern); |
| } | ||
| current = current[key]; | ||
| } | ||
| current[keys[keys.length - 1]] = parsedValue; |
Check warning
Code scanning / CodeQL
Prototype-polluting function Medium
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 7 days ago
In general, to fix this kind of issue you must prevent user-controlled property names from reaching special prototype-related keys such as __proto__, constructor, and prototype, or only recurse/assign when the destination already has that key as an own property. Here the simplest, least invasive fix is to reject or skip any key path segment that matches a dangerous name, and fail with a clear error rather than silently polluting prototypes.
Concretely, in cmdConfigSet in templates/get-shit-done/bin/lib/config.cjs, we should:
- Before iterating over
keys, validate that none of the segments equals"__proto__","constructor", or"prototype". If such a segment is found, callerror(...)and stop. - Optionally, we could also ensure during traversal that we never create or traverse those keys, but the pre-validation step is sufficient and simpler.
This change only touches the cmdConfigSet function around where keys is derived and before the for (let i = 0; i < keys.length - 1; i++) loop. No new imports or external libraries are required; we reuse the existing error function from ./core.cjs.
| @@ -99,6 +99,14 @@ | ||
|
|
||
| // Set nested value using dot notation (e.g., "workflow.research") | ||
| const keys = keyPath.split('.'); | ||
|
|
||
| // Prevent prototype pollution via special property names in the key path | ||
| for (const segment of keys) { | ||
| if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') { | ||
| error('Invalid config key path segment: ' + segment); | ||
| } | ||
| } | ||
|
|
||
| let current = config; | ||
| for (let i = 0; i < keys.length - 1; i++) { | ||
| const key = keys[i]; |
Update to: gsd-build/get-shit-done@c4ea358