@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/1602.index.js +275 -19
  3. package/dist/{4d06c8944ae121d7085c.json → 92f78fd3e2dbe30fb58d.json} +5 -5
  4. package/dist/9560.index.js +61 -4
  5. package/dist/agentic-security.mjs +2 -2
  6. package/dist/agentic-security.mjs.sha256 +1 -1
  7. package/package.json +5 -5
  8. package/src/capabilities/decide.js +7 -1
  9. package/src/dataflow/CLAUDE.md +15 -0
  10. package/src/dataflow/catalog-haskell.js +7 -1
  11. package/src/dataflow/catalog.js +14 -0
  12. package/src/dataflow/engine.js +147 -10
  13. package/src/dataflow/privacy-catalog-haskell.js +2 -1
  14. package/src/engine.js +11 -3
  15. package/src/language/CLAUDE.md +26 -0
  16. package/src/language/compliance-map.js +6 -0
  17. package/src/language/context.js +20 -0
  18. package/src/language/haskell-comments.js +65 -0
  19. package/src/language/haskell-disclosure.js +16 -4
  20. package/src/language/haskell-findings.js +2 -1
  21. package/src/language/haskell-fix.js +118 -2
  22. package/src/language/haskell-guards.js +132 -5
  23. package/src/language/haskell-ir.js +228 -13
  24. package/src/language/haskell-models.js +108 -8
  25. package/src/language/haskell-security-rules.js +223 -41
  26. package/src/language/haskell-syntax.js +10 -1
  27. package/src/language/haskell-trust.js +268 -0
  28. package/src/language/haskell-web.js +46 -9
  29. package/src/language/support-registry.js +3 -2
  30. package/src/mcp/tools.js +54 -3
  31. package/src/posture/attack-taxonomy.js +10 -0
  32. package/src/posture/entrypoint-inventory.js +6 -2
  33. package/src/posture/proof-fabric/generate-haskell.js +266 -17
  34. package/src/posture/proof-fabric/generate.js +10 -3
  35. package/src/posture/proof-fabric/oracles.js +21 -0
  36. package/src/posture/stable-id.js +2 -1
  37. package/src/report/index.js +3 -0
  38. package/src/sast/cloud-iam.js +3 -1
  39. 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
- if (flat.length > 0) return { fnName: fun.name, handlers: flat, fun };
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] || '').trim().slice(0, 240);
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. It never changes a row's status (the
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
- const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
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.trace || null,
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.trace || null,
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
- const isWebhook = /webhook/i.test(path) || /webhook/i.test(String(route.handler || ''));
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(route.handler || `${method} ${path}`.trim() || type);
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';