Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion _Sprintpilot/lib/orchestrator/profile-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions _Sprintpilot/lib/orchestrator/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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',
);
Expand Down
2 changes: 1 addition & 1 deletion _Sprintpilot/lib/runtime/secrets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions _Sprintpilot/lib/runtime/yaml-lite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions _Sprintpilot/scripts/inject-tasks-section.js
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions _Sprintpilot/scripts/resolve-dag.js
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion _Sprintpilot/scripts/resolve-profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions _Sprintpilot/scripts/scan.js
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion _Sprintpilot/scripts/sprint-plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 4 additions & 1 deletion _Sprintpilot/scripts/stack-snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@
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');

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp Warning

RegExp() called with a storyKey function argument, this might allow an attacker to cause a Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on user-controlled input, consider performing input validation or use a regex checking/sanitization library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear vulnerable to ReDoS.
const m = sprintStatusText.match(re);
return m ? m[1] : 'unknown';
}
Expand Down
6 changes: 3 additions & 3 deletions _Sprintpilot/scripts/state-shard.js
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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] = [];
Expand Down
5 changes: 4 additions & 1 deletion lib/commands/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,10 @@
// 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');

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp Warning

RegExp() called with a key function argument, this might allow an attacker to cause a Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on user-controlled input, consider performing input validation or use a regex checking/sanitization library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear vulnerable to ReDoS.
if (replaceRe.test(source)) {
return source.replace(replaceRe, (_m, indent, _oldValue, tail) => {
return `${indent}${key}: ${value}${tail || ''}`;
Expand Down
2 changes: 1 addition & 1 deletion lib/core/config-merger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
24 changes: 17 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
6 changes: 4 additions & 2 deletions tests/e2e/greenfield.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,13 @@
// 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',
);

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp Warning test

RegExp() called with a name function argument, this might allow an attacker to cause a Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on user-controlled input, consider performing input validation or use a regex checking/sanitization library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear vulnerable to ReDoS.
};

const features = {
hasWinDetection:
Expand Down
6 changes: 3 additions & 3 deletions tests/e2e/harness/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[key];
current = (current as Record<string, unknown>)[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) {
Expand All @@ -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<string, unknown>)[key];
current = (current as Record<string, unknown>)[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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading