nubos-pilot 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/agents/np-executor.md +20 -0
  3. package/agents/np-security-reviewer.md +49 -3
  4. package/bin/install.js +7 -2
  5. package/bin/np-tools/_commands.cjs +2 -0
  6. package/bin/np-tools/doctor.cjs +15 -2
  7. package/bin/np-tools/graph-impact.cjs +111 -0
  8. package/bin/np-tools/graph-impact.test.cjs +119 -0
  9. package/bin/np-tools/scan-codebase.cjs +21 -1
  10. package/bin/np-tools/security.cjs +177 -0
  11. package/bin/np-tools/security.test.cjs +82 -0
  12. package/lib/checkpoint.cjs +3 -0
  13. package/lib/codebase-graph.cjs +0 -0
  14. package/lib/codebase-graph.test.cjs +174 -0
  15. package/lib/codebase-manifest.cjs +3 -0
  16. package/lib/config-defaults.cjs +23 -0
  17. package/lib/config-defaults.test.cjs +15 -0
  18. package/lib/config-schema.cjs +19 -0
  19. package/lib/config-schema.test.cjs +58 -0
  20. package/lib/install/claude-hooks.cjs +100 -7
  21. package/lib/install/claude-hooks.test.cjs +96 -0
  22. package/lib/learnings.cjs +19 -95
  23. package/lib/memory.cjs +38 -33
  24. package/lib/messaging.cjs +12 -6
  25. package/lib/metrics-aggregate.cjs +14 -2
  26. package/lib/migrate.cjs +29 -0
  27. package/lib/migrate.test.cjs +91 -0
  28. package/lib/schemas/data/checkpoint.v1.json +13 -0
  29. package/lib/schemas/data/codebase-manifest.v1.json +22 -0
  30. package/lib/schemas/data/learnings.v1.json +28 -0
  31. package/lib/schemas/data/memory-manifest.v1.json +14 -0
  32. package/lib/schemas/data/memory-record.v1.json +16 -0
  33. package/lib/schemas/data/message.v1.json +19 -0
  34. package/lib/schemas/data/metrics-record.v1.json +11 -0
  35. package/lib/security/ledger.cjs +203 -0
  36. package/lib/security/ledger.test.cjs +139 -0
  37. package/lib/security/patterns.cjs +119 -0
  38. package/lib/security/review.cjs +220 -0
  39. package/lib/security/review.test.cjs +143 -0
  40. package/lib/security/scan.cjs +180 -0
  41. package/lib/security/scan.test.cjs +137 -0
  42. package/lib/validate.cjs +301 -0
  43. package/lib/validate.test.cjs +242 -0
  44. package/np-tools.cjs +2 -0
  45. package/package.json +3 -1
  46. package/templates/claude/payload/hooks/np-security-hook.cjs +50 -0
  47. package/workflows/execute-phase.md +11 -1
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+
8
+ const git = require('../git.cjs');
9
+ const ledger = require('./ledger.cjs');
10
+
11
+ const REVIEWER_AGENT = 'np-security-reviewer';
12
+ const MAX_DIFF_BYTES = 96 * 1024;
13
+ const MAX_UNTRACKED_BYTES = 16 * 1024;
14
+ const SHA_RE = /^[0-9a-fA-F]{7,40}$/;
15
+
16
+ function _safeRef(ref) {
17
+ return typeof ref === 'string' && SHA_RE.test(ref) ? ref : 'HEAD';
18
+ }
19
+
20
+ function _lines(stdout) {
21
+ return String(stdout || '').split(/\r?\n/).filter(Boolean);
22
+ }
23
+
24
+ function isRepo(cwd) {
25
+ const r = git.runGit(['rev-parse', '--is-inside-work-tree'], { cwd });
26
+ return r.ok && String(r.stdout).trim() === 'true';
27
+ }
28
+
29
+ function headSha(cwd) {
30
+ const r = git.runGit(['rev-parse', 'HEAD'], { cwd });
31
+ return r.ok ? String(r.stdout).trim() : null;
32
+ }
33
+
34
+ function computeStopDiff(cwd, baseline, maxFiles) {
35
+ const ref = _safeRef(baseline && baseline.head);
36
+ const cap = Number.isFinite(maxFiles) ? maxFiles : 30;
37
+
38
+ const tracked = git.runGit(['--no-pager', 'diff', '--name-only', '--end-of-options', ref], { cwd });
39
+ const untracked = git.runGit(['ls-files', '--others', '--exclude-standard'], { cwd });
40
+ const files = [..._lines(tracked.stdout), ..._lines(untracked.stdout)];
41
+ const uniqueFiles = [...new Set(files)].slice(0, cap);
42
+ const truncatedFiles = files.length > cap;
43
+
44
+ let diffText = '';
45
+ const trackedDiff = git.runGit(['--no-pager', 'diff', '--no-color', '--end-of-options', ref], { cwd });
46
+ if (trackedDiff.ok) diffText += String(trackedDiff.stdout || '').slice(0, MAX_DIFF_BYTES);
47
+
48
+ let untrackedBudget = MAX_UNTRACKED_BYTES;
49
+ for (const f of _lines(untracked.stdout)) {
50
+ if (untrackedBudget <= 0) break;
51
+ let body = '';
52
+ try { body = fs.readFileSync(path.join(cwd, f), 'utf-8'); } catch { continue; }
53
+ const chunk = '\n--- new file: ' + f + ' ---\n' + body.slice(0, untrackedBudget);
54
+ diffText += chunk;
55
+ untrackedBudget -= chunk.length;
56
+ }
57
+
58
+ return { ref, files: uniqueFiles, truncatedFiles, diffText };
59
+ }
60
+
61
+ function computeCommitDiff(cwd, maxFiles) {
62
+ const cap = Number.isFinite(maxFiles) ? maxFiles : 30;
63
+ const names = git.runGit(['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], { cwd });
64
+ const files = [..._lines(names.stdout)].slice(0, cap);
65
+ const show = git.runGit(['--no-pager', 'show', '--no-color', 'HEAD'], { cwd });
66
+ const diffText = show.ok ? String(show.stdout || '').slice(0, MAX_DIFF_BYTES) : '';
67
+ return { ref: 'HEAD', files, truncatedFiles: _lines(names.stdout).length > cap, diffText };
68
+ }
69
+
70
+ function _readGuidance(guidancePath) {
71
+ if (!guidancePath) return '';
72
+ try { return fs.readFileSync(guidancePath, 'utf-8').slice(0, 8 * 1024); }
73
+ catch { return ''; }
74
+ }
75
+
76
+ function buildReviewerPrompt(opts) {
77
+ const o = opts || {};
78
+ const mode = o.mode === 'commit' ? 'commit' : 'stop';
79
+ const guidance = _readGuidance(o.guidancePath);
80
+ const surrounding = mode === 'commit'
81
+ ? 'This is a pre-existing commit review. Read surrounding code (callers, sanitizers, related files) with your Read/Grep tools before deciding whether a finding is real, to keep false positives low.'
82
+ : 'Review only what this turn changed. Start from the diff; do not assume issues outside it.';
83
+
84
+ const parts = [];
85
+ parts.push('<security_scan mode="' + mode + '">');
86
+ parts.push('You are running in SESSION/DIFF mode (Modus B), not milestone mode.');
87
+ parts.push(surrounding);
88
+ parts.push('');
89
+ parts.push('Changed files (' + o.files.length + (o.truncatedFiles ? '+, truncated' : '') + '):');
90
+ parts.push(o.files.map((f) => '- ' + f).join('\n'));
91
+ if (guidance) {
92
+ parts.push('');
93
+ parts.push('Project security guidance (ADDITIVE — augments built-in checks, never disables them):');
94
+ parts.push(guidance);
95
+ }
96
+ parts.push('');
97
+ parts.push('Diff under review:');
98
+ parts.push('```diff');
99
+ parts.push(o.diffText);
100
+ parts.push('```');
101
+ parts.push('');
102
+ parts.push('Output ONLY a single JSON object (no prose, no markdown fence) of the form:');
103
+ parts.push('{"status":"clean|risks-found","findings":[{"category":"...","severity":"high|medium|low","file":"path","line":<int|null>,"title":"...","evidence":"...","mitigation_hint":"..."}]}');
104
+ parts.push('Report ONLY concrete Risk findings. Omit Pass/no-risk entries. If nothing, return {"status":"clean","findings":[]}.');
105
+ parts.push('</security_scan>');
106
+ return parts.join('\n');
107
+ }
108
+
109
+ function parseReviewerOutput(raw) {
110
+ if (!raw || typeof raw !== 'string') return { findings: [], status: 'unknown', parse_ok: false };
111
+ let resultText = raw;
112
+ try {
113
+ const outer = JSON.parse(raw);
114
+ if (outer && typeof outer === 'object' && typeof outer.result === 'string') resultText = outer.result;
115
+ } catch { /* raw may already be the agent text */ }
116
+
117
+ let envelope = _tryParseJson(resultText);
118
+ if (!envelope) envelope = _tryParseJson(_stripFence(resultText));
119
+ if (!envelope || typeof envelope !== 'object' || !Array.isArray(envelope.findings)) {
120
+ return { findings: [], status: 'unknown', parse_ok: false };
121
+ }
122
+ const findings = envelope.findings
123
+ .filter((f) => f && typeof f === 'object')
124
+ .map((f) => ({
125
+ category: f.category || null,
126
+ severity: _normSeverity(f.severity),
127
+ file: f.file || null,
128
+ line: Number.isFinite(f.line) ? f.line : null,
129
+ title: f.title || null,
130
+ mitigation_hint: f.mitigation_hint || null,
131
+ }));
132
+ return { findings, status: envelope.status || 'unknown', parse_ok: true };
133
+ }
134
+
135
+ function _normSeverity(s) {
136
+ const v = String(s || '').toLowerCase();
137
+ if (v === 'high' || v === 'critical') return 'risk';
138
+ if (v === 'risk' || v === 'fail') return 'risk';
139
+ if (v === 'medium' || v === 'low' || v === 'warn' || v === 'nit') return 'warn';
140
+ return 'risk';
141
+ }
142
+
143
+ function _tryParseJson(s) {
144
+ try { return JSON.parse(s); } catch { return null; }
145
+ }
146
+
147
+ function _stripFence(s) {
148
+ const m = String(s).match(/```(?:json)?\s*([\s\S]*?)```/);
149
+ return m ? m[1] : s;
150
+ }
151
+
152
+ function _defaultSpawn(promptText, opts) {
153
+ const spawnHeadless = require('../../bin/np-tools/spawn-headless.cjs');
154
+ const tmp = os.tmpdir();
155
+ const tag = process.pid + '-' + crypto.randomBytes(4).toString('hex');
156
+ const promptPath = path.join(tmp, 'np-sec-prompt-' + tag + '.txt');
157
+ const outputPath = path.join(tmp, 'np-sec-out-' + tag + '.json');
158
+ fs.writeFileSync(promptPath, promptText, 'utf-8');
159
+ const captured = [];
160
+ try {
161
+ spawnHeadless.run(
162
+ ['--agent', REVIEWER_AGENT, '--prompt-path', promptPath, '--output-path', outputPath,
163
+ '--timeout-ms', String(opts.timeoutMs)],
164
+ { cwd: opts.cwd, stdout: { write: (s) => captured.push(s) } },
165
+ );
166
+ return fs.readFileSync(outputPath, 'utf-8');
167
+ } finally {
168
+ try { fs.unlinkSync(promptPath); } catch {}
169
+ try { fs.unlinkSync(outputPath); } catch {}
170
+ }
171
+ }
172
+
173
+ function runReview(opts) {
174
+ const o = opts || {};
175
+ const cwd = o.cwd || process.cwd();
176
+ const sid = o.sid;
177
+ const mode = o.mode === 'commit' ? 'commit' : 'stop';
178
+ const config = o.config || {};
179
+ const spawn = typeof o.spawnImpl === 'function' ? o.spawnImpl : _defaultSpawn;
180
+
181
+ if (!isRepo(cwd)) return { ran: false, reason: 'not-a-repo' };
182
+
183
+ const begin = ledger.tryBeginReview(sid, { staleMs: (config.review_timeout_ms || 180000) + 60000 });
184
+ if (!begin.began) return { ran: false, reason: begin.reason };
185
+
186
+ try {
187
+ const maxFiles = Number.isFinite(config.max_files_per_review) ? config.max_files_per_review : 30;
188
+ const diff = mode === 'commit'
189
+ ? computeCommitDiff(cwd, maxFiles)
190
+ : computeStopDiff(cwd, ledger.readLedger(sid).baseline, maxFiles);
191
+
192
+ if (!diff.files.length || !String(diff.diffText).trim()) {
193
+ return { ran: true, mode, findings_added: 0, reason: 'empty-diff' };
194
+ }
195
+
196
+ const promptText = buildReviewerPrompt({
197
+ mode, files: diff.files, truncatedFiles: diff.truncatedFiles,
198
+ diffText: diff.diffText, guidancePath: config.guidance_path,
199
+ });
200
+
201
+ const raw = spawn(promptText, { cwd, timeoutMs: config.review_timeout_ms || 180000 });
202
+ const parsed = parseReviewerOutput(raw);
203
+ const risks = parsed.findings.filter((f) => f.severity === 'risk');
204
+ const merged = ledger.addReviewFindings(sid, risks, mode);
205
+ return { ran: true, mode, parse_ok: parsed.parse_ok, findings_total: parsed.findings.length, findings_added: merged.added };
206
+ } finally {
207
+ ledger.endReview(sid);
208
+ }
209
+ }
210
+
211
+ module.exports = {
212
+ REVIEWER_AGENT,
213
+ isRepo,
214
+ headSha,
215
+ computeStopDiff,
216
+ computeCommitDiff,
217
+ buildReviewerPrompt,
218
+ parseReviewerOutput,
219
+ runReview,
220
+ };
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { execFileSync } = require('node:child_process');
9
+
10
+ const review = require('./review.cjs');
11
+ const ledger = require('./ledger.cjs');
12
+
13
+ function git(cwd, args) {
14
+ execFileSync('git', args, { cwd, stdio: 'pipe' });
15
+ }
16
+
17
+ function tempRepo() {
18
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-sec-repo-'));
19
+ git(dir, ['init', '-q']);
20
+ git(dir, ['config', 'user.email', 't@t.test']);
21
+ git(dir, ['config', 'user.name', 'Test']);
22
+ fs.writeFileSync(path.join(dir, 'app.js'), 'function ok(){ return 1; }\n');
23
+ git(dir, ['add', '-A']);
24
+ git(dir, ['commit', '-q', '-m', 'init']);
25
+ return dir;
26
+ }
27
+ function headOf(dir) {
28
+ return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf-8' }).trim();
29
+ }
30
+
31
+ let _c = 0;
32
+ function freshSid() { _c += 1; return 'rev-test-' + process.pid + '-' + _c; }
33
+ function cleanup(sid) { ledger.removeLedger(sid); try { fs.unlinkSync(ledger.ledgerPath(sid) + '.lock'); } catch {} }
34
+
35
+ test('REV-1 computeStopDiff captures tracked + untracked changes since baseline', () => {
36
+ const dir = tempRepo();
37
+ try {
38
+ const base = headOf(dir);
39
+ fs.appendFileSync(path.join(dir, 'app.js'), 'const x = eval(input);\n');
40
+ fs.writeFileSync(path.join(dir, 'new.js'), 'el.innerHTML = data;\n');
41
+ const diff = review.computeStopDiff(dir, { head: base }, 30);
42
+ assert.ok(diff.files.includes('app.js'));
43
+ assert.ok(diff.files.includes('new.js'));
44
+ assert.ok(diff.diffText.includes('eval(input)'));
45
+ assert.ok(diff.diffText.includes('new file: new.js'));
46
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
47
+ });
48
+
49
+ test('REV-2 computeStopDiff caps file count', () => {
50
+ const dir = tempRepo();
51
+ try {
52
+ const base = headOf(dir);
53
+ for (let i = 0; i < 10; i++) fs.writeFileSync(path.join(dir, 'f' + i + '.js'), 'x\n');
54
+ const diff = review.computeStopDiff(dir, { head: base }, 3);
55
+ assert.equal(diff.files.length, 3);
56
+ assert.equal(diff.truncatedFiles, true);
57
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
58
+ });
59
+
60
+ test('REV-3 computeCommitDiff reads the HEAD commit', () => {
61
+ const dir = tempRepo();
62
+ try {
63
+ fs.appendFileSync(path.join(dir, 'app.js'), 'const y = 2;\n');
64
+ git(dir, ['add', '-A']);
65
+ git(dir, ['commit', '-q', '-m', 'change']);
66
+ const diff = review.computeCommitDiff(dir, 30);
67
+ assert.ok(diff.files.includes('app.js'));
68
+ assert.ok(diff.diffText.includes('const y = 2'));
69
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
70
+ });
71
+
72
+ test('REV-4 buildReviewerPrompt includes guidance additively and the schema instruction', () => {
73
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-sec-g-'));
74
+ const gp = path.join(dir, 'guidance.md');
75
+ fs.writeFileSync(gp, 'Never log customer_id.');
76
+ try {
77
+ const prompt = review.buildReviewerPrompt({
78
+ mode: 'stop', files: ['a.js'], truncatedFiles: false, diffText: '+ eval(x)', guidancePath: gp,
79
+ });
80
+ assert.ok(prompt.includes('Modus B') || prompt.includes('SESSION/DIFF'));
81
+ assert.ok(prompt.includes('Never log customer_id.'));
82
+ assert.ok(prompt.includes('ADDITIVE'));
83
+ assert.ok(prompt.includes('"status":"clean|risks-found"'));
84
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
85
+ });
86
+
87
+ test('REV-5 parseReviewerOutput handles claude -p envelope, fences, and junk', () => {
88
+ const envelope = JSON.stringify({ result: '{"status":"risks-found","findings":[{"category":"injection","severity":"high","file":"a.js","line":3,"title":"SQLi","mitigation_hint":"parameterize"}]}' });
89
+ const a = review.parseReviewerOutput(envelope);
90
+ assert.equal(a.parse_ok, true);
91
+ assert.equal(a.findings.length, 1);
92
+ assert.equal(a.findings[0].severity, 'risk');
93
+
94
+ const fenced = JSON.stringify({ result: '```json\n{"status":"clean","findings":[]}\n```' });
95
+ const b = review.parseReviewerOutput(fenced);
96
+ assert.equal(b.parse_ok, true);
97
+ assert.equal(b.findings.length, 0);
98
+
99
+ const junk = review.parseReviewerOutput('not json at all');
100
+ assert.equal(junk.parse_ok, false);
101
+ });
102
+
103
+ test('REV-6 runReview guard blocks a concurrent review (no double spawn)', () => {
104
+ const dir = tempRepo();
105
+ const sid = freshSid();
106
+ try {
107
+ ledger.setBaseline(sid, { head: headOf(dir) });
108
+ fs.appendFileSync(path.join(dir, 'app.js'), 'const z = eval(q);\n');
109
+ ledger.tryBeginReview(sid, {}); // simulate an in-flight review
110
+ let spawnCalls = 0;
111
+ const r = review.runReview({ cwd: dir, sid, mode: 'stop', config: {}, spawnImpl: () => { spawnCalls++; return '{}'; } });
112
+ assert.equal(r.ran, false);
113
+ assert.equal(r.reason, 'in-flight');
114
+ assert.equal(spawnCalls, 0);
115
+ } finally { ledger.endReview(sid); cleanup(sid); fs.rmSync(dir, { recursive: true, force: true }); }
116
+ });
117
+
118
+ test('REV-7 runReview spawns, parses, and merges risk findings into the ledger', () => {
119
+ const dir = tempRepo();
120
+ const sid = freshSid();
121
+ try {
122
+ ledger.setBaseline(sid, { head: headOf(dir) });
123
+ fs.appendFileSync(path.join(dir, 'app.js'), 'const z = eval(q);\n');
124
+ const stub = () => JSON.stringify({ result: '{"status":"risks-found","findings":[{"category":"dynamic-exec","severity":"high","file":"app.js","line":2,"title":"eval"}]}' });
125
+ const r = review.runReview({ cwd: dir, sid, mode: 'stop', config: {}, spawnImpl: stub });
126
+ assert.equal(r.ran, true);
127
+ assert.equal(r.findings_added, 1);
128
+ const taken = ledger.takeUnsurfacedRisks(sid, {});
129
+ assert.equal(taken.findings.length, 1);
130
+ } finally { cleanup(sid); fs.rmSync(dir, { recursive: true, force: true }); }
131
+ });
132
+
133
+ test('REV-8 runReview on an empty diff does not spawn', () => {
134
+ const dir = tempRepo();
135
+ const sid = freshSid();
136
+ try {
137
+ ledger.setBaseline(sid, { head: headOf(dir) });
138
+ let spawnCalls = 0;
139
+ const r = review.runReview({ cwd: dir, sid, mode: 'stop', config: {}, spawnImpl: () => { spawnCalls++; return '{}'; } });
140
+ assert.equal(r.findings_added, 0);
141
+ assert.equal(spawnCalls, 0);
142
+ } finally { cleanup(sid); fs.rmSync(dir, { recursive: true, force: true }); }
143
+ });
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { BUILTIN_PATTERNS } = require('./patterns.cjs');
7
+
8
+ const MAX_CUSTOM_RULES = 50;
9
+ const MAX_REMINDER_BYTES = 1024;
10
+ const NESTED_QUANTIFIER_RE = /\([^)]*[+*][^)]*\)\s*[+*]/;
11
+
12
+ function _looksCatastrophic(src) {
13
+ return NESTED_QUANTIFIER_RE.test(src);
14
+ }
15
+
16
+ function _globToRegExp(glob) {
17
+ let re = '';
18
+ for (let i = 0; i < glob.length; i++) {
19
+ const c = glob[i];
20
+ if (c === '*') {
21
+ if (glob[i + 1] === '*') {
22
+ re += '.*';
23
+ i++;
24
+ if (glob[i + 1] === '/') i++;
25
+ } else {
26
+ re += '[^/]*';
27
+ }
28
+ } else if (c === '?') {
29
+ re += '[^/]';
30
+ } else if ('\\^$.|+()[]{}'.includes(c)) {
31
+ re += '\\' + c;
32
+ } else {
33
+ re += c;
34
+ }
35
+ }
36
+ return new RegExp('^' + re + '$');
37
+ }
38
+
39
+ function _pathMatchesAny(filePath, globs) {
40
+ if (!Array.isArray(globs) || globs.length === 0) return false;
41
+ const normalized = String(filePath).replace(/\\/g, '/');
42
+ for (const g of globs) {
43
+ if (typeof g !== 'string' || g.length === 0) continue;
44
+ try {
45
+ if (_globToRegExp(g).test(normalized)) return true;
46
+ } catch { /* skip malformed glob */ }
47
+ }
48
+ return false;
49
+ }
50
+
51
+ function _compileRule(rule, source) {
52
+ if (!rule || typeof rule !== 'object') return null;
53
+ const ruleName = typeof rule.rule_name === 'string' ? rule.rule_name.trim() : '';
54
+ if (!ruleName) return { skipped: 'missing-rule_name' };
55
+
56
+ let reminder = typeof rule.reminder === 'string' ? rule.reminder : '';
57
+ if (Buffer.byteLength(reminder, 'utf-8') > MAX_REMINDER_BYTES) {
58
+ reminder = Buffer.from(reminder, 'utf-8').slice(0, MAX_REMINDER_BYTES).toString('utf-8');
59
+ }
60
+
61
+ const paths = Array.isArray(rule.paths) ? rule.paths : null;
62
+ const excludePaths = Array.isArray(rule.exclude_paths) ? rule.exclude_paths : null;
63
+
64
+ const compiled = {
65
+ rule_name: ruleName,
66
+ category: typeof rule.category === 'string' && rule.category ? rule.category : 'custom',
67
+ severity: typeof rule.severity === 'string' && rule.severity ? rule.severity : 'warn',
68
+ reminder,
69
+ source,
70
+ paths,
71
+ exclude_paths: excludePaths,
72
+ path_only: rule.path_only === true,
73
+ };
74
+
75
+ if (compiled.path_only) {
76
+ if (!paths) return { skipped: 'path_only-without-paths' };
77
+ return compiled;
78
+ }
79
+
80
+ if (typeof rule.regex === 'string' && rule.regex.length > 0) {
81
+ if (_looksCatastrophic(rule.regex)) return { skipped: 'catastrophic-regex' };
82
+ try { compiled.regex = new RegExp(rule.regex); }
83
+ catch { return { skipped: 'invalid-regex' }; }
84
+ return compiled;
85
+ }
86
+
87
+ if (Array.isArray(rule.substrings) && rule.substrings.length > 0) {
88
+ compiled.substrings = rule.substrings.filter((s) => typeof s === 'string' && s.length > 0);
89
+ if (compiled.substrings.length === 0) return { skipped: 'empty-substrings' };
90
+ return compiled;
91
+ }
92
+
93
+ return { skipped: 'no-matcher' };
94
+ }
95
+
96
+ function loadCustomRules(customRulesPath) {
97
+ if (!customRulesPath) return { rules: [], skipped: [] };
98
+ let raw;
99
+ try { raw = fs.readFileSync(customRulesPath, 'utf-8'); }
100
+ catch { return { rules: [], skipped: [{ reason: 'unreadable', file: path.basename(String(customRulesPath)) }] }; }
101
+ let parsed;
102
+ try { parsed = JSON.parse(raw); }
103
+ catch { return { rules: [], skipped: [{ reason: 'invalid-json', file: path.basename(String(customRulesPath)) }] }; }
104
+ const list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.patterns) ? parsed.patterns : null);
105
+ if (!list) return { rules: [], skipped: [{ reason: 'no-patterns-array', file: path.basename(String(customRulesPath)) }] };
106
+
107
+ const rules = [];
108
+ const skipped = [];
109
+ for (const entry of list.slice(0, MAX_CUSTOM_RULES)) {
110
+ const c = _compileRule(entry, 'custom');
111
+ if (c && c.skipped) { skipped.push({ reason: c.skipped, rule_name: entry && entry.rule_name }); continue; }
112
+ if (c) rules.push(c);
113
+ }
114
+ if (list.length > MAX_CUSTOM_RULES) {
115
+ skipped.push({ reason: 'rule-cap-exceeded', dropped: list.length - MAX_CUSTOM_RULES });
116
+ }
117
+ return { rules, skipped };
118
+ }
119
+
120
+ let _builtinCompiled = null;
121
+ function _builtins() {
122
+ if (_builtinCompiled) return _builtinCompiled;
123
+ _builtinCompiled = BUILTIN_PATTERNS.map((r) => _compileRule(r, 'builtin')).filter((c) => c && !c.skipped);
124
+ return _builtinCompiled;
125
+ }
126
+
127
+ function _firstMatchLine(content, compiled) {
128
+ const lines = content.split(/\r?\n/);
129
+ for (let i = 0; i < lines.length; i++) {
130
+ const line = lines[i];
131
+ if (compiled.regex) {
132
+ if (compiled.regex.test(line)) return i + 1;
133
+ } else if (compiled.substrings) {
134
+ for (const s of compiled.substrings) {
135
+ if (line.includes(s)) return i + 1;
136
+ }
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ function scanContent(opts) {
143
+ const o = opts || {};
144
+ const filePath = o.filePath || '';
145
+ const content = typeof o.content === 'string' ? o.content : '';
146
+ const custom = loadCustomRules(o.customRulesPath);
147
+ const rules = _builtins().concat(custom.rules);
148
+ const findings = [];
149
+
150
+ for (const rule of rules) {
151
+ if (rule.paths && !_pathMatchesAny(filePath, rule.paths)) continue;
152
+ if (rule.exclude_paths && _pathMatchesAny(filePath, rule.exclude_paths)) continue;
153
+
154
+ if (rule.path_only) {
155
+ findings.push({
156
+ rule_name: rule.rule_name, category: rule.category, severity: rule.severity,
157
+ file: filePath, line: 1, reminder: rule.reminder, source: rule.source,
158
+ });
159
+ continue;
160
+ }
161
+
162
+ const line = _firstMatchLine(content, rule);
163
+ if (line != null) {
164
+ findings.push({
165
+ rule_name: rule.rule_name, category: rule.category, severity: rule.severity,
166
+ file: filePath, line, reminder: rule.reminder, source: rule.source,
167
+ });
168
+ }
169
+ }
170
+
171
+ return { findings, custom_skipped: custom.skipped };
172
+ }
173
+
174
+ module.exports = {
175
+ scanContent,
176
+ loadCustomRules,
177
+ _globToRegExp,
178
+ _looksCatastrophic,
179
+ MAX_CUSTOM_RULES,
180
+ };
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+
9
+ const { scanContent, loadCustomRules, _globToRegExp, _looksCatastrophic } = require('./scan.cjs');
10
+
11
+ function cats(findings) {
12
+ return new Set(findings.map((f) => f.category));
13
+ }
14
+
15
+ test('SCAN-1 each built-in category triggers on representative content', () => {
16
+ const samples = {
17
+ 'dynamic-exec': 'const r = eval(userInput);',
18
+ 'unsafe-deserialization': 'data = pickle.loads(blob)',
19
+ 'dom-injection': 'el.innerHTML = userInput;',
20
+ 'hardcoded-secret': 'const key = "-----BEGIN PRIVATE KEY-----";',
21
+ };
22
+ for (const [category, content] of Object.entries(samples)) {
23
+ const { findings } = scanContent({ filePath: 'src/x.js', content });
24
+ assert.ok(cats(findings).has(category), category + ' should trigger; got ' + [...cats(findings)].join(','));
25
+ }
26
+ });
27
+
28
+ test('SCAN-2 workflow-file is path-only and fires regardless of content', () => {
29
+ const { findings } = scanContent({ filePath: '.github/workflows/deploy.yml', content: 'name: ci' });
30
+ assert.ok(findings.some((f) => f.category === 'workflow-file'));
31
+ });
32
+
33
+ test('SCAN-3 clean code produces no findings (no false positives)', () => {
34
+ const content = [
35
+ 'function add(a, b) {',
36
+ ' return a + b;',
37
+ '}',
38
+ 'const greeting = "hello world";',
39
+ 'el.textContent = greeting;',
40
+ ].join('\n');
41
+ const { findings } = scanContent({ filePath: 'src/util.js', content });
42
+ assert.deepEqual(findings, []);
43
+ });
44
+
45
+ test('SCAN-4 finding carries the first matching line number', () => {
46
+ const content = 'line one\nline two\nconst r = eval(x);\n';
47
+ const { findings } = scanContent({ filePath: 'a.js', content });
48
+ const evalFinding = findings.find((f) => f.rule_name === 'eval_call');
49
+ assert.equal(evalFinding.line, 3);
50
+ });
51
+
52
+ test('SCAN-5 custom rules augment built-ins (both present)', () => {
53
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-scan-'));
54
+ const rulesFile = path.join(dir, 'rules.json');
55
+ fs.writeFileSync(rulesFile, JSON.stringify({
56
+ patterns: [{
57
+ rule_name: 'tenant_unfiltered_query',
58
+ category: 'multi-tenant',
59
+ severity: 'risk',
60
+ regex: '\\.objects\\.all\\(\\)',
61
+ reminder: 'Filter by org_id.',
62
+ }],
63
+ }));
64
+ try {
65
+ const content = 'q = Model.objects.all()\nr = eval(z)';
66
+ const { findings } = scanContent({ filePath: 'src/tenants/x.py', content, customRulesPath: rulesFile });
67
+ assert.ok(findings.some((f) => f.rule_name === 'tenant_unfiltered_query'), 'custom rule fires');
68
+ assert.ok(findings.some((f) => f.rule_name === 'eval_call'), 'built-in still fires');
69
+ } finally {
70
+ fs.rmSync(dir, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test('SCAN-6 custom rule paths scope limits where it applies', () => {
75
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-scan-'));
76
+ const rulesFile = path.join(dir, 'rules.json');
77
+ fs.writeFileSync(rulesFile, JSON.stringify({
78
+ patterns: [{
79
+ rule_name: 'tenant_unfiltered_query',
80
+ regex: '\\.objects\\.all\\(\\)',
81
+ paths: ['**/src/tenants/**'],
82
+ reminder: 'scoped',
83
+ }],
84
+ }));
85
+ try {
86
+ const content = 'q = Model.objects.all()';
87
+ const inScope = scanContent({ filePath: 'src/tenants/a.py', content, customRulesPath: rulesFile });
88
+ const outScope = scanContent({ filePath: 'src/public/a.py', content, customRulesPath: rulesFile });
89
+ assert.ok(inScope.findings.some((f) => f.rule_name === 'tenant_unfiltered_query'));
90
+ assert.ok(!outScope.findings.some((f) => f.rule_name === 'tenant_unfiltered_query'));
91
+ } finally {
92
+ fs.rmSync(dir, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test('SCAN-7 catastrophic regex in custom rule is skipped, not loaded', () => {
97
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-scan-'));
98
+ const rulesFile = path.join(dir, 'rules.json');
99
+ fs.writeFileSync(rulesFile, JSON.stringify({
100
+ patterns: [{ rule_name: 'evil', regex: '(a+)+$', reminder: 'x' }],
101
+ }));
102
+ try {
103
+ const { rules, skipped } = loadCustomRules(rulesFile);
104
+ assert.equal(rules.length, 0);
105
+ assert.ok(skipped.some((s) => s.reason === 'catastrophic-regex'));
106
+ } finally {
107
+ fs.rmSync(dir, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ test('SCAN-8 custom rule cap at 50 enforced with diagnostic', () => {
112
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-scan-'));
113
+ const rulesFile = path.join(dir, 'rules.json');
114
+ const many = [];
115
+ for (let i = 0; i < 60; i++) many.push({ rule_name: 'r' + i, substrings: ['ZZZ' + i], reminder: 'x' });
116
+ fs.writeFileSync(rulesFile, JSON.stringify({ patterns: many }));
117
+ try {
118
+ const { rules, skipped } = loadCustomRules(rulesFile);
119
+ assert.equal(rules.length, 50);
120
+ assert.ok(skipped.some((s) => s.reason === 'rule-cap-exceeded'));
121
+ } finally {
122
+ fs.rmSync(dir, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ test('SCAN-9 missing custom rules path is a no-op (additive, resilient)', () => {
127
+ const { rules, skipped } = loadCustomRules(null);
128
+ assert.deepEqual(rules, []);
129
+ assert.deepEqual(skipped, []);
130
+ });
131
+
132
+ test('SCAN-10 glob and catastrophic helpers behave', () => {
133
+ assert.ok(_globToRegExp('**/src/tenants/**').test('app/src/tenants/x.py'));
134
+ assert.ok(!_globToRegExp('**/src/tenants/**').test('app/src/public/x.py'));
135
+ assert.ok(_looksCatastrophic('(.*)*'));
136
+ assert.ok(!_looksCatastrophic('\\beval\\s*\\('));
137
+ });