@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.
- package/CHANGELOG.md +10 -0
- package/dist/{92f78fd3e2dbe30fb58d.json → 051c3705cb9cb458cd2f.json} +6 -3
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -3
- package/src/dataflow/engine.js +2 -1
- package/src/engine.js +24 -4
- package/src/ir/index.js +6 -5
- package/src/language/CLAUDE.md +3 -1
- package/src/language/haskell-ir.js +311 -16
- package/src/language/haskell-manifests.js +88 -5
- package/src/language/haskell-parser.js +18 -4
- package/src/language/haskell-reachability.js +84 -0
- package/src/language/haskell-security-rules.js +23 -3
- package/src/language/haskell-syntax.js +2 -1
- package/src/language/haskell-trust.js +24 -6
- package/src/language/haskell-web.js +3 -3
- package/src/posture/proof-fabric/container.js +13 -2
- package/src/posture/relevance.js +21 -1
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
260
|
-
f
|
|
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
|
-
//
|
|
15
|
-
//
|
|
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';
|
package/src/posture/relevance.js
CHANGED
|
@@ -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
|
-
|
|
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
|