fix(#663): resolve open CodeQL/Dependabot security alerts (ReDoS, prototype pollution, workflow perms, qs DoS) (#665)
* fix(#663): resolve open CodeQL/Dependabot security alerts - ReDoS: collapse ambiguous nested quantifiers in phase-heading regexes (verify/validate/commands) and the plan-filename lookahead (phase) to provably-equivalent non-backtracking forms - prototype pollution: guard __proto__/constructor/prototype in setConfigValue - remove dead no-op .replace(/-/g,'-') in phase.cts - escape all regex metachars in bug-2839 test - add contents:read permissions to security-scan + install-smoke workflows - pin qs >= 6.15.2 via overrides (DoS GHSA) - broaden prompt-injection allowlist to translated security-model docs Closes #663 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#663): regression tests for prototype-pollution guard and roadmap-phase ReDoS Behavioral test that config-set rejects __proto__/constructor/prototype keys without polluting Object.prototype, plus a ReDoS guard (timing-bound) and behavior-preservation assertions for the collapsed phase-heading regexes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#663): make ReDoS regression assert structured result, not elapsed time Replace elapsed-time assertions (which tripped local/no-elapsed-assertion ESLint rule and were unsound for synchronous ReDoS) with structured-result assertions on adversarial inputs: assert that malformed phase headings/ unchecked-item lines without a terminating colon/space yield an empty Set, which is both the correct behavior and an exercise of the fixed linear regex on the catastrophic-backtracking input shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#663): add Security changeset fragment for #665 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#663): fold prototype-pollution regression into config.test.cjs The standalone bug-663-config-prototype-pollution.test.cjs was a 9th config-module test file, tripping lint-test-file-count (the allowlist is ratcheted and must not grow). Consolidated into config.test.cjs instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1215,7 +1215,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
// Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings.
|
||||
// Also tolerates optional [bracket-token] scope prefix on phase headings.
|
||||
const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:\s*([^\n]+)/gi;
|
||||
const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:\s*([^\n]+)/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = headingPattern.exec(roadmapContent)) !== null) {
|
||||
const key = normalizePhaseName(match[1]);
|
||||
|
||||
@@ -419,6 +419,10 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set
|
||||
|
||||
// Set nested value using dot notation (e.g., "workflow.research")
|
||||
const keys = keyPath.split('.');
|
||||
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
if (keys.some((k) => FORBIDDEN_KEYS.has(k))) {
|
||||
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
let current: Record<string, unknown> = config;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
|
||||
@@ -694,7 +694,7 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?:
|
||||
let _dirName: string;
|
||||
|
||||
if (customId || config.phase_naming === 'custom') {
|
||||
_newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
|
||||
_newPhaseId = customId || slug.toUpperCase();
|
||||
if (!_newPhaseId) error('--id required when phase_naming is "custom"');
|
||||
_dirName = `${prefix}${_newPhaseId}-${slug}`;
|
||||
} else {
|
||||
@@ -805,7 +805,7 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo
|
||||
let newPhaseId: number | string;
|
||||
let dirName: string;
|
||||
if (config.phase_naming === 'custom') {
|
||||
newPhaseId = slug.toUpperCase().replace(/-/g, '-');
|
||||
newPhaseId = slug.toUpperCase();
|
||||
dirName = `${prefix}${newPhaseId}-${slug}`;
|
||||
} else {
|
||||
maxPhase += 1;
|
||||
@@ -1176,7 +1176,7 @@ function updateRoadmapAfterPhaseRemoval(
|
||||
`${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`,
|
||||
);
|
||||
content = content.replace(
|
||||
/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)*-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g,
|
||||
/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g,
|
||||
(_match, phaseNum: string, planNum: string) =>
|
||||
`${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`,
|
||||
);
|
||||
|
||||
@@ -94,7 +94,7 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV
|
||||
const roadmapPhaseVariants = new Set<string>();
|
||||
// Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:),
|
||||
// and bracket-prefixed (### [GSD] Phase 2-01:) headings.
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
||||
roadmapPhases.add(m[1]);
|
||||
@@ -106,7 +106,7 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV
|
||||
export function buildNotStartedPhaseVariants(roadmapContent: string): Set<string> {
|
||||
const notStartedPhases = new Set<string>();
|
||||
// Also matches milestone-prefixed and bracket-prefixed checklist items.
|
||||
const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)[:\s*]/gi;
|
||||
const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*)[:\s*]/gi;
|
||||
let um: RegExpExecArray | null;
|
||||
while ((um = uncheckedPattern.exec(roadmapContent)) !== null) {
|
||||
for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant);
|
||||
|
||||
@@ -643,7 +643,7 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void {
|
||||
|
||||
const roadmapPhases = new Set<string>();
|
||||
const phasePattern =
|
||||
/#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
|
||||
/#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
||||
roadmapPhases.add(m[1]);
|
||||
@@ -651,7 +651,7 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void {
|
||||
|
||||
const fullRoadmapPhases = new Set<string>();
|
||||
const fullPhasePattern =
|
||||
/#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
|
||||
/#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let fm: RegExpExecArray | null;
|
||||
while ((fm = fullPhasePattern.exec(roadmapContentRaw)) !== null) {
|
||||
fullRoadmapPhases.add(fm[1]);
|
||||
|
||||
Reference in New Issue
Block a user