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.
- package/CHANGELOG.md +33 -1
- 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 +2 -0
- package/bin/np-tools/doctor.cjs +15 -2
- package/bin/np-tools/graph-impact.cjs +111 -0
- package/bin/np-tools/graph-impact.test.cjs +119 -0
- package/bin/np-tools/scan-codebase.cjs +21 -1
- package/bin/np-tools/security.cjs +177 -0
- package/bin/np-tools/security.test.cjs +82 -0
- package/lib/checkpoint.cjs +3 -0
- package/lib/codebase-graph.cjs +0 -0
- package/lib/codebase-graph.test.cjs +174 -0
- package/lib/codebase-manifest.cjs +3 -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/learnings.cjs +19 -95
- package/lib/memory.cjs +38 -33
- package/lib/messaging.cjs +12 -6
- package/lib/metrics-aggregate.cjs +14 -2
- package/lib/migrate.cjs +29 -0
- package/lib/migrate.test.cjs +91 -0
- package/lib/schemas/data/checkpoint.v1.json +13 -0
- package/lib/schemas/data/codebase-manifest.v1.json +22 -0
- package/lib/schemas/data/learnings.v1.json +28 -0
- package/lib/schemas/data/memory-manifest.v1.json +14 -0
- package/lib/schemas/data/memory-record.v1.json +16 -0
- package/lib/schemas/data/message.v1.json +19 -0
- package/lib/schemas/data/metrics-record.v1.json +11 -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/lib/validate.cjs +301 -0
- package/lib/validate.test.cjs +242 -0
- package/np-tools.cjs +2 -0
- package/package.json +3 -1
- package/templates/claude/payload/hooks/np-security-hook.cjs +50 -0
- package/workflows/execute-phase.md +11 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const { runMigrators, MAX_HOPS } = require('./migrate.cjs');
|
|
7
|
+
const { NubosPilotError } = require('./core.cjs');
|
|
8
|
+
|
|
9
|
+
test('MIG-1: object already at target version is returned unchanged', () => {
|
|
10
|
+
const obj = { version: 2, payload: 'x' };
|
|
11
|
+
const out = runMigrators(obj, { targetVersion: 2, migrators: {} });
|
|
12
|
+
assert.equal(out, obj);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('MIG-2: single hop runs the matching migrator', () => {
|
|
16
|
+
const out = runMigrators({ version: 1, n: 1 }, {
|
|
17
|
+
targetVersion: 2,
|
|
18
|
+
migrators: { 1: (v) => ({ version: 2, n: v.n + 1 }) },
|
|
19
|
+
});
|
|
20
|
+
assert.deepEqual(out, { version: 2, n: 2 });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('MIG-3: multi-hop chain runs each hop until target', () => {
|
|
24
|
+
const out = runMigrators({ version: 0, steps: [] }, {
|
|
25
|
+
targetVersion: 3,
|
|
26
|
+
migrators: {
|
|
27
|
+
0: (v) => ({ version: 1, steps: [...v.steps, 'a'] }),
|
|
28
|
+
1: (v) => ({ version: 2, steps: [...v.steps, 'b'] }),
|
|
29
|
+
2: (v) => ({ version: 3, steps: [...v.steps, 'c'] }),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
assert.deepEqual(out.steps, ['a', 'b', 'c']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('MIG-4: missing migrator for a version returns null', () => {
|
|
36
|
+
const out = runMigrators({ version: 99 }, { targetVersion: 1, migrators: {} });
|
|
37
|
+
assert.equal(out, null);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('MIG-5: migrator returning a non-object returns null', () => {
|
|
41
|
+
const out = runMigrators({ version: 0 }, { targetVersion: 1, migrators: { 0: () => null } });
|
|
42
|
+
assert.equal(out, null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('MIG-6: hop cap prevents an infinite migrator cycle', () => {
|
|
46
|
+
const out = runMigrators({ version: 0 }, {
|
|
47
|
+
targetVersion: 999,
|
|
48
|
+
migrators: { 0: () => ({ version: 0 }) },
|
|
49
|
+
});
|
|
50
|
+
assert.equal(out, null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('MIG-7: pure function — input object is not mutated', () => {
|
|
54
|
+
const input = { version: 0, n: 1 };
|
|
55
|
+
runMigrators(input, { targetVersion: 1, migrators: { 0: (v) => ({ version: 1, n: v.n + 1 }) } });
|
|
56
|
+
assert.deepEqual(input, { version: 0, n: 1 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('MIG-8: migrated shape is validated against the given schema', () => {
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => runMigrators({ version: 0, learnings: [] }, {
|
|
62
|
+
targetVersion: 1,
|
|
63
|
+
migrators: { 0: () => ({ version: 1, learnings: [{ fingerprint: 'NOTHEX', occurrence: 1 }] }) },
|
|
64
|
+
schema: 'learnings.v1',
|
|
65
|
+
code: 'learnings-store-corrupt',
|
|
66
|
+
details: { path: '<test>' },
|
|
67
|
+
}),
|
|
68
|
+
(err) => err instanceof NubosPilotError && err.code === 'learnings-store-corrupt',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('MIG-9: custom versionField is honoured', () => {
|
|
73
|
+
const out = runMigrators({ schema_version: 1, ok: true }, {
|
|
74
|
+
versionField: 'schema_version',
|
|
75
|
+
targetVersion: 1,
|
|
76
|
+
migrators: {},
|
|
77
|
+
});
|
|
78
|
+
assert.deepEqual(out, { schema_version: 1, ok: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('MIG-10: MAX_HOPS is a finite positive cap', () => {
|
|
82
|
+
assert.ok(Number.isInteger(MAX_HOPS) && MAX_HOPS > 0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('MIG-11: a version matching an Object.prototype member returns null, not a raw throw', () => {
|
|
86
|
+
for (const poison of ['valueOf', 'hasOwnProperty', 'toString', 'constructor', '__proto__']) {
|
|
87
|
+
let out;
|
|
88
|
+
assert.doesNotThrow(() => { out = runMigrators({ version: poison }, { targetVersion: 1, migrators: {} }); });
|
|
89
|
+
assert.equal(out, null);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "checkpoint.v1",
|
|
3
|
+
"title": "Task checkpoint (checkpoints/<task_id>.json)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schema_version"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schema_version": { "type": "integer", "minimum": 1 },
|
|
8
|
+
"task_id": { "type": "string" },
|
|
9
|
+
"status": { "type": "string" },
|
|
10
|
+
"files_touched": { "type": "array" },
|
|
11
|
+
"nubosloop": { "type": "object" }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "codebase-manifest.v1",
|
|
3
|
+
"title": "Codebase hash manifest (codebase/.hashes.json)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schema_version"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schema_version": { "type": "integer", "minimum": 1 },
|
|
8
|
+
"generated_at": { "type": ["string", "null"] },
|
|
9
|
+
"files": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["sha256"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"sha256": { "type": "string" },
|
|
16
|
+
"size": { "type": "integer", "minimum": 0 },
|
|
17
|
+
"ext": { "type": "string" }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "learnings.v1",
|
|
3
|
+
"title": "Learnings store (knowledge/learnings.json)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["version", "learnings"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"version": { "type": "integer", "minimum": 1 },
|
|
8
|
+
"learnings": {
|
|
9
|
+
"type": "array",
|
|
10
|
+
"items": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": ["fingerprint", "pattern", "outcome", "occurrence", "first_seen", "last_seen"],
|
|
13
|
+
"properties": {
|
|
14
|
+
"fingerprint": { "type": "string", "pattern": "^[a-f0-9]{16}$" },
|
|
15
|
+
"pattern": { "type": "string", "maxBytes": 4096 },
|
|
16
|
+
"outcome": { "type": "string", "maxBytes": 4096 },
|
|
17
|
+
"occurrence": { "type": "integer", "minimum": 1 },
|
|
18
|
+
"first_seen": { "type": "string" },
|
|
19
|
+
"last_seen": { "type": "string" },
|
|
20
|
+
"tokens": { "type": "array", "items": { "type": "string" } },
|
|
21
|
+
"task_ids": { "type": "array", "items": { "type": "string" } },
|
|
22
|
+
"milestone_ids": { "type": "array", "items": { "type": "string" } },
|
|
23
|
+
"outcome_history": { "type": "array" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "memory-manifest.v1",
|
|
3
|
+
"title": "Memory manifest (memory/manifest.json)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schema_version", "model", "dim"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schema_version": { "type": "integer", "minimum": 1 },
|
|
8
|
+
"model": { "type": "string", "minLength": 1 },
|
|
9
|
+
"dim": { "type": "integer", "minimum": 1 },
|
|
10
|
+
"alpha": { "type": "number" },
|
|
11
|
+
"created_at": { "type": "string" },
|
|
12
|
+
"rebuilt_at": { "type": "string" }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "memory-record.v1",
|
|
3
|
+
"title": "Memory record (memory/records.jsonl line)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["type", "title", "body"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"type": { "enum": ["learning", "handoff", "critic", "research"] },
|
|
8
|
+
"title": { "type": "string", "minLength": 1 },
|
|
9
|
+
"body": { "type": "string" },
|
|
10
|
+
"tags": { "type": ["array", "null"] },
|
|
11
|
+
"provenance": { "enum": ["VERIFIED", "CITED", "ASSUMED", "CACHED", null] },
|
|
12
|
+
"id": { "type": ["string", "null"] },
|
|
13
|
+
"phase": { "type": ["string", "null"] },
|
|
14
|
+
"created_at": { "type": ["string", "null"] }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "message.v1",
|
|
3
|
+
"title": "Inter-agent message (messages/inbox/<agent>/<id>.json)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["id", "from", "to", "phase", "kind", "subject", "body", "created_at"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"id": { "type": "string", "pattern": "^\\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" },
|
|
8
|
+
"from": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
|
|
9
|
+
"to": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" },
|
|
10
|
+
"phase": { "type": "string", "minLength": 1 },
|
|
11
|
+
"round": { "type": ["integer", "null"], "minimum": 0 },
|
|
12
|
+
"kind": { "enum": ["request", "response", "notify"] },
|
|
13
|
+
"subject": { "type": "string", "minLength": 1 },
|
|
14
|
+
"body": { "type": "string", "maxBytes": 262144 },
|
|
15
|
+
"expects_reply": { "type": "boolean" },
|
|
16
|
+
"in_reply_to": { "type": ["string", "null"] },
|
|
17
|
+
"created_at": { "type": "string" }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "metrics-record.v1",
|
|
3
|
+
"title": "Metrics record (metrics/phase-<phase>.jsonl, meta.jsonl line)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"duration_ms": { "type": ["number", "null"], "minimum": 0 },
|
|
7
|
+
"tokens_in": { "type": ["number", "null"] },
|
|
8
|
+
"tokens_out": { "type": ["number", "null"] },
|
|
9
|
+
"retry_count": { "type": ["number", "null"], "minimum": 0 }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -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 };
|