@clear-capabilities/agentic-security-scanner 0.128.1 → 0.130.0
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 +101 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +2 -2
- package/dist/113.index.js +209 -7
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -162
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +22 -14
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +154 -7
- package/src/integrations/index.js +1 -1
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +5 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/posture/CLAUDE.md +115 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +45 -1
- package/src/posture/fix-verify.js +55 -2
- package/src/posture/license-policy.js +1 -1
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/report/index.js +11 -0
- package/src/runScan.js +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/yaml.js +24 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Scan checkpointing / resume (roadmap R8).
|
|
2
|
+
//
|
|
3
|
+
// Long scans currently restart from zero if interrupted, which is what caps the
|
|
4
|
+
// repository size this engine can usefully handle. This module lets the per-file
|
|
5
|
+
// loop in `engine.js#runFullScan` durably record what it has already analysed so
|
|
6
|
+
// a second invocation replays that work instead of redoing it.
|
|
7
|
+
//
|
|
8
|
+
// THE PROPERTY THAT MATTERS: a resumed scan must produce the same finding set as
|
|
9
|
+
// an uninterrupted one. A checkpoint that silently drops findings converts a slow
|
|
10
|
+
// scan into a quietly incomplete one, which is strictly worse than no checkpoint
|
|
11
|
+
// at all. Three design decisions follow from that and should not be relaxed:
|
|
12
|
+
//
|
|
13
|
+
// 1. We persist the *complete* per-file contribution, not just findings —
|
|
14
|
+
// routes, taint sources/sinks/sanitizers, logic vulns, secrets, ciphers,
|
|
15
|
+
// the per-file result the cross-file taint pass reads, and the suppression
|
|
16
|
+
// log delta. Anything the per-file loop appends to must round-trip, or the
|
|
17
|
+
// post-loop cross-file passes would see a different world on resume.
|
|
18
|
+
// 2. Only the per-file loop is checkpointed. Every cross-file pass and the
|
|
19
|
+
// whole annotation pipeline re-runs from scratch on resume, so nothing that
|
|
20
|
+
// depends on the global picture can be stale by construction.
|
|
21
|
+
// 3. Invalidation is conservative to the point of being blunt. The run key
|
|
22
|
+
// covers the engine version, the ruleset version, the bundle SHA, a content
|
|
23
|
+
// hash of every file in the scan (which subsumes mtime), and the scanner's
|
|
24
|
+
// own environment switches. If any of it moved, the checkpoint is discarded
|
|
25
|
+
// and the scan starts clean. Redoing work is merely slow; resuming stale
|
|
26
|
+
// work is a correctness bug.
|
|
27
|
+
//
|
|
28
|
+
// CRASH SAFETY: append-and-fsync. The file is a JSONL log — one header line
|
|
29
|
+
// pinning the run key, then one self-describing record per completed file,
|
|
30
|
+
// each carrying a SHA-256 of its own payload. Every record is written with a
|
|
31
|
+
// single `writeSync` and immediately `fsyncSync`'d before the next file is
|
|
32
|
+
// analysed, so a process killed at any instant leaves either a complete record
|
|
33
|
+
// or a torn tail. On recovery we read forward while records verify and truncate
|
|
34
|
+
// the file at the last byte offset that did, so a torn tail is discarded rather
|
|
35
|
+
// than resumed into. Nothing is ever rewritten in place, so there is no window
|
|
36
|
+
// in which the file is neither the old state nor the new one.
|
|
37
|
+
//
|
|
38
|
+
// Everything here follows the posture convention of never throwing: a failure to
|
|
39
|
+
// open, read or append degrades to "no checkpoint", which just means a full scan.
|
|
40
|
+
|
|
41
|
+
import * as fs from 'node:fs';
|
|
42
|
+
import * as path from 'node:path';
|
|
43
|
+
import * as crypto from 'node:crypto';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
|
|
46
|
+
const STATE_DIR = '.agentic-security';
|
|
47
|
+
const FILE_NAME = 'scan-checkpoint.jsonl';
|
|
48
|
+
const FORMAT = 'agentic-security-scan-checkpoint/1';
|
|
49
|
+
|
|
50
|
+
// Env switches that change what the engine emits are part of the run identity.
|
|
51
|
+
// These three are deliberately excluded: they change how the run is driven, not
|
|
52
|
+
// what it would find.
|
|
53
|
+
const RUN_KEY_ENV_EXCLUDE = new Set([
|
|
54
|
+
'AGENTIC_SECURITY_RESUME',
|
|
55
|
+
'AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER',
|
|
56
|
+
'AGENTIC_SECURITY_HMAC_KEY',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
export function checkpointPath(scanRoot) {
|
|
60
|
+
return path.join(scanRoot || '.', STATE_DIR, FILE_NAME);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _sha(s) {
|
|
64
|
+
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// SHA-256 of the running bundle, taken from the sidecar next to it. Returns
|
|
68
|
+
// 'unavailable' when running from source — same convention as the attestation
|
|
69
|
+
// path, and deliberately not a guess at some other bundle's hash.
|
|
70
|
+
export function bundleShaForRunKey() {
|
|
71
|
+
try {
|
|
72
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
73
|
+
// src/posture/ -> src/ -> scanner/
|
|
74
|
+
const sidecar = path.resolve(here, '..', '..', 'dist', 'agentic-security.mjs.sha256');
|
|
75
|
+
const raw = fs.readFileSync(sidecar, 'utf8').trim();
|
|
76
|
+
const m = /^([0-9a-f]{64})\b/.exec(raw);
|
|
77
|
+
if (m) return m[1];
|
|
78
|
+
} catch { /* not running from a built tree */ }
|
|
79
|
+
return 'unavailable';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Everything that would invalidate previously-completed per-file work, reduced
|
|
84
|
+
* to one hex digest. Content hashes rather than mtimes: strictly stronger, and
|
|
85
|
+
* immune to filesystems with coarse or non-monotonic timestamps.
|
|
86
|
+
*/
|
|
87
|
+
export function computeRunKey({
|
|
88
|
+
engineVersion, rulesetVersion, bundleSha,
|
|
89
|
+
fileContents = {}, depFileContents = {}, env = process.env,
|
|
90
|
+
} = {}) {
|
|
91
|
+
const h = crypto.createHash('sha256');
|
|
92
|
+
h.update(FORMAT); h.update('\n');
|
|
93
|
+
h.update(String(engineVersion ?? '')); h.update('\n');
|
|
94
|
+
h.update(String(rulesetVersion ?? '')); h.update('\n');
|
|
95
|
+
h.update(String(bundleSha ?? 'unavailable')); h.update('\n');
|
|
96
|
+
for (const [label, map] of [['f', fileContents], ['d', depFileContents]]) {
|
|
97
|
+
const names = Object.keys(map || {}).sort();
|
|
98
|
+
h.update(label); h.update(String(names.length)); h.update('\n');
|
|
99
|
+
for (const n of names) {
|
|
100
|
+
h.update(n); h.update('\0');
|
|
101
|
+
h.update(_sha(String(map[n] ?? '')));
|
|
102
|
+
h.update('\n');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const envKeys = Object.keys(env || {})
|
|
106
|
+
.filter(k => k.startsWith('AGENTIC_SECURITY_') && !RUN_KEY_ENV_EXCLUDE.has(k))
|
|
107
|
+
.sort();
|
|
108
|
+
h.update('e'); h.update(String(envKeys.length)); h.update('\n');
|
|
109
|
+
for (const k of envKeys) { h.update(k); h.update('='); h.update(String(env[k])); h.update('\n'); }
|
|
110
|
+
return h.digest('hex');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A value is safe to checkpoint only if JSON can carry it back unchanged. Dates,
|
|
114
|
+
// regexes, Maps, Sets, functions and BigInts all survive `JSON.stringify` in a
|
|
115
|
+
// lossy or throwing way; recording one would mean the resumed run sees different
|
|
116
|
+
// data than the uninterrupted run did. We refuse the record instead, and the
|
|
117
|
+
// file just gets rescanned.
|
|
118
|
+
function _jsonSafe(v, depth = 0, seen = new Set()) {
|
|
119
|
+
if (depth > 24) return false;
|
|
120
|
+
if (v === null || v === undefined) return true;
|
|
121
|
+
const t = typeof v;
|
|
122
|
+
if (t === 'string' || t === 'boolean') return true;
|
|
123
|
+
if (t === 'number') return Number.isFinite(v);
|
|
124
|
+
if (t === 'function' || t === 'symbol' || t === 'bigint') return false;
|
|
125
|
+
if (t !== 'object') return false;
|
|
126
|
+
if (seen.has(v)) return false;
|
|
127
|
+
seen.add(v);
|
|
128
|
+
try {
|
|
129
|
+
if (Array.isArray(v)) {
|
|
130
|
+
for (const x of v) if (!_jsonSafe(x, depth + 1, seen)) return false;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
const proto = Object.getPrototypeOf(v);
|
|
134
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
135
|
+
for (const k of Object.keys(v)) if (!_jsonSafe(v[k], depth + 1, seen)) return false;
|
|
136
|
+
return true;
|
|
137
|
+
} finally {
|
|
138
|
+
seen.delete(v);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _emptyHandle(reason) {
|
|
143
|
+
return {
|
|
144
|
+
enabled: false, file: null, fd: null, runKey: null,
|
|
145
|
+
recovered: new Map(), order: [], written: new Set(),
|
|
146
|
+
discarded: false, reason,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _headerLine(runKey) {
|
|
151
|
+
return JSON.stringify({ v: FORMAT, runKey }) + '\n';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Read forward from a byte offset, keeping records while they verify. Returns
|
|
155
|
+
// the offset of the first byte that did NOT verify, so the caller can truncate.
|
|
156
|
+
function _recover(handle, file, runKey) {
|
|
157
|
+
let buf;
|
|
158
|
+
try { buf = fs.readFileSync(file); }
|
|
159
|
+
catch { return -1; } // no file yet
|
|
160
|
+
const text = buf.toString('utf8');
|
|
161
|
+
const nl = text.indexOf('\n');
|
|
162
|
+
if (nl < 0) return 0;
|
|
163
|
+
let header = null;
|
|
164
|
+
try { header = JSON.parse(text.slice(0, nl)); } catch { return 0; }
|
|
165
|
+
if (!header || header.v !== FORMAT || header.runKey !== runKey) return 0;
|
|
166
|
+
|
|
167
|
+
let offset = Buffer.byteLength(text.slice(0, nl + 1), 'utf8');
|
|
168
|
+
let cursor = nl + 1;
|
|
169
|
+
for (;;) {
|
|
170
|
+
const end = text.indexOf('\n', cursor);
|
|
171
|
+
if (end < 0) break; // torn tail: no terminating newline
|
|
172
|
+
const line = text.slice(cursor, end);
|
|
173
|
+
cursor = end + 1;
|
|
174
|
+
if (!line) { offset = Buffer.byteLength(text.slice(0, cursor), 'utf8'); continue; }
|
|
175
|
+
let rec;
|
|
176
|
+
try { rec = JSON.parse(line); } catch { break; }
|
|
177
|
+
if (!rec || typeof rec.f !== 'string' || typeof rec.d !== 'string') break;
|
|
178
|
+
if (rec.c !== _sha(rec.d)) break; // tampered or torn-then-patched
|
|
179
|
+
let payload;
|
|
180
|
+
try { payload = JSON.parse(rec.d); } catch { break; }
|
|
181
|
+
if (!handle.recovered.has(rec.f)) handle.order.push(rec.f);
|
|
182
|
+
handle.recovered.set(rec.f, payload);
|
|
183
|
+
offset = Buffer.byteLength(text.slice(0, cursor), 'utf8');
|
|
184
|
+
}
|
|
185
|
+
return offset;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Open (or start) the checkpoint for `scanRoot` under `runKey`. Never throws.
|
|
190
|
+
* A handle whose `enabled` is false silently no-ops through the rest of the API.
|
|
191
|
+
*/
|
|
192
|
+
export function openCheckpoint(scanRoot, { runKey } = {}) {
|
|
193
|
+
if (!scanRoot || !runKey) return _emptyHandle('no-run-key');
|
|
194
|
+
const handle = _emptyHandle(null);
|
|
195
|
+
try {
|
|
196
|
+
const dir = path.join(scanRoot, STATE_DIR);
|
|
197
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
198
|
+
const file = checkpointPath(scanRoot);
|
|
199
|
+
handle.file = file;
|
|
200
|
+
handle.runKey = runKey;
|
|
201
|
+
|
|
202
|
+
const keepBytes = _recover(handle, file, runKey);
|
|
203
|
+
if (keepBytes <= 0) {
|
|
204
|
+
// Absent, foreign, or unreadable — start clean. Conservative by design.
|
|
205
|
+
handle.recovered.clear();
|
|
206
|
+
handle.order.length = 0;
|
|
207
|
+
handle.discarded = keepBytes === 0;
|
|
208
|
+
fs.writeFileSync(file, _headerLine(runKey));
|
|
209
|
+
} else {
|
|
210
|
+
// Drop any torn tail so appends land after the last verified record.
|
|
211
|
+
try {
|
|
212
|
+
const size = fs.statSync(file).size;
|
|
213
|
+
if (size !== keepBytes) fs.truncateSync(file, keepBytes);
|
|
214
|
+
} catch { /* best-effort */ }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
handle.fd = fs.openSync(file, 'a');
|
|
218
|
+
handle.enabled = true;
|
|
219
|
+
} catch (e) {
|
|
220
|
+
try { if (handle.fd !== null) fs.closeSync(handle.fd); } catch { /* ignore */ }
|
|
221
|
+
return _emptyHandle(String((e && e.message) || e));
|
|
222
|
+
}
|
|
223
|
+
return handle;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Durably record that `relPath` is fully analysed, along with everything that
|
|
228
|
+
* analysis produced. `findings` is the per-file payload object (see the engine
|
|
229
|
+
* call site); it must be plain JSON data. Returns true only if the record is on
|
|
230
|
+
* disk and fsync'd.
|
|
231
|
+
*/
|
|
232
|
+
export function recordFileDone(handle, relPath, findings) {
|
|
233
|
+
if (!handle || !handle.enabled || handle.fd === null || typeof relPath !== 'string') return false;
|
|
234
|
+
try {
|
|
235
|
+
if (!_jsonSafe(findings)) return false;
|
|
236
|
+
const d = JSON.stringify(findings === undefined ? null : findings);
|
|
237
|
+
if (typeof d !== 'string') return false;
|
|
238
|
+
const line = JSON.stringify({ f: relPath, c: _sha(d), d }) + '\n';
|
|
239
|
+
fs.writeSync(handle.fd, line);
|
|
240
|
+
fs.fsyncSync(handle.fd);
|
|
241
|
+
handle.written.add(relPath);
|
|
242
|
+
return true;
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Files already analysed — recovered from a prior run plus written by this one. */
|
|
249
|
+
export function completedFiles(handle) {
|
|
250
|
+
const out = new Set();
|
|
251
|
+
if (!handle) return out;
|
|
252
|
+
for (const f of handle.recovered ? handle.recovered.keys() : []) out.add(f);
|
|
253
|
+
for (const f of handle.written || []) out.add(f);
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Recovered per-file payloads, in the order they were originally recorded. */
|
|
258
|
+
export function resumeFindings(handle) {
|
|
259
|
+
if (!handle || !handle.recovered) return [];
|
|
260
|
+
return (handle.order || []).map(file => ({ file, findings: handle.recovered.get(file) }));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Close the handle. `complete: true` means the scan finished — the checkpoint is
|
|
265
|
+
* removed so the next run cannot resume state that has already been consumed.
|
|
266
|
+
*/
|
|
267
|
+
export function closeCheckpoint(handle, { complete = false } = {}) {
|
|
268
|
+
if (!handle || !handle.enabled) return false;
|
|
269
|
+
let ok = true;
|
|
270
|
+
try { if (handle.fd !== null) fs.closeSync(handle.fd); } catch { ok = false; }
|
|
271
|
+
handle.fd = null;
|
|
272
|
+
handle.enabled = false;
|
|
273
|
+
if (complete && handle.file) {
|
|
274
|
+
try { fs.rmSync(handle.file, { force: true }); } catch { ok = false; }
|
|
275
|
+
}
|
|
276
|
+
return ok;
|
|
277
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// R5 (partial, roadmap) — the project's own test suite as a verification
|
|
2
|
+
// stage for `verifyFix()` (see `fix-verify.js`). Closes the gap where a
|
|
3
|
+
// "verified" fix only proved a finding's stableId stopped firing — a patch
|
|
4
|
+
// that deletes the feature entirely would satisfy that just as well as a
|
|
5
|
+
// real fix. Running the project's own tests is the cheapest available check
|
|
6
|
+
// that the application still works.
|
|
7
|
+
//
|
|
8
|
+
// Execution-safety note: this spawns the TARGET PROJECT's own test command
|
|
9
|
+
// in the target project's own directory. That is deliberately NOT routed
|
|
10
|
+
// through the R1 confinement sandbox (`../sandbox/`). That sandbox exists to
|
|
11
|
+
// contain untrusted proof-of-concept exploit code the scanner itself
|
|
12
|
+
// synthesizes — code nobody has vetted, being run for the first time. A
|
|
13
|
+
// project's pre-existing test suite is the opposite case: it is the
|
|
14
|
+
// project's own trusted source, already sitting on disk, and running it is
|
|
15
|
+
// exactly what a human developer does by hand before trusting a fix.
|
|
16
|
+
// Wrapping "npm test" / "pytest" / "go test" in the PoC sandbox's
|
|
17
|
+
// syscall/network/filesystem restrictions would break the large majority of
|
|
18
|
+
// real test suites (they bind local ports, spawn child processes, write temp
|
|
19
|
+
// fixtures, etc.) for no corresponding security benefit.
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import * as fs from 'node:fs';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
26
|
+
const NPM_PLACEHOLDER = /Error: no test specified/i;
|
|
27
|
+
|
|
28
|
+
function _exists(scanRoot, rel) {
|
|
29
|
+
try { return fs.existsSync(path.join(scanRoot, rel)); } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _isDir(scanRoot, rel) {
|
|
33
|
+
try { return fs.statSync(path.join(scanRoot, rel)).isDirectory(); } catch { return false; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _binaryAvailable(cmd) {
|
|
37
|
+
try {
|
|
38
|
+
const r = spawnSync(cmd, ['--version'], { timeout: 5_000, stdio: 'ignore' });
|
|
39
|
+
return !(r.error && r.error.code === 'ENOENT');
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Detect the project's test command. Read-only — never spawns the actual
|
|
46
|
+
// test run, only (optionally) a cheap `--version` probe to confirm a tool
|
|
47
|
+
// like `pytest` is actually installed before committing to it. Returns
|
|
48
|
+
// `null` when nothing detectable is found — most scanned repos will hit
|
|
49
|
+
// this path, and that must not be treated as a failure by callers.
|
|
50
|
+
export function detectTestCommand(scanRoot) {
|
|
51
|
+
if (!scanRoot) return null;
|
|
52
|
+
|
|
53
|
+
// JS/TS — package.json with a real (non-placeholder) `scripts.test`.
|
|
54
|
+
let pkg = null;
|
|
55
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(scanRoot, 'package.json'), 'utf8')); } catch { pkg = null; }
|
|
56
|
+
const testScript = pkg && pkg.scripts && pkg.scripts.test;
|
|
57
|
+
if (testScript && !NPM_PLACEHOLDER.test(String(testScript))) {
|
|
58
|
+
if (_exists(scanRoot, 'pnpm-lock.yaml')) return { cmd: 'pnpm', args: ['test'], kind: 'pnpm' };
|
|
59
|
+
if (_exists(scanRoot, 'yarn.lock')) return { cmd: 'yarn', args: ['test'], kind: 'yarn' };
|
|
60
|
+
if (_exists(scanRoot, 'bun.lockb') || _exists(scanRoot, 'bun.lock')) return { cmd: 'bun', args: ['test'], kind: 'bun' };
|
|
61
|
+
return { cmd: 'npm', args: ['test', '--silent'], kind: 'npm' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Python — pytest.ini / pyproject.toml / tox.ini / a tests/ dir, and the
|
|
65
|
+
// `pytest` binary actually available. If pytest isn't installed we do NOT
|
|
66
|
+
// report a python test command — falling through lets a later language
|
|
67
|
+
// marker (e.g. go.mod in a polyglot repo) still be detected.
|
|
68
|
+
const pyMarker = _exists(scanRoot, 'pytest.ini') || _exists(scanRoot, 'pyproject.toml') ||
|
|
69
|
+
_exists(scanRoot, 'tox.ini') || _isDir(scanRoot, 'tests');
|
|
70
|
+
if (pyMarker && _binaryAvailable('pytest')) {
|
|
71
|
+
return { cmd: 'pytest', args: ['-q'], kind: 'pytest' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Go
|
|
75
|
+
if (_exists(scanRoot, 'go.mod')) {
|
|
76
|
+
return { cmd: 'go', args: ['test', './...'], kind: 'go' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Run the detected test command with a walltime budget. Always returns a
|
|
83
|
+
// result object — never throws. Distinguishes four outcomes:
|
|
84
|
+
// - no detectable/runnable command -> status: 'skipped' (does NOT fail)
|
|
85
|
+
// - ran and exited 0 -> status: 'passed'
|
|
86
|
+
// - ran and exited non-zero -> status: 'failed'
|
|
87
|
+
// - ran past the timeout budget -> status: 'failed', timedOut: true
|
|
88
|
+
export function runProjectTests(scanRoot, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
89
|
+
const startedAt = Date.now();
|
|
90
|
+
const command = detectTestCommand(scanRoot);
|
|
91
|
+
if (!command) {
|
|
92
|
+
return {
|
|
93
|
+
status: 'skipped', passed: null, skipped: true,
|
|
94
|
+
reason: 'no-test-command-detected', exitCode: null, timedOut: false,
|
|
95
|
+
durationMs: Date.now() - startedAt,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let r;
|
|
100
|
+
try {
|
|
101
|
+
r = spawnSync(command.cmd, command.args, {
|
|
102
|
+
cwd: scanRoot,
|
|
103
|
+
encoding: 'utf8',
|
|
104
|
+
timeout: timeoutMs,
|
|
105
|
+
env: { ...process.env, CI: '1' },
|
|
106
|
+
});
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// The spawn call itself threw (rare — e.g. cwd vanished). Treat as
|
|
109
|
+
// "could not run", not "ran and failed".
|
|
110
|
+
return {
|
|
111
|
+
status: 'skipped', passed: null, skipped: true,
|
|
112
|
+
reason: `spawn-error: ${e.message}`, exitCode: null, timedOut: false,
|
|
113
|
+
durationMs: Date.now() - startedAt,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const durationMs = Date.now() - startedAt;
|
|
117
|
+
|
|
118
|
+
if (r.error && r.error.code === 'ENOENT') {
|
|
119
|
+
// The detected tool isn't actually installed on this machine. Not a
|
|
120
|
+
// test failure — the suite never ran.
|
|
121
|
+
return {
|
|
122
|
+
status: 'skipped', passed: null, skipped: true,
|
|
123
|
+
reason: `${command.kind}-not-installed`, exitCode: null, timedOut: false, durationMs,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (r.status === null) {
|
|
128
|
+
// spawnSync sets status:null both on timeout-kill and on being killed by
|
|
129
|
+
// another signal; either way the run did not complete, which is a
|
|
130
|
+
// verification failure, never a skip — we asked for a result and the
|
|
131
|
+
// process was terminated before producing one.
|
|
132
|
+
return {
|
|
133
|
+
status: 'failed', passed: false, skipped: false,
|
|
134
|
+
reason: 'timed-out', exitCode: null, timedOut: true, durationMs,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
status: r.status === 0 ? 'passed' : 'failed',
|
|
140
|
+
passed: r.status === 0,
|
|
141
|
+
skipped: false,
|
|
142
|
+
reason: r.status === 0 ? null : 'test-failures',
|
|
143
|
+
exitCode: r.status,
|
|
144
|
+
timedOut: false,
|
|
145
|
+
durationMs,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// R7 — adversarial verification with ENFORCED SEPARATION.
|
|
2
|
+
//
|
|
3
|
+
// The falsification pass (`falsification.js`) already tries to DISPROVE a
|
|
4
|
+
// finding. What it could not previously do is *prove* that whoever checked the
|
|
5
|
+
// finding was not whoever produced it. That guarantee is what this module adds,
|
|
6
|
+
// plus a recorded multi-perspective verdict for contested findings.
|
|
7
|
+
//
|
|
8
|
+
// The property that matters: a verifier is structurally unable to rubber-stamp
|
|
9
|
+
// its own finding.
|
|
10
|
+
// - `recordProducer` stamps who produced the finding, WRITE-ONCE. A later
|
|
11
|
+
// party cannot re-stamp itself as the producer to manufacture separation.
|
|
12
|
+
// - `assertSeparation` refuses when the verifier id equals the recorded
|
|
13
|
+
// producer id, and FAILS CLOSED when no producer was recorded at all
|
|
14
|
+
// (separation that cannot be established is not separation).
|
|
15
|
+
// - `recordVerdict` runs that check itself, so there is no code path that
|
|
16
|
+
// records a verdict without it. One verifier gets one vote per lens: a
|
|
17
|
+
// re-vote replaces the previous one rather than stuffing the ballot.
|
|
18
|
+
//
|
|
19
|
+
// RECALL-PRESERVING, same precedent as `falsification.js` and
|
|
20
|
+
// `dataflow/proof-gate.js`: nothing here ever removes a finding and nothing
|
|
21
|
+
// here ever touches `severity`. A `refuted` consensus is a triage signal, not
|
|
22
|
+
// a deletion — absence of proof is not proof of absence.
|
|
23
|
+
//
|
|
24
|
+
// NO THROWING (posture/CLAUDE.md convention): every entry point returns a
|
|
25
|
+
// refusal object `{ ok:false, refused:true, reason }` instead of throwing, so
|
|
26
|
+
// an annotator can call it inside the engine's pipeline without a guard.
|
|
27
|
+
|
|
28
|
+
export const VERIFICATION_VERDICTS = ['upheld', 'refuted', 'undecided'];
|
|
29
|
+
|
|
30
|
+
// Verifier ids are namespaced so they can never collide with a producer id.
|
|
31
|
+
export const VERIFIER_FALSIFICATION = 'verifier:falsification';
|
|
32
|
+
export const VERIFIER_LLM_REVIEW = 'verifier:llm-review';
|
|
33
|
+
|
|
34
|
+
function _refuse(reason) { return { ok: false, refused: true, reason }; }
|
|
35
|
+
|
|
36
|
+
/** Namespaced id for whoever produced a finding — derived from its detector. */
|
|
37
|
+
export function producerIdOf(finding) {
|
|
38
|
+
const p = finding && finding.parser ? String(finding.parser) : 'unknown';
|
|
39
|
+
return `detector:${p}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Stamp who produced this finding. Write-once: a second call with a different
|
|
44
|
+
* id is REFUSED (this is what stops a verifier reassigning provenance to
|
|
45
|
+
* itself). A repeat call with the same id is a no-op success.
|
|
46
|
+
*/
|
|
47
|
+
export function recordProducer(finding, producerId) {
|
|
48
|
+
if (!finding || typeof finding !== 'object') return _refuse('no finding');
|
|
49
|
+
const id = producerId ? String(producerId) : '';
|
|
50
|
+
if (!id) return _refuse('no producer id');
|
|
51
|
+
if (!finding.verification || typeof finding.verification !== 'object') {
|
|
52
|
+
finding.verification = { producer: id, verdicts: [] };
|
|
53
|
+
return { ok: true, producer: id };
|
|
54
|
+
}
|
|
55
|
+
if (!finding.verification.producer) {
|
|
56
|
+
finding.verification.producer = id;
|
|
57
|
+
if (!Array.isArray(finding.verification.verdicts)) finding.verification.verdicts = [];
|
|
58
|
+
return { ok: true, producer: id };
|
|
59
|
+
}
|
|
60
|
+
if (finding.verification.producer === id) return { ok: true, producer: id };
|
|
61
|
+
return _refuse(`producer already recorded as "${finding.verification.producer}" — write-once`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The separation check. `{ ok:true, producer }` when the verifier is a party
|
|
66
|
+
* other than the producer; a refusal otherwise. Fails closed when no producer
|
|
67
|
+
* has been recorded.
|
|
68
|
+
*/
|
|
69
|
+
export function assertSeparation(finding, verifierId) {
|
|
70
|
+
if (!finding || typeof finding !== 'object') return _refuse('no finding');
|
|
71
|
+
const vid = verifierId ? String(verifierId) : '';
|
|
72
|
+
if (!vid) return _refuse('no verifier id');
|
|
73
|
+
const producer = finding.verification && finding.verification.producer;
|
|
74
|
+
if (!producer) {
|
|
75
|
+
return _refuse('no producer recorded — separation cannot be established');
|
|
76
|
+
}
|
|
77
|
+
if (producer === vid) {
|
|
78
|
+
return _refuse(`verifier "${vid}" is the producer of this finding — separation violated`);
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, producer };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Record one verifier's verdict from one perspective (`lens`, e.g.
|
|
85
|
+
* 'reachability' | 'control-flow' | 'data-shape'). Refuses — recording
|
|
86
|
+
* nothing — when separation fails or the verdict is not one of
|
|
87
|
+
* VERIFICATION_VERDICTS. Never touches severity, never removes anything.
|
|
88
|
+
*/
|
|
89
|
+
export function recordVerdict(finding, { verifierId, lens, verdict, reason } = {}) {
|
|
90
|
+
if (!finding || typeof finding !== 'object') return _refuse('no finding');
|
|
91
|
+
if (!lens) return _refuse('no lens');
|
|
92
|
+
if (!VERIFICATION_VERDICTS.includes(verdict)) {
|
|
93
|
+
return _refuse(`unknown verdict "${verdict}" — expected one of ${VERIFICATION_VERDICTS.join('|')}`);
|
|
94
|
+
}
|
|
95
|
+
const sep = assertSeparation(finding, verifierId);
|
|
96
|
+
if (!sep.ok) return sep;
|
|
97
|
+
|
|
98
|
+
const entry = {
|
|
99
|
+
verifierId: String(verifierId),
|
|
100
|
+
lens: String(lens),
|
|
101
|
+
verdict,
|
|
102
|
+
...(reason ? { reason: String(reason) } : {}),
|
|
103
|
+
};
|
|
104
|
+
const list = finding.verification.verdicts;
|
|
105
|
+
const i = list.findIndex(v => v.verifierId === entry.verifierId && v.lens === entry.lens);
|
|
106
|
+
if (i >= 0) list[i] = entry; else list.push(entry);
|
|
107
|
+
return { ok: true, recorded: entry };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Majority across every recorded verdict. Ties — including "no verdicts at
|
|
112
|
+
* all" — are 'undecided'. `lenses` is the sorted set of perspectives that
|
|
113
|
+
* actually voted, so a caller can see whether a verdict is one-eyed or a panel.
|
|
114
|
+
*/
|
|
115
|
+
export function consensusOf(finding) {
|
|
116
|
+
const list = (finding && finding.verification && Array.isArray(finding.verification.verdicts))
|
|
117
|
+
? finding.verification.verdicts : [];
|
|
118
|
+
let upheld = 0, refuted = 0, undecided = 0;
|
|
119
|
+
const lenses = new Set();
|
|
120
|
+
for (const v of list) {
|
|
121
|
+
if (!v) continue;
|
|
122
|
+
if (v.lens) lenses.add(String(v.lens));
|
|
123
|
+
if (v.verdict === 'upheld') upheld++;
|
|
124
|
+
else if (v.verdict === 'refuted') refuted++;
|
|
125
|
+
else if (v.verdict === 'undecided') undecided++;
|
|
126
|
+
}
|
|
127
|
+
let verdict = 'undecided';
|
|
128
|
+
if (upheld > refuted) verdict = 'upheld';
|
|
129
|
+
else if (refuted > upheld) verdict = 'refuted';
|
|
130
|
+
return { verdict, upheld, refuted, undecided, lenses: [...lenses].sort() };
|
|
131
|
+
}
|
package/src/report/index.js
CHANGED
|
@@ -180,6 +180,11 @@ export function normalizeFindings(scan){
|
|
|
180
180
|
paramKeyConfidence: f.poc.paramKeyConfidence || null,
|
|
181
181
|
paramKeyInferred: typeof f.poc.paramKeyInferred === 'boolean' ? f.poc.paramKeyInferred : null,
|
|
182
182
|
} : null,
|
|
183
|
+
// R2: execution-proof tier — copied through only when the annotator
|
|
184
|
+
// (posture/proof-tier.js) actually attached it. Never synthesised here;
|
|
185
|
+
// a finding with no proof backing simply omits these fields.
|
|
186
|
+
...(f.proofTier !== undefined ? { proofTier: f.proofTier } : {}),
|
|
187
|
+
...(f.proofEvidence !== undefined ? { proofEvidence: f.proofEvidence } : {}),
|
|
183
188
|
// Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
|
|
184
189
|
// 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
|
|
185
190
|
// for this family; `calibration_reason` explains why.
|
|
@@ -401,6 +406,12 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
401
406
|
// threw and were skipped. The findings still ship; downstream consumers
|
|
402
407
|
// see the gap.
|
|
403
408
|
annotatorErrors: Array.isArray(scan.annotatorErrors) ? scan.annotatorErrors : [],
|
|
409
|
+
// R4 — run attestation: a stable, order-independent digest over this
|
|
410
|
+
// finding set bound to the engine/ruleset/bundle that produced it.
|
|
411
|
+
// Attached by the CLI (posture/attestation.js); null when not computed.
|
|
412
|
+
// It carries its own `proves` / `doesNotProve` statement — do not quote
|
|
413
|
+
// the digest as cross-machine reproducibility, which it is not.
|
|
414
|
+
attestation: scan.attestation || null,
|
|
404
415
|
_scanMeta: scan._scanMeta || null,
|
|
405
416
|
};
|
|
406
417
|
if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
|
package/src/runScan.js
CHANGED
|
@@ -122,7 +122,9 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
// R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
|
|
126
|
+
// AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
|
|
127
|
+
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume }, opts.onProgress || (()=>{}));
|
|
126
128
|
// Premortem 2R4.2: stamp ruleset version + source on the scan result, and
|
|
127
129
|
// notify if the operator pinned a different version than what's installed.
|
|
128
130
|
try { stampScan(root, scan); } catch {}
|