@clear-capabilities/agentic-security-scanner 0.195.27 → 0.195.29
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 +24 -0
- package/bin/agentic-security.js +41 -4
- package/dist/5856.index.js +634 -0
- package/dist/7033.index.js +74 -14
- package/dist/9220.index.js +24 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{90ebba38d8860ec77376.json → c06a1b4c20155c06fb48.json} +4 -3
- package/package.json +4 -3
- package/src/dataflow/catalog.js +42 -1
- package/src/engine.js +91 -11
- package/src/language/CLAUDE.md +7 -1
- package/src/language/assurance.js +53 -2
- package/src/language/haskell-budget-state.js +85 -0
- package/src/language/haskell-coverage.js +230 -0
- package/src/language/haskell-guards.js +26 -5
- package/src/language/haskell-project-models.js +107 -0
- package/src/language/nix-eval-isolation.js +74 -14
- package/src/llm-validator/bounded-context.js +193 -0
- package/src/llm-validator/claim-groups.js +105 -0
- package/src/llm-validator/disposition.js +264 -0
- package/src/llm-validator/index.js +52 -26
- package/src/pipeline/evidence-record.js +112 -0
- package/src/pipeline/scan-health.js +12 -1
- package/src/posture/artifact-registry.js +2 -0
- package/src/posture/drift.js +19 -1
- package/src/posture/reachability-filter.js +3 -3
- package/src/posture/suppressions.js +21 -0
- package/src/posture/watch-mode.js +22 -1
- package/src/report/disposition-view.js +90 -0
- package/src/report/index.js +47 -0
- package/src/runScan.js +60 -6
- package/src/sast/oidc-trust.js +74 -0
- package/src/sast/pipeline.js +14 -1
- package/src/sast/workflow-flow.js +272 -0
- package/src/sca/basis.js +88 -0
- package/src/scope/flags.js +55 -0
- package/src/scope/inventory.js +241 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Declarative project models for Haskell wrappers (PRD FR 03).
|
|
2
|
+
//
|
|
3
|
+
// A wrapper the shipped registry does not know (a project's own `runQuery`, a vendored HTTP helper) can be declared in
|
|
4
|
+
// `.agentic-security/haskell-models.json`. A suggestive function name is never enough: every model must carry provenance (who
|
|
5
|
+
// reviewed it and why), the argument position it concerns, and a paired positive and negative test that exist in the scanned
|
|
6
|
+
// source. A model that lacks any of these is rejected, with the reason, and has no effect on the scan.
|
|
7
|
+
//
|
|
8
|
+
// File shape:
|
|
9
|
+
// { "schema": "haskell-project-models/1",
|
|
10
|
+
// "models": [ { "id": "app-runquery", "role": "sink", "module": "App.Db", "name": "runQuery",
|
|
11
|
+
// "family": "sql", "cwe": "CWE-89", "argIndex": 0,
|
|
12
|
+
// "provenance": { "reviewer": "…", "reviewedAt": "2026-11-01", "reason": "…" },
|
|
13
|
+
// "tests": { "positive": "test/RunQueryVuln.hs", "negative": "test/RunQuerySafe.hs" } } ] }
|
|
14
|
+
//
|
|
15
|
+
// Roles: `source` (its result is untrusted input), `sink` (argument `argIndex` is dangerous for `family`), `sanitizer`
|
|
16
|
+
// (its result is safe for the families in `appliesTo`, and only those; `*` is not accepted).
|
|
17
|
+
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { statePath } from '../posture/state-dir.js';
|
|
21
|
+
|
|
22
|
+
export const PROJECT_MODEL_SCHEMA = 'haskell-project-models/1';
|
|
23
|
+
|
|
24
|
+
const FAMILIES = Object.freeze({
|
|
25
|
+
sql: 'CWE-89', cmd: 'CWE-78', path: 'CWE-22', ssrf: 'CWE-918', xss: 'CWE-79', log: 'CWE-532', code: 'CWE-94', redirect: 'CWE-601',
|
|
26
|
+
});
|
|
27
|
+
const SEVERITY = { sql: 'high', cmd: 'high', path: 'high', ssrf: 'high', xss: 'high', log: 'medium', code: 'critical', redirect: 'medium' };
|
|
28
|
+
const MODULE_RE = /^[A-Z][A-Za-z0-9_']*(?:\.[A-Z][A-Za-z0-9_']*)*$/;
|
|
29
|
+
const NAME_RE = /^[a-z_][A-Za-z0-9_']*$/;
|
|
30
|
+
|
|
31
|
+
function check(m, exists) {
|
|
32
|
+
if (!m || typeof m !== 'object') return 'not an object';
|
|
33
|
+
if (!m.id || typeof m.id !== 'string') return 'missing id';
|
|
34
|
+
if (!['source', 'sink', 'sanitizer'].includes(m.role)) return `role must be source, sink or sanitizer, got ${JSON.stringify(m.role)}`;
|
|
35
|
+
if (!MODULE_RE.test(String(m.module || ''))) return 'module must be a qualified module name such as App.Db';
|
|
36
|
+
if (!NAME_RE.test(String(m.name || ''))) return 'name must be a function name';
|
|
37
|
+
const pv = m.provenance;
|
|
38
|
+
if (!pv || !pv.reviewer || !pv.reviewedAt || !pv.reason) return 'provenance needs reviewer, reviewedAt and reason: a function name alone is not a model';
|
|
39
|
+
const t = m.tests;
|
|
40
|
+
if (!t || !t.positive || !t.negative) return 'a paired positive and negative test is required';
|
|
41
|
+
if (t.positive === t.negative) return 'the positive and negative tests must be different files';
|
|
42
|
+
for (const k of ['positive', 'negative']) {
|
|
43
|
+
if (!exists(String(t[k]))) return `${k} test "${t[k]}" does not exist inside the project`;
|
|
44
|
+
}
|
|
45
|
+
if (m.role === 'sink') {
|
|
46
|
+
if (!FAMILIES[m.family]) return `family must be one of ${Object.keys(FAMILIES).join(', ')}`;
|
|
47
|
+
if (!(Number.isInteger(m.argIndex) && m.argIndex >= 0) && m.argIndex !== 'all') return 'a sink needs argIndex (a non-negative integer or "all")';
|
|
48
|
+
}
|
|
49
|
+
if (m.role === 'sanitizer') {
|
|
50
|
+
if (!Array.isArray(m.appliesTo) || !m.appliesTo.length) return 'a sanitizer needs appliesTo, the families it neutralises';
|
|
51
|
+
if (m.appliesTo.includes('*')) return 'a project sanitizer may not claim every family';
|
|
52
|
+
const bad = m.appliesTo.find((f) => !FAMILIES[f]);
|
|
53
|
+
if (bad) return `unknown family in appliesTo: ${bad}`;
|
|
54
|
+
}
|
|
55
|
+
if (m.role === 'source' && !m.label) return 'a source needs a label saying what it returns';
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A predicate for 'this relative path is a regular file inside the project root'. */
|
|
60
|
+
export function projectFileExists(scanRoot) {
|
|
61
|
+
const root = path.resolve(scanRoot);
|
|
62
|
+
return (rel) => {
|
|
63
|
+
try {
|
|
64
|
+
if (path.isAbsolute(rel)) return false;
|
|
65
|
+
const abs = path.resolve(root, rel);
|
|
66
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) return false;
|
|
67
|
+
return fs.lstatSync(abs).isFile();
|
|
68
|
+
} catch { return false; }
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read the model file from the scan root (an exact path, never a traversal). Returns the parsed document or null. */
|
|
73
|
+
export function readProjectModelFile(scanRoot) {
|
|
74
|
+
try {
|
|
75
|
+
const fp = statePath(scanRoot, 'haskell-models.json');
|
|
76
|
+
return JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
77
|
+
} catch { return null; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @returns {{present: boolean, accepted: object[], rejected: {id: string, reason: string}[], catalog: object[]}}
|
|
82
|
+
*/
|
|
83
|
+
export function loadProjectModels(doc, exists = () => false) {
|
|
84
|
+
if (!doc) return { present: false, accepted: [], rejected: [], catalog: [] };
|
|
85
|
+
if (doc.schema !== PROJECT_MODEL_SCHEMA || !Array.isArray(doc.models)) {
|
|
86
|
+
return { present: true, accepted: [], rejected: [{ id: '(file)', reason: `expected schema ${PROJECT_MODEL_SCHEMA} with a models array` }], catalog: [] };
|
|
87
|
+
}
|
|
88
|
+
const accepted = []; const rejected = []; const seen = new Set();
|
|
89
|
+
for (const m of doc.models) {
|
|
90
|
+
const id = String(m && m.id || '(no id)');
|
|
91
|
+
const why = seen.has(id) ? 'duplicate id' : check(m, exists);
|
|
92
|
+
seen.add(id);
|
|
93
|
+
if (why) rejected.push({ id, reason: why }); else accepted.push(m);
|
|
94
|
+
}
|
|
95
|
+
const catalog = accepted.map((m) => {
|
|
96
|
+
const callee = `${m.module}.${m.name}`;
|
|
97
|
+
const base = { id: `hs-project-${m.id}`.toLowerCase(), language: 'hs', framework: 'haskell', match: { type: 'call', callee }, projectModel: m.id };
|
|
98
|
+
if (m.role === 'source') return { ...base, kind: 'source', label: m.label, provenance: 'project-model' };
|
|
99
|
+
if (m.role === 'sink') return {
|
|
100
|
+
...base, kind: 'sink', argIndex: m.argIndex,
|
|
101
|
+
vuln: { name: m.vuln || `${m.family} sink (project model ${m.id})`, severity: SEVERITY[m.family], cwe: m.cwe || FAMILIES[m.family], remediation: m.remediation || 'See the project model that declares this wrapper.' },
|
|
102
|
+
hs: { family: m.family, shell: m.shell === true, argv: false, package: 'project', htmlSkeleton: false },
|
|
103
|
+
};
|
|
104
|
+
return { ...base, kind: 'sanitizer', effect: 'strip', appliesTo: m.appliesTo, note: `project model ${m.id}` };
|
|
105
|
+
});
|
|
106
|
+
return { present: true, accepted, rejected, catalog };
|
|
107
|
+
}
|
|
@@ -69,7 +69,7 @@ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p);
|
|
|
69
69
|
* The sandboxed command for a backend. Reads are an ALLOWLIST (the listed roots, system library paths and the
|
|
70
70
|
* evaluator); writes are an allowlist of one scratch directory; network and process creation are denied.
|
|
71
71
|
* @param {{backend:string, path?:string}} sb
|
|
72
|
-
* @param {{execFile:string, args:string[], readOnly:string[], readFiles?:string[], writable:string[], cwd:string}} spec
|
|
72
|
+
* @param {{execFile:string, args:string[], readOnly:string[], readFiles?:string[], writable:string[], cwd:string, env?:Record<string,string>}} spec
|
|
73
73
|
*/
|
|
74
74
|
export function sandboxCommand(sb, spec) {
|
|
75
75
|
const ro = [...new Set(spec.readOnly.map(real))];
|
|
@@ -92,34 +92,94 @@ export function sandboxCommand(sb, spec) {
|
|
|
92
92
|
for (const p of ro) a.push('--ro-bind', p, p);
|
|
93
93
|
for (const p of files) a.push('--ro-bind', p, p);
|
|
94
94
|
for (const p of rw) a.push('--bind', p, p);
|
|
95
|
+
// --clearenv empties the environment bwrap itself was given, so the clean one is set explicitly inside.
|
|
96
|
+
for (const [k, v] of Object.entries(spec.env || {})) a.push('--setenv', k, String(v));
|
|
97
|
+
// Namespaces do not stop process creation; the seccomp filter does. Before it, a sandboxed process could spawn anything the
|
|
98
|
+
// read-only binds expose (found the first time a CI runner had bwrap installed). On an architecture with no filter there is
|
|
99
|
+
// none, and the probe then reports the spawn and refuses to evaluate.
|
|
100
|
+
const seccomp = seccompNoProcesses(sb.arch || process.arch);
|
|
101
|
+
if (seccomp) a.push('--seccomp', '3');
|
|
95
102
|
a.push('--chdir', spec.cwd, exec, ...spec.args);
|
|
96
|
-
return { file: sb.path, args: a, profile: null };
|
|
103
|
+
return { file: sb.path, args: a, profile: null, fd3: seccomp };
|
|
97
104
|
}
|
|
98
105
|
return null;
|
|
99
106
|
}
|
|
100
107
|
|
|
108
|
+
// ── seccomp: threads yes, processes no ──────────────────────────────────────
|
|
109
|
+
// A classic-BPF seccomp program, as `bwrap --seccomp FD` reads it (struct sock_filter, native little-endian). It denies fork and vfork,
|
|
110
|
+
// denies clone unless CLONE_THREAD is set (so pthread_create works and posix_spawn, fork and vfork through clone do not), answers clone3
|
|
111
|
+
// with ENOSYS (its flags are behind a pointer a filter cannot read; glibc then falls back to clone), denies x32 syscalls, and kills a
|
|
112
|
+
// process that switches to another syscall ABI.
|
|
113
|
+
const SECCOMP_ARCH = {
|
|
114
|
+
x64: { audit: 0xc000003e, clone: 56, clone3: 435, deny: [57, 58], x32: true },
|
|
115
|
+
arm64: { audit: 0xc00000b7, clone: 220, clone3: 435, deny: [], x32: false },
|
|
116
|
+
};
|
|
117
|
+
const CLONE_THREAD = 0x10000;
|
|
118
|
+
export function seccompNoProcesses(arch) {
|
|
119
|
+
const A = SECCOMP_ARCH[arch];
|
|
120
|
+
if (!A) return null;
|
|
121
|
+
const LD = 0x20, JEQ = 0x15, JGE = 0x35, JSET = 0x45, RET = 0x06;
|
|
122
|
+
const ALLOW = 0x7fff0000, EPERM = 0x00050000 | 1, ENOSYS = 0x00050000 | 38, KILL = 0x80000000;
|
|
123
|
+
// Instructions with symbolic jump targets, resolved to relative offsets below.
|
|
124
|
+
const prog = [
|
|
125
|
+
[LD, 0, 0, 4], // arch
|
|
126
|
+
[JEQ, null, 'kill', A.audit],
|
|
127
|
+
[LD, 0, 0, 0], // syscall number
|
|
128
|
+
...(A.x32 ? [[JGE, 'eperm', null, 0x40000000]] : []),
|
|
129
|
+
[JEQ, 'enosys', null, A.clone3],
|
|
130
|
+
...A.deny.map((nr) => [JEQ, 'eperm', null, nr]),
|
|
131
|
+
[JEQ, null, 'allow', A.clone],
|
|
132
|
+
[LD, 0, 0, 16], // clone flags, args[0] low word
|
|
133
|
+
[JSET, 'allow', 'eperm', CLONE_THREAD],
|
|
134
|
+
['allow', RET, ALLOW], ['eperm', RET, EPERM], ['enosys', RET, ENOSYS], ['kill', RET, KILL],
|
|
135
|
+
];
|
|
136
|
+
const at = {};
|
|
137
|
+
prog.forEach((ins, i) => { if (typeof ins[0] === 'string') at[ins[0]] = i; });
|
|
138
|
+
const buf = Buffer.alloc(prog.length * 8);
|
|
139
|
+
prog.forEach((ins, i) => {
|
|
140
|
+
const [code, jt, jf, k] = typeof ins[0] === 'string' ? [ins[1], 0, 0, ins[2]] : ins;
|
|
141
|
+
const rel = (t) => (t === null || t === 0 ? 0 : at[t] - i - 1);
|
|
142
|
+
buf.writeUInt16LE(code, i * 8); buf.writeUInt8(rel(jt), i * 8 + 2); buf.writeUInt8(rel(jf), i * 8 + 3); buf.writeUInt32LE(k >>> 0, i * 8 + 4);
|
|
143
|
+
});
|
|
144
|
+
return buf;
|
|
145
|
+
}
|
|
146
|
+
|
|
101
147
|
// ── process supervisor ───────────────────────────────────────────────────────
|
|
102
|
-
|
|
103
|
-
|
|
148
|
+
// The memory of the whole process tree under `root`, in MB. Not the process group: bwrap's --new-session puts the sandboxed process in
|
|
149
|
+
// a session and group of its own, so a group sum never saw the evaluator on Linux.
|
|
150
|
+
function rssOfTree(root) {
|
|
151
|
+
const r = spawnSync('/bin/ps', ['-A', '-o', 'pid=,ppid=,rss='], { encoding: 'utf8', timeout: 2000 });
|
|
104
152
|
if (r.status !== 0) return 0;
|
|
105
|
-
|
|
153
|
+
const kids = new Map(); const rss = new Map();
|
|
154
|
+
for (const line of r.stdout.split('\n')) {
|
|
155
|
+
const [pid, ppid, kb] = line.trim().split(/\s+/).map((x) => parseInt(x, 10));
|
|
156
|
+
if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
|
|
157
|
+
rss.set(pid, Number.isFinite(kb) ? kb : 0);
|
|
158
|
+
if (!kids.has(ppid)) kids.set(ppid, []);
|
|
159
|
+
kids.get(ppid).push(pid);
|
|
160
|
+
}
|
|
161
|
+
let total = 0; const seen = new Set(); const stack = [root];
|
|
162
|
+
while (stack.length) { const p = stack.pop(); if (seen.has(p)) continue; seen.add(p); total += rss.get(p) || 0; stack.push(...(kids.get(p) || [])); }
|
|
163
|
+
return total / 1024;
|
|
106
164
|
}
|
|
107
165
|
function killGroup(pid, sig) { try { process.kill(-pid, sig); } catch { try { process.kill(pid, sig); } catch { /* gone */ } } }
|
|
108
166
|
|
|
109
167
|
/** Run a command to completion under a deadline, an output cap and a memory ceiling. Never throws. */
|
|
110
|
-
export function supervise(file, args, { env, cwd, deadlineMs, maxOutputBytes, maxRssMb, killGraceMs = 1500 }) {
|
|
168
|
+
export function supervise(file, args, { env, cwd, deadlineMs, maxOutputBytes, maxRssMb, killGraceMs = 1500, fd3 = null }) {
|
|
111
169
|
return new Promise((resolveP) => {
|
|
112
170
|
const t0 = Date.now();
|
|
113
171
|
let child;
|
|
114
|
-
try { child = spawn(file, args, { env, cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
172
|
+
try { child = spawn(file, args, { env, cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe', ...(fd3 ? ['pipe'] : [])] }); }
|
|
115
173
|
catch (e) { return resolveP({ status: 'failed', reason: `could not start: ${e.message}`, stdout: '', stderr: '', exitCode: null, signal: null, ms: 0 }); }
|
|
174
|
+
// fd 3 carries the seccomp program to bwrap; a child that exits before reading it closes the pipe, which is not an error here.
|
|
175
|
+
if (fd3) { child.stdio[3].on('error', () => {}); child.stdio[3].end(fd3); }
|
|
116
176
|
let out = ''; let err = ''; let bytes = 0; let state = null; let done = false;
|
|
117
177
|
const finish = (extra = {}) => { if (done) return; done = true; clearInterval(poll); clearTimeout(dl); resolveP({ stdout: out, stderr: err.slice(0, 4000), ms: Date.now() - t0, ...extra }); };
|
|
118
178
|
const stop = (why) => { if (state) return; state = why; killGroup(child.pid, 'SIGTERM'); setTimeout(() => killGroup(child.pid, 'SIGKILL'), killGraceMs).unref(); };
|
|
119
179
|
child.stdout.on('data', (d) => { bytes += d.length; if (bytes > maxOutputBytes) { stop('output-limit'); return; } out += d.toString('utf8'); });
|
|
120
180
|
child.stderr.on('data', (d) => { if (err.length < 8000) err += d.toString('utf8'); });
|
|
121
181
|
const dl = setTimeout(() => stop('timed-out'), deadlineMs);
|
|
122
|
-
const poll = setInterval(() => { if (!state && maxRssMb &&
|
|
182
|
+
const poll = setInterval(() => { if (!state && maxRssMb && rssOfTree(child.pid) > maxRssMb) stop('resource-limit'); }, 250);
|
|
123
183
|
child.on('error', (e) => finish({ status: 'failed', reason: e.message, exitCode: null, signal: null }));
|
|
124
184
|
child.on('close', (code, signal) => {
|
|
125
185
|
killGroup(child.pid, 'SIGKILL'); // reap any descendants
|
|
@@ -197,8 +257,8 @@ export async function probeSandbox(sb, opts = {}) {
|
|
|
197
257
|
const port = tcp.address().port;
|
|
198
258
|
try {
|
|
199
259
|
const exe = opts.nodePath || process.execPath;
|
|
200
|
-
const cmd = sandboxCommand(sb, { execFile: exe, args: [script, join(allowed, 'a.txt'), join(outside, 'secret.txt'), join(outside, 'w.txt'), join(scratch, 'w.txt'), String(port), sockPath], readOnly: [allowed, dirname(dirname(real(exe)))], readFiles: [script], writable: [scratch], cwd: scratch });
|
|
201
|
-
const r = await supervise(cmd.file, cmd.args, { env: cleanEnv(scratch), cwd: scratch, deadlineMs: limits.probeDeadlineMs, maxOutputBytes: 64 * 1024, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
|
|
260
|
+
const cmd = sandboxCommand(sb, { env: cleanEnv(scratch), execFile: exe, args: [script, join(allowed, 'a.txt'), join(outside, 'secret.txt'), join(outside, 'w.txt'), join(scratch, 'w.txt'), String(port), sockPath], readOnly: [allowed, dirname(dirname(real(exe)))], readFiles: [script], writable: [scratch], cwd: scratch });
|
|
261
|
+
const r = await supervise(cmd.file, cmd.args, { fd3: cmd.fd3, env: cleanEnv(scratch), cwd: scratch, deadlineMs: limits.probeDeadlineMs, maxOutputBytes: 64 * 1024, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
|
|
202
262
|
let attempts = null;
|
|
203
263
|
try { attempts = JSON.parse(r.stdout.trim()); } catch { /* unparseable: not verified */ }
|
|
204
264
|
if (!attempts) return { verified: false, backend: sb.backend, reason: `the probe did not complete inside the sandbox (${r.reason || r.stderr.slice(0, 120) || 'no output'})`, attempts: {} };
|
|
@@ -221,8 +281,8 @@ export async function probeSandbox(sb, opts = {}) {
|
|
|
221
281
|
/** @param {{file:string, prefixArgs?:string[]}} nix the evaluator command (a stand-in in tests) */
|
|
222
282
|
export async function detectEvaluator(nix, sb, ctx) {
|
|
223
283
|
const run = async (args) => {
|
|
224
|
-
const cmd = sandboxCommand(sb, { execFile: nix.file, args: [...(nix.prefixArgs || []), ...args], readOnly: ctx.readOnly, readFiles: [...(ctx.readFiles || []), ...(nix.prefixArgs || []).filter((a) => !a.startsWith('-'))], writable: [ctx.work], cwd: ctx.work });
|
|
225
|
-
return supervise(cmd.file, cmd.args, { env: cleanEnv(ctx.work), cwd: ctx.work, deadlineMs: 10_000, maxOutputBytes: 64 * 1024, maxRssMb: ctx.limits.maxRssMb, killGraceMs: ctx.limits.killGraceMs });
|
|
284
|
+
const cmd = sandboxCommand(sb, { env: cleanEnv(ctx.work), execFile: nix.file, args: [...(nix.prefixArgs || []), ...args], readOnly: ctx.readOnly, readFiles: [...(ctx.readFiles || []), ...(nix.prefixArgs || []).filter((a) => !a.startsWith('-'))], writable: [ctx.work], cwd: ctx.work });
|
|
285
|
+
return supervise(cmd.file, cmd.args, { fd3: cmd.fd3, env: cleanEnv(ctx.work), cwd: ctx.work, deadlineMs: 10_000, maxOutputBytes: 64 * 1024, maxRssMb: ctx.limits.maxRssMb, killGraceMs: ctx.limits.killGraceMs });
|
|
226
286
|
};
|
|
227
287
|
const v = await run(['--version']);
|
|
228
288
|
const ver = (/(\d+\.\d+(?:\.\d+)?)/.exec(v.stdout) || [])[1] || null;
|
|
@@ -276,8 +336,8 @@ export async function runIsolatedEval(o) {
|
|
|
276
336
|
const feat = await detectEvaluator(o.nix, sb, ctx);
|
|
277
337
|
if (!feat.ok) return stopBefore('unsupported', `${feat.reason}; the project's Nix code was NOT evaluated`, { sandbox: sb, probe: { verified: true }, evaluator: feat });
|
|
278
338
|
const args = [...(o.nix.prefixArgs || []), ...buildEvalArgs(target)];
|
|
279
|
-
const cmd = sandboxCommand(sb, { execFile: o.nix.file, args, readOnly: ctx.readOnly, readFiles: nixFiles, writable: [ctx.work], cwd: ctx.work });
|
|
280
|
-
const r = await supervise(cmd.file, cmd.args, { env: cleanEnv(ctx.work, o.env || {}), cwd: ctx.work, deadlineMs: limits.deadlineMs, maxOutputBytes: limits.maxOutputBytes, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
|
|
339
|
+
const cmd = sandboxCommand(sb, { env: cleanEnv(ctx.work, o.env || {}), execFile: o.nix.file, args, readOnly: ctx.readOnly, readFiles: nixFiles, writable: [ctx.work], cwd: ctx.work });
|
|
340
|
+
const r = await supervise(cmd.file, cmd.args, { fd3: cmd.fd3, env: cleanEnv(ctx.work, o.env || {}), cwd: ctx.work, deadlineMs: limits.deadlineMs, maxOutputBytes: limits.maxOutputBytes, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
|
|
281
341
|
const base = { ...keep, projectCodeEvaluated: true, sandbox: { backend: sb.backend }, evaluator: { version: feat.version }, ms: r.ms, target: { attribute: target.attribute, system: target.system, installable: target.installable } };
|
|
282
342
|
if (r.status !== 'ok') return { ...base, status: r.status === 'failed' ? 'failed' : r.status, reason: r.reason || r.stderr.slice(0, 200) };
|
|
283
343
|
let data;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Bounded context for model-assisted validation (PRD FR 05 "required validation flow").
|
|
2
|
+
//
|
|
3
|
+
// The model is shown numbered spans, each with an id (S1, S2, ...), a role and exact file and line bounds: the code around the
|
|
4
|
+
// finding, the source and sink when they lie outside it, each traced path step, and the related files on a Haskell/Nix evidence
|
|
5
|
+
// chain. It is asked to cite the span lines its verdict rests on. A citation is checked against the spans actually sent:
|
|
6
|
+
//
|
|
7
|
+
// valid every citation names a span and a line inside it
|
|
8
|
+
// none the reply cited nothing (recorded; the verdict is kept, and the disposition lists the missing citation)
|
|
9
|
+
// invalid a citation names a span or a line that was not sent: the model reasoned about code it was not shown, so the reply
|
|
10
|
+
// is not used (the caller turns it into `escalate`)
|
|
11
|
+
//
|
|
12
|
+
// Callers and configuration are collected by name, and the prompt says so:
|
|
13
|
+
//
|
|
14
|
+
// callers the nearest function definition above the finding, then call sites of that name in scanned files of the same
|
|
15
|
+
// language. Matched by name, not resolved through imports or dispatch: a same-named function can appear, and
|
|
16
|
+
// calls through aliases, callbacks or dynamic dispatch are missed.
|
|
17
|
+
// configuration environment keys read in the finding's span, then the scanned configuration files that set them. Dotenv files
|
|
18
|
+
// other than examples are never read, and the value of a secret-like key is masked before redaction.
|
|
19
|
+
//
|
|
20
|
+
// What is not collected is stated in `gaps`, never implied.
|
|
21
|
+
|
|
22
|
+
import { languagePromptExtras } from '../language/context.js';
|
|
23
|
+
|
|
24
|
+
const lineOf = (x) => (x && Number.isInteger(x.line) && x.line > 0 ? x.line : null);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @returns {{spans:{id:string, role:string, file:string, start:number, end:number, text:string}[], gaps:string[]}}
|
|
28
|
+
*/
|
|
29
|
+
export function collectBoundedContext(finding, fileContents, { window = 20, stepWindow = 3, maxSpans = 10, maxCallers = 3, maxConfig = 2 } = {}) {
|
|
30
|
+
const raw = [];
|
|
31
|
+
const gaps = [];
|
|
32
|
+
const add = (role, file, line, w, mask = null) => {
|
|
33
|
+
const code = fileContents && typeof fileContents[file] === 'string' ? fileContents[file] : null;
|
|
34
|
+
if (code === null) { gaps.push(`${role} file ${file} was not available`); return; }
|
|
35
|
+
const lines = mask ? code.split('\n').map(mask) : code.split('\n');
|
|
36
|
+
const start = Math.max(1, line - w);
|
|
37
|
+
const end = Math.min(lines.length, line + w);
|
|
38
|
+
if (start > end) return;
|
|
39
|
+
raw.push({ role, file, start, end, lines });
|
|
40
|
+
};
|
|
41
|
+
if (finding && finding.file && lineOf(finding)) add('finding', finding.file, finding.line, window);
|
|
42
|
+
const src = finding && finding.source; const snk = finding && finding.sink;
|
|
43
|
+
if (lineOf(src)) add('source', src.file || finding.file, src.line, stepWindow);
|
|
44
|
+
if (lineOf(snk)) add('sink', snk.file || finding.file, snk.line, stepWindow);
|
|
45
|
+
for (const step of Array.isArray(finding && finding.path) ? finding.path : []) {
|
|
46
|
+
if (lineOf(step)) add('path-step', step.file || finding.file, step.line, stepWindow);
|
|
47
|
+
}
|
|
48
|
+
if (finding && finding.file && lineOf(finding) && typeof (fileContents || {})[finding.file] === 'string') {
|
|
49
|
+
collectCallers(finding, fileContents, { maxCallers, stepWindow, add, gaps });
|
|
50
|
+
collectConfiguration(finding, fileContents, { window, maxConfig, add, gaps });
|
|
51
|
+
} else {
|
|
52
|
+
gaps.push('the finding has no readable source line, so callers and configuration were not collected');
|
|
53
|
+
}
|
|
54
|
+
// Merge spans that overlap or touch in the same file; the first role stays, the others are kept as `alsoRoles`.
|
|
55
|
+
const merged = [];
|
|
56
|
+
for (const s of raw) {
|
|
57
|
+
const hit = merged.find((m) => m.file === s.file && s.start <= m.end + 1 && s.end >= m.start - 1);
|
|
58
|
+
if (hit) { hit.start = Math.min(hit.start, s.start); hit.end = Math.max(hit.end, s.end); if (hit.role !== s.role && !hit.alsoRoles.includes(s.role)) hit.alsoRoles.push(s.role); continue; }
|
|
59
|
+
merged.push({ ...s, alsoRoles: [] });
|
|
60
|
+
}
|
|
61
|
+
const spans = merged.slice(0, maxSpans).map((m, i) => ({
|
|
62
|
+
id: `S${i + 1}`, role: m.role, ...(m.alsoRoles.length ? { alsoRoles: m.alsoRoles } : {}), file: m.file, start: m.start, end: m.end,
|
|
63
|
+
text: m.lines.slice(m.start - 1, m.end).map((l, k) => `${m.start + k}: ${l}`).join('\n'),
|
|
64
|
+
}));
|
|
65
|
+
if (merged.length > maxSpans) gaps.push(`${merged.length - maxSpans} span(s) beyond the first ${maxSpans} were not sent`);
|
|
66
|
+
try {
|
|
67
|
+
for (const x of languagePromptExtras(finding, fileContents)) {
|
|
68
|
+
const n = x.text.split('\n').length;
|
|
69
|
+
spans.push({ id: `S${spans.length + 1}`, role: 'related', file: x.file, start: x.startLine, end: x.startLine + n - 1, text: x.text });
|
|
70
|
+
}
|
|
71
|
+
} catch { /* related excerpts are advisory */ }
|
|
72
|
+
if (!spans.length) gaps.push('no source text was available for the finding');
|
|
73
|
+
gaps.push('deployment configuration outside the repository (environment, secrets stores, cloud policy) was not collected');
|
|
74
|
+
return { spans, gaps };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── callers ─────────────────────────────────────────────────────────────────
|
|
78
|
+
const NOT_A_NAME = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'else', 'do', 'try', 'new', 'typeof', 'await',
|
|
79
|
+
'with', 'sizeof', 'elif', 'except', 'when', 'match', 'case', 'super', 'this', 'self', 'import', 'require', 'where', 'let', 'in']);
|
|
80
|
+
// Lines carrying a CWE-1333 pragma: bounded by MAX_DEF_LINE below, so the polynomial worst case is capped per line.
|
|
81
|
+
const LANGS = [
|
|
82
|
+
{ ext: /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i, defs: [/\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/, /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/, /^\s*(?:(?:public|private|protected|static|async|get|set)\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/] }, // agentic-security-ignore: CWE-1333
|
|
83
|
+
{ ext: /\.py$/i, defs: [/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/] },
|
|
84
|
+
{ ext: /\.rb$/i, defs: [/^\s*def\s+(?:self\.)?([A-Za-z_]\w*[?!]?)/] },
|
|
85
|
+
{ ext: /\.go$/i, defs: [/^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*[[(]/] },
|
|
86
|
+
{ ext: /\.rs$/i, defs: [/\bfn\s+([A-Za-z_]\w*)/] },
|
|
87
|
+
{ ext: /\.php$/i, defs: [/\bfunction\s+([A-Za-z_]\w*)\s*\(/] },
|
|
88
|
+
{ ext: /\.(?:java|kt|kts|cs|scala)$/i, defs: [/^\s*(?:(?:public|private|protected|internal|static|final|override|suspend|async|virtual|abstract|synchronized|open)\s+)*(?:fun\s+|def\s+)?(?:[\w<>\[\],.?]+\s+)?([A-Za-z_]\w*)\s*\([^;]*$/] }, // agentic-security-ignore: CWE-1333
|
|
89
|
+
{ ext: /\.hs$/i, defs: [/^([a-z_][\w']*)\s*::/, /^([a-z_][\w']*)\b[^=\n]*=/], bare: true },
|
|
90
|
+
];
|
|
91
|
+
const langOf = (file) => LANGS.find((l) => l.ext.test(file)) || null;
|
|
92
|
+
const escapeRe = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
93
|
+
// The definition patterns run over scanned (untrusted) source. None backtracks exponentially, but the modifier and type groups can go
|
|
94
|
+
// quadratic on a long line, so a line longer than any real definition header is never matched.
|
|
95
|
+
const MAX_DEF_LINE = 400;
|
|
96
|
+
const defName = (lang, line) => {
|
|
97
|
+
if (line.length > MAX_DEF_LINE) return null;
|
|
98
|
+
for (const re of lang.defs) { const m = re.exec(line); if (m && !NOT_A_NAME.has(m[1])) return m[1]; }
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** The nearest function definition at or above `line` (1-based), within 400 lines. */
|
|
103
|
+
export function enclosingFunction(file, code, line) {
|
|
104
|
+
const lang = langOf(file);
|
|
105
|
+
if (!lang) return null;
|
|
106
|
+
const lines = code.split('\n');
|
|
107
|
+
for (let i = Math.min(line, lines.length) - 1; i >= 0 && i >= line - 400; i--) {
|
|
108
|
+
const name = defName(lang, lines[i]);
|
|
109
|
+
if (name) return { name, line: i + 1 };
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function collectCallers(finding, fileContents, { maxCallers, stepWindow, add, gaps }) {
|
|
115
|
+
const lang = langOf(finding.file);
|
|
116
|
+
const fn = lang ? enclosingFunction(finding.file, fileContents[finding.file], finding.line) : null;
|
|
117
|
+
if (!fn) { gaps.push('the enclosing function could not be determined, so callers were not collected'); return; }
|
|
118
|
+
const call = lang.bare ? new RegExp(`(?<![\\w'.])${escapeRe(fn.name)}(?![\\w'])`) : new RegExp(`(?<![\\w$])${escapeRe(fn.name)}\\s*\\(`);
|
|
119
|
+
const sites = [];
|
|
120
|
+
for (const [file, code] of Object.entries(fileContents)) {
|
|
121
|
+
if (typeof code !== 'string' || code.length > 1_000_000 || !lang.ext.test(file) || !code.includes(fn.name)) continue;
|
|
122
|
+
code.split('\n').forEach((text, i) => {
|
|
123
|
+
if (!call.test(text) || defName(lang, text) === fn.name) return; // the definition (or a signature) is not a call
|
|
124
|
+
if (file === finding.file && i + 1 >= fn.line && i + 1 <= finding.line) return; // recursion inside the finding's own function
|
|
125
|
+
sites.push({ file, line: i + 1 });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (!sites.length) {
|
|
129
|
+
gaps.push(`no call site of ${fn.name} was found by name in the scanned files of the same language (it may be an entry point, exported, or called dynamically)`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const s of sites.slice(0, maxCallers)) add('caller', s.file, s.line, stepWindow);
|
|
133
|
+
gaps.push(`callers of ${fn.name} were matched by name, not resolved: a same-named function can appear, and calls through aliases, callbacks or dynamic dispatch are missed`);
|
|
134
|
+
if (sites.length > maxCallers) gaps.push(`${sites.length - maxCallers} further call site(s) of ${fn.name} were not sent`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── configuration ───────────────────────────────────────────────────────────
|
|
138
|
+
const ENV_READS = [
|
|
139
|
+
/process\.env\.([A-Za-z_][\w]*)/g, /process\.env\[\s*['"]([^'"]+)['"]\s*\]/g, /os\.environ(?:\.get)?\s*[[(]\s*['"]([^'"]+)['"]/g,
|
|
140
|
+
/\bgetenv\(\s*['"]([^'"]+)['"]/g, /\b(?:getEnv|lookupEnv)\s+"([^"]+)"/g, /System\.getenv\(\s*"([^"]+)"/g,
|
|
141
|
+
/\bENV(?:\.fetch\(\s*|\[\s*)['"]([^'"]+)['"]/g, /os\.(?:Getenv|LookupEnv)\(\s*"([^"]+)"/g, /env::var\(\s*"([^"]+)"/g,
|
|
142
|
+
];
|
|
143
|
+
const CONFIG_FILE = /(?:^|\/)(?:\.env\.(?:example|sample|template|dist)|[^/]+\.(?:ya?ml|toml|ini|conf|cfg|properties|nix)|docker-compose[^/]*|Dockerfile[^/]*|app\.json)$/i;
|
|
144
|
+
const SECRET_KEY = /secret|token|passw|pwd|credential|private|api[_-]?key|auth/i;
|
|
145
|
+
// Masks the value of a secret-like key; the generic redaction then runs over the whole span.
|
|
146
|
+
const maskSecretValues = (text) => text.replace(/^(\s*(?:export\s+|-\s+)?["']?([\w.-]+)["']?\s*[:=]\s*)(.+)$/, (all, head, key) => (SECRET_KEY.test(key) ? `${head}<masked>` : all));
|
|
147
|
+
|
|
148
|
+
function collectConfiguration(finding, fileContents, { window, maxConfig, add, gaps }) {
|
|
149
|
+
const lines = fileContents[finding.file].split('\n');
|
|
150
|
+
const span = lines.slice(Math.max(0, finding.line - 1 - window), finding.line + window).join('\n');
|
|
151
|
+
const keys = new Set();
|
|
152
|
+
for (const re of ENV_READS) for (const m of span.matchAll(re)) keys.add(m[1]);
|
|
153
|
+
if (!keys.size) { gaps.push("no configuration key is read near the finding, so no configuration file was collected"); return; }
|
|
154
|
+
const found = new Set(); let sent = 0; let more = 0;
|
|
155
|
+
for (const [file, code] of Object.entries(fileContents)) {
|
|
156
|
+
if (typeof code !== 'string' || !CONFIG_FILE.test(file) || code.length > 500_000) continue;
|
|
157
|
+
const cl = code.split('\n');
|
|
158
|
+
for (const key of keys) {
|
|
159
|
+
const re = new RegExp(`(?<![\\w])${escapeRe(key)}(?![\\w])`);
|
|
160
|
+
const i = cl.findIndex((t) => re.test(t));
|
|
161
|
+
if (i < 0) continue;
|
|
162
|
+
found.add(key);
|
|
163
|
+
if (sent < maxConfig) { add('configuration', file, i + 1, 1, maskSecretValues); sent++; } else more++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const missing = [...keys].filter((k) => !found.has(k)).sort();
|
|
167
|
+
if (missing.length) gaps.push(`configuration key(s) ${missing.join(', ')} read near the finding are set in no scanned configuration file (they may be set at deploy time)`);
|
|
168
|
+
if (more) gaps.push(`${more} further configuration match(es) were not sent`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Render spans as the untrusted excerpt block. */
|
|
172
|
+
export function renderSpans(spans) {
|
|
173
|
+
return spans.map((s) => `[${s.id} ${s.role}${s.alsoRoles ? `+${s.alsoRoles.join('+')}` : ''}] ${s.file}:${s.start}-${s.end}\n${s.text}`).join('\n\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Check a reply's citations against the spans that were sent. */
|
|
177
|
+
export function checkCitations(citations, spans) {
|
|
178
|
+
if (citations === undefined || citations === null || (Array.isArray(citations) && citations.length === 0)) return { status: 'none', resolved: [], invalid: [] };
|
|
179
|
+
if (!Array.isArray(citations)) return { status: 'invalid', resolved: [], invalid: [String(citations).slice(0, 40)] };
|
|
180
|
+
const byId = new Map(spans.map((s) => [s.id, s]));
|
|
181
|
+
const resolved = []; const invalid = [];
|
|
182
|
+
for (const c of citations.slice(0, 20)) {
|
|
183
|
+
const m = /^\s*(S\d+)\s*:\s*(\d+)\s*$/.exec(String(c));
|
|
184
|
+
const s = m && byId.get(m[1]);
|
|
185
|
+
const line = m ? Number(m[2]) : NaN;
|
|
186
|
+
if (s && line >= s.start && line <= s.end) resolved.push({ span: s.id, file: s.file, line });
|
|
187
|
+
else invalid.push(String(c).slice(0, 40));
|
|
188
|
+
}
|
|
189
|
+
return { status: invalid.length ? 'invalid' : 'valid', resolved, invalid };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The span list as recorded on a finding: bounds and roles, never the text. */
|
|
193
|
+
export const spanRecord = (spans) => spans.map(({ text, ...rest }) => rest);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Equivalent detector claims (PRD FR 05 validation flow, FR 07 "group multiple detector claims without merging independent sinks").
|
|
2
|
+
//
|
|
3
|
+
// Two detectors often report the same weakness at the same place: a regex rule and a language analyzer both see `pickle.loads(x)`,
|
|
4
|
+
// a structural rule and a password-hash rule both see `createHash('md5')` on a password. Shown twice, one weakness is counted twice
|
|
5
|
+
// and reviewed twice. Grouped, it is one actionable result that names every detector that found it.
|
|
6
|
+
//
|
|
7
|
+
// Claims are equivalent only when ALL of these hold:
|
|
8
|
+
// - same report category (kind), same file, same integer line, same CWE
|
|
9
|
+
// - neither is a composite (chain) claim
|
|
10
|
+
// - different detectors (parser). Two claims from ONE detector at one line are two findings it chose to emit (two calls, two
|
|
11
|
+
// sources), so they are never merged here.
|
|
12
|
+
// - neither names a sink line the other contradicts (independent sinks stay apart)
|
|
13
|
+
// - neither is a dependency finding
|
|
14
|
+
// The primary is the claim that locates the most of the path (source and sink, then the sink alone), then one the IR taint engine
|
|
15
|
+
// produced over a lexical rule, then the highest severity, then the lowest id, so the choice is deterministic. Nothing is deleted: every grouped claim is returned for the raw export, pointing at its primary.
|
|
16
|
+
|
|
17
|
+
const SEV = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
18
|
+
|
|
19
|
+
// A composite claim (an attack chain, an agent chain) asserts that several weaknesses combine; it is never the same claim as one of
|
|
20
|
+
// its hops, even where it is anchored on that hop's line.
|
|
21
|
+
const isComposite = (f) => Array.isArray(f.chain) || f.parser === 'CHAIN' || /chain/i.test(String(f.family || ''));
|
|
22
|
+
|
|
23
|
+
function groupKey(f) {
|
|
24
|
+
if (!f || typeof f !== 'object' || f.kind === 'sca' || f.type === 'vulnerable_dep' || isComposite(f)) return null;
|
|
25
|
+
if (!Number.isInteger(f.line) || f.line <= 0 || !f.file || !f.cwe) return null;
|
|
26
|
+
return `${f.kind || 'sast'}\0${f.file}\0${f.line}\0${f.cwe}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// How much of the path a claim locates (both ends, the sink alone, or neither), then whether the IR taint engine located it
|
|
30
|
+
// (semantic) or a lexical rule did. A reviewer starts from the claim that points at the most code.
|
|
31
|
+
const IR_TRACED = new Set(['IR-TAINT', 'MULTI-SINK', 'IR-PRIVACY-TAINT']);
|
|
32
|
+
const located = (f) => (f.source && f.sink ? 2 : f.sink ? 1 : 0);
|
|
33
|
+
|
|
34
|
+
function rank(a, b) {
|
|
35
|
+
const l = located(b) - located(a);
|
|
36
|
+
if (l) return l;
|
|
37
|
+
const p = (IR_TRACED.has(b.parser) ? 1 : 0) - (IR_TRACED.has(a.parser) ? 1 : 0);
|
|
38
|
+
if (p) return p;
|
|
39
|
+
const s = (SEV[b.severity] ?? -1) - (SEV[a.severity] ?? -1);
|
|
40
|
+
if (s) return s;
|
|
41
|
+
return String(a.stableId || a.id || '').localeCompare(String(b.stableId || b.id || ''));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sinkLine = (f) => (f.sink && Number.isInteger(f.sink.line) ? f.sink.line : null);
|
|
45
|
+
|
|
46
|
+
const claimOf = (f) => ({ id: f.id ?? null, stableId: f.stableId ?? null, parser: f.parser ?? null, vuln: f.vuln ?? null, severity: f.severity ?? null });
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Group equivalent claims. Returns { kept, grouped, groups } where `kept` is the input order with grouped members removed, `grouped`
|
|
50
|
+
* holds each removed claim (annotated `groupedInto`), and `groups` counts the groups formed. The primary gains `claims` (every
|
|
51
|
+
* claim in its group, itself first). Severity and confidence are never changed.
|
|
52
|
+
*/
|
|
53
|
+
export function groupEquivalentClaims(findings) {
|
|
54
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
55
|
+
const byKey = new Map();
|
|
56
|
+
for (const f of list) {
|
|
57
|
+
const k = groupKey(f);
|
|
58
|
+
if (k === null) continue;
|
|
59
|
+
if (!byKey.has(k)) byKey.set(k, []);
|
|
60
|
+
byKey.get(k).push(f);
|
|
61
|
+
}
|
|
62
|
+
const drop = new Set();
|
|
63
|
+
const grouped = [];
|
|
64
|
+
let groups = 0;
|
|
65
|
+
for (const members of byKey.values()) {
|
|
66
|
+
if (members.length < 2) continue;
|
|
67
|
+
const sorted = [...members].sort(rank);
|
|
68
|
+
const primary = sorted[0];
|
|
69
|
+
const taken = [primary];
|
|
70
|
+
const seenParsers = new Set([primary.parser]);
|
|
71
|
+
for (const m of sorted.slice(1)) {
|
|
72
|
+
if (seenParsers.has(m.parser)) continue;
|
|
73
|
+
const a = sinkLine(primary); const b = sinkLine(m);
|
|
74
|
+
if (a !== null && b !== null && a !== b) continue;
|
|
75
|
+
seenParsers.add(m.parser);
|
|
76
|
+
taken.push(m);
|
|
77
|
+
}
|
|
78
|
+
if (taken.length < 2) continue;
|
|
79
|
+
groups++;
|
|
80
|
+
primary.claims = taken.map(claimOf);
|
|
81
|
+
for (const m of taken.slice(1)) {
|
|
82
|
+
m.groupedInto = primary.stableId || primary.id || null;
|
|
83
|
+
drop.add(m);
|
|
84
|
+
grouped.push(m);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { kept: list.filter((f) => !drop.has(f)), grouped, groups };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One reconciliation of the candidate counts, so no candidate is counted twice:
|
|
92
|
+
* raw = grouped + refuted + (confirmed + needs-review + hardening + unvalidated)
|
|
93
|
+
* `suppressed` counts candidates removed before this stage (pragmas, rules, accepted risk); it is reported beside raw, not inside it.
|
|
94
|
+
*/
|
|
95
|
+
export function reconcileCounts({ raw, grouped, refuted, dispositionCounts, suppressed = 0 }) {
|
|
96
|
+
const d = dispositionCounts || {};
|
|
97
|
+
const active = ['confirmed', 'needs-review', 'hardening', 'unvalidated'].reduce((n, k) => n + (d[k] || 0), 0);
|
|
98
|
+
const out = {
|
|
99
|
+
raw, grouped, refuted, deduplicated: raw - grouped,
|
|
100
|
+
confirmed: d.confirmed || 0, needsReview: d['needs-review'] || 0, hardening: d.hardening || 0, unvalidated: d.unvalidated || 0,
|
|
101
|
+
suppressedBeforeValidation: suppressed,
|
|
102
|
+
};
|
|
103
|
+
out.balanced = raw === grouped + refuted + active;
|
|
104
|
+
return out;
|
|
105
|
+
}
|