nubos-pilot 1.2.0 → 1.2.1
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.
- package/agents/np-executor.md +20 -0
- package/agents/np-security-reviewer.md +49 -3
- package/bin/install.js +7 -2
- package/bin/np-tools/_commands.cjs +1 -0
- package/bin/np-tools/security.cjs +177 -0
- package/bin/np-tools/security.test.cjs +82 -0
- package/lib/config-defaults.cjs +23 -0
- package/lib/config-defaults.test.cjs +15 -0
- package/lib/config-schema.cjs +19 -0
- package/lib/config-schema.test.cjs +58 -0
- package/lib/install/claude-hooks.cjs +100 -7
- package/lib/install/claude-hooks.test.cjs +96 -0
- package/lib/security/ledger.cjs +203 -0
- package/lib/security/ledger.test.cjs +139 -0
- package/lib/security/patterns.cjs +119 -0
- package/lib/security/review.cjs +220 -0
- package/lib/security/review.test.cjs +143 -0
- package/lib/security/scan.cjs +180 -0
- package/lib/security/scan.test.cjs +137 -0
- package/np-tools.cjs +1 -0
- package/package.json +1 -1
- package/templates/claude/payload/hooks/np-security-hook.cjs +50 -0
- package/workflows/execute-phase.md +11 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const BUILTIN_PATTERNS = Object.freeze([
|
|
4
|
+
{
|
|
5
|
+
rule_name: 'eval_call',
|
|
6
|
+
category: 'dynamic-exec',
|
|
7
|
+
severity: 'risk',
|
|
8
|
+
regex: '\\beval\\s*\\(',
|
|
9
|
+
reminder: 'Dynamic code execution via eval(). Avoid evaluating runtime strings; parse data explicitly.',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
rule_name: 'new_function',
|
|
13
|
+
category: 'dynamic-exec',
|
|
14
|
+
severity: 'risk',
|
|
15
|
+
regex: 'new\\s+Function\\s*\\(',
|
|
16
|
+
reminder: 'new Function() evaluates strings as code. Replace with explicit logic or a safe parser.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
rule_name: 'node_child_process_exec',
|
|
20
|
+
category: 'dynamic-exec',
|
|
21
|
+
severity: 'risk',
|
|
22
|
+
regex: 'child_process\\.(exec|execSync)\\s*\\(',
|
|
23
|
+
reminder: 'Shell exec of a string is injection-prone. Prefer execFile/spawn with an argv array.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
rule_name: 'python_os_system',
|
|
27
|
+
category: 'dynamic-exec',
|
|
28
|
+
severity: 'risk',
|
|
29
|
+
regex: '\\bos\\.system\\s*\\(',
|
|
30
|
+
reminder: 'os.system runs a shell command string. Use subprocess.run([...]) with a list, shell=False.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
rule_name: 'python_subprocess_shell',
|
|
34
|
+
category: 'dynamic-exec',
|
|
35
|
+
severity: 'risk',
|
|
36
|
+
regex: 'subprocess\\.(call|run|Popen|check_output)\\s*\\([^)]*shell\\s*=\\s*True',
|
|
37
|
+
reminder: 'subprocess with shell=True is injection-prone. Pass an argument list and shell=False.',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
rule_name: 'python_pickle_load',
|
|
41
|
+
category: 'unsafe-deserialization',
|
|
42
|
+
severity: 'risk',
|
|
43
|
+
regex: '\\bpickle\\.loads?\\s*\\(',
|
|
44
|
+
reminder: 'pickle deserialization executes arbitrary code on untrusted input. Use json or a safe format.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
rule_name: 'yaml_unsafe_load',
|
|
48
|
+
category: 'unsafe-deserialization',
|
|
49
|
+
severity: 'risk',
|
|
50
|
+
regex: 'yaml\\.load\\s*\\((?![^)]*Safe)',
|
|
51
|
+
reminder: 'yaml.load without SafeLoader can instantiate arbitrary objects. Use yaml.safe_load.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
rule_name: 'php_unserialize',
|
|
55
|
+
category: 'unsafe-deserialization',
|
|
56
|
+
severity: 'risk',
|
|
57
|
+
regex: '\\bunserialize\\s*\\(',
|
|
58
|
+
reminder: 'unserialize() on untrusted input enables object injection. Use json_decode for data.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
rule_name: 'react_dangerously_set_inner_html',
|
|
62
|
+
category: 'dom-injection',
|
|
63
|
+
severity: 'risk',
|
|
64
|
+
substrings: ['dangerouslySetInnerHTML'],
|
|
65
|
+
reminder: 'dangerouslySetInnerHTML can introduce XSS. Sanitize input or render as text.',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
rule_name: 'dom_inner_html_assignment',
|
|
69
|
+
category: 'dom-injection',
|
|
70
|
+
severity: 'risk',
|
|
71
|
+
regex: '\\.innerHTML\\s*=',
|
|
72
|
+
reminder: 'Assigning to .innerHTML with untrusted data is an XSS vector. Use textContent or sanitize.',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
rule_name: 'dom_document_write',
|
|
76
|
+
category: 'dom-injection',
|
|
77
|
+
severity: 'risk',
|
|
78
|
+
regex: 'document\\.write\\s*\\(',
|
|
79
|
+
reminder: 'document.write with dynamic input enables XSS. Build DOM nodes explicitly instead.',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
rule_name: 'github_workflow_edit',
|
|
83
|
+
category: 'workflow-file',
|
|
84
|
+
severity: 'warn',
|
|
85
|
+
path_only: true,
|
|
86
|
+
paths: ['**/.github/workflows/**'],
|
|
87
|
+
reminder: 'Workflow files can grant repository-level permissions. Review trigger and permission scope.',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
rule_name: 'private_key_block',
|
|
91
|
+
category: 'hardcoded-secret',
|
|
92
|
+
severity: 'risk',
|
|
93
|
+
regex: '-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----',
|
|
94
|
+
reminder: 'Hardcoded private key material. Load keys from a secret manager, never from source.',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
rule_name: 'aws_access_key_id',
|
|
98
|
+
category: 'hardcoded-secret',
|
|
99
|
+
severity: 'risk',
|
|
100
|
+
regex: '\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b',
|
|
101
|
+
reminder: 'Hardcoded AWS access key id. Move credentials to the secret manager / environment.',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
rule_name: 'stripe_live_secret',
|
|
105
|
+
category: 'hardcoded-secret',
|
|
106
|
+
severity: 'risk',
|
|
107
|
+
substrings: ['sk_live_'],
|
|
108
|
+
reminder: 'Hardcoded live Stripe secret key prefix. Load credentials from the secret manager.',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
rule_name: 'generic_secret_assignment',
|
|
112
|
+
category: 'hardcoded-secret',
|
|
113
|
+
severity: 'warn',
|
|
114
|
+
regex: '(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\\s*[:=]\\s*[\'"][^\'"\\s]{8,}[\'"]',
|
|
115
|
+
reminder: 'Looks like a hardcoded credential. Load it from configuration / a secret manager instead.',
|
|
116
|
+
},
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
module.exports = { BUILTIN_PATTERNS };
|
|
@@ -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
|
+
};
|