@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/agentic-security.js +41 -4
  3. package/dist/5856.index.js +634 -0
  4. package/dist/7033.index.js +74 -14
  5. package/dist/9220.index.js +24 -1
  6. package/dist/agentic-security.mjs +9 -9
  7. package/dist/agentic-security.mjs.sha256 +1 -1
  8. package/dist/{90ebba38d8860ec77376.json → c06a1b4c20155c06fb48.json} +4 -3
  9. package/package.json +4 -3
  10. package/src/dataflow/catalog.js +42 -1
  11. package/src/engine.js +91 -11
  12. package/src/language/CLAUDE.md +7 -1
  13. package/src/language/assurance.js +53 -2
  14. package/src/language/haskell-budget-state.js +85 -0
  15. package/src/language/haskell-coverage.js +230 -0
  16. package/src/language/haskell-guards.js +26 -5
  17. package/src/language/haskell-project-models.js +107 -0
  18. package/src/language/nix-eval-isolation.js +74 -14
  19. package/src/llm-validator/bounded-context.js +193 -0
  20. package/src/llm-validator/claim-groups.js +105 -0
  21. package/src/llm-validator/disposition.js +264 -0
  22. package/src/llm-validator/index.js +52 -26
  23. package/src/pipeline/evidence-record.js +112 -0
  24. package/src/pipeline/scan-health.js +12 -1
  25. package/src/posture/artifact-registry.js +2 -0
  26. package/src/posture/drift.js +19 -1
  27. package/src/posture/reachability-filter.js +3 -3
  28. package/src/posture/suppressions.js +21 -0
  29. package/src/posture/watch-mode.js +22 -1
  30. package/src/report/disposition-view.js +90 -0
  31. package/src/report/index.js +47 -0
  32. package/src/runScan.js +60 -6
  33. package/src/sast/oidc-trust.js +74 -0
  34. package/src/sast/pipeline.js +14 -1
  35. package/src/sast/workflow-flow.js +272 -0
  36. package/src/sca/basis.js +88 -0
  37. package/src/scope/flags.js +55 -0
  38. 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
- function rssOfGroup(pgid) {
103
- const r = spawnSync('/bin/ps', ['-o', 'rss=', '-g', String(pgid)], { encoding: 'utf8', timeout: 2000 });
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
- return r.stdout.split('\n').map((x) => parseInt(x, 10)).filter(Number.isFinite).reduce((a, b) => a + b, 0) / 1024;
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 && rssOfGroup(child.pid) > maxRssMb) stop('resource-limit'); }, 250);
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
+ }