@clear-capabilities/agentic-security-scanner 0.186.2 → 0.188.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 +20 -0
- package/dist/1602.index.js +275 -19
- package/dist/{4d06c8944ae121d7085c.json → 92f78fd3e2dbe30fb58d.json} +5 -5
- package/dist/9560.index.js +61 -4
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +5 -5
- package/src/capabilities/decide.js +7 -1
- package/src/dataflow/CLAUDE.md +15 -0
- package/src/dataflow/catalog-haskell.js +7 -1
- package/src/dataflow/catalog.js +14 -0
- package/src/dataflow/engine.js +147 -10
- package/src/dataflow/privacy-catalog-haskell.js +2 -1
- package/src/engine.js +11 -3
- package/src/language/CLAUDE.md +26 -0
- package/src/language/compliance-map.js +6 -0
- package/src/language/context.js +20 -0
- package/src/language/haskell-comments.js +65 -0
- package/src/language/haskell-disclosure.js +16 -4
- package/src/language/haskell-findings.js +2 -1
- package/src/language/haskell-fix.js +118 -2
- package/src/language/haskell-guards.js +132 -5
- package/src/language/haskell-ir.js +228 -13
- package/src/language/haskell-models.js +108 -8
- package/src/language/haskell-security-rules.js +223 -41
- package/src/language/haskell-syntax.js +10 -1
- package/src/language/haskell-trust.js +268 -0
- package/src/language/haskell-web.js +46 -9
- package/src/language/support-registry.js +3 -2
- package/src/mcp/tools.js +54 -3
- package/src/posture/attack-taxonomy.js +10 -0
- package/src/posture/entrypoint-inventory.js +6 -2
- package/src/posture/proof-fabric/generate-haskell.js +266 -17
- package/src/posture/proof-fabric/generate.js +10 -3
- package/src/posture/proof-fabric/oracles.js +21 -0
- package/src/posture/stable-id.js +2 -1
- package/src/report/index.js +3 -0
- package/src/sast/cloud-iam.js +3 -1
- package/dist/2066.index.js +0 -851
|
@@ -0,0 +1,268 @@
|
|
|
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
|
+
* Stamps `trustBoundary` on every Haskell IR-TAINT finding and lowers to `info` the verdicts that name no boundary crossed
|
|
222
|
+
* (`operator-controlled`, `api-contract`). Mutates the findings; returns counts.
|
|
223
|
+
*/
|
|
224
|
+
export function applyTrustBoundary(findings, perFile) {
|
|
225
|
+
const stats = { attackerReachable: 0, operatorControlled: 0, apiContract: 0, entryParameter: 0, dataInput: 0, unattributed: 0 };
|
|
226
|
+
for (const f of findings || []) {
|
|
227
|
+
if (!f || f.parser !== 'IR-TAINT' || !/\.(?:l?hs|hsc)$/i.test(String(f.file || ''))) continue;
|
|
228
|
+
const roots = rootsOf(f);
|
|
229
|
+
const classes = new Set(roots.map(classOf));
|
|
230
|
+
const role = roleOf(f, perFile);
|
|
231
|
+
let verdict;
|
|
232
|
+
let reason;
|
|
233
|
+
let inferred = false;
|
|
234
|
+
if (classes.has('external')) { verdict = 'attacker-reachable'; reason = 'a request, socket, header, cookie or remote response supplies the value'; }
|
|
235
|
+
else if (roots.length && [...classes].every((c) => c === 'operator')) {
|
|
236
|
+
verdict = 'operator-controlled';
|
|
237
|
+
reason = 'only command-line arguments or environment variables supply the value; whoever starts the program chooses them, so no trust boundary is crossed';
|
|
238
|
+
} else if (roots.length && role === 'cli' && roots.every((r) => r.provenance === 'file-read' || OPERATOR.has(r.provenance))) {
|
|
239
|
+
verdict = 'operator-controlled';
|
|
240
|
+
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';
|
|
241
|
+
} else if (classes.has('data')) { verdict = 'data-input'; reason = 'standard input or file content supplies the value'; }
|
|
242
|
+
else if (classes.has('weak-param')) {
|
|
243
|
+
verdict = 'entry-parameter';
|
|
244
|
+
reason = 'a parameter of an exported function that nothing in the analysed code calls';
|
|
245
|
+
const pt = passThroughVerdict(perFile, f);
|
|
246
|
+
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'; }
|
|
247
|
+
} else if (!roots.length && role === 'cli') {
|
|
248
|
+
// No origin was recorded. Every external origin the model knows (a request, a socket, a remote response) is recorded when it is read, so
|
|
249
|
+
// an empty record in a command-line program means the value came from the operator's side: its arguments, environment, files or
|
|
250
|
+
// configuration reached the sink through a path the engine could not attribute. Stated as an inference on the finding.
|
|
251
|
+
verdict = 'operator-controlled';
|
|
252
|
+
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)';
|
|
253
|
+
inferred = true;
|
|
254
|
+
} else { verdict = 'unattributed'; reason = roots.length ? 'the recorded origins are not classified' : 'the engine recorded no origin for the value'; }
|
|
255
|
+
|
|
256
|
+
f.trustBoundary = { verdict, reason, role, ...(inferred ? { inferred: true } : {}), roots: roots.slice(0, 5) };
|
|
257
|
+
const key = verdict.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
258
|
+
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 };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return stats;
|
|
268
|
+
}
|
|
@@ -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';
|
|
@@ -267,6 +268,7 @@ function kidsIr(e) {
|
|
|
267
268
|
for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') out.push(e[k]);
|
|
268
269
|
for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) out.push(...e[k]);
|
|
269
270
|
if (Array.isArray(e.props)) for (const p of e.props) if (p && p.value) out.push(p.value);
|
|
271
|
+
if (e.hs && e.hs.inner && typeof e.hs.inner === 'object') out.push(e.hs.inner); // `oid :: Int`: a number to the taint engine, still the id it names here
|
|
270
272
|
return out;
|
|
271
273
|
}
|
|
272
274
|
function* walkIr(e, depth = 0) { if (!e || typeof e !== 'object' || depth > 80) return; yield e; for (const c of kidsIr(e)) yield* walkIr(c, depth + 1); }
|
|
@@ -295,6 +297,8 @@ function handlerFacts(ctx, fn, opts = {}) {
|
|
|
295
297
|
if (n.kind === 'if' && n.cond) conds.push(n.cond);
|
|
296
298
|
for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
|
|
297
299
|
if (e.kind === 'call' && typeof e.callee === 'string') ordered.push({ call: e, line: e.line || n.line, node: n });
|
|
300
|
+
// `when (owner /= principal) notFound`, `unless (owner == principal) (permissionDenied ..)`: the condition is the first argument of the call
|
|
301
|
+
if (e.kind === 'call' && /(?:^|\.)(?:when|unless)$/.test(e.callee || '') && e.args && e.args[0]) conds.push(e.args[0]);
|
|
298
302
|
if (e.kind === 'union' && e.hs && e.hs.branchConds) for (const bc of e.hs.branchConds) conds.push(bc.cond);
|
|
299
303
|
}
|
|
300
304
|
}
|
|
@@ -349,6 +353,9 @@ function handlerFacts(ctx, fn, opts = {}) {
|
|
|
349
353
|
const hard = allCalls.findIndex((r) => r !== rd.call && isRejection(r) && !SCOTTY_STATUS.test(r.callee) && stopsHandler(r, allCalls));
|
|
350
354
|
if (hard >= 0) { inlineIdx = hard; inlineKind = rd.kind; inlineLine = allCalls[hard].line; inlineCallee = rd.callee; inlineRejectCallee = allCalls[hard].callee; break; }
|
|
351
355
|
}
|
|
356
|
+
// A credential read and a rejection that ends the handler exist, but the rejection is not before the first sensitive operation BY LINE (a `case`
|
|
357
|
+
// lists the success branch first and the rejecting branch last). Their order is then a question about control flow, answered from the syntax tree.
|
|
358
|
+
const inlineCandidate = reads.length > 0 && allCalls.some((r) => isRejection(r) && stopsHandler(r, allCalls));
|
|
352
359
|
const principals = new Set();
|
|
353
360
|
for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
|
|
354
361
|
// ids read from the request
|
|
@@ -360,7 +367,7 @@ function handlerFacts(ctx, fn, opts = {}) {
|
|
|
360
367
|
if (nm && /id$/i.test(nm)) idVars.add(a.target);
|
|
361
368
|
}
|
|
362
369
|
}
|
|
363
|
-
return { ordered, guardIdx, inlineIdx, inlineKind, inlineLine, inlineCallee, inlineRejectCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
|
|
370
|
+
return { ordered, guardIdx, inlineIdx, inlineCandidate, inlineKind, inlineLine, inlineCallee, inlineRejectCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
|
|
364
371
|
}
|
|
365
372
|
|
|
366
373
|
function hasOwnership(ctx, facts) {
|
|
@@ -368,7 +375,7 @@ function hasOwnership(ctx, facts) {
|
|
|
368
375
|
const mentionsPrincipal = (e) => { for (const n of identsIr(e)) if (principals.has(n)) return true; return false; };
|
|
369
376
|
// 1. a comparison involving the principal
|
|
370
377
|
for (const c of facts.conds) for (const x of walkIr(c)) {
|
|
371
|
-
if (x.kind === 'binary' && ['==', '===', '!='].includes(x.op) && (mentionsPrincipal(x.left) || mentionsPrincipal(x.right))) return { kind: 'principal-comparison' };
|
|
378
|
+
if (x.kind === 'binary' && ['==', '===', '!=', '/='].includes(x.op) && (mentionsPrincipal(x.left) || mentionsPrincipal(x.right))) return { kind: 'principal-comparison' };
|
|
372
379
|
if (x.kind === 'call' && /(?:^|\.)(?:elem|notElem)$/.test(x.callee || '') && (x.args || []).some(mentionsPrincipal)) return { kind: 'principal-membership' };
|
|
373
380
|
}
|
|
374
381
|
for (const o of facts.ordered) {
|
|
@@ -396,7 +403,7 @@ function hasOwnership(ctx, facts) {
|
|
|
396
403
|
// source, a sensitive call the walk did not find) makes the order UNKNOWN, and unknown is reported as late, never as authenticated.
|
|
397
404
|
// Leaf expressions are classified by lowering them alone and reusing the classifiers above (credentialReadsOf, isRejection, stopsHandler,
|
|
398
405
|
// isSensitiveCall), so this adds ordering and no second definition of what a credential, a rejection or a sensitive call is.
|
|
399
|
-
const HARD_REJECT_HEAD = /^(?:throwError|permissionDenied|notAuthenticated)$/;
|
|
406
|
+
const HARD_REJECT_HEAD = /^(?:throwError|permissionDenied|notAuthenticated|raiseStatus)$/;
|
|
400
407
|
const SOFT_REJECT_HEAD = /^(?:respond|return|pure)$/;
|
|
401
408
|
|
|
402
409
|
function rhsBranches(rhs) {
|
|
@@ -425,7 +432,7 @@ function inlineOrder(ctx, fn, facts, opts) {
|
|
|
425
432
|
const mentions = (e, names) => { for (const x of walkAst(e)) if (x.t === 'var' && !x.qual && names.has(x.name)) return x.name; return null; };
|
|
426
433
|
const classify = (e) => {
|
|
427
434
|
if (memo.has(e)) return memo.get(e);
|
|
428
|
-
let out = { reads: false, rej: false, sensN: 0 };
|
|
435
|
+
let out = { reads: false, rej: false, sensN: 0, stat: false, halt: false };
|
|
429
436
|
try {
|
|
430
437
|
const f2 = lowerHandlerExpr(ctx.project, src.mod, e, `order${seq++}`, Number.isFinite(e.line) ? e.line : 1);
|
|
431
438
|
const calls = [...callsOf(f2), ...nullaryRejectionsOf(f2)].filter((c) => typeof c.callee === 'string');
|
|
@@ -434,6 +441,10 @@ function inlineOrder(ctx, fn, facts, opts) {
|
|
|
434
441
|
reads: credentialReadsOf(f2).size > 0 || Object.values(f2.cfg.nodes).some((n) => exprsOfNode(n).some((r) => [...walkIr(r)].some((x) => x.kind === 'ident' && typeof x.name === 'string' && !!credentialKind({ callee: x.name, args: [] })))),
|
|
435
442
|
rej: calls.some((c) => isRejection(c) && !SCOTTY_STATUS.test(c.callee) && stopsHandler(c, calls)),
|
|
436
443
|
sensN: callsOf(f2).filter((c) => typeof c.callee === 'string' && isSensitiveCall(ctx, c)).length,
|
|
444
|
+
// Scotty: `status 40x` only sets the code; a halting call (`finish`, `raise`, `redirect`) ends the handler. Both are tracked so the PAIR counts
|
|
445
|
+
// as a rejection wherever the two sit in sequence (`status status401 >> finish`), in either order of statements within one branch.
|
|
446
|
+
stat: calls.some((c) => SCOTTY_STATUS.test(c.callee) && isRejection(c)),
|
|
447
|
+
halt: haltLinesOf(f2).length > 0,
|
|
437
448
|
};
|
|
438
449
|
} catch { T.failed = true; }
|
|
439
450
|
// A lambda inside the expression is lifted out by the lowering, so the calls in its body are not among the expression's own: classify
|
|
@@ -441,7 +452,7 @@ function inlineOrder(ctx, fn, facts, opts) {
|
|
|
441
452
|
memo.set(e, out);
|
|
442
453
|
for (const x of walkAst(e)) {
|
|
443
454
|
const bodies = x.t === 'lam' ? [x.body] : x.t === 'lamcase' ? (x.alts || []).flatMap((a) => rhsBranches(a.rhs).map((b) => b.body)) : [];
|
|
444
|
-
for (const b of bodies) { const sub = classify(b); out = { reads: out.reads || sub.reads, rej: out.rej || sub.rej, sensN: out.sensN + sub.sensN }; }
|
|
455
|
+
for (const b of bodies) { const sub = classify(b); out = { reads: out.reads || sub.reads, rej: out.rej || sub.rej, sensN: out.sensN + sub.sensN, stat: out.stat || sub.stat, halt: out.halt || sub.halt }; }
|
|
445
456
|
}
|
|
446
457
|
T.astSens += out.sensN;
|
|
447
458
|
memo.set(e, out);
|
|
@@ -459,6 +470,9 @@ function inlineOrder(ctx, fn, facts, opts) {
|
|
|
459
470
|
for (let i = 0; i < n; i++) acc.sens.push({ guarded: s.guarded, readMay: s.readMay });
|
|
460
471
|
const sp = spine(e);
|
|
461
472
|
const hn = sp && sp.head ? sp.head.name : null;
|
|
473
|
+
if (c.stat) s = { ...s, statusRej: true };
|
|
474
|
+
// a Scotty halting call after a rejecting status on this path, once a credential has been read
|
|
475
|
+
if (c.halt && n === 0 && s.statusRej && s.read) { acc.guards++; return { st: s, term: true }; }
|
|
462
476
|
if (c.rej && n === 0 && hn && (HARD_REJECT_HEAD.test(hn) || (tail && SOFT_REJECT_HEAD.test(hn))) && s.read) { acc.guards++; return { st: s, term: true }; }
|
|
463
477
|
if (c.rej && n > 0) T.unordered = true;
|
|
464
478
|
return { st: s, term: false };
|
|
@@ -586,6 +600,9 @@ function analyzeRoute(ctx, route, handlerFn, opts) {
|
|
|
586
600
|
const ord = R.framework === 'scotty' ? { verdict: 'before' } : inlineOrder(ctx, handlerFn, facts, opts);
|
|
587
601
|
if (ord.verdict === 'before') R.auth = { status: 'authenticated', kind: facts.inlineKind, evidence: [{ kind: 'inline-guard', callee: facts.inlineCallee, line: facts.inlineLine }] };
|
|
588
602
|
else R.auth = { status: 'late', kind: null, evidence: [{ kind: 'late-guard', callee: facts.inlineRejectCallee || facts.inlineCallee, line: facts.inlineLine, order: ord.verdict, reason: ord.reason }] };
|
|
603
|
+
} else if (facts.inlineCandidate && inlineOrder(ctx, handlerFn, facts, opts).verdict === 'before') {
|
|
604
|
+
// by line the rejection looked late or absent; the handler's structure proves every sensitive operation sits on the non-rejecting side of it
|
|
605
|
+
R.auth = { status: 'authenticated', kind: facts.inlineKind || 'token', evidence: [{ kind: 'inline-guard', callee: 'structure', line: R.line }] };
|
|
589
606
|
} else R.auth = { status: 'none', kind: null, evidence: [] };
|
|
590
607
|
// role
|
|
591
608
|
const roleCall = facts.ordered.find((o) => ROLE_CALL.test(o.call.callee || ''));
|
|
@@ -765,7 +782,10 @@ function servantHandlers(ctx, mod, apiName) {
|
|
|
765
782
|
const flat = [];
|
|
766
783
|
const go = (e) => { e = unparen(e); if (e && e.t === 'op' && e.op === ':<|>') { go(e.l); go(e.r); } else flat.push(e); };
|
|
767
784
|
if (body) go(body);
|
|
768
|
-
|
|
785
|
+
// `server _account rowId = do ...`: a single endpoint written as a function, whose parameters ARE the endpoint's auth, capture and query values
|
|
786
|
+
const clause = fun.clauses[0];
|
|
787
|
+
const params = flat.length === 1 && clause && (clause.pats || []).length ? clause.pats.map((x) => (x && x.t === 'pvar' ? x.name : null)) : null;
|
|
788
|
+
if (flat.length > 0) return { fnName: fun.name, handlers: flat, fun, params };
|
|
769
789
|
}
|
|
770
790
|
return null;
|
|
771
791
|
}
|
|
@@ -874,7 +894,7 @@ export function analyzeHaskellWeb(files, opts = {}) {
|
|
|
874
894
|
list.forEach((e, i) => {
|
|
875
895
|
const hAst = h && h.handlers.length === list.length ? h.handlers[i] : null;
|
|
876
896
|
if (h && h.handlers.length !== list.length) ctx.gaps.push({ kind: 'servant-handler-arity', detail: `${apiName} has ${list.length} endpoints but ${h.fnName} lists ${h.handlers.length} handlers; handlers are not paired`, file: mod.file, line: e.line });
|
|
877
|
-
add({ framework: 'servant', method: e.method, path: e.path, file: mod.file, line: e.line, mod, handlerAst: hAst, servant: e, owner: apiName, whereDecls: h && h.fun && h.fun.clauses[0] && h.fun.clauses[0].rhs.where });
|
|
897
|
+
add({ framework: 'servant', method: e.method, path: e.path, file: mod.file, line: e.line, mod, handlerAst: hAst, handlerParams: h && h.handlers.length === list.length ? h.params : null, servant: e, owner: apiName, whereDecls: h && h.fun && h.fun.clauses[0] && h.fun.clauses[0].rhs.where });
|
|
878
898
|
});
|
|
879
899
|
}
|
|
880
900
|
|
|
@@ -890,6 +910,23 @@ export function analyzeHaskellWeb(files, opts = {}) {
|
|
|
890
910
|
}
|
|
891
911
|
}
|
|
892
912
|
|
|
913
|
+
// Yesod handlers named by the framework's convention (`getInvoiceR`, `postPurgeR`, `handleHomeR`) whose route table is not in an analysed
|
|
914
|
+
// file (it usually lives in `config/routes`, read by a Template Haskell splice): the name and the `Handler` result type identify the entry point.
|
|
915
|
+
// A handler a route table already names is not added twice.
|
|
916
|
+
if (fw.has('yesod')) {
|
|
917
|
+
const named = new Set(pending.filter((p) => p.framework === 'yesod').map((p) => p.handlerName));
|
|
918
|
+
for (const mod of ctx.modules) {
|
|
919
|
+
if (!mod.imports.some((i) => /^Yesod(?:\.|$)/.test(i.module))) continue;
|
|
920
|
+
for (const fun of mod.groups.funs) {
|
|
921
|
+
const m = /^(get|post|put|delete|patch|options|head|handle)([A-Z][A-Za-z0-9_']*R)$/.exec(fun.name);
|
|
922
|
+
const sig = mod.sigs.get(fun.name);
|
|
923
|
+
if (!m || named.has(fun.name) || !sig || !/\bHandler(?:For|T)?\b/.test(sig.text)) continue;
|
|
924
|
+
const method = m[1] === 'handle' ? 'ANY' : m[1].toUpperCase();
|
|
925
|
+
add({ framework: 'yesod', method, path: `/${m[2].replace(/R$/, '').replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()}`, file: mod.file, line: fun.line, mod, handlerName: fun.name, resource: m[2], owner: m[2], conventional: true });
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
893
930
|
// ── resolve handlers and analyse ─────────────────────────────────────────
|
|
894
931
|
for (const p of pending) {
|
|
895
932
|
const R = newRoute({ framework: p.framework, method: p.method, path: p.path, file: p.file, line: p.line, owner: p.owner });
|
|
@@ -919,7 +956,7 @@ export function analyzeHaskellWeb(files, opts = {}) {
|
|
|
919
956
|
else if (q) handlerName = q.name;
|
|
920
957
|
}
|
|
921
958
|
} else {
|
|
922
|
-
try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, p.handlerAst, `${p.framework}-${p.line}`, p.line); ctx.handlerAst.set(handlerFn, { mod: p.mod, rhss: [{ guards: null, body: p.handlerAst, where: [] }] }); handlerName = '<inline>'; } catch { handlerFn = null; }
|
|
959
|
+
try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, p.handlerAst, `${p.framework}-${p.line}`, p.line); ctx.handlerAst.set(handlerFn, { mod: p.mod, rhss: [{ guards: null, body: p.handlerAst, where: [] }] }); handlerName = '<inline>'; if (p.handlerParams) paramNames = p.handlerParams; } catch { handlerFn = null; }
|
|
923
960
|
}
|
|
924
961
|
}
|
|
925
962
|
R.handler = handlerFn ? { kind: handlerName === '<inline>' ? 'inline' : 'named', name: handlerName } : { kind: 'unknown', name: handlerName };
|
|
@@ -1019,7 +1056,7 @@ function emitFindings(ctx, result, yesodAuthz) {
|
|
|
1019
1056
|
const vs = result.versionStatus[R.framework];
|
|
1020
1057
|
const untested = vs && vs.status !== 'tested';
|
|
1021
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` }] };
|
|
1022
|
-
const snip = (text.split('\n')[line - 1] || '').
|
|
1059
|
+
const snip = codeOfLine(text.split('\n')[line - 1] || '').slice(0, 240);
|
|
1023
1060
|
out.push({
|
|
1024
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,
|
|
1025
1062
|
description: `${r.vuln}: ${R.method} ${R.path} (${R.framework})${extra.detail ? ` - ${extra.detail}` : ''}`,
|
|
@@ -221,7 +221,8 @@ export function evaluateSupport(language, input) {
|
|
|
221
221
|
const strict = r.evidence.metric === 'sast-holdout' || r.evidence.metric === 'taint-holdout' ? (detection.layers || {})[key] && (detection.layers[key].strict || null) : null;
|
|
222
222
|
r.evidence = withInterval({ ...r.evidence, ...(strict && Number.isFinite(strict.precision) ? { strictPrecision: strict.precision } : {}) });
|
|
223
223
|
}
|
|
224
|
-
// Generalisation: the SAME layers measured on shapes absent from train, validation and holdout.
|
|
224
|
+
// Generalisation: the SAME layers measured on shapes absent from train, validation and holdout. When that split has become a DEVELOPMENT set (its
|
|
225
|
+
// failures were read and the engine was changed for them) the result is a regression number and is labelled so (`role`). It never changes a row's status (the
|
|
225
226
|
// PRD's gates are defined on the frozen holdout); it is stated beside it, with the shortfalls named, so "supported" is not read as more.
|
|
226
227
|
const un = input.unseen && input.unseen.ecosystems && input.unseen.ecosystems[language] && input.unseen.ecosystems[language].detection;
|
|
227
228
|
out.generalizationGaps = [];
|
|
@@ -234,7 +235,7 @@ export function evaluateSupport(language, input) {
|
|
|
234
235
|
if (!(u.precision >= TARGETS.precision)) shortfalls.push(`precision ${pct(u.precision)} < ${pct(TARGETS.precision)}`);
|
|
235
236
|
if (!(u.recall >= TARGETS.recall)) shortfalls.push(`recall ${pct(u.recall)} < ${pct(TARGETS.recall)}`);
|
|
236
237
|
if (!(u.f1 >= TARGETS.f1)) shortfalls.push(`F1 ${pct(u.f1)} < ${pct(TARGETS.f1)}`);
|
|
237
|
-
r.generalization = { status: shortfalls.length ? 'below-target' : 'meets-targets', shortfalls, cases: u.cases, tp: u.tp, fp: u.fp, fn: u.fn, tn: u.tn, precision: u.precision, recall: u.recall, f1: u.f1, strictPrecision: u.strict && u.strict.precision, ci: { precision: ciShapes(u.tp, u.tp + u.fp), recall: ciShapes(u.tp, u.tp + u.fn), level: 0.95, method: `Clopper-Pearson over shapes (${SHAPE_CLUSTER} near-identical cases per shape)` } };
|
|
238
|
+
r.generalization = { status: shortfalls.length ? 'below-target' : 'meets-targets', role: (input.unseen && input.unseen.splitRole) || 'generalisation', shortfalls, cases: u.cases, tp: u.tp, fp: u.fp, fn: u.fn, tn: u.tn, precision: u.precision, recall: u.recall, f1: u.f1, strictPrecision: u.strict && u.strict.precision, ci: { precision: ciShapes(u.tp, u.tp + u.fp), recall: ciShapes(u.tp, u.tp + u.fn), level: 0.95, method: `Clopper-Pearson over shapes (${SHAPE_CLUSTER} near-identical cases per shape)` } };
|
|
238
239
|
if (shortfalls.length) out.generalizationGaps.push(`${r.capability}: ${shortfalls.join(', ')}`);
|
|
239
240
|
}
|
|
240
241
|
// a capability whose tool is absent is blocked, not passed and not skipped
|
package/src/mcp/tools.js
CHANGED
|
@@ -475,6 +475,22 @@ export const scan_diff = {
|
|
|
475
475
|
},
|
|
476
476
|
};
|
|
477
477
|
|
|
478
|
+
// The ordered source-to-sink steps of a finding: its `trace` when the detector wrote one, else the `chain` the IR taint
|
|
479
|
+
// engine records (file, line, label, kind, provenance). Null when the finding carries neither.
|
|
480
|
+
function _flowSteps(f) {
|
|
481
|
+
if (Array.isArray(f.trace) && f.trace.length) return f.trace;
|
|
482
|
+
if (f.trace && !Array.isArray(f.trace)) return f.trace;
|
|
483
|
+
if (Array.isArray(f.chain) && f.chain.length) {
|
|
484
|
+
return f.chain.map((s) => ({ file: s.file ?? null, line: s.line ?? null, label: s.label ?? null, kind: s.kind || (s.provenance ? 'source' : 'step'), provenance: s.provenance ?? null }));
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
// Labels of the structured source, sink and rule, for matching only.
|
|
489
|
+
function _flowEnds(f) {
|
|
490
|
+
const lab = (x) => (x && typeof x === 'object' ? [x.label, x.family, x.kind].filter((v) => typeof v === 'string').join(' ') : '');
|
|
491
|
+
return [lab(f.source), lab(f.sink), typeof f.rule === 'string' ? f.rule : ''].join(' ');
|
|
492
|
+
}
|
|
493
|
+
|
|
478
494
|
// ─── query_taint ─────────────────────────────────────────────────────────────
|
|
479
495
|
export const query_taint = {
|
|
480
496
|
name: 'query_taint',
|
|
@@ -505,13 +521,16 @@ export const query_taint = {
|
|
|
505
521
|
// dilutes its reasoning; the agent receives a bounded slice plus the
|
|
506
522
|
// cursor to fetch the rest if it wants.
|
|
507
523
|
const all = (scan.findings || []).filter(f => {
|
|
508
|
-
|
|
524
|
+
// The structured flow counts too: an IR taint finding (Haskell included) records its path as `source`, `sink`, `rule`
|
|
525
|
+
// and `chain`, and carries no free-text description or `trace`, so matching only the text fields would answer
|
|
526
|
+
// "no path" for a path the scan did find.
|
|
527
|
+
const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(_flowSteps(f) || ''), _flowEnds(f)].join(' ').toLowerCase();
|
|
509
528
|
return hay.includes(srcL) && hay.includes(sinkL);
|
|
510
529
|
});
|
|
511
530
|
const page = all.slice(off, off + lim).map(f => redactFinding({
|
|
512
531
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
513
532
|
title: f.title || f.vuln, description: f.description,
|
|
514
|
-
trace: f
|
|
533
|
+
trace: _flowSteps(f),
|
|
515
534
|
}));
|
|
516
535
|
return {
|
|
517
536
|
_meta: META,
|
|
@@ -551,7 +570,7 @@ export const explain_finding = {
|
|
|
551
570
|
title: f.title || f.vuln, cwe: f.cwe,
|
|
552
571
|
description: f.description, remediation: f.remediation,
|
|
553
572
|
snippet: f.snippet || null,
|
|
554
|
-
trace: f
|
|
573
|
+
trace: _flowSteps(f),
|
|
555
574
|
});
|
|
556
575
|
// Harness-anatomy #1: explain_finding's trace is the most-likely-large
|
|
557
576
|
// field on a single finding. Offload when it crosses the threshold so
|
|
@@ -1211,6 +1230,9 @@ export const synthesize_fix = {
|
|
|
1211
1230
|
languageFix.applyWith = { finding_id: f.id, confirm: true, plan_digest: languageFix.planDigest };
|
|
1212
1231
|
}
|
|
1213
1232
|
else if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
|
|
1233
|
+
} else if (lc.isLanguageDependencyFinding(f)) {
|
|
1234
|
+
// Named, not silent: a Hackage or flake dependency is fixed by raising a constraint, which has its own tool.
|
|
1235
|
+
languageFix = { status: 'use-sca-upgrade', ok: false, label: null, tier: null, reason: 'this is a dependency finding: use synthesize_sca_upgrade for the constraint edit (apply_sca_upgrade writes it)', diff: null, explanation: null, consequences: [] };
|
|
1214
1236
|
}
|
|
1215
1237
|
} catch { /* best-effort: the preview is advisory */ }
|
|
1216
1238
|
}
|
|
@@ -1625,6 +1647,22 @@ export const query_cache_telemetry = {
|
|
|
1625
1647
|
},
|
|
1626
1648
|
};
|
|
1627
1649
|
|
|
1650
|
+
async function _languageUpgrade(lc, f, ctx, apply) {
|
|
1651
|
+
const proj = lc.loadLanguageProject(ctx.sessionRoot);
|
|
1652
|
+
return lc.languageUpgradePreview(f, { ...proj.files, ...proj.manifests }, { apply, root: ctx.sessionRoot });
|
|
1653
|
+
}
|
|
1654
|
+
// The result shape both SCA upgrade tools return for a Hackage or flake dependency. `verified` is never true: the edit
|
|
1655
|
+
// changes a declared constraint or a ref, not the resolved version.
|
|
1656
|
+
function _languageUpgradeResult(f, r) {
|
|
1657
|
+
return {
|
|
1658
|
+
ok: true, mode: r.ok ? 'source-edit' : 'blocked', ecosystem: f.ecosystem, package: f.package || null,
|
|
1659
|
+
currentVersion: f.version || null, targetVersion: null, command: null,
|
|
1660
|
+
reason: r.ok ? null : (r.reason || 'no upgrade could be planned'),
|
|
1661
|
+
languageUpgrade: { status: r.status || null, tier: r.tier || null, label: r.label || null, file: r.file || null, diff: r.diff || null, explanation: r.explanation || null, consequences: r.consequences || [], backup: r.backup ? r.backup.id || null : null },
|
|
1662
|
+
verified: false,
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1628
1666
|
// ─── synthesize_sca_upgrade ───────────────────────────────────────────────
|
|
1629
1667
|
// Phase 3 / Item 5 of the SCA improvement plan. Read-only counterpart to
|
|
1630
1668
|
// apply_sca_upgrade — produces a structured upgrade plan via the
|
|
@@ -1653,6 +1691,10 @@ export const synthesize_sca_upgrade = {
|
|
|
1653
1691
|
if (f.type !== 'vulnerable_dep') {
|
|
1654
1692
|
return { _meta: META, ok: false, reason: 'finding is not an SCA vulnerable_dep — use synthesize_fix for SAST findings' };
|
|
1655
1693
|
}
|
|
1694
|
+
// Hackage constraints and flake inputs are source edits the language planner owns; without this the generic planner
|
|
1695
|
+
// answers "no automated upgrade" for a dependency the CLI can fix. Read-only: nothing is written or run.
|
|
1696
|
+
const lc = await import('../language/context.js');
|
|
1697
|
+
if (lc.isLanguageDependencyFinding(f)) return { _meta: META, ..._languageUpgradeResult(f, await _languageUpgrade(lc, f, ctx, false)) };
|
|
1656
1698
|
const { planScaUpgrade } = await _getScaUpgrade();
|
|
1657
1699
|
const plan = await planScaUpgrade({ scanRoot: ctx.sessionRoot, finding: f });
|
|
1658
1700
|
return { _meta: META, ...plan };
|
|
@@ -1693,6 +1735,15 @@ export const apply_sca_upgrade = {
|
|
|
1693
1735
|
if (f.type !== 'vulnerable_dep') {
|
|
1694
1736
|
return { _meta: META, applied: false, reason: 'finding is not an SCA vulnerable_dep — use apply_fix for SAST findings' };
|
|
1695
1737
|
}
|
|
1738
|
+
const lc = await import('../language/context.js');
|
|
1739
|
+
if (lc.isLanguageDependencyFinding(f)) {
|
|
1740
|
+
// Plan first, confine the one file it names, then write it with a backup and a history entry (`undo` reverts it).
|
|
1741
|
+
const plan = await _languageUpgrade(lc, f, ctx, false);
|
|
1742
|
+
if (!plan.ok) return { _meta: META, applied: false, ..._languageUpgradeResult(f, plan) };
|
|
1743
|
+
try { _confine(ctx.sessionRoot, plan.file, 'upgrade.file'); } catch (e) { return { _meta: META, applied: false, reason: String((e && e.message) || e) }; }
|
|
1744
|
+
const done = await _languageUpgrade(lc, f, ctx, true);
|
|
1745
|
+
return { _meta: META, applied: done.applied === true, ..._languageUpgradeResult(f, done), testsRun: false, testsNote: 'a constraint edit is not re-resolved or re-tested by this tool; resolve (for example `cabal build --dry-run`), rescan and run the project tests before relying on it' };
|
|
1746
|
+
}
|
|
1696
1747
|
const { applyScaUpgrade } = await _getScaUpgrade();
|
|
1697
1748
|
const result = await applyScaUpgrade({ scanRoot: ctx.sessionRoot, finding: f, runTests: run_tests });
|
|
1698
1749
|
return { _meta: META, ...result };
|
|
@@ -167,6 +167,16 @@ const FAMILY_ALIAS = {
|
|
|
167
167
|
'license': 'license-graph',
|
|
168
168
|
'cloud-iam-overpermissive': 'iam-overpermissive',
|
|
169
169
|
'http-no-tls': 'crypto-tls-version',
|
|
170
|
+
// Haskell producers (language/haskell-*.js) name the same defects more finely; each maps to the canonical family of
|
|
171
|
+
// the same kind of defect, and a family with no canonical counterpart stays unmapped and is listed as such.
|
|
172
|
+
'missing-authentication': 'auth-missing',
|
|
173
|
+
'weak-randomness': 'crypto-weak-rng',
|
|
174
|
+
'weak-hash': 'crypto-weak-hash',
|
|
175
|
+
'password-hashing': 'crypto-kdf-weak',
|
|
176
|
+
'tls-validation': 'crypto-tls-no-verify',
|
|
177
|
+
'hardcoded-credentials': 'hardcoded-secret',
|
|
178
|
+
'broken-object-authorization': 'idor',
|
|
179
|
+
'broken-function-authorization': 'authz',
|
|
170
180
|
};
|
|
171
181
|
|
|
172
182
|
function _canonical(family) {
|
|
@@ -140,9 +140,13 @@ export function buildEntrypointInventory(fileContents, opts = {}) {
|
|
|
140
140
|
const line = Number.isInteger(route.line) ? route.line : 0;
|
|
141
141
|
const method = typeof route.method === 'string' ? route.method : 'GET';
|
|
142
142
|
const path = typeof route.path === 'string' ? route.path : '';
|
|
143
|
-
|
|
143
|
+
// A handler is a name for most frameworks and an object ({kind, name}) for Haskell routes; String() of the object
|
|
144
|
+
// is "[object Object]". An inline handler has no name of its own, so the route stands in for it.
|
|
145
|
+
const h = route.handler;
|
|
146
|
+
const handlerName = typeof h === 'string' ? h : (h && typeof h === 'object' && typeof h.name === 'string' && h.name && !/^<.*>$/.test(h.name) ? h.name : '');
|
|
147
|
+
const isWebhook = /webhook/i.test(path) || /webhook/i.test(handlerName);
|
|
144
148
|
const type = isWebhook ? 'webhook' : 'http';
|
|
145
|
-
const name = _clip(
|
|
149
|
+
const name = _clip(handlerName || `${method} ${path}`.trim() || type);
|
|
146
150
|
|
|
147
151
|
// Trust: an authenticated route (hasAuth) or a nearby auth token wins.
|
|
148
152
|
let trust = route.hasAuth === true ? 'authenticated' : 'unauthenticated';
|