diff --git a/_Sprintpilot/lib/orchestrator/profile-rules.js b/_Sprintpilot/lib/orchestrator/profile-rules.js index 5252698..5beafe4 100644 --- a/_Sprintpilot/lib/orchestrator/profile-rules.js +++ b/_Sprintpilot/lib/orchestrator/profile-rules.js @@ -92,7 +92,7 @@ function get(obj, dottedKey) { let cur = obj; for (const p of parts) { if (cur === null || cur === undefined || typeof cur !== 'object') return undefined; - cur = cur[p]; + cur = cur[p]; // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- read-only path traversal; no write sink, cannot pollute a prototype } return cur; } diff --git a/_Sprintpilot/lib/orchestrator/verify.js b/_Sprintpilot/lib/orchestrator/verify.js index a0b94be..c091b1d 100644 --- a/_Sprintpilot/lib/orchestrator/verify.js +++ b/_Sprintpilot/lib/orchestrator/verify.js @@ -67,7 +67,7 @@ function storyStatusFromSprintStatus(text, storyKey) { if (!text || !storyKey) return null; const k = escapeRe(storyKey); // Block form first — has a `status:` line inside the indented block. - const blockRe = new RegExp(`^(\\s+)${k}:\\s*\\n((?:\\1\\s+[^\\n]+\\n)+)`, 'm'); + const blockRe = new RegExp(`^(\\s+)${k}:\\s*\\n((?:\\1\\s+[^\\n]+\\n)+)`, 'm'); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pattern built from a regex-escaped story key const bm = text.match(blockRe); if (bm) { const inner = bm[2]; @@ -77,7 +77,7 @@ function storyStatusFromSprintStatus(text, storyKey) { // Inline form: ` story-key: done` (status as scalar value). // Optional trailing `# comment` is allowed so `done # PR #N merged` // matches `done` instead of failing the whole line. - const inlineRe = new RegExp( + const inlineRe = new RegExp( // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pattern built from a regex-escaped story key `^\\s+${k}:\\s*["']?([\\w-]+)["']?\\s*(?:#.*)?$`, 'm', ); diff --git a/_Sprintpilot/lib/runtime/secrets.js b/_Sprintpilot/lib/runtime/secrets.js index e9f2551..0546558 100644 --- a/_Sprintpilot/lib/runtime/secrets.js +++ b/_Sprintpilot/lib/runtime/secrets.js @@ -91,7 +91,7 @@ function globToRegex(glob) { i++; } src += '$'; - return new RegExp(src); + return new RegExp(src); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- regex compiled from a trusted glob; metacharacters escaped during translation } function parseAllowlist(filePath) { diff --git a/_Sprintpilot/lib/runtime/yaml-lite.js b/_Sprintpilot/lib/runtime/yaml-lite.js index 162096f..1934b7c 100644 --- a/_Sprintpilot/lib/runtime/yaml-lite.js +++ b/_Sprintpilot/lib/runtime/yaml-lite.js @@ -44,7 +44,7 @@ function pushRawField(arr, name, formatted) { } function hasStoryBlock(existing, storyKey) { - return new RegExp(`^ ${escapeRegex(storyKey)}:\\s*$`, 'm').test(existing); + return new RegExp(`^ ${escapeRegex(storyKey)}:\\s*$`, 'm').test(existing); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pattern built from a regex-escaped story key } // Leading whitespace length of a line (counts spaces; tabs count as 1 — the @@ -63,7 +63,7 @@ function replaceStoryBlock(existing, storyKey, newBlock) { // Story headers live at 2-space indent in the addon shape. Any line with // MORE indent is a continuation of the block; any line with equal-or-less // non-blank indent is a sibling/parent and must be preserved. - const headerRe = new RegExp(`^(\\s*)${escapeRegex(storyKey)}:\\s*$`); + const headerRe = new RegExp(`^(\\s*)${escapeRegex(storyKey)}:\\s*$`); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pattern built from a regex-escaped story key const out = []; let i = 0; let replaced = false; @@ -126,8 +126,8 @@ function unquoteScalar(s) { // header's indent (2 spaces) AND that line is not blank. function readStoryField(text, storyKey, field) { const lines = text.split(/\r?\n/); - const headerRe = new RegExp(`^(\\s*)${escapeRegex(storyKey)}:\\s*$`); - const fieldRe = new RegExp(`^\\s+${escapeRegex(field)}:\\s*(.*)$`); + const headerRe = new RegExp(`^(\\s*)${escapeRegex(storyKey)}:\\s*$`); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pattern built from a regex-escaped story key + const fieldRe = new RegExp(`^\\s+${escapeRegex(field)}:\\s*(.*)$`); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pattern built from a regex-escaped field name let headerIndent = -1; for (const line of lines) { diff --git a/_Sprintpilot/scripts/inject-tasks-section.js b/_Sprintpilot/scripts/inject-tasks-section.js old mode 100755 new mode 100644 index 9aa882b..62fcb4e --- a/_Sprintpilot/scripts/inject-tasks-section.js +++ b/_Sprintpilot/scripts/inject-tasks-section.js @@ -131,6 +131,7 @@ function inspectTasksSection(body) { // Scanning is strictly bounded to within the AC section. function extractAcceptanceCriteria(body, sectionName) { const lines = body.split('\n'); + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pattern built from a regex-escaped section name const headerRe = new RegExp( `^(#{2,})\\s+${sectionName.replace(/[.*+?^${}()|[\\\]]/g, '\\$&')}\\s*$`, 'i', diff --git a/_Sprintpilot/scripts/resolve-dag.js b/_Sprintpilot/scripts/resolve-dag.js old mode 100755 new mode 100644 index c8239ea..aba1187 --- a/_Sprintpilot/scripts/resolve-dag.js +++ b/_Sprintpilot/scripts/resolve-dag.js @@ -489,7 +489,7 @@ const STRIP_CONTROL = /[\x00-\x09\x0b-\x1f\x7f]/g; // Unicode bidi-override / isolate / embedding marks. These can reorder // the visual presentation of a label in confusing ways even when the // underlying codepoints are benign — strip them entirely. -const STRIP_BIDI = /[‪-‮⁦-⁩؜]/g; +const STRIP_BIDI = /[‪-‮⁦-⁩؜]/g; // nosemgrep: generic.unicode.security.bidi.contains-bidirectional-characters -- regex intentionally contains bidi code points in order to strip them function mermaidEscapeLabel(s) { return ( @@ -582,7 +582,7 @@ function dotEscapeLabel(s) { .replace(/[\\"]/g, '\\$&') .replace(/\n/g, '\\n') .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '') - .replace(/[‪-‮⁦-⁩؜]/g, ''); + .replace(/[‪-‮⁦-⁩؜]/g, ''); // nosemgrep: generic.unicode.security.bidi.contains-bidirectional-characters -- regex intentionally contains bidi code points in order to strip them } function renderGraphviz(dag, plan) { diff --git a/_Sprintpilot/scripts/resolve-profile.js b/_Sprintpilot/scripts/resolve-profile.js index abd482b..1627fec 100644 --- a/_Sprintpilot/scripts/resolve-profile.js +++ b/_Sprintpilot/scripts/resolve-profile.js @@ -266,7 +266,7 @@ function getByDottedKey(obj, key) { let cur = obj; for (const p of parts) { if (!isPlainObject(cur) || !(p in cur)) return undefined; - cur = cur[p]; + cur = cur[p]; // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- read-only path traversal; no write sink, cannot pollute a prototype } return cur; } diff --git a/_Sprintpilot/scripts/scan.js b/_Sprintpilot/scripts/scan.js old mode 100755 new mode 100644 index 9f10270..b3fd5d9 --- a/_Sprintpilot/scripts/scan.js +++ b/_Sprintpilot/scripts/scan.js @@ -246,7 +246,7 @@ function globToRegex(glob) { re += c; i++; } - return new RegExp('^' + re + '$'); + return new RegExp('^' + re + '$'); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- regex compiled from a trusted glob; metacharacters escaped during translation } function toPosix(p) { @@ -546,7 +546,7 @@ function cmdGrep(opts) { let regexes; try { - regexes = patterns.map((p) => new RegExp(p)); + regexes = patterns.map((p) => new RegExp(p)); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- operator-supplied grep pattern by design; guarded by try/catch } catch (e) { log.fail(`grep: invalid pattern (${e.message})`); return; diff --git a/_Sprintpilot/scripts/sprint-plan.js b/_Sprintpilot/scripts/sprint-plan.js index 90a1f81..ab0a2f3 100644 --- a/_Sprintpilot/scripts/sprint-plan.js +++ b/_Sprintpilot/scripts/sprint-plan.js @@ -574,7 +574,7 @@ function removeStories(keys, { projectRoot, status = 'skipped' }) { // - Unicode RTL/LTR override marks (visual-reorder attack) // Tracker IDs from Jira/Linear/GitHub/GitLab don't legitimately use // any of these. -const ISSUE_ID_REJECT_CHARS = /[[\]<>|;&\n\r\x00-\x1f\x7f‪-‮⁦-⁩؜]/; +const ISSUE_ID_REJECT_CHARS = /[[\]<>|;&\n\r\x00-\x1f\x7f‪-‮⁦-⁩؜]/; // nosemgrep: generic.unicode.security.bidi.contains-bidirectional-characters -- reject-char class intentionally contains bidi code points in order to reject them // Set issue_id on either an epic or a story entity. Looks up the entity // by key/id (epic first since epic ids are typically shorter strings). diff --git a/_Sprintpilot/scripts/stack-snapshot.js b/_Sprintpilot/scripts/stack-snapshot.js index 320b80a..e8dffef 100644 --- a/_Sprintpilot/scripts/stack-snapshot.js +++ b/_Sprintpilot/scripts/stack-snapshot.js @@ -73,7 +73,10 @@ function readSprintStatus(projectRoot) { function statusForStory(sprintStatusText, storyKey) { if (!sprintStatusText) return 'unknown'; // Narrow regex — accept either "story_key: status" or block form. - const re = new RegExp(`^\\s*${storyKey}:\\s*(\\w+)`, 'm'); + // Escape the key so a metacharacter in it can't alter the pattern. + const safeKey = storyKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pattern built from a regex-escaped, trusted story key + const re = new RegExp(`^\\s*${safeKey}:\\s*(\\w+)`, 'm'); const m = sprintStatusText.match(re); return m ? m[1] : 'unknown'; } diff --git a/_Sprintpilot/scripts/state-shard.js b/_Sprintpilot/scripts/state-shard.js old mode 100755 new mode 100644 index d48f492..f271a3f --- a/_Sprintpilot/scripts/state-shard.js +++ b/_Sprintpilot/scripts/state-shard.js @@ -295,7 +295,7 @@ function setByDottedPath(obj, key, value) { if (!cur[p] || typeof cur[p] !== 'object' || Array.isArray(cur[p])) { cur[p] = {}; } - cur = cur[p]; + cur = cur[p]; // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- write is guarded above by the UNSAFE_KEYS check; segment can never be __proto__/constructor/prototype } cur[parts[parts.length - 1]] = value; return obj; @@ -306,7 +306,7 @@ function getByDottedPath(obj, key) { let cur = obj; for (const p of parts) { if (!cur || typeof cur !== 'object') return undefined; - cur = cur[p]; + cur = cur[p]; // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- read-only path traversal; no write sink, cannot pollute a prototype } return cur; } @@ -364,7 +364,7 @@ function appendToListAtPath(obj, dottedPath, entry) { if (!cur[p] || typeof cur[p] !== 'object' || Array.isArray(cur[p])) { cur[p] = {}; } - cur = cur[p]; + cur = cur[p]; // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- write is guarded above by the UNSAFE_KEYS check; segment can never be __proto__/constructor/prototype } const last = parts[parts.length - 1]; if (!Array.isArray(cur[last])) cur[last] = []; diff --git a/lib/commands/install.js b/lib/commands/install.js index d8f4383..951932c 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -982,7 +982,10 @@ function applyScalar(source, key, value) { // Using [ \t] instead of \s everywhere INSIDE the line so the match // cannot cross a newline — `\s*` would greedily consume the trailing // `\n` after "key:" on an empty-value line, breaking the rewrite. - const replaceRe = new RegExp(`^([ \\t]*)${key}:[ \\t]*(\\S[^#\\n]*?)?([ \\t]*#.*)?$`, 'm'); + // Escape the key so a metacharacter in it can't alter the pattern. + const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pattern built from a regex-escaped config key + const replaceRe = new RegExp(`^([ \\t]*)${safeKey}:[ \\t]*(\\S[^#\\n]*?)?([ \\t]*#.*)?$`, 'm'); if (replaceRe.test(source)) { return source.replace(replaceRe, (_m, indent, _oldValue, tail) => { return `${indent}${key}: ${value}${tail || ''}`; diff --git a/lib/core/config-merger.js b/lib/core/config-merger.js index 2111fa9..c1f9007 100644 --- a/lib/core/config-merger.js +++ b/lib/core/config-merger.js @@ -230,7 +230,7 @@ function renderOrphanBlock(orphans, values) { for (let i = 0; i < segs.length - 1; i++) { const s = segs[i]; if (!node[s] || typeof node[s] !== 'object') node[s] = Object.create(null); - node = node[s]; + node = node[s]; // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- tree is Object.create(null); a __proto__ segment becomes a harmless own key and cannot reach Object.prototype } node[segs[segs.length - 1]] = values[p]; } diff --git a/package-lock.json b/package-lock.json index 89f1ce6..46de195 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "@ikunin/sprintpilot", - "version": "2.0.10", + "version": "2.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ikunin/sprintpilot", - "version": "2.0.10", + "version": "2.7.4", "license": "Apache-2.0", "dependencies": { "@clack/core": "^1.0.0", "@clack/prompts": "^1.0.0", "commander": "^14.0.0", "fs-extra": "^11.3.0", - "js-yaml": "^4.1.0", + "js-yaml": "^4.3.0", "picocolors": "^1.1.1", "semver": "^7.6.3" }, @@ -21,7 +21,7 @@ "sprintpilot": "bin/sprintpilot.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.12.0" } }, "node_modules/@clack/core": { @@ -106,9 +106,19 @@ "license": "ISC" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index 56600ed..7b14369 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@clack/prompts": "^1.0.0", "commander": "^14.0.0", "fs-extra": "^11.3.0", - "js-yaml": "^4.1.0", + "js-yaml": "^4.3.0", "picocolors": "^1.1.1", "semver": "^7.6.3" }, diff --git a/tests/e2e/greenfield.test.ts b/tests/e2e/greenfield.test.ts index 2f3a93c..390b082 100644 --- a/tests/e2e/greenfield.test.ts +++ b/tests/e2e/greenfield.test.ts @@ -173,11 +173,13 @@ function isGameComplete(dir: string): boolean { // e.g. `function checkWin`, `checkWin(`, `const winner =`, `class Board`. // Prevents false positives from stray tokens in type names, enums used // only for export, etc. - const identifierRe = (name: string) => - new RegExp( + const identifierRe = (name: string) => { + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- controlled test identifier, not external input + return new RegExp( `(?:function|const|let|var|class|=>|\\bexport\\s+)?\\s*${name}\\s*[=(:<{]|\\b${name}\\s*\\(`, 'i', ); + }; const features = { hasWinDetection: diff --git a/tests/e2e/harness/assertions.ts b/tests/e2e/harness/assertions.ts index 2913e79..8e21520 100644 --- a/tests/e2e/harness/assertions.ts +++ b/tests/e2e/harness/assertions.ts @@ -57,7 +57,7 @@ export function assertYamlField(filePath: string, fieldPath: string, expected: u if (current === null || current === undefined || typeof current !== 'object') { throw new Error(`YAML field path '${fieldPath}' broken at '${key}' in ${filePath}`); } - current = (current as Record)[key]; + current = (current as Record)[key]; // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- read-only path traversal; no write sink, cannot pollute a prototype } if (current !== expected) { @@ -76,7 +76,7 @@ export function assertYamlFieldExists(filePath: string, fieldPath: string): void if (current === null || current === undefined || typeof current !== 'object') { throw new Error(`YAML field '${fieldPath}' does not exist in ${filePath}`); } - current = (current as Record)[key]; + current = (current as Record)[key]; // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop -- read-only path traversal; no write sink, cannot pollute a prototype } if (current === undefined || current === null) { @@ -151,7 +151,7 @@ export function assertMarkdownHasSections(filePath: string, sections: string[]): const content = readFileSync(filePath, 'utf-8'); const missing = sections.filter((s) => { // Match heading at any level (# through ####) at the start of a line - const pattern = new RegExp(`^#{1,4}\\s+${s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm'); + const pattern = new RegExp(`^#{1,4}\\s+${s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm'); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- pattern built from regex-escaped input return !pattern.test(content); }); if (missing.length > 0) {