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
|
@@ -16,8 +16,21 @@ const { atomicWriteFileSync, NubosPilotError, withFileLock } = require('../core.
|
|
|
16
16
|
|
|
17
17
|
const STATUSLINE_REL = '.claude/nubos-pilot/hooks/np-statusline.cjs';
|
|
18
18
|
const CTX_MONITOR_REL = '.claude/nubos-pilot/hooks/np-ctx-monitor.cjs';
|
|
19
|
+
const SECURITY_HOOK_REL = '.claude/nubos-pilot/hooks/np-security-hook.cjs';
|
|
19
20
|
const NP_STATUSLINE_MARKER = 'np-statusline.';
|
|
20
21
|
const NP_CTX_MONITOR_MARKER = 'np-ctx-monitor.';
|
|
22
|
+
const NP_SECURITY_MARKER = 'np-security-hook.';
|
|
23
|
+
|
|
24
|
+
// ADR-0020: in-session security review layer. One DRY hook script, registered
|
|
25
|
+
// against five Claude Code lifecycle events, differentiated by a trailing verb.
|
|
26
|
+
const SECURITY_HOOKS = Object.freeze([
|
|
27
|
+
{ verb: 'session-start', event: 'SessionStart', matcher: undefined },
|
|
28
|
+
{ verb: 'baseline', event: 'UserPromptSubmit', matcher: undefined },
|
|
29
|
+
{ verb: 'scan', event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit|NotebookEdit' },
|
|
30
|
+
{ verb: 'review', event: 'Stop', matcher: undefined },
|
|
31
|
+
{ verb: 'commit', event: 'PostToolUse', matcher: 'Bash' },
|
|
32
|
+
]);
|
|
33
|
+
const SECURITY_EVENTS = Object.freeze(['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse']);
|
|
21
34
|
|
|
22
35
|
function _settingsPath(scope, projectRoot) {
|
|
23
36
|
if (scope === 'global') return path.join(os.homedir(), '.claude', 'settings.json');
|
|
@@ -101,6 +114,64 @@ function _installPostToolUse(settings, cmd) {
|
|
|
101
114
|
return { action: 'installed' };
|
|
102
115
|
}
|
|
103
116
|
|
|
117
|
+
function _verbOf(command) {
|
|
118
|
+
const m = String(command).match(/"\s+([a-z-]+)\s*$/);
|
|
119
|
+
return m ? m[1] : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function _installVerbHook(settings, eventName, matcher, cmd, verb) {
|
|
123
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
124
|
+
if (!Array.isArray(settings.hooks[eventName])) settings.hooks[eventName] = [];
|
|
125
|
+
const list = settings.hooks[eventName];
|
|
126
|
+
for (const entry of list) {
|
|
127
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
128
|
+
for (const h of hooks) {
|
|
129
|
+
if (h && typeof h.command === 'string' && h.command.includes(NP_SECURITY_MARKER) && _verbOf(h.command) === verb) {
|
|
130
|
+
h.command = cmd;
|
|
131
|
+
h.type = 'command';
|
|
132
|
+
if (matcher !== undefined) entry.matcher = matcher;
|
|
133
|
+
return { action: 'updated' };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const entry = matcher !== undefined
|
|
138
|
+
? { matcher, hooks: [{ type: 'command', command: cmd }] }
|
|
139
|
+
: { hooks: [{ type: 'command', command: cmd }] };
|
|
140
|
+
list.push(entry);
|
|
141
|
+
return { action: 'installed' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _installSecurity(settings, scope, projectRoot) {
|
|
145
|
+
const base = _hookCommand(SECURITY_HOOK_REL, scope, projectRoot);
|
|
146
|
+
const results = {};
|
|
147
|
+
for (const h of SECURITY_HOOKS) {
|
|
148
|
+
results[h.verb] = _installVerbHook(settings, h.event, h.matcher, base + ' ' + h.verb, h.verb);
|
|
149
|
+
}
|
|
150
|
+
return results;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _removeSecurity(settings) {
|
|
154
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') return { action: 'absent' };
|
|
155
|
+
let removed = 0;
|
|
156
|
+
for (const eventName of SECURITY_EVENTS) {
|
|
157
|
+
if (!Array.isArray(settings.hooks[eventName])) continue;
|
|
158
|
+
const filtered = [];
|
|
159
|
+
for (const entry of settings.hooks[eventName]) {
|
|
160
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
161
|
+
const kept = hooks.filter((h) => !(h && typeof h.command === 'string' && h.command.includes(NP_SECURITY_MARKER)));
|
|
162
|
+
if (kept.length > 0) {
|
|
163
|
+
filtered.push(kept.length === hooks.length ? entry : Object.assign({}, entry, { hooks: kept }));
|
|
164
|
+
} else {
|
|
165
|
+
removed++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
settings.hooks[eventName] = filtered;
|
|
169
|
+
if (filtered.length === 0) delete settings.hooks[eventName];
|
|
170
|
+
}
|
|
171
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
172
|
+
return { action: removed > 0 ? 'removed' : 'absent' };
|
|
173
|
+
}
|
|
174
|
+
|
|
104
175
|
function _removeStatusLine(settings) {
|
|
105
176
|
const existing = settings.statusLine;
|
|
106
177
|
if (existing && typeof existing === 'object'
|
|
@@ -140,13 +211,19 @@ function installClaudeHooks(opts) {
|
|
|
140
211
|
const which = o.which || 'both';
|
|
141
212
|
const settingsPath = _settingsPath(scope, projectRoot);
|
|
142
213
|
|
|
214
|
+
const wantStatusline = which === 'statusline' || which === 'both' || which === 'all';
|
|
215
|
+
const wantCtxMonitor = which === 'ctx-monitor' || which === 'both' || which === 'all';
|
|
216
|
+
const wantSecurity = which === 'security' || which === 'all';
|
|
217
|
+
|
|
143
218
|
const statuslineCmd = _hookCommand(STATUSLINE_REL, scope, projectRoot);
|
|
144
219
|
const ctxMonitorCmd = _hookCommand(CTX_MONITOR_REL, scope, projectRoot);
|
|
145
220
|
|
|
146
|
-
const
|
|
147
|
-
const
|
|
221
|
+
const base = scope === 'global' ? os.homedir() : projectRoot;
|
|
222
|
+
const statuslineAbs = path.join(base, STATUSLINE_REL);
|
|
223
|
+
const ctxMonitorAbs = path.join(base, CTX_MONITOR_REL);
|
|
224
|
+
const securityAbs = path.join(base, SECURITY_HOOK_REL);
|
|
148
225
|
|
|
149
|
-
if (
|
|
226
|
+
if (wantStatusline) {
|
|
150
227
|
if (!fs.existsSync(statuslineAbs)) {
|
|
151
228
|
throw new NubosPilotError(
|
|
152
229
|
'claude-hooks-script-missing',
|
|
@@ -155,7 +232,7 @@ function installClaudeHooks(opts) {
|
|
|
155
232
|
);
|
|
156
233
|
}
|
|
157
234
|
}
|
|
158
|
-
if (
|
|
235
|
+
if (wantCtxMonitor) {
|
|
159
236
|
if (!fs.existsSync(ctxMonitorAbs)) {
|
|
160
237
|
throw new NubosPilotError(
|
|
161
238
|
'claude-hooks-script-missing',
|
|
@@ -164,6 +241,15 @@ function installClaudeHooks(opts) {
|
|
|
164
241
|
);
|
|
165
242
|
}
|
|
166
243
|
}
|
|
244
|
+
if (wantSecurity) {
|
|
245
|
+
if (!fs.existsSync(securityAbs)) {
|
|
246
|
+
throw new NubosPilotError(
|
|
247
|
+
'claude-hooks-script-missing',
|
|
248
|
+
'Security hook script not found: ' + securityAbs,
|
|
249
|
+
{ script: securityAbs },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
167
253
|
|
|
168
254
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
169
255
|
|
|
@@ -171,12 +257,15 @@ function installClaudeHooks(opts) {
|
|
|
171
257
|
const settings = _readJsonSafe(settingsPath);
|
|
172
258
|
const results = {};
|
|
173
259
|
|
|
174
|
-
if (
|
|
260
|
+
if (wantStatusline) {
|
|
175
261
|
results.statusline = _installStatusLine(settings, statuslineCmd, force);
|
|
176
262
|
}
|
|
177
|
-
if (
|
|
263
|
+
if (wantCtxMonitor) {
|
|
178
264
|
results.ctxMonitor = _installPostToolUse(settings, ctxMonitorCmd);
|
|
179
265
|
}
|
|
266
|
+
if (wantSecurity) {
|
|
267
|
+
results.security = _installSecurity(settings, scope, projectRoot);
|
|
268
|
+
}
|
|
180
269
|
|
|
181
270
|
if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
|
|
182
271
|
|
|
@@ -190,13 +279,14 @@ function uninstallClaudeHooks(opts) {
|
|
|
190
279
|
const projectRoot = o.projectRoot || process.cwd();
|
|
191
280
|
const scope = o.scope === 'global' ? 'global' : 'local';
|
|
192
281
|
const settingsPath = _settingsPath(scope, projectRoot);
|
|
193
|
-
if (!fs.existsSync(settingsPath)) return { path: settingsPath, results: { statusline: { action: 'absent' }, ctxMonitor: { action: 'absent' } } };
|
|
282
|
+
if (!fs.existsSync(settingsPath)) return { path: settingsPath, results: { statusline: { action: 'absent' }, ctxMonitor: { action: 'absent' }, security: { action: 'absent' } } };
|
|
194
283
|
|
|
195
284
|
return withFileLock(settingsPath, () => {
|
|
196
285
|
const settings = _readJsonSafe(settingsPath);
|
|
197
286
|
const results = {
|
|
198
287
|
statusline: _removeStatusLine(settings),
|
|
199
288
|
ctxMonitor: _removePostToolUse(settings),
|
|
289
|
+
security: _removeSecurity(settings),
|
|
200
290
|
};
|
|
201
291
|
if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
|
|
202
292
|
atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
@@ -209,8 +299,11 @@ module.exports = {
|
|
|
209
299
|
uninstallClaudeHooks,
|
|
210
300
|
STATUSLINE_REL,
|
|
211
301
|
CTX_MONITOR_REL,
|
|
302
|
+
SECURITY_HOOK_REL,
|
|
212
303
|
NP_STATUSLINE_MARKER,
|
|
213
304
|
NP_CTX_MONITOR_MARKER,
|
|
305
|
+
NP_SECURITY_MARKER,
|
|
306
|
+
SECURITY_HOOKS,
|
|
214
307
|
_settingsPath,
|
|
215
308
|
_hookCommand,
|
|
216
309
|
};
|
|
@@ -225,3 +225,99 @@ test('claude-hooks: settings file with only whitespace is treated as empty', ()
|
|
|
225
225
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
226
226
|
}
|
|
227
227
|
});
|
|
228
|
+
|
|
229
|
+
function _mkSandboxAll() {
|
|
230
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-claude-hooks-all-'));
|
|
231
|
+
const hooksDir = path.join(dir, '.claude', 'nubos-pilot', 'hooks');
|
|
232
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
233
|
+
for (const f of ['np-statusline.cjs', 'np-ctx-monitor.cjs', 'np-security-hook.cjs']) {
|
|
234
|
+
fs.writeFileSync(path.join(hooksDir, f), '// stub\n');
|
|
235
|
+
}
|
|
236
|
+
return dir;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
test('claude-hooks SEC: which=all registers all five security lifecycle hooks', () => {
|
|
240
|
+
const dir = _mkSandboxAll();
|
|
241
|
+
try {
|
|
242
|
+
const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
|
|
243
|
+
const r = res.results.security;
|
|
244
|
+
assert.equal(r['session-start'].action, 'installed');
|
|
245
|
+
assert.equal(r.baseline.action, 'installed');
|
|
246
|
+
assert.equal(r.scan.action, 'installed');
|
|
247
|
+
assert.equal(r.review.action, 'installed');
|
|
248
|
+
assert.equal(r.commit.action, 'installed');
|
|
249
|
+
const s = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
|
|
250
|
+
assert.equal(s.hooks.SessionStart[0].hooks[0].command.replace(/.*np-security-hook\.cjs"\s*/, '').trim(), 'session-start');
|
|
251
|
+
assert.equal(s.hooks.UserPromptSubmit[0].hooks[0].command.trim().endsWith('baseline'), true);
|
|
252
|
+
assert.equal(s.hooks.Stop[0].hooks[0].command.trim().endsWith('review'), true);
|
|
253
|
+
const ptu = s.hooks.PostToolUse;
|
|
254
|
+
const scan = ptu.find((e) => e.matcher === 'Edit|Write|MultiEdit|NotebookEdit');
|
|
255
|
+
const commit = ptu.find((e) => e.matcher === 'Bash');
|
|
256
|
+
assert.ok(scan && scan.hooks[0].command.trim().endsWith('scan'));
|
|
257
|
+
assert.ok(commit && commit.hooks[0].command.trim().endsWith('commit'));
|
|
258
|
+
assert.ok(ptu.find((e) => e.matcher === '.*'), 'ctx-monitor still present under all');
|
|
259
|
+
} finally {
|
|
260
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('claude-hooks SEC: re-install is idempotent (no duplicate entries)', () => {
|
|
265
|
+
const dir = _mkSandboxAll();
|
|
266
|
+
try {
|
|
267
|
+
mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
|
|
268
|
+
const res2 = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
|
|
269
|
+
assert.equal(res2.results.security.scan.action, 'updated');
|
|
270
|
+
const s = JSON.parse(fs.readFileSync(res2.path, 'utf-8'));
|
|
271
|
+
assert.equal(s.hooks.SessionStart.length, 1);
|
|
272
|
+
assert.equal(s.hooks.Stop.length, 1);
|
|
273
|
+
assert.equal(s.hooks.PostToolUse.filter((e) => e.hooks[0].command.includes('np-security-hook.')).length, 2);
|
|
274
|
+
} finally {
|
|
275
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('claude-hooks SEC: which=both does NOT install security hooks (legacy unchanged)', () => {
|
|
280
|
+
const dir = _mkSandboxAll();
|
|
281
|
+
try {
|
|
282
|
+
const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'both' });
|
|
283
|
+
assert.equal(res.results.security, undefined);
|
|
284
|
+
const s = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
|
|
285
|
+
assert.ok(!s.hooks.SessionStart);
|
|
286
|
+
assert.ok(!s.hooks.Stop);
|
|
287
|
+
} finally {
|
|
288
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('claude-hooks SEC: uninstall removes security hooks but preserves foreign ones', () => {
|
|
293
|
+
const dir = _mkSandboxAll();
|
|
294
|
+
try {
|
|
295
|
+
const settingsPath = path.join(dir, '.claude', 'settings.local.json');
|
|
296
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
297
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo foreign-stop' }] }] },
|
|
298
|
+
}));
|
|
299
|
+
mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
|
|
300
|
+
const res = mod.uninstallClaudeHooks({ projectRoot: dir, scope: 'local' });
|
|
301
|
+
assert.equal(res.results.security.action, 'removed');
|
|
302
|
+
const s = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
|
|
303
|
+
assert.equal(s.hooks.Stop.length, 1);
|
|
304
|
+
assert.equal(s.hooks.Stop[0].hooks[0].command, 'echo foreign-stop');
|
|
305
|
+
assert.ok(!s.hooks.SessionStart, 'our SessionStart removed');
|
|
306
|
+
assert.ok(!s.hooks.PostToolUse || !s.hooks.PostToolUse.some((e) => e.hooks[0].command.includes('np-security-hook.')));
|
|
307
|
+
} finally {
|
|
308
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('claude-hooks SEC: which=security missing script throws structured error', () => {
|
|
313
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-claude-hooks-nosec-'));
|
|
314
|
+
fs.mkdirSync(path.join(dir, '.claude', 'nubos-pilot', 'hooks'), { recursive: true });
|
|
315
|
+
try {
|
|
316
|
+
assert.throws(
|
|
317
|
+
() => mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'security' }),
|
|
318
|
+
(err) => err && err.code === 'claude-hooks-script-missing',
|
|
319
|
+
);
|
|
320
|
+
} finally {
|
|
321
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const { withFileLock, atomicWriteFileSync } = require('../core.cjs');
|
|
8
|
+
|
|
9
|
+
const LEDGER_VERSION = 1;
|
|
10
|
+
const RISK_SEVERITIES = new Set(['risk', 'high', 'critical', 'fail']);
|
|
11
|
+
|
|
12
|
+
function sanitizeSid(sid) {
|
|
13
|
+
return String(sid || '').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ledgerPath(sid) {
|
|
17
|
+
return path.join(os.tmpdir(), 'claude-sec-' + sanitizeSid(sid) + '.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function _skeleton(sid) {
|
|
21
|
+
return {
|
|
22
|
+
session_id: String(sid || ''),
|
|
23
|
+
version: LEDGER_VERSION,
|
|
24
|
+
created_at: Date.now(),
|
|
25
|
+
baseline: null,
|
|
26
|
+
seen_scan: {},
|
|
27
|
+
findings: [],
|
|
28
|
+
review_in_flight: null,
|
|
29
|
+
stop_streak: 0,
|
|
30
|
+
commit_review_times: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _read(sid) {
|
|
35
|
+
const p = ledgerPath(sid);
|
|
36
|
+
let raw;
|
|
37
|
+
try { raw = fs.readFileSync(p, 'utf-8'); }
|
|
38
|
+
catch { return _skeleton(sid); }
|
|
39
|
+
if (!raw || raw.trim() === '') return _skeleton(sid);
|
|
40
|
+
let parsed;
|
|
41
|
+
try { parsed = JSON.parse(raw); }
|
|
42
|
+
catch { return _skeleton(sid); }
|
|
43
|
+
if (!parsed || typeof parsed !== 'object') return _skeleton(sid);
|
|
44
|
+
return Object.assign(_skeleton(sid), parsed);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _isPidAlive(pid) {
|
|
48
|
+
if (!Number.isInteger(pid)) return false;
|
|
49
|
+
try { process.kill(pid, 0); return true; }
|
|
50
|
+
catch (err) { return !!(err && err.code === 'EPERM'); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function withLedger(sid, fn) {
|
|
54
|
+
const p = ledgerPath(sid);
|
|
55
|
+
return withFileLock(p, () => {
|
|
56
|
+
const ledger = _read(sid);
|
|
57
|
+
const result = fn(ledger);
|
|
58
|
+
atomicWriteFileSync(p, JSON.stringify(ledger), 'utf-8', 0o600);
|
|
59
|
+
return result;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readLedger(sid) {
|
|
64
|
+
return _read(sid);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function initSession(sid) {
|
|
68
|
+
return withLedger(sid, (l) => { l.created_at = l.created_at || Date.now(); return { session_id: l.session_id }; });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setBaseline(sid, baseline) {
|
|
72
|
+
return withLedger(sid, (l) => {
|
|
73
|
+
l.baseline = Object.assign({ captured_at: Date.now() }, baseline || {});
|
|
74
|
+
return l.baseline;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function _scanKey(f) {
|
|
79
|
+
return String(f.file) + '::' + String(f.rule_name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function markScanReported(sid, findings) {
|
|
83
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
84
|
+
return withLedger(sid, (l) => {
|
|
85
|
+
const fresh = [];
|
|
86
|
+
for (const f of list) {
|
|
87
|
+
const key = _scanKey(f);
|
|
88
|
+
if (l.seen_scan[key]) continue;
|
|
89
|
+
l.seen_scan[key] = true;
|
|
90
|
+
fresh.push(f);
|
|
91
|
+
}
|
|
92
|
+
return fresh;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _fingerprint(f) {
|
|
97
|
+
return [
|
|
98
|
+
String(f.file || ''),
|
|
99
|
+
String(f.line == null ? '' : f.line),
|
|
100
|
+
String(f.category || ''),
|
|
101
|
+
String(f.rule_name || f.title || ''),
|
|
102
|
+
].join('|');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function addReviewFindings(sid, findings, layer) {
|
|
106
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
107
|
+
return withLedger(sid, (l) => {
|
|
108
|
+
const existing = new Set(l.findings.map((f) => f.fp));
|
|
109
|
+
let added = 0;
|
|
110
|
+
for (const f of list) {
|
|
111
|
+
const fp = _fingerprint(f);
|
|
112
|
+
if (existing.has(fp)) continue;
|
|
113
|
+
existing.add(fp);
|
|
114
|
+
l.findings.push({
|
|
115
|
+
fp,
|
|
116
|
+
file: f.file || null,
|
|
117
|
+
line: f.line == null ? null : f.line,
|
|
118
|
+
category: f.category || null,
|
|
119
|
+
severity: f.severity || 'risk',
|
|
120
|
+
title: f.title || f.rule_name || null,
|
|
121
|
+
mitigation_hint: f.mitigation_hint || f.reminder || null,
|
|
122
|
+
layer: layer || null,
|
|
123
|
+
surfaced: false,
|
|
124
|
+
addressed: false,
|
|
125
|
+
created_at: Date.now(),
|
|
126
|
+
});
|
|
127
|
+
added++;
|
|
128
|
+
}
|
|
129
|
+
return { added };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function takeUnsurfacedRisks(sid, opts) {
|
|
134
|
+
const maxStreak = opts && Number.isFinite(opts.maxStreak) ? opts.maxStreak : 3;
|
|
135
|
+
return withLedger(sid, (l) => {
|
|
136
|
+
const unsurfaced = l.findings.filter((f) => !f.surfaced && RISK_SEVERITIES.has(String(f.severity)));
|
|
137
|
+
if (unsurfaced.length === 0) {
|
|
138
|
+
l.stop_streak = 0;
|
|
139
|
+
return { findings: [], yielded: false };
|
|
140
|
+
}
|
|
141
|
+
if (l.stop_streak >= maxStreak) {
|
|
142
|
+
for (const f of unsurfaced) f.surfaced = true;
|
|
143
|
+
l.stop_streak = 0;
|
|
144
|
+
return { findings: [], yielded: true };
|
|
145
|
+
}
|
|
146
|
+
for (const f of unsurfaced) f.surfaced = true;
|
|
147
|
+
l.stop_streak += 1;
|
|
148
|
+
return { findings: unsurfaced.map((f) => ({ ...f })), yielded: false };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function tryBeginReview(sid, opts) {
|
|
153
|
+
const staleMs = opts && Number.isFinite(opts.staleMs) ? opts.staleMs : 5 * 60 * 1000;
|
|
154
|
+
return withLedger(sid, (l) => {
|
|
155
|
+
const cur = l.review_in_flight;
|
|
156
|
+
if (cur && typeof cur === 'object') {
|
|
157
|
+
const age = Date.now() - Number(cur.started_at || 0);
|
|
158
|
+
const stale = age > staleMs || !_isPidAlive(Number(cur.pid));
|
|
159
|
+
if (!stale) return { began: false, reason: 'in-flight' };
|
|
160
|
+
}
|
|
161
|
+
l.review_in_flight = { pid: process.pid, started_at: Date.now() };
|
|
162
|
+
return { began: true };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function endReview(sid) {
|
|
167
|
+
return withLedger(sid, (l) => { l.review_in_flight = null; return { ok: true }; });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function tryRecordCommitReview(sid, opts) {
|
|
171
|
+
const maxPerHour = opts && Number.isFinite(opts.maxPerHour) ? opts.maxPerHour : 20;
|
|
172
|
+
const windowMs = 60 * 60 * 1000;
|
|
173
|
+
return withLedger(sid, (l) => {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
l.commit_review_times = (l.commit_review_times || []).filter((t) => now - t < windowMs);
|
|
176
|
+
if (l.commit_review_times.length >= maxPerHour) return { allowed: false, count: l.commit_review_times.length };
|
|
177
|
+
l.commit_review_times.push(now);
|
|
178
|
+
return { allowed: true, count: l.commit_review_times.length };
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function removeLedger(sid) {
|
|
183
|
+
try { fs.unlinkSync(ledgerPath(sid)); } catch {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
LEDGER_VERSION,
|
|
188
|
+
RISK_SEVERITIES,
|
|
189
|
+
sanitizeSid,
|
|
190
|
+
ledgerPath,
|
|
191
|
+
withLedger,
|
|
192
|
+
readLedger,
|
|
193
|
+
initSession,
|
|
194
|
+
setBaseline,
|
|
195
|
+
markScanReported,
|
|
196
|
+
addReviewFindings,
|
|
197
|
+
takeUnsurfacedRisks,
|
|
198
|
+
tryBeginReview,
|
|
199
|
+
endReview,
|
|
200
|
+
tryRecordCommitReview,
|
|
201
|
+
removeLedger,
|
|
202
|
+
_fingerprint,
|
|
203
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
|
|
7
|
+
const ledger = require('./ledger.cjs');
|
|
8
|
+
|
|
9
|
+
let _sidCounter = 0;
|
|
10
|
+
function freshSid() {
|
|
11
|
+
_sidCounter += 1;
|
|
12
|
+
return 'test-sec-' + process.pid + '-' + _sidCounter;
|
|
13
|
+
}
|
|
14
|
+
function cleanup(sid) {
|
|
15
|
+
ledger.removeLedger(sid);
|
|
16
|
+
try { fs.unlinkSync(ledger.ledgerPath(sid) + '.lock'); } catch {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('LED-1 scan report-once: same pattern+file reported only once per session', () => {
|
|
20
|
+
const sid = freshSid();
|
|
21
|
+
try {
|
|
22
|
+
const f = [{ file: 'a.js', rule_name: 'eval_call' }];
|
|
23
|
+
const first = ledger.markScanReported(sid, f);
|
|
24
|
+
const second = ledger.markScanReported(sid, f);
|
|
25
|
+
assert.equal(first.length, 1);
|
|
26
|
+
assert.equal(second.length, 0);
|
|
27
|
+
} finally { cleanup(sid); }
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('LED-2 scan dedup is per-file: same rule on a different file is fresh', () => {
|
|
31
|
+
const sid = freshSid();
|
|
32
|
+
try {
|
|
33
|
+
ledger.markScanReported(sid, [{ file: 'a.js', rule_name: 'eval_call' }]);
|
|
34
|
+
const other = ledger.markScanReported(sid, [{ file: 'b.js', rule_name: 'eval_call' }]);
|
|
35
|
+
assert.equal(other.length, 1);
|
|
36
|
+
} finally { cleanup(sid); }
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('LED-3 review findings dedup by fingerprint (cross-layer)', () => {
|
|
40
|
+
const sid = freshSid();
|
|
41
|
+
try {
|
|
42
|
+
const finding = { file: 'x.js', line: 10, category: 'injection', severity: 'risk', title: 'SQLi' };
|
|
43
|
+
const a = ledger.addReviewFindings(sid, [finding], 'stop');
|
|
44
|
+
const b = ledger.addReviewFindings(sid, [finding], 'commit');
|
|
45
|
+
assert.equal(a.added, 1);
|
|
46
|
+
assert.equal(b.added, 0);
|
|
47
|
+
} finally { cleanup(sid); }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('LED-4 takeUnsurfacedRisks surfaces once, then nothing', () => {
|
|
51
|
+
const sid = freshSid();
|
|
52
|
+
try {
|
|
53
|
+
ledger.addReviewFindings(sid, [{ file: 'x.js', line: 1, category: 'authz', severity: 'risk', title: 'bypass' }], 'stop');
|
|
54
|
+
const first = ledger.takeUnsurfacedRisks(sid, { maxStreak: 3 });
|
|
55
|
+
const second = ledger.takeUnsurfacedRisks(sid, { maxStreak: 3 });
|
|
56
|
+
assert.equal(first.findings.length, 1);
|
|
57
|
+
assert.equal(second.findings.length, 0);
|
|
58
|
+
} finally { cleanup(sid); }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('LED-5 only risk-class severities surface; warn does not block', () => {
|
|
62
|
+
const sid = freshSid();
|
|
63
|
+
try {
|
|
64
|
+
ledger.addReviewFindings(sid, [{ file: 'x.js', line: 2, category: 'style', severity: 'warn', title: 'nit' }], 'stop');
|
|
65
|
+
const r = ledger.takeUnsurfacedRisks(sid, {});
|
|
66
|
+
assert.equal(r.findings.length, 0);
|
|
67
|
+
} finally { cleanup(sid); }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('LED-6 a single surfacing drains all currently-unsurfaced risks at once', () => {
|
|
71
|
+
const sid = freshSid();
|
|
72
|
+
try {
|
|
73
|
+
for (let i = 0; i < 5; i++) {
|
|
74
|
+
ledger.addReviewFindings(sid, [{ file: 'f' + i + '.js', line: i, category: 'injection', severity: 'risk', title: 't' + i }], 'stop');
|
|
75
|
+
}
|
|
76
|
+
const r1 = ledger.takeUnsurfacedRisks(sid, { maxStreak: 3 });
|
|
77
|
+
const r2 = ledger.takeUnsurfacedRisks(sid, { maxStreak: 3 });
|
|
78
|
+
assert.equal(r1.findings.length, 5);
|
|
79
|
+
assert.equal(r2.findings.length, 0);
|
|
80
|
+
} finally { cleanup(sid); }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('LED-6b once streak hits max with leftovers, it yields back to the user', () => {
|
|
84
|
+
const sid = freshSid();
|
|
85
|
+
try {
|
|
86
|
+
let streak = 0;
|
|
87
|
+
let yielded = false;
|
|
88
|
+
for (let turn = 0; turn < 10; turn++) {
|
|
89
|
+
ledger.addReviewFindings(sid, [{ file: 'g' + turn + '.js', line: turn, category: 'injection', severity: 'risk', title: 'g' + turn }], 'stop');
|
|
90
|
+
const r = ledger.takeUnsurfacedRisks(sid, { maxStreak: 3 });
|
|
91
|
+
if (r.yielded) { yielded = true; break; }
|
|
92
|
+
streak++;
|
|
93
|
+
}
|
|
94
|
+
assert.ok(yielded, 'should yield within a few turns');
|
|
95
|
+
assert.ok(streak <= 3, 'never more than maxStreak consecutive blocks');
|
|
96
|
+
} finally { cleanup(sid); }
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('LED-7 concurrency guard: second begin while in-flight is rejected', () => {
|
|
100
|
+
const sid = freshSid();
|
|
101
|
+
try {
|
|
102
|
+
const a = ledger.tryBeginReview(sid, {});
|
|
103
|
+
const b = ledger.tryBeginReview(sid, {});
|
|
104
|
+
assert.equal(a.began, true);
|
|
105
|
+
assert.equal(b.began, false);
|
|
106
|
+
ledger.endReview(sid);
|
|
107
|
+
const c = ledger.tryBeginReview(sid, {});
|
|
108
|
+
assert.equal(c.began, true);
|
|
109
|
+
} finally { cleanup(sid); }
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('LED-7b stale in-flight (old timestamp) is reclaimed', () => {
|
|
113
|
+
const sid = freshSid();
|
|
114
|
+
try {
|
|
115
|
+
ledger.tryBeginReview(sid, {});
|
|
116
|
+
ledger.withLedger(sid, (l) => { l.review_in_flight.started_at = Date.now() - 10 * 60 * 1000; });
|
|
117
|
+
const c = ledger.tryBeginReview(sid, { staleMs: 5 * 60 * 1000 });
|
|
118
|
+
assert.equal(c.began, true);
|
|
119
|
+
} finally { cleanup(sid); }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('LED-8 commit rolling-hour cap enforced', () => {
|
|
123
|
+
const sid = freshSid();
|
|
124
|
+
try {
|
|
125
|
+
let lastAllowed = true;
|
|
126
|
+
for (let i = 0; i < 20; i++) lastAllowed = ledger.tryRecordCommitReview(sid, { maxPerHour: 20 }).allowed;
|
|
127
|
+
assert.equal(lastAllowed, true);
|
|
128
|
+
const over = ledger.tryRecordCommitReview(sid, { maxPerHour: 20 });
|
|
129
|
+
assert.equal(over.allowed, false);
|
|
130
|
+
} finally { cleanup(sid); }
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('LED-9 baseline round-trips', () => {
|
|
134
|
+
const sid = freshSid();
|
|
135
|
+
try {
|
|
136
|
+
ledger.setBaseline(sid, { head: 'abc123' });
|
|
137
|
+
assert.equal(ledger.readLedger(sid).baseline.head, 'abc123');
|
|
138
|
+
} finally { cleanup(sid); }
|
|
139
|
+
});
|