@clear-capabilities/agentic-security-scanner 0.187.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,286 @@
1
+ // Trust-boundary adjudication of Haskell taint findings.
2
+ //
3
+ // A taint finding says "data from a SOURCE reaches a SINK". Whether that is a vulnerability depends on whether the party that controls the
4
+ // source is a different party from the one the program runs for. This module reads the origins the taint engine recorded for each finding
5
+ // (`sourceRoots`, or the evidence chain) and decides, from code facts only, which side of that line they sit on:
6
+ //
7
+ // attacker-reachable a request, a socket, a header, a cookie, a remote response: the peer is not the operator
8
+ // operator-controlled command-line arguments and environment variables (the person who starts the process chooses them), and, in a
9
+ // command-line program, standard input and the files it is pointed at: no boundary is crossed. The finding
10
+ // is lowered to `info`, never dropped
11
+ // api-contract the value is a parameter of an exported function that nothing in the analysed code calls, and the function
12
+ // hands it WHOLE (or as the root it appends fixed text to) to a path or command operation: a thin wrapper.
13
+ // The caller chose what to read or run, and the wrapper has no fixed part to escape from. Lowered to `info`
14
+ // entry-parameter a parameter of such a function used inside a larger value the function builds: its own construction, kept
15
+ // data-input standard input or a file's content in a service or a library: data the program processes, kept
16
+ // unattributed the engine recorded no origin: kept
17
+ //
18
+ // A command-line program is one reached only from `main`s whose reachable modules import no server framework or socket library
19
+ // (`entryRole` in haskell-ir.js). The role is a fact about the call graph, not about a name or a path.
20
+ //
21
+ // Nothing here reads a comment, a name or a path: the inputs are the recorded origins, the lowered expression at the sink and the
22
+ // parameter annotations the IR derived from the signature.
23
+
24
+ import { findSites, identNames } from './haskell-guards.js';
25
+
26
+ export const TRUST_VERDICTS = Object.freeze(['attacker-reachable', 'operator-controlled', 'api-contract', 'entry-parameter', 'data-input', 'unattributed']);
27
+
28
+ const OPERATOR = new Set(['cli', 'env']);
29
+ const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'network']);
30
+ const DATA = new Set(['stdin', 'file-read']);
31
+ const WEAK_PARAM_LABEL = /\(caller-controlled\)$/;
32
+ const WRAPPER_FAMILIES = new Set(['CWE-78', 'CWE-88']);
33
+ const CONVERSIONS = /(?:^|\.)(?:pack|unpack|fromString|toString|show|encodeUtf8|decodeUtf8|toStrict|fromStrict|toText|unpackFS|fromText)$/;
34
+ const PATH_JOIN = /(?:^|\.)(?:<\/>|combine|joinPath|<\.>)$/;
35
+ // list plumbing that only lays the parameters out as text (`unwords args`, `concatMap (' ':) args`): no fixed words are added
36
+ const LIST_LAYOUT = /(?:^|\.)(?:unwords|unlines|intercalate|concatMap|concat|map|fmap)$/;
37
+ const SPEC_CTORS = new Set(['System.Process.shell', 'System.Process.proc', 'System.Process.Typed.shell', 'System.Process.Typed.proc']);
38
+ const hasAlnum = (v) => /[A-Za-z0-9]/.test(String(v));
39
+
40
+ function rootsOf(f) {
41
+ // A hop through a function's return that names no origin behind it (`interproc`, `param`) is not an origin.
42
+ if (Array.isArray(f.sourceRoots) && f.sourceRoots.length) return f.sourceRoots.filter((r) => r && r.provenance !== 'interproc' && r.provenance !== 'param').map((r) => ({ label: r.label, provenance: r.provenance || null, ...(r.file ? { file: r.file } : {}), ...(Number.isInteger(r.line) ? { line: r.line } : {}) }));
43
+ const out = [];
44
+ for (const c of f.chain || []) {
45
+ if (!c || !c.label || c.kind === 'sink') continue;
46
+ if (c.provenance === 'interproc' || c.provenance === 'param') continue;
47
+ out.push({ label: c.label, provenance: c.provenance || null });
48
+ }
49
+ if (!out.length && f.source && f.source.label && !/^(?:interproc-return:|parameter )/.test(f.source.label)) out.push({ label: f.source.label, provenance: f.sourceProvenance || null });
50
+ return out;
51
+ }
52
+
53
+ const classOf = (r) => {
54
+ if (r.provenance && OPERATOR.has(r.provenance)) return 'operator';
55
+ if (r.provenance && EXTERNAL.has(r.provenance)) return 'external';
56
+ if (r.provenance && DATA.has(r.provenance)) return 'data';
57
+ if (WEAK_PARAM_LABEL.test(String(r.label || ''))) return 'weak-param';
58
+ return 'other';
59
+ };
60
+
61
+ // The expression a sink was handed, with the conversions that only change its type (`unpack`, `fromString`, `show`) peeled off.
62
+ function peel(e, depth = 0) {
63
+ while (e && e.kind === 'call' && depth++ < 8 && typeof e.callee === 'string' && CONVERSIONS.test(e.callee) && (e.args || []).length === 1) e = e.args[0];
64
+ return e;
65
+ }
66
+
67
+ // Flatten a concatenation / path join into its operands, left to right.
68
+ function operandsOf(e, out = [], depth = 0, resolve = null) {
69
+ e = peel(e);
70
+ if (!e || depth > 12) { out.push(e); return out; }
71
+ if (resolve && e.kind === 'ident') { const r = resolve(e); if (r && r !== e) return operandsOf(r, out, depth + 1, resolve); }
72
+ if (e.kind === 'binary' && e.hs && e.hs.concat) { operandsOf(e.left, out, depth + 1, resolve); operandsOf(e.right, out, depth + 1, resolve); return out; }
73
+ if (e.kind === 'binary' && (e.op === '</>' || e.op === '<>' || e.op === '++')) { operandsOf(e.left, out, depth + 1, resolve); operandsOf(e.right, out, depth + 1, resolve); return out; }
74
+ if (e.kind === 'call' && typeof e.callee === 'string' && PATH_JOIN.test(e.callee) && (e.args || []).length === 2) { operandsOf(e.args[0], out, depth + 1, resolve); operandsOf(e.args[1], out, depth + 1, resolve); return out; }
75
+ out.push(e);
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * Is the sink argument a value the function merely passes on? True when every non-literal operand is one of `params` (or list plumbing over
81
+ * them) and either (a) nothing in the value is fixed text that names something (only separators), or (b) for a path, the parameter is the
82
+ * LEFTMOST operand, so it chooses the whole location and the fixed tail is only a file name inside it.
83
+ */
84
+ export function isPassThrough(arg, params, family, resolve = null) {
85
+ const ops = operandsOf(arg, [], 0, resolve);
86
+ if (!ops.length || ops.some((o) => !o)) return false;
87
+ const dyn = ops.filter((o) => o.kind !== 'literal');
88
+ if (!dyn.length) return false;
89
+ // a dynamic operand is acceptable when it is made only of parameters and separators (a list laid out as text, a member of a parameter)
90
+ const ok = (o, depth = 0) => {
91
+ if (!o || depth > 10) return false;
92
+ if (o.kind === 'literal') return !hasAlnum(o.value);
93
+ if (o.kind === 'ident') return params.has(o.name);
94
+ if (o.kind === 'member') return ok(o.object, depth + 1);
95
+ if (o.kind === 'array') return o.elements.length > 0 && o.elements.every((x) => ok(x, depth + 1));
96
+ if (o.kind === 'call' && typeof o.callee === 'string' && LIST_LAYOUT.test(o.callee)) return (o.args || []).length > 0 && o.args.every((x) => ok(x, depth + 1));
97
+ return false;
98
+ };
99
+ if (!dyn.every((o) => ok(o))) return false;
100
+ const namedLiteral = ops.some((o) => o.kind === 'literal' && hasAlnum(o.value));
101
+ if (!namedLiteral) return true;
102
+ return family === 'path' && ops[0].kind === 'ident' && params.has(ops[0].name);
103
+ }
104
+
105
+ function weakParamsOf(fn) {
106
+ const out = new Set();
107
+ for (const a of fn.paramAnnotations || []) if (a && a.decorator === 'hs:caller-controlled' && a.name) out.add(a.name);
108
+ return out;
109
+ }
110
+
111
+ // A local name bound exactly once (`let cmd = exe <> " " <> args`) stands for its definition when the sink argument is judged.
112
+ function localResolver(fn) {
113
+ const defs = new Map();
114
+ for (const n of Object.values(fn.cfg.nodes)) if (n.kind === 'assign' && typeof n.target === 'string' && n.source) defs.set(n.target, [...(defs.get(n.target) || []), n.source]);
115
+ const params = new Set(Array.isArray(fn.params) ? fn.params : []);
116
+ return (e) => (e && e.kind === 'ident' && !params.has(e.name) && defs.has(e.name) && defs.get(e.name).length === 1 ? defs.get(e.name)[0] : e);
117
+ }
118
+
119
+ const fnByQid = (perFile, qid) => {
120
+ if (!qid) return null;
121
+ const file = String(qid).split('::')[0];
122
+ const ir = perFile && perFile[file];
123
+ return ir ? ir.functions.find((x) => x.qid === qid) || null : null;
124
+ };
125
+
126
+ // The role the IR gave the function the finding was found in: `cli` (reached only from mains that import no server framework), `service`, or
127
+ // `library` (no main reaches it, or the walk could not).
128
+ function roleOf(f, perFile) {
129
+ const fn = fnByQid(perFile, f._funcQid);
130
+ return (fn && fn.hs && fn.hs.entryRole) || 'library';
131
+ }
132
+
133
+ // The function that holds the sink call, and the sink's argument expressions there. A finding found while analysing a CALLER (its `_funcQid`) is
134
+ // located at the callee's line; the sink function is the one in the finding's file that has the call at that line.
135
+ function locateSink(perFile, f) {
136
+ const ir = perFile && perFile[f.file];
137
+ if (!ir || !f.callee) return null;
138
+ const direct = fnByQid(perFile, f._funcQid);
139
+ const candidates = direct && direct.file === f.file ? [direct, ...ir.functions.filter((x) => x !== direct)] : ir.functions;
140
+ for (const fn of candidates) {
141
+ const sites = findSites(fn, f.callee, f.line);
142
+ if (sites.length) return { fn, sites };
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Is this finding a thin wrapper's pass-through? `weakParams` are the caller-controlled parameters of the function the engine analysed. When the
149
+ * sink sits in a callee, the callee must itself hand its own parameter on whole, and every call of it from the analysed function must pass
150
+ * values built only from that function's caller-controlled parameters (one hop; a longer chain is not judged).
151
+ */
152
+ function passThroughVerdict(perFile, f) {
153
+ if (!WRAPPER_FAMILIES.has(f.cwe)) return null;
154
+ const family = f.cwe === 'CWE-22' ? 'path' : 'cmd';
155
+ const sink = locateSink(perFile, f);
156
+ if (!sink) return null;
157
+ const argIdx = Number.isInteger(f.argIndex) ? f.argIndex : 0;
158
+ let args = sink.sites.map((s) => (s.call.args || [])[argIdx]).filter(Boolean);
159
+ // A process is started from a SPEC value (`createProcess ((shell cmd) { cwd = d })`): what decides the verdict is what the spec constructor
160
+ // was handed, so the constructor's own arguments are judged instead of the record update around it.
161
+ const specArgs = [];
162
+ const findSpecs = (e, d = 0) => {
163
+ if (!e || typeof e !== 'object' || d > 20) return;
164
+ if (e.kind === 'call' && typeof e.callee === 'string' && SPEC_CTORS.has(e.callee)) { for (const a of (e.args || []).slice(0, 2)) if (a) specArgs.push(a); return; }
165
+ for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') findSpecs(e[k], d + 1);
166
+ for (const k of ['args', 'elements', 'branches']) if (Array.isArray(e[k])) e[k].forEach((x) => findSpecs(x, d + 1));
167
+ if (Array.isArray(e.props)) for (const p of e.props) findSpecs(p && p.value, d + 1);
168
+ };
169
+ for (const a of args) findSpecs(a);
170
+ if (specArgs.length) args = specArgs;
171
+ if (!args.length) return null;
172
+ const analysed = fnByQid(perFile, f._funcQid);
173
+ if (!analysed) return null;
174
+ if (sink.fn.qid === analysed.qid) {
175
+ const weak = weakParamsOf(analysed);
176
+ if (!weak.size) return null;
177
+ const own = new Set(Array.isArray(analysed.params) ? analysed.params : []);
178
+ const res = localResolver(analysed);
179
+ return args.every((a) => isPassThrough(a, own, family, res)) && args.some((a) => { const seen = new Set(); const walkIds = (e, d = 0) => { if (!e || d > 12) return; if (e.kind === 'ident') seen.add(e.name); for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') walkIds(e[k], d + 1); for (const k of ['args', 'elements']) if (Array.isArray(e[k])) e[k].forEach((x) => walkIds(x, d + 1)); }; let x = a; for (let i = 0; i < 4; i++) { const r = res(x); if (r === x) break; x = r; } walkIds(x); return [...seen].some((n) => weak.has(n)); }) ? 'direct' : null;
180
+ }
181
+ // the sink is in a callee
182
+ const own = new Set(Array.isArray(sink.fn.params) ? sink.fn.params : []);
183
+ const ownVars = [...new Set(args.flatMap((a) => [...identNames(a)]))];
184
+ if (!own.size || !ownVars.length || !ownVars.every((v) => own.has(v)) || !args.every((a) => isPassThrough(a, own, family))) return null;
185
+ const weak = weakParamsOf(analysed);
186
+ if (!weak.size) return null;
187
+ const calls = findCallsTo(analysed, sink.fn);
188
+ if (!calls.length) return null;
189
+ const used = new Set(ownVars);
190
+ for (const call of calls) {
191
+ for (let i = 0; i < (call.args || []).length; i++) {
192
+ const p = sink.fn.params[i];
193
+ if (!p || !used.has(p)) continue;
194
+ const a = call.args[i];
195
+ if (!a) return null;
196
+ if (a.kind === 'literal') continue;
197
+ if (!isPassThrough(a, weak, family)) return null;
198
+ }
199
+ }
200
+ return 'chain';
201
+ }
202
+
203
+ function findCallsTo(fn, target) {
204
+ const out = [];
205
+ const visit = (e, d) => {
206
+ if (!e || typeof e !== 'object' || d > 50) return;
207
+ if (e.kind === 'call' && e.callee === target.name) out.push(e);
208
+ for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') visit(e[k], d + 1);
209
+ for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) for (const x of e[k]) visit(x, d + 1);
210
+ if (Array.isArray(e.props)) for (const p of e.props) visit(p && p.value, d + 1);
211
+ };
212
+ for (const n of Object.values(fn.cfg.nodes)) {
213
+ if (n.kind === 'call' && n.callee === target.name) out.push({ kind: 'call', callee: n.callee, args: n.args || [] });
214
+ for (const k of ['source', 'value', 'cond']) if (n[k]) visit(n[k], 0);
215
+ if (Array.isArray(n.args)) for (const a of n.args) visit(a, 0);
216
+ }
217
+ return out;
218
+ }
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
+
242
+ /**
243
+ * Stamps `trustBoundary` on every Haskell IR-TAINT finding and lowers to `info` the verdicts that name no boundary crossed
244
+ * (`operator-controlled`, `api-contract`). Mutates the findings; returns counts.
245
+ */
246
+ export function applyTrustBoundary(findings, perFile) {
247
+ const stats = { attackerReachable: 0, operatorControlled: 0, apiContract: 0, entryParameter: 0, dataInput: 0, unattributed: 0 };
248
+ for (const f of findings || []) {
249
+ if (!f || f.parser !== 'IR-TAINT' || !/\.(?:l?hs|hsc)$/i.test(String(f.file || ''))) continue;
250
+ const roots = rootsOf(f);
251
+ const classes = new Set(roots.map(classOf));
252
+ const role = roleOf(f, perFile);
253
+ let verdict;
254
+ let reason;
255
+ let inferred = false;
256
+ if (classes.has('external')) { verdict = 'attacker-reachable'; reason = 'a request, socket, header, cookie or remote response supplies the value'; }
257
+ else if (roots.length && [...classes].every((c) => c === 'operator')) {
258
+ verdict = 'operator-controlled';
259
+ reason = 'only command-line arguments or environment variables supply the value; whoever starts the program chooses them, so no trust boundary is crossed';
260
+ } else if (roots.length && role === 'cli' && roots.every((r) => r.provenance === 'file-read' || OPERATOR.has(r.provenance))) {
261
+ verdict = 'operator-controlled';
262
+ reason = 'the function belongs to a command-line program (reached from a main that imports no server framework) and the value comes from files the person running it pointed the program at, or from its arguments and environment; standard input is not covered by this rule';
263
+ } else if (classes.has('data')) { verdict = 'data-input'; reason = 'standard input or file content supplies the value'; }
264
+ else if (classes.has('weak-param')) {
265
+ verdict = 'entry-parameter';
266
+ reason = 'a parameter of an exported function that nothing in the analysed code calls';
267
+ const pt = passThroughVerdict(perFile, f);
268
+ if (pt) { verdict = 'api-contract'; reason = 'the function hands its parameter on whole (or as the root of a fixed tail) to a path or command operation: a thin wrapper, whose caller chose what to read or run'; }
269
+ } else if (!roots.length && role === 'cli') {
270
+ // No origin was recorded. Every external origin the model knows (a request, a socket, a remote response) is recorded when it is read, so
271
+ // an empty record in a command-line program means the value came from the operator's side: its arguments, environment, files or
272
+ // configuration reached the sink through a path the engine could not attribute. Stated as an inference on the finding.
273
+ verdict = 'operator-controlled';
274
+ reason = 'no origin was recorded for the tainted value and the function belongs to a command-line program; no request, socket or remote-response read feeds it (inferred, not proven)';
275
+ inferred = true;
276
+ } else { verdict = 'unattributed'; reason = roots.length ? 'the recorded origins are not classified' : 'the engine recorded no origin for the value'; }
277
+
278
+ f.trustBoundary = { verdict, reason, role, ...(inferred ? { inferred: true } : {}), roots: roots.slice(0, 5) };
279
+ const key = verdict.replace(/-(\w)/g, (_, c) => c.toUpperCase());
280
+ stats[key] = (stats[key] || 0) + 1;
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' });
283
+ }
284
+ }
285
+ return stats;
286
+ }
@@ -19,6 +19,7 @@
19
19
  // is recorded as `unknown` coverage, never as protected and never omitted.
20
20
 
21
21
  import { buildHaskellIR, lowerHandlerExpr, resolveName } from './haskell-ir.js';
22
+ import { codeOfLine } from './haskell-comments.js';
22
23
  import { qualifyAmbiguous, HS_WEB_FRAMEWORKS, HS_WEB_MODEL_VERSION, modelStatus } from './haskell-models.js';
23
24
 
24
25
  export const HS_WEB_RULES_VERSION = 'haskell-web-rules/1';
@@ -82,9 +83,9 @@ const strLit = (e) => { e = unparen(e); return e && e.t === 'lit' && e.kind ===
82
83
 
83
84
  // ── analysis context ─────────────────────────────────────────────────────────
84
85
  class Ctx {
85
- constructor(files) {
86
+ constructor(files, reachability) {
86
87
  this.files = files;
87
- this.ir = buildHaskellIR(files);
88
+ this.ir = buildHaskellIR(files, reachability || {});
88
89
  this.project = this.ir.project;
89
90
  this.modules = [...this.ir.modules.values()];
90
91
  this.fnByQid = new Map();
@@ -836,7 +837,7 @@ export function analyzeHaskellWeb(files, opts = {}) {
836
837
  for (const [f, t] of Object.entries(files || {})) if (/\.hs$/i.test(f) && typeof t === 'string') hs[f] = t;
837
838
  const result = { routes: [], findings: [], middleware: [], frameworks: [], coverage: {}, gaps: [], modelVersion: HS_WEB_MODEL_VERSION, rulesetVersion: HS_WEB_RULES_VERSION, versionStatus: {} };
838
839
  if (!Object.keys(hs).length) return result;
839
- const ctx = new Ctx(hs);
840
+ const ctx = new Ctx(hs, opts.reachability);
840
841
  const pending = [];
841
842
  const add = (r) => pending.push(r);
842
843
 
@@ -1055,7 +1056,7 @@ function emitFindings(ctx, result, yesodAuthz) {
1055
1056
  const vs = result.versionStatus[R.framework];
1056
1057
  const untested = vs && vs.status !== 'tested';
1057
1058
  if (untested) extra = { ...extra, confidence: (extra.confidence ?? 0.75) * 0.8, uncertainty: [...(extra.uncertainty || []), { kind: 'untested-model', detail: `${vs.package} ${vs.version || '(version unknown)'} is not in the tested set (${vs.tested.join(', ')}); the route model is applied best-effort` }] };
1058
- const snip = (text.split('\n')[line - 1] || '').trim().slice(0, 240);
1059
+ const snip = codeOfLine(text.split('\n')[line - 1] || '').slice(0, 240);
1059
1060
  out.push({
1060
1061
  id: `hs-web:${rule}:${R.file}:${line}:${R.method}:${R.path}`, severity: extra.severity || r.severity, file: R.file, line, vuln: r.vuln, cwe: r.cwe,
1061
1062
  description: `${r.vuln}: ${R.method} ${R.path} (${R.framework})${extra.detail ? ` - ${extra.detail}` : ''}`,
@@ -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
@@ -363,7 +363,9 @@ export function scanCloudIam(fp, raw) {
363
363
  if (!raw || raw.length > 500_000) return [];
364
364
  const out = [];
365
365
  const seen = new Set();
366
- const isJson = /\.json$/i.test(fp) || raw.trimStart().startsWith('{');
366
+ // A file is JSON by its extension or because it OPENS like a JSON object: a brace, then a quoted key or a closing brace. A bare leading `{` is not
367
+ // enough: a Haskell module that starts with a `{-# LANGUAGE ... #-}` pragma (or a `{- ... -}` comment) begins with one too, and its prose was read as policy.
368
+ const isJson = /\.json$/i.test(fp) || /^\s*\{\s*(?:"|\})/.test(raw.slice(0, 4096));
367
369
  const isYaml = /\.(?:yaml|yml)$/i.test(fp);
368
370
  const isBicep = /\.bicep$/i.test(fp);
369
371
  const isTf = /\.tf$/i.test(fp);