@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.
- package/CHANGELOG.md +19 -0
- package/dist/{deabc47d0e27e21bf99b.json → 051c3705cb9cb458cd2f.json} +7 -4
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -4
- package/src/dataflow/CLAUDE.md +13 -0
- package/src/dataflow/catalog.js +14 -0
- package/src/dataflow/engine.js +133 -11
- package/src/engine.js +33 -5
- package/src/ir/index.js +6 -5
- package/src/language/CLAUDE.md +9 -0
- package/src/language/haskell-comments.js +65 -0
- package/src/language/haskell-findings.js +2 -1
- package/src/language/haskell-fix.js +118 -2
- package/src/language/haskell-guards.js +2 -2
- package/src/language/haskell-ir.js +354 -16
- package/src/language/haskell-manifests.js +88 -5
- package/src/language/haskell-models.js +15 -4
- package/src/language/haskell-parser.js +18 -4
- package/src/language/haskell-reachability.js +84 -0
- package/src/language/haskell-security-rules.js +134 -10
- package/src/language/haskell-syntax.js +2 -1
- package/src/language/haskell-trust.js +286 -0
- package/src/language/haskell-web.js +5 -4
- package/src/posture/proof-fabric/container.js +13 -2
- package/src/posture/relevance.js +21 -1
- package/src/sast/cloud-iam.js +3 -1
|
@@ -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] || '').
|
|
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
|
-
//
|
|
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
|
package/src/sast/cloud-iam.js
CHANGED
|
@@ -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
|
-
|
|
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);
|