@clear-capabilities/agentic-security-scanner 0.188.0 → 0.189.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.
@@ -0,0 +1,84 @@
1
+ // Project-reachability adjudication of Haskell taint findings.
2
+ //
3
+ // `haskell-ir.js` `projectReachability` stamps every function with `hs.reachability {status, rootKind, holeReason}`: could any caller exist, judged from
4
+ // the project's entry points over the call graph? This module reads that verdict for the function a taint finding was found in and records it on the
5
+ // finding as `reachability {status, reason, premise, roots, holeReason}`. It is a SEPARATE record from `trustBoundary`: that one answers who controls
6
+ // the input, this one answers whether a caller can exist, and either may be `unknown` independently.
7
+ //
8
+ // status entrypoint-reachable | unreachable-in-project | external-api | unknown
9
+ // premise what the finding's own claim rests on:
10
+ // caller-controlled-parameter the only origin recorded is the assumption that an untrusted caller supplies a parameter of
11
+ // an exported function (the weak label, `hs:caller-controlled`)
12
+ // recorded-read a real environment, command-line, file, standard-input, socket or request read is on the chain
13
+ //
14
+ // What the verdict does to the finding:
15
+ // unreachable-in-project and premise caller-controlled-parameter the premise is withdrawn (an entry-point-rooted walk over a hole-free graph says
16
+ // there is no caller at all): lowered to `info`, never dropped (`lowerFinding`, `_reachLowered`)
17
+ // unreachable-in-project and premise recorded-read NOT lowered. "Nothing calls this" is a claim about a call graph in a language where typeclass
18
+ // dispatch, re-exports and point-free aliasing routinely defeat call graphs; being wrong costs a real
19
+ // vulnerability, so it may cost rank (`posture/relevance.js` reads `f.reachability`) and not visibility
20
+ // unknown, external-api, entrypoint-reachable nothing happens to the severity
21
+ //
22
+ // Nothing here reads a comment, a name or a path: the inputs are the call-graph verdict and the origins the taint engine recorded.
23
+
24
+ import { lowerFinding, originClasses } from './haskell-trust.js';
25
+
26
+ const HS_FILE = /\.(?:l?hs|hsc)$/i;
27
+
28
+ const fnByQid = (perFile, qid) => {
29
+ if (!qid) return null;
30
+ const file = String(qid).split('::')[0];
31
+ const ir = perFile && perFile[file];
32
+ return ir ? ir.functions.find((x) => x.qid === qid) || null : null;
33
+ };
34
+
35
+ const REASONS = Object.freeze({
36
+ 'entrypoint-reachable': 'a call path from a project entry point reaches the function',
37
+ 'external-api': 'the function belongs to a module the package exposes, so its callers are outside the analysed code',
38
+ 'unreachable-in-project': 'no entry point of the project reaches the function through the call graph (complete scan, hole-free graph, a main and a manifest present, module not exposed)',
39
+ });
40
+ const HOLE_TEXT = Object.freeze({
41
+ 'partial-scan': 'the scan covered only part of the project',
42
+ 'policy-excluded': 'Haskell sources were kept out of the scan by the path policy',
43
+ 'size-skipped': 'Haskell sources were too large to read',
44
+ 'no-entrypoint': 'the analysed files contain no `main`, so there is no entry point to walk from',
45
+ 'no-manifest': 'no manifest says which modules the package exposes',
46
+ 'parse-failure': 'a declaration could not be read, so its calls are missing from the graph',
47
+ 'function-cap': 'functions past the per-file cap are not in the graph',
48
+ 'opaque-boundary': 'Template Haskell, a quasi-quote, a foreign export, a build step that generates code or a source format the IR does not read can call functions the graph does not show',
49
+ 'unresolved-call': 'a function reachable from an entry point makes a call the IR could not resolve',
50
+ 'instance-method': 'an instance method can be invoked by anything that can see its type',
51
+ 'unresolved-route': 'a route registration could not be resolved',
52
+ });
53
+
54
+ /** What the finding's own claim rests on (see the header). `null` when the recorded origins do not settle it. */
55
+ function premiseOf(f) {
56
+ const { roots, classes } = originClasses(f);
57
+ if (!roots.length) return null;
58
+ if ([...classes].every((c) => c === 'weak-param')) return 'caller-controlled-parameter';
59
+ if ([...classes].some((c) => c === 'operator' || c === 'external' || c === 'data')) return 'recorded-read';
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Stamps `reachability` on every Haskell IR-TAINT finding that has a verdict for its function, and lowers to `info` the ones whose only premise
65
+ * (a caller-controlled parameter) the verdict withdraws. Mutates the findings; returns counts.
66
+ */
67
+ export function applyHaskellReachability(findings, perFile) {
68
+ const stats = { 'entrypoint-reachable': 0, 'unreachable-in-project': 0, 'external-api': 0, unknown: 0, lowered: 0, rankOnly: 0, unstamped: 0 };
69
+ for (const f of findings || []) {
70
+ if (!f || f.parser !== 'IR-TAINT' || !HS_FILE.test(String(f.file || ''))) continue;
71
+ const fn = fnByQid(perFile, f._funcQid);
72
+ const r = fn && fn.hs && fn.hs.reachability;
73
+ if (!r || !r.status) { stats.unstamped++; continue; }
74
+ const premise = premiseOf(f);
75
+ const reason = r.status === 'unknown' ? (HOLE_TEXT[r.holeReason] || 'the call graph cannot settle it') : REASONS[r.status];
76
+ f.reachability = { status: r.status, reason, premise, roots: r.rootKind ? [r.rootKind] : [], holeReason: r.holeReason || null };
77
+ stats[r.status] = (stats[r.status] || 0) + 1;
78
+ if (r.status !== 'unreachable-in-project') continue;
79
+ if (premise === 'caller-controlled-parameter') {
80
+ if (lowerFinding(f, { kind: 'unreachable-in-project', reason: 'the finding rests on a caller of an exported function supplying a parameter, and nothing in the project can call it', controlKind: 'no-caller-in-project', guard: '_reachLowered', keepNote: true })) stats.lowered++;
81
+ } else if (premise === 'recorded-read') stats.rankOnly++;
82
+ }
83
+ return stats;
84
+ }
@@ -162,6 +162,26 @@ function snippetOf(text, line) {
162
162
  // socket or connection handle is filled by the peer. A read of standard input is the operator's in a program's own control flow, and
163
163
  // the caller's only in an entry point nothing in the analysed code calls (a library function handing back whatever its caller pipes in).
164
164
  const NETWORK_HANDLE_CALL = /(?:^|\.)(?:socketToHandle|connectTo|accept|acceptOn|openTCPConnection)$/;
165
+
166
+ /**
167
+ * Are this function's callers outside the analysed code? With no project-reachability verdict, or an `unknown` one, the answer is exactly what it
168
+ * has always been (nothing in the analysed code calls or references it). A function no caller can reach (`unreachable-in-project`) has none. One
169
+ * reached from an entry point takes its parameters from that path, EXCEPT a root nothing in the analysed code calls (a framework handler, a foreign
170
+ * export, code the parser withheld): its caller is outside.
171
+ *
172
+ * A function of an exposed module (`external-api`) is DELIBERATELY answered as today: the plan for this change said "a public API's callers are
173
+ * external, so true", but for an exposed function that something in the project also calls that turns on the partial-function and unbounded-read
174
+ * rules for every such function, and on the development set it added seven flagged sites and no true positive (precision 35.7% -> 23.8%). The
175
+ * status is still recorded and still keeps the taint finding at full severity; it just does not widen these two rules. Flipping it is the one line
176
+ * below, with that measurement as the cost.
177
+ */
178
+ export function callersUnknown(fn) {
179
+ const hs = (fn && fn.hs) || {};
180
+ const r = hs.reachability || null;
181
+ if (!r || r.status === 'unknown' || r.status === 'external-api') return !hs.reached;
182
+ if (r.status === 'entrypoint-reachable') return !hs.reached && r.rootKind !== 'main';
183
+ return false;
184
+ }
165
185
  function readsOutsideInput(fn, callee, args, importsNetwork) {
166
186
  const h = args && args[0];
167
187
  const isStdin = !!h && h.kind === 'ident' && /(?:^|\.)stdin$/.test(h.name || '');
@@ -169,7 +189,7 @@ function readsOutsideInput(fn, callee, args, importsNetwork) {
169
189
  // the networking packages are not all in the model registry, so a call keeps its bare name; the module imports say which library it is
170
190
  if (handleRead) return !!importsNetwork && (fn.calls || []).some((x) => typeof x.callee === 'string' && NETWORK_HANDLE_CALL.test(x.callee));
171
191
  const hs = fn.hs || {};
172
- return !hs.reached && !/(?:^|\.)main$/.test(fn.name || '');
192
+ return callersUnknown(fn) && !/(?:^|\.)main$/.test(fn.name || '');
173
193
  }
174
194
 
175
195
  const EQUALITY_OPS = new Set(['==', '/=', '!=']);
@@ -244,7 +264,7 @@ export function analyzeHaskellRules(files, opts = {}) {
244
264
  const notes = [];
245
265
  if (!Object.keys(hs).length) return { findings, controls, notes };
246
266
 
247
- const ir = buildHaskellIR(hs);
267
+ const ir = buildHaskellIR(hs, opts.reachability || {});
248
268
 
249
269
  // project-level facts
250
270
  let requestLimit = null;
@@ -291,7 +311,7 @@ export function analyzeHaskellRules(files, opts = {}) {
291
311
  // calls cannot say the text comes from outside.
292
312
  const fnHs = fn.hs || {};
293
313
  const strongParam = new Set((fnHs.paramSources || []).filter((p) => /^hs:handler-param:/.test(p.decorator || '')).map((p) => p.name));
294
- const entryLike = !fnHs.reached;
314
+ const entryLike = callersUnknown(fn);
295
315
  const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
296
316
  if (derivedText.size) {
297
317
  for (let round = 0; round < 8; round++) {
@@ -840,7 +840,8 @@ export function parseSyntax(src, opts = {}) {
840
840
  // `foreign import ccall unsafe "string.h strlen" c_strlen :: ...` names the Haskell-side function.
841
841
  let fname = null;
842
842
  for (let q = a; q < b; q++) if (isRop(T[q], '::')) { if (q > a && T[q - 1].t === 'var') fname = T[q - 1].v; break; }
843
- res.boundaries.push({ kind: 'ffi', line: t0.line, detail: 'foreign declaration; the foreign code is not analyzed', ...(fname ? { name: fname } : {}) });
843
+ const dir = T[a + 1] && (T[a + 1].v === 'import' || T[a + 1].v === 'export') ? T[a + 1].v : null;
844
+ res.boundaries.push({ kind: 'ffi', line: t0.line, detail: 'foreign declaration; the foreign code is not analyzed', ...(dir ? { direction: dir } : {}), ...(fname ? { name: fname } : {}) });
844
845
  continue;
845
846
  }
846
847
  if (t0.v === 'class' || t0.v === 'instance') {
@@ -217,6 +217,28 @@ function findCallsTo(fn, target) {
217
217
  return out;
218
218
  }
219
219
 
220
+ /**
221
+ * Lower a finding to `info` and say why, without dropping it: the severity it would have had is kept (`originalSeverity`), a control records the
222
+ * reason (`controls`), and `guardNote` is set (with `keepNote`, only when no earlier pass set one). Idempotent per `guard` (a finding is lowered once by each reason), and two
223
+ * reasons compose: the second finds the severity already `info`, keeps the original severity the first recorded, and adds its own control.
224
+ * Used by the trust-boundary pass (`_trustLowered`) and the reachability pass (`_reachLowered`).
225
+ */
226
+ export function lowerFinding(f, { kind, reason, controlKind, guard, keepNote = false }) {
227
+ if (!f || !guard || f[guard]) return false;
228
+ f[guard] = true;
229
+ f.originalSeverity = f.originalSeverity || f.severity;
230
+ f.severity = 'info';
231
+ f.controls = [...(f.controls || []), { kind: controlKind, evidenceKind: 'source' }];
232
+ if (!keepNote || !f.guardNote) f.guardNote = { kind, reason };
233
+ return true;
234
+ }
235
+
236
+ /** The classes of origin a finding's recorded roots fall in (`operator`, `external`, `data`, `weak-param`, `other`), and the roots themselves. */
237
+ export function originClasses(f) {
238
+ const roots = rootsOf(f);
239
+ return { roots, classes: new Set(roots.map(classOf)) };
240
+ }
241
+
220
242
  /**
221
243
  * Stamps `trustBoundary` on every Haskell IR-TAINT finding and lowers to `info` the verdicts that name no boundary crossed
222
244
  * (`operator-controlled`, `api-contract`). Mutates the findings; returns counts.
@@ -256,12 +278,8 @@ export function applyTrustBoundary(findings, perFile) {
256
278
  f.trustBoundary = { verdict, reason, role, ...(inferred ? { inferred: true } : {}), roots: roots.slice(0, 5) };
257
279
  const key = verdict.replace(/-(\w)/g, (_, c) => c.toUpperCase());
258
280
  stats[key] = (stats[key] || 0) + 1;
259
- if ((verdict === 'operator-controlled' || verdict === 'api-contract') && !f._trustLowered) {
260
- f._trustLowered = true;
261
- f.originalSeverity = f.originalSeverity || f.severity;
262
- f.severity = 'info';
263
- f.controls = [...(f.controls || []), { kind: verdict === 'operator-controlled' ? 'operator-controlled-input' : 'api-contract-wrapper', evidenceKind: 'source' }];
264
- f.guardNote = { kind: verdict, reason };
281
+ if (verdict === 'operator-controlled' || verdict === 'api-contract') {
282
+ lowerFinding(f, { kind: verdict, reason, controlKind: verdict === 'operator-controlled' ? 'operator-controlled-input' : 'api-contract-wrapper', guard: '_trustLowered' });
265
283
  }
266
284
  }
267
285
  return stats;
@@ -83,9 +83,9 @@ const strLit = (e) => { e = unparen(e); return e && e.t === 'lit' && e.kind ===
83
83
 
84
84
  // ── analysis context ─────────────────────────────────────────────────────────
85
85
  class Ctx {
86
- constructor(files) {
86
+ constructor(files, reachability) {
87
87
  this.files = files;
88
- this.ir = buildHaskellIR(files);
88
+ this.ir = buildHaskellIR(files, reachability || {});
89
89
  this.project = this.ir.project;
90
90
  this.modules = [...this.ir.modules.values()];
91
91
  this.fnByQid = new Map();
@@ -837,7 +837,7 @@ export function analyzeHaskellWeb(files, opts = {}) {
837
837
  for (const [f, t] of Object.entries(files || {})) if (/\.hs$/i.test(f) && typeof t === 'string') hs[f] = t;
838
838
  const result = { routes: [], findings: [], middleware: [], frameworks: [], coverage: {}, gaps: [], modelVersion: HS_WEB_MODEL_VERSION, rulesetVersion: HS_WEB_RULES_VERSION, versionStatus: {} };
839
839
  if (!Object.keys(hs).length) return result;
840
- const ctx = new Ctx(hs);
840
+ const ctx = new Ctx(hs, opts.reachability);
841
841
  const pending = [];
842
842
  const add = (r) => pending.push(r);
843
843
 
@@ -11,8 +11,19 @@
11
11
  // The container is named, and removed (`rm -f`) on every exit path including timeout and cancellation, because killing the `docker`
12
12
  // client does not by itself stop the container.
13
13
  //
14
- // HONEST LIMIT: this has been exercised against a fake engine that records the arguments and runs the command locally, which proves
15
- // the plumbing and the flags. It has not been run against a real Docker or Podman daemon in this repository's test environment.
14
+ // WHAT HAS AND HAS NOT BEEN RUN. The arguments are pinned flag by flag against a fake engine that records them. Against a REAL engine it has
15
+ // run: bench/proof-fabric/RESULT-linux-container.json records 72 container runs (36 vulnerable reproducers confirmed, 0 false confirmations, the
16
+ // timeouts degraded as designed) on Docker 28.0.4 on a Linux x64 runner, over the gcc, eclipse-temurin:21, node:24-slim and python:3-slim images
17
+ // (the Temurin run is what found the `javac: not found` PATH problem above), and the `proof-container` CI job runs it on every change. The
18
+ // residual limits, none of them closed by that run:
19
+ // * ONE engine and ONE version on ONE host class. Other Docker versions, rootless Docker and other kernels are unmeasured.
20
+ // * Podman is not probed: it is a supported engine name, and no run against it exists.
21
+ // * macOS is unmeasured. Docker Desktop runs containers in a VM, so bind mounts, uids and file ownership behave differently from the Linux
22
+ // measurement; no macOS container run has been recorded.
23
+ // * The haskell:9 image is wired (CONTAINER_TOOLS, CONTAINER_PATH) but its toolchain layout for a non-root user was never run against a real
24
+ // engine; the measurement skips Haskell unless AGENTIC_SECURITY_PROOF_CONTAINER_HASKELL=1 (bench/proof-fabric/measure.mjs). The result file
25
+ // names no .NET image either, so the dotnet SDK image is unverified in the same way.
26
+ // * A reproducer that needs the loopback callback listener (SSRF) cannot run with `--network none` and is skipped, not measured.
16
27
  import { spawnSync } from 'node:child_process';
17
28
  import { superviseSpawn } from '../../sandbox/supervise.js';
18
29
  import { scrubEnv } from '../../sandbox/trust-domains.js';
@@ -262,6 +262,21 @@ function _threatBonus(f, threatModel, factors) {
262
262
  return bonus;
263
263
  }
264
264
 
265
+ // A language that reasons about its own call graph (Haskell: `language/haskell-reachability.js`) states `f.reachability = {status, reason, premise,
266
+ // roots, holeReason}`. That verdict is a statement about the FUNCTION, from the project's entry points, so it outranks this module's file-level
267
+ // import graph, which has no Haskell extraction. Only a POSITIVE verdict moves the tier; `unknown` (and any finding without one) falls through
268
+ // to the file-level logic unchanged. It re-ranks only: severity is never touched here (a lowering, where one is justified, is the language
269
+ // pass's own decision and is recorded on the finding).
270
+ const LANGUAGE_STATUSES = new Set(['entrypoint-reachable', 'unreachable-in-project', 'external-api', 'unknown']);
271
+ function languageVerdict(f) {
272
+ const r = f && f.reachability;
273
+ if (!r || typeof r !== 'object' || !LANGUAGE_STATUSES.has(r.status) || !Object.hasOwn(r, 'holeReason')) return null;
274
+ if (r.status === 'unreachable-in-project') return { tier: 'unreachable', reachable: false, factor: `no call path from any entry point of the project (${r.reason || 'call graph complete'})` };
275
+ if (r.status === 'entrypoint-reachable') return { tier: 'indirect', reachable: true, factor: 'reachable from a project entry point through the call graph' };
276
+ if (r.status === 'external-api') return { tier: 'indirect', reachable: true, factor: 'part of the package\'s exposed API: its callers are outside the analysed code' };
277
+ return null;
278
+ }
279
+
265
280
  /**
266
281
  * Pure scorer. Does NOT mutate the finding.
267
282
  *
@@ -280,7 +295,12 @@ function scoreRelevance(f, graph, threatModel) {
280
295
 
281
296
  let tier;
282
297
  let reachable;
283
- if (!file || entryFiles.size === 0) {
298
+ const lang = languageVerdict(f);
299
+ if (lang && !(lang.tier === 'indirect' && file && entryFiles.has(file))) {
300
+ tier = lang.tier;
301
+ reachable = lang.reachable;
302
+ factors.push(lang.factor);
303
+ } else if (!file || entryFiles.size === 0) {
284
304
  tier = 'unknown';
285
305
  reachable = null;
286
306
  factors.push(entryFiles.size === 0