@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
@@ -30,7 +30,7 @@ const FAMILY_BY_CWE = { 'CWE-22': 'path', 'CWE-918': 'url', 'CWE-78': 'cmd', 'CW
30
30
 
31
31
  const isStr = (e) => e && e.kind === 'literal' && typeof e.value === 'string';
32
32
 
33
- function identNames(e, out = new Set(), depth = 0) {
33
+ export function identNames(e, out = new Set(), depth = 0) {
34
34
  if (!e || typeof e !== 'object' || depth > 30) return out;
35
35
  if (e.kind === 'ident' && e.name && !(e.hs && e.hs.functionRef)) out.add(e.name);
36
36
  for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') identNames(e[k], out, depth + 1);
@@ -44,6 +44,8 @@ const mentions = (e, vars) => { for (const n of identNames(e)) if (vars.has(n))
44
44
  /** Classify a condition over the value `vars`. -> {safeWhen, kind, families, needsCanon?, subject} | null */
45
45
  export function classifyCondition(c, vars, derived = new Set()) {
46
46
  if (!c || typeof c !== 'object') return null;
47
+ // The IR lowers `&&` / `||` to a `logical` node (`and` / `or`); a `binary` node is kept for the same operators from other lowerings.
48
+ if (c.kind === 'logical' && (c.op === 'and' || c.op === 'or')) return classifyCondition({ kind: 'binary', op: c.op === 'and' ? '&&' : '||', left: c.left, right: c.right }, vars, derived);
47
49
  if (c.kind === 'binary') {
48
50
  if (c.op === '&&') { const a = classifyCondition(c.left, vars, derived); const b = classifyCondition(c.right, vars, derived); return [a, b].find((x) => x && x.safeWhen === true) || null; }
49
51
  if (c.op === '||') { const a = classifyCondition(c.left, vars, derived); const b = classifyCondition(c.right, vars, derived); return [a, b].find((x) => x && x.safeWhen === false) || null; }
@@ -72,8 +74,12 @@ export function classifyCondition(c, vars, derived = new Set()) {
72
74
  }
73
75
  // `uriRegName a `elem` ["api.example.com"]` where `a` was parsed from the checked URL: the host is on an allow-list, which is the
74
76
  // URL family's control. Only a HOST accessor over a value derived from the checked one counts (a path or query allow-list does not).
75
- if ((IS_ELEM.has(name) || NOT_ELEM.has(name)) && a[0] && a[0].kind === 'call' && /(?:^|\.)(?:uriRegName|uriAuthority|getHost)$/.test(a[0].callee || '') && a[0].args && a[0].args[0] && a[0].args[0].kind === 'ident'
76
- && (vars.has(a[0].args[0].name) || derived.has(a[0].args[0].name)) && a[1] && a[1].kind === 'array' && a[1].elements.length && a[1].elements.every(isStr)) {
77
+ // The list may be written inline (a literal list of strings) or be a named constant (`allowedHosts`): either way it must not be built from the
78
+ // checked value, or the check would compare the value with itself.
79
+ const fixedList = (l) => l && ((l.kind === 'array' && l.elements.length && l.elements.every((x) => isStr(x) || (x && x.kind === 'call' && /(?:^|\.)(?:pack|fromString)$/.test(x.callee || '') && x.args && x.args.length === 1 && isStr(x.args[0]))))
80
+ || (l.kind === 'ident' && !mentions(l, vars) && !mentions(l, derived)));
81
+ if ((IS_ELEM.has(name) || NOT_ELEM.has(name)) && a[0] && a[0].kind === 'call' && /(?:^|\.)(?:uriRegName|uriAuthority|getHost|host|hostName)$/.test(a[0].callee || '') && a[0].args && a[0].args[0] && a[0].args[0].kind === 'ident'
82
+ && (vars.has(a[0].args[0].name) || derived.has(a[0].args[0].name)) && fixedList(a[1])) {
77
83
  return { safeWhen: IS_ELEM.has(name), kind: 'host-allow-list', families: ['url'], subject: a[0].args[0].name };
78
84
  }
79
85
  if ((IS_ELEM.has(name) || NOT_ELEM.has(name)) && a[0] && a[0].kind === 'ident' && vars.has(a[0].name) && !mentions(a[1], vars)) {
@@ -112,7 +118,7 @@ const exprChildren = (e) => {
112
118
  };
113
119
 
114
120
  // Find the sink call at `line` with callee `callee`; returns [{ nodeId, facts, call }].
115
- function findSites(fn, callee, line) {
121
+ export function findSites(fn, callee, line) {
116
122
  const sites = [];
117
123
  const visit = (e, nodeId, facts, depth) => {
118
124
  if (!e || typeof e !== 'object' || depth > 60) return;
@@ -264,6 +270,62 @@ function isCanonical(fn, name) {
264
270
  return false;
265
271
  }
266
272
 
273
+ // A hand-written HTML escaper: a project function (or one of its local helpers) that spells out the entities for `<`, `>` and `&`. Recognised by
274
+ // what the code does (it produces those entity strings), never by its name. Kept per IR build.
275
+ const ESCAPERS = new WeakMap();
276
+ const ENTITY = { lt: /&lt;|&#0*60;|&#x0*3c;/i, gt: /&gt;|&#0*62;|&#x0*3e;/i, amp: /&amp;|&#0*38;|&#x0*26;/i };
277
+ function stringLiteralsOf(fn) {
278
+ const out = [];
279
+ const visit = (e, depth) => {
280
+ if (!e || typeof e !== 'object' || depth > 60) return;
281
+ if (e.kind === 'literal' && typeof e.value === 'string') out.push(e.value);
282
+ for (const [, c] of exprChildren(e)) visit(c, depth + 1);
283
+ if (Array.isArray(e.branches)) for (const b of e.branches) visit(b, depth + 1); // the arms of a conditional or a multi-clause function
284
+ };
285
+ for (const n of nodeList(fn)) { for (const k of ['source', 'value', 'cond']) visit(n[k], 0); if (Array.isArray(n.args)) for (const a of n.args) visit(a, 0); }
286
+ return out;
287
+ }
288
+ export function htmlEscapers(perFile) {
289
+ if (!perFile || typeof perFile !== 'object') return new Set();
290
+ let hit = ESCAPERS.get(perFile);
291
+ if (hit) return hit;
292
+ hit = new Set();
293
+ for (const ir of Object.values(perFile)) {
294
+ const fns = ir.functions || [];
295
+ // local helpers and lambdas record their parent's qid: one pass groups them, so the whole file costs one visit per function
296
+ const children = new Map();
297
+ for (const fn of fns) { const par = fn.hs && fn.hs.parent; if (par) { if (!children.has(par)) children.set(par, []); children.get(par).push(fn); } }
298
+ const lits = new Map();
299
+ const litsOf = (fn, depth = 0) => {
300
+ if (lits.has(fn.qid)) return lits.get(fn.qid);
301
+ let out = stringLiteralsOf(fn);
302
+ if (depth < 8) for (const c of children.get(fn.qid) || []) out = out.concat(litsOf(c, depth + 1));
303
+ lits.set(fn.qid, out);
304
+ return out;
305
+ };
306
+ for (const fn of fns) {
307
+ if (!fn.name || fn.name.endsWith('.<module>') || (fn.hs && (fn.hs.kind === 'closure' || fn.hs.kind === 'local-function'))) continue;
308
+ const all = litsOf(fn);
309
+ if (all.some((l) => ENTITY.lt.test(l)) && all.some((l) => ENTITY.gt.test(l)) && all.some((l) => ENTITY.amp.test(l))) hit.add(fn.name);
310
+ }
311
+ }
312
+ ESCAPERS.set(perFile, hit);
313
+ return hit;
314
+ }
315
+
316
+ /** Every occurrence of a variable inside `e` is an argument of an escaper (or a value an escaper call produced): nothing raw reaches the sink. */
317
+ function allUsesEscaped(fn, e, escapers, depth = 0) {
318
+ if (!e || typeof e !== 'object' || depth > 40) return true;
319
+ if (e.kind === 'call' && escapers.has(e.callee)) return true; // whatever it receives is escaped
320
+ if (e.kind === 'ident') {
321
+ if (e.hs && e.hs.functionRef) return true;
322
+ const defs = nodeList(fn).filter((n) => n.kind === 'assign' && n.target === e.name && n.source);
323
+ return defs.length > 0 && defs.every((n) => n.source.kind === 'call' && escapers.has(n.source.callee));
324
+ }
325
+ for (const [, c] of exprChildren(e)) if (!allUsesEscaped(fn, c, escapers, depth + 1)) return false;
326
+ return true;
327
+ }
328
+
267
329
  /**
268
330
  * Judge one finding. Mutates it when a dominating, family-appropriate guard refutes the flow.
269
331
  * @returns {{status:'refuted'|'weak-guard'|'none', guard?:object}}
@@ -289,7 +351,16 @@ export function judgeFinding(f, perFile) {
289
351
  const derived = derivedFrom(fn, vars); // values built FROM the checked one (`a` in `Just a <- parseURI url`)
290
352
  const facts = [...site.facts, ...cfgBranchFacts(fn, site.nodeId), ...exitGuardFacts(fn, site.nodeId)];
291
353
  let refuted = null;
354
+ if (fam === 'xss') {
355
+ const escapers = htmlEscapers(perFile);
356
+ const calls = [];
357
+ const find = (e, d) => { if (!e || typeof e !== 'object' || d > 40) return; if (e.kind === 'call' && escapers.has(e.callee)) calls.push(e); for (const [, c] of exprChildren(e)) find(c, d + 1); };
358
+ find(argExpr, 0);
359
+ for (const n of nodeList(fn)) if (n.kind === 'assign' && n.source && identNames(argExpr).has(n.target)) find(n.source, 0);
360
+ if (calls.length && allUsesEscaped(fn, argExpr, escapers)) refuted = { kind: 'html-escaper', line: calls[0].line || fn.line, subject: calls[0].callee };
361
+ }
292
362
  for (const fact of facts) {
363
+ if (refuted) break;
293
364
  const cls = classifyCondition(fact.cond, vars, derived);
294
365
  if (!cls || cls.safeWhen !== fact.when) continue;
295
366
  if (!(cls.families.includes('*') || cls.families.includes(fam))) continue; // wrong-context control
@@ -346,7 +417,10 @@ export function promoteShellInterpreter(f, perFile) {
346
417
  if (!isStr(prog) || !SHELLS.test(prog.value) || !list || list.kind !== 'array') continue;
347
418
  const flag = list.elements.findIndex((e) => isStr(e) && SHELL_C.test(e.value));
348
419
  if (flag < 0) continue;
349
- if (list.elements.slice(flag + 1).some((e) => e && e.kind !== 'literal')) {
420
+ // `sh -c SCRIPT arg0 arg1 ..`: only the SCRIPT is parsed by the shell. The elements after it are the positional parameters ($0, $1, ...), plain
421
+ // data to a script that does not interpolate them into code; a tainted script is injection, tainted positionals are judged by `quotedPositionals`.
422
+ const script = list.elements[flag + 1];
423
+ if (script && script.kind !== 'literal') {
350
424
  f.cwe = 'CWE-78'; f.severity = 'critical'; f.vuln = 'OS Command Injection (shell interpreter -c)';
351
425
  f.id = String(f.id).replace('cwe-88', 'cwe-78'); f.shellInterpreter = prog.value;
352
426
  return true;
@@ -355,12 +429,65 @@ export function promoteShellInterpreter(f, perFile) {
355
429
  return false;
356
430
  }
357
431
 
432
+ /**
433
+ * `callProcess "sh" ["-c", "gzip -9 -- \"$1\"", "sh", target]`: the script is a constant and the tainted value is a positional parameter the
434
+ * script expands INSIDE DOUBLE QUOTES after `--`. The shell does not re-parse a quoted expansion, and `--` ends the program's option parsing, so
435
+ * neither shell nor option injection is possible through it. A reference that is unquoted, quoted with single quotes, or not preceded by `--` is not
436
+ * covered: the finding stays. Returns the control or null.
437
+ */
438
+ export function quotedPositionals(f, perFile) {
439
+ if (!f || f.cwe !== 'CWE-88' || !PROCESS_FNS.has(f.callee)) return null;
440
+ const ir = perFile && perFile[f.file];
441
+ const fn = ir && f._funcQid && ir.functions.find((x) => x.qid === f._funcQid);
442
+ if (!fn) return null;
443
+ const sites = findSites(fn, f.callee, f.line);
444
+ if (!sites.length) return null;
445
+ for (const site of sites) {
446
+ const [prog, list] = site.call.args || [];
447
+ if (!isStr(prog) || !SHELLS.test(prog.value) || !list || list.kind !== 'array') return null;
448
+ const flag = list.elements.findIndex((e) => isStr(e) && SHELL_C.test(e.value));
449
+ const script = flag >= 0 ? list.elements[flag + 1] : null;
450
+ if (!isStr(script)) return null;
451
+ // a tainted element BEFORE the script (or the script itself) is not a positional parameter
452
+ if (list.elements.slice(0, flag + 2).some((e) => e && e.kind !== 'literal')) return null;
453
+ // the literal keeps the Haskell string's own escapes (`\"`, `\\`, `\n`): decode them to get the text the shell receives
454
+ const text = script.value.replace(/\\(["'\\ntr])/g, (m, c) => ({ n: '\n', t: '\t', r: '\r' }[c] || c));
455
+ let dq = false; let sq = false; let seenDashes = false;
456
+ for (let i = 0; i < text.length; i++) {
457
+ const ch = text[i];
458
+ if (ch === '\\' && !sq) { i++; continue; }
459
+ if (ch === "'" && !dq) { sq = !sq; continue; }
460
+ if (ch === '"' && !sq) { dq = !dq; continue; }
461
+ if (!dq && !sq && ch === '-' && text[i + 1] === '-' && /\s/.test(text[i + 2] || ' ') && (i === 0 || /\s/.test(text[i - 1]))) { seenDashes = true; continue; }
462
+ if (ch === '$' && !sq) {
463
+ const rest = text.slice(i + 1);
464
+ if (/^(?:[0-9]|\{[0-9]+\}|[@*]|\{[@*]\})/.test(rest)) {
465
+ if (!dq || !seenDashes) return null;
466
+ } else if (/^[A-Za-z_(`{]/.test(rest)) { return null; } // another expansion or a command substitution: not judged here
467
+ }
468
+ if (ch === '`' && !sq) return null;
469
+ }
470
+ return { kind: 'quoted-positional-parameters' };
471
+ }
472
+ return null;
473
+ }
474
+
358
475
  /** Applies judgeFinding to every Haskell taint finding. Returns counts. */
359
476
  export function applyHaskellGuards(findings, perFile) {
360
477
  const stats = { refuted: 0, weak: 0 };
361
478
  for (const f of findings || []) {
362
479
  if (!f || f.parser !== 'IR-TAINT') continue;
363
480
  try { promoteShellInterpreter(f, perFile); } catch { /* judged as an argument-injection below */ }
481
+ try {
482
+ const qp = quotedPositionals(f, perFile);
483
+ if (qp && !f._quotedPositionals) {
484
+ f._quotedPositionals = true; f.severity = 'info';
485
+ f.controls = [...(f.controls || []), { kind: 'quoted-positional-parameters', evidenceKind: 'source' }];
486
+ f.guardNote = { kind: 'quoted-positional-parameters', reason: 'the script is a constant and expands the tainted value only inside double quotes after "--"; the shell does not re-parse it and the program stops reading options' };
487
+ stats.weak++;
488
+ continue;
489
+ }
490
+ } catch { /* judged below */ }
364
491
  try {
365
492
  const ot = optionTerminator(f, perFile);
366
493
  if (ot && !f._optionTerminated) {
@@ -27,7 +27,7 @@ import { createHash } from 'node:crypto';
27
27
  import { parseSyntax, groupDecls, patternVars } from './haskell-syntax.js';
28
28
  import { preprocessForSyntax } from './haskell-parser.js';
29
29
  import { deriveCppContext } from './haskell-cpp.js';
30
- import { isKnownApi, isSourceApi, qualifyAmbiguous } from './haskell-models.js';
30
+ import { isKnownApi, isSourceApi, qualifyAmbiguous, handlerFrameworkOf } from './haskell-models.js';
31
31
 
32
32
  export const HS_IR_LIMITS = Object.freeze({
33
33
  maxExprDepth: 200,
@@ -50,9 +50,14 @@ const HOF = {
50
50
  forM_: { fn: 1, coll: 0, kind: 'traverse_' }, for_: { fn: 1, coll: 0, kind: 'traverse_' },
51
51
  filter: { fn: 0, coll: 1, kind: 'filter' },
52
52
  foldr: { fn: 0, seed: 1, coll: 2, kind: 'fold' }, foldl: { fn: 0, seed: 1, coll: 2, kind: 'fold' }, "foldl'": { fn: 0, seed: 1, coll: 2, kind: 'fold' },
53
+ foldM: { fn: 0, seed: 1, coll: 2, kind: 'fold', monadic: true }, foldM_: { fn: 0, seed: 1, coll: 2, kind: 'fold', monadic: true },
54
+ zipWith: { fn: 0, coll: 1, coll2: 2, kind: 'map' }, zipWithM: { fn: 0, coll: 1, coll2: 2, kind: 'traverse' }, zipWithM_: { fn: 0, coll: 1, coll2: 2, kind: 'traverse_' },
53
55
  sequence: { coll: 0, kind: 'sequence' }, sequenceA: { coll: 0, kind: 'sequence' }, sequence_: { coll: 0, kind: 'sequence' },
54
56
  };
55
57
 
58
+ // Predicates that accept only letters and digits (Data.Char): a string filtered by one holds nothing else.
59
+ const CHAR_CLASS = new Set(['Data.Char.isAlphaNum', 'Data.Char.isAlpha', 'Data.Char.isDigit', 'Data.Char.isHexDigit', 'Data.Char.isOctDigit', 'Data.Char.isLetter', 'Data.Char.isNumber']);
60
+
56
61
  const ARITH = new Set(['+', '-', '*', '/', '^', '**', '==', '/=', '<', '<=', '>', '>=', '!!', '%']);
57
62
 
58
63
  const unknownExpr = (hs) => (hs ? { kind: 'unknown', hs } : { kind: 'unknown' });
@@ -84,7 +89,7 @@ function sigInfo(tokens, sig) {
84
89
  else if (depth === 0 && t.t === 'rop' && t.v === '->') { argTypes.push(tokens.slice(start, i).map((x) => x.v).join(' ')); start = i + 1; }
85
90
  }
86
91
  }
87
- return { arity, returnsIO: !!first && first.t === 'con' && first.v === 'IO', text: tokens.slice(sig.ta, sig.tb).map((t) => t.v).join(' '), argTypes };
92
+ return { arity, returnsIO: !!first && first.t === 'con' && first.v === 'IO', text: tokens.slice(sig.ta, sig.tb).map((t) => t.v).join(' '), argTypes, resultText: tokens.slice(resStart, sig.tb).map((t) => t.v).join(' ') };
88
93
  }
89
94
 
90
95
  // Parameters of an EXPORTED function whose declared type is text-like: the caller supplies that text, and nothing in the
@@ -92,10 +97,72 @@ function sigInfo(tokens, sig) {
92
97
  // request or stdin read: the finding says so). A function without a signature, a non-exported function and a parameter
93
98
  // of any other type (a connection, a number, a record) are never sources.
94
99
  const TEXT_LIKE = /^\(?\s*(?:Maybe\s+)?(?:String|Text|ByteString|FilePath|URI|Url|URL|\[\s*Char\s*\]|\[\s*(?:String|Text)\s*\])\s*\)?$/;
95
- function callerControlledParams(fn, sig, exported) {
100
+ // Parameters of an exported function whose declared type is a container or text (a list, a map, a set, a sequence, a vector, a string):
101
+ // a PARTIAL function (`maximum`, `head`, `Map.!`) applied to one fails on the empty or missing case the caller can supply. Wider than
102
+ // TEXT_LIKE on purpose, and used only by the partial-function rule, never as an injection source.
103
+ const COLLECTION_LIKE = /^\(?\s*(?:Maybe\s+)?(?:\[.*\]|(?:Map|HashMap|IntMap|Set|HashSet|IntSet|Seq|Vector|Text|String|ByteString)\b.*)\)?$/;
104
+ function inputParams(fn, sig, exported) {
105
+ if (!exported || !sig || !Array.isArray(sig.argTypes) || !Array.isArray(fn.params)) return [];
106
+ const out = [];
107
+ sig.argTypes.forEach((t, i) => { const name = fn.params[i]; if (name && (TEXT_LIKE.test(t) || COLLECTION_LIKE.test(t))) out.push(name); });
108
+ return out;
109
+ }
110
+ function callerControlledParams(fn, sig, exported, imports) {
96
111
  if (!exported || !sig || !Array.isArray(sig.argTypes) || !Array.isArray(fn.params)) return [];
97
112
  const out = [];
98
- sig.argTypes.forEach((t, i) => { const name = fn.params[i]; if (name && TEXT_LIKE.test(t)) out.push({ index: i, name, decorator: 'hs:caller-controlled' }); });
113
+ // The result type is the part of the signature after the last top-level arrow; a function returning a framework handler value gets its text
114
+ // parameters labelled as request input, any other exported function keeps the weaker caller-controlled label.
115
+ const handler = handlerFrameworkOf(imports, sig.resultText);
116
+ const decorator = handler ? handler.decorator : 'hs:caller-controlled';
117
+ sig.argTypes.forEach((t, i) => { const name = fn.params[i]; if (name && TEXT_LIKE.test(t)) out.push({ index: i, name, decorator }); });
118
+ return out;
119
+ }
120
+
121
+ // A Servant endpoint written as a function (`server acct rowId = ...` for `type API = AuthProtect "jwt" :> Capture "id" Int :> Get ..`): its parameters are
122
+ // the values the API type takes from the request, in order of appearance. Only a single-endpoint API can be paired with one function by position (several
123
+ // endpoints are joined with `:<|>` and each handler is its own function, which the handler-result rule already covers). A parameter is a request source
124
+ // when its API combinator reads the request (capture, query, header, body) and its declared type is text; an authenticated principal is not.
125
+ const SERVANT_READS = new Set(['Capture', "Capture'", 'QueryParam', "QueryParam'", 'QueryParams', 'Header', "Header'", 'ReqBody', "ReqBody'"]);
126
+ const SERVANT_PRINCIPAL = new Set(['AuthProtect', 'BasicAuth', 'Auth']);
127
+ function servantEndpointParams(mod, fn, sig, exported) {
128
+ if (!exported || !sig || !sig.resultText || !Array.isArray(fn.params) || !fn.params.length) return [];
129
+ const m = /^\s*(?:Server|ServerT)\s+([A-Z][A-Za-z0-9_']*)\b/.exec(sig.resultText);
130
+ if (!m || !mod.imports.some((i) => /^Servant(?:\.|$)/.test(i.module))) return [];
131
+ const td = (mod.parse.typeDecls || []).find((d) => d.name === m[1]);
132
+ if (!td) return [];
133
+ const T = mod.parse.tokens;
134
+ const slice = T.slice(td.a, td.b);
135
+ const eq = slice.findIndex((t) => t.t === 'rop' && t.v === '=');
136
+ if (eq < 0) return [];
137
+ const split = (tokens, sep) => {
138
+ const parts = [[]]; let depth = 0;
139
+ for (const t of tokens) {
140
+ if (t.t === 'sp' && (t.v === '(' || t.v === '[')) depth++;
141
+ if (t.t === 'sp' && (t.v === ')' || t.v === ']')) depth--;
142
+ if (depth === 0 && t.t === 'op' && t.v === sep) { parts.push([]); continue; }
143
+ parts[parts.length - 1].push(t);
144
+ }
145
+ return parts;
146
+ };
147
+ const body = slice.slice(eq + 1);
148
+ if (split(body, ':<|>').filter((p) => p.length).length !== 1) return [];
149
+ const out = [];
150
+ let idx = 0;
151
+ for (const part of split(body, ':>').filter((p) => p.length)) {
152
+ const head = part[0];
153
+ if (!head || head.t !== 'con') continue;
154
+ const isPrincipal = SERVANT_PRINCIPAL.has(head.v);
155
+ if (!isPrincipal && !SERVANT_READS.has(head.v)) continue;
156
+ const name = fn.params[idx];
157
+ idx++;
158
+ if (isPrincipal || !name) continue;
159
+ // the carried type is what follows the name string (or the content-type list for a body): its text decides whether it is text
160
+ const lastStr = part.map((t) => t.t).lastIndexOf('str');
161
+ let rest = part.slice(lastStr >= 0 ? lastStr + 1 : 1);
162
+ if (/^ReqBody/.test(head.v)) { const close = rest.findIndex((t) => t.t === 'sp' && t.v === ']'); rest = close >= 0 ? rest.slice(close + 1) : rest; }
163
+ const type = rest.map((t) => t.v).join(' ').trim();
164
+ if (type && TEXT_LIKE.test(type.replace(/^\[\s*(.*)\s*\]$/, '[ $1 ]').replace(/\s+/g, ' '))) out.push({ index: idx - 1, name, decorator: 'hs:handler-param:servant' });
165
+ }
99
166
  return out;
100
167
  }
101
168
 
@@ -197,6 +264,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
197
264
  const hits = [];
198
265
  const wildcard = [];
199
266
  let unknownReason = null;
267
+ let unknownCandidates = null;
200
268
  for (const imp of visibleImports(mod, name, qual)) {
201
269
  const target = proj.modules.get(imp.module);
202
270
  if (target) {
@@ -207,7 +275,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
207
275
  const owner = r.kind === 'def' ? (r.def.kind === 'field' ? (proj.modules.get(r.mod)?.cons.get(r.def.con)?.type) : r.def.kind === 'method' ? r.def.cls : null) : null;
208
276
  if (!owner || !(imp.typeAll || []).includes(owner)) continue;
209
277
  }
210
- if (r.kind === 'unknown') unknownReason = r.reason; else hits.push(r);
278
+ if (r.kind === 'unknown') { unknownReason = r.reason; if (r.candidates) unknownCandidates = r.candidates; } else hits.push(r);
211
279
  } else if (qual || (imp.items && !imp.hiding && imp.items.includes(name))) hits.push({ kind: 'external', module: imp.module, name, certain: true });
212
280
  else wildcard.push(imp.module); // includes `import M (T(..))` of a module we cannot read: possible, never certain
213
281
  }
@@ -215,7 +283,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
215
283
  const distinct = [...new Map(hits.map((r) => [keyOf(r), r])).values()];
216
284
  if (distinct.length === 1) return distinct[0];
217
285
  if (distinct.length > 1) return { kind: 'unknown', reason: 'ambiguous-import', candidates: distinct.map(keyOf) };
218
- if (unknownReason) return { kind: 'unknown', reason: unknownReason };
286
+ if (unknownReason) return unknownCandidates ? { kind: 'unknown', reason: unknownReason, candidates: unknownCandidates } : { kind: 'unknown', reason: unknownReason };
219
287
  if (!qual && PRELUDE.has(name)) return { kind: 'builtin', module: 'Prelude', name };
220
288
  if (wildcard.length) return { kind: 'external', module: wildcard.length === 1 ? wildcard[0] : null, candidates: wildcard, name, certain: false };
221
289
  return { kind: 'unknown', reason: qual ? 'unknown-qualifier' : 'unbound-name' };
@@ -363,6 +431,25 @@ function inlinableBody(fnAst) {
363
431
  return acc.ok ? { params: c.pats.map((p) => p.name), body: c.rhs.body } : null;
364
432
  }
365
433
 
434
+ // A point-free alias: `tidy = takeFileName`, `clean = trim . takeFileName`. A zero-argument binding whose whole right-hand side is a
435
+ // function name or a composition of function names IS the function it names, so applying it is applying them. Without this the
436
+ // application is an unresolved call, and a sanitizer (or a sink) reached through the alias loses its identity.
437
+ // Returns the right-hand side AST and the unqualified names it mentions, or null for anything else (guards, where, any other form).
438
+ function aliasBodyOf(fnAst) {
439
+ if (!fnAst || fnAst.clauses.length !== 1) return null;
440
+ const c = fnAst.clauses[0];
441
+ if (c.pats.length || !c.rhs || (c.rhs.guards && c.rhs.guards.length) || (c.rhs.where && c.rhs.where.length) || !c.rhs.body) return null;
442
+ const names = [];
443
+ const ok = (e) => {
444
+ const x = unparen(e);
445
+ if (!x) return false;
446
+ if (x.t === 'var') { if (!x.qual) names.push(x.name); return x.name !== fnAst.name; }
447
+ if (x.t === 'op' && x.op === '.' && !x.qual) return ok(x.l) && ok(x.r);
448
+ return false;
449
+ };
450
+ return ok(c.rhs.body) ? { body: c.rhs.body, names } : null;
451
+ }
452
+
366
453
  // ── per-function lowering ────────────────────────────────────────────────────
367
454
  class FnLowerer {
368
455
  constructor(proj, mod, fnInfo, scopes) {
@@ -429,6 +516,10 @@ class FnLowerer {
429
516
  case 'app': return this.lowerApp(e.f, e.args, ln);
430
517
  case 'op': return this.lowerOp(e, ln);
431
518
  case 'neg': return { kind: 'binary', op: '-', left: { kind: 'literal', value: 0 }, right: this.lowerExpr(e.e, ln) };
519
+ // `read s :: Int`: the value is a number, so it is a clean constant for the taint engine. The lowered inner expression rides on `hs.inner`, which the rule and web
520
+ // analyses read (an unsafe coercion in it, the id it names), exactly as they read `hs.selector`. (A sanitizer call would be wrong here: the engine's sanitizer label is per expression, so a
521
+ // number in `"kill " ++ show n ++ name` would mark the tainted `name` sanitized too.)
522
+ case 'typed': return { kind: 'literal', value: 0, hs: { numeric: e.type, inner: this.lowerExpr(e.e, ln) } };
432
523
  case 'tuple': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { tuple: true } };
433
524
  case 'list': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)) };
434
525
  case 'range': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { range: true } };
@@ -494,6 +585,7 @@ class FnLowerer {
494
585
  return { kind: 'ident', name: this.canonical(r), hs: { functionRef: r.qid } };
495
586
  }
496
587
  if (r.kind === 'param' || r.kind === 'local') return { kind: 'ident', name: e.name };
588
+ if (r.kind === 'unknown' && r.reason === 'ambiguous-import') for (const q of r.candidates || []) this.noteRef(q, 'ambiguous-import', ln);
497
589
  const lib = libraryName(r, e.name, e.qual);
498
590
  const viaReg = lib.viaRegistry ? { viaRegistry: true, certain: false } : {};
499
591
  // A modelled SOURCE action named as a value (`getLine >>= f`, `fmap g getLine`) yields untrusted data
@@ -526,7 +618,9 @@ class FnLowerer {
526
618
  }
527
619
  if (f.t === 'rsec') return this.lowerOp({ t: 'op', op: f.op, qual: f.qual, l: argAsts[0], r: f.e, line: ln }, ln);
528
620
  if (f.t === 'lsec') return this.lowerOp({ t: 'op', op: f.op, qual: f.qual, l: f.e, r: argAsts[0], line: ln }, ln);
529
- if (f.t === 'lam') return this.betaReduce(f, argAsts, ln);
621
+ if (f.t === 'lam') return this.betaReduce(f, argAsts, ln, via);
622
+ // `m >>= \case ...`: a \case applied to its argument is a case on that argument, run here (not a call of an unknown lifted function)
623
+ if (f.t === 'lamcase') return this.betaReduce({ params: [{ t: 'pvar', name: '$lc' }], body: { t: 'case', scrut: { t: 'var', name: '$lc', qual: null }, alts: f.alts, line: ln }, line: ln }, argAsts, ln, via);
530
624
  if (f.t === 'var') return this.applyVar(f, argAsts, ln, via);
531
625
  if (f.t === 'con') {
532
626
  const cons = this.mod.cons.get(f.name) || this.proj.findCon(f.name);
@@ -539,11 +633,14 @@ class FnLowerer {
539
633
  return { kind: 'call', callee: '<indirect>', args: [fv, ...argAsts.map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'computed-function', via } };
540
634
  }
541
635
 
542
- betaReduce(lam, argAsts, ln) {
636
+ betaReduce(lam, argAsts, ln, via) {
543
637
  const n = Math.min(lam.params.length, argAsts.length);
544
638
  this.push();
545
639
  try {
546
- for (let i = 0; i < n; i++) this.bindPattern(lam.params[i], this.lowerExpr(argAsts[i], ln), ln);
640
+ // `action >>= \x -> k` and `f <$> action` RUN the action and hand its result to the lambda, even when the lambda ignores it (`\_ -> k`):
641
+ // the action is executed where it is sequenced, not only where its result is used.
642
+ const runs = via === 'bind' || via === 'fmap' || via === 'applicative';
643
+ for (let i = 0; i < n; i++) this.bindPattern(lam.params[i], this.lowerExpr(argAsts[i], ln), ln, runs ? { executed: true } : undefined);
547
644
  if (lam.params.length > argAsts.length) return { kind: 'array', elements: argAsts.map((a) => this.lowerExpr(a, ln)), hs: { partial: { remaining: lam.params.length - argAsts.length }, closure: true } };
548
645
  const body = this.lowerExpr(lam.body, ln);
549
646
  if (argAsts.length > n) return { kind: 'call', callee: '<indirect>', args: [body, ...argAsts.slice(n).map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'result-applied' } };
@@ -558,11 +655,30 @@ class FnLowerer {
558
655
  const out = this.lowerHof(f.name, argAsts, ln);
559
656
  if (out) return out;
560
657
  }
658
+ // `maybe d f m` and `either f g e` apply a function to the value inside: `f` (and `g`) are called with it, so a sink passed as the function
659
+ // (`maybe (pure ()) callCommand mc`) is reached by the value. The result is whichever branch ran.
660
+ if ((r.kind === 'builtin' || r.kind === 'external') && ((f.name === 'maybe' && argAsts.length >= 3) || (f.name === 'either' && argAsts.length >= 3))) {
661
+ // The scrutinee is examined whichever branch runs, so it is the condition of a two-way branch (an ignored payload, `\\_ -> ..`, still reads it).
662
+ const scrut = this.lowerExpr(argAsts[2], ln);
663
+ const inner = { t: 'lowered', expr: scrut };
664
+ return f.name === 'maybe'
665
+ ? this.branch(scrut, ln, () => this.lowerApp(argAsts[1], [inner], ln, f.name), () => this.lowerExpr(argAsts[0], ln))
666
+ : this.branch(scrut, ln, () => this.lowerApp(argAsts[1], [inner], ln, f.name), () => this.lowerApp(argAsts[0], [inner], ln, f.name));
667
+ }
668
+ // Selectors and combinators over a tuple that is written out: `fst (a, b)` is `a`, `const x y` is `x`, `uncurry f (a, b)` is `f a b`.
669
+ if ((r.kind === 'builtin' || r.kind === 'external') && !f.qual) {
670
+ const tup = (a) => { const u = unparen(a); return u && u.t === 'tuple' && u.items.length === 2 ? u.items : null; };
671
+ if ((f.name === 'fst' || f.name === 'snd') && argAsts.length === 1 && tup(argAsts[0])) return this.lowerExpr(tup(argAsts[0])[f.name === 'fst' ? 0 : 1], ln);
672
+ if (f.name === 'const' && argAsts.length === 2) { this.lowerExpr(argAsts[1], ln); return this.lowerExpr(argAsts[0], ln); }
673
+ if (f.name === 'uncurry' && argAsts.length === 2 && tup(argAsts[1])) return this.lowerApp(argAsts[0], tup(argAsts[1]), ln, via);
674
+ }
561
675
  if (r.kind === 'def' && r.def.kind === 'field' && argAsts.length >= 1) {
562
676
  const obj = this.lowerExpr(argAsts[0], ln);
563
677
  const mem = { kind: 'member', object: obj, prop: r.name };
564
678
  return argAsts.length === 1 ? mem : { kind: 'call', callee: '<indirect>', args: [mem, ...argAsts.slice(1).map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'field-applied' } };
565
679
  }
680
+ const viaAlias = this.applyAlias(r, argAsts, ln, via);
681
+ if (viaAlias) return viaAlias;
566
682
  let args = argAsts.map((a) => this.lowerExpr(a, ln));
567
683
  // `lookup key table` returns an element OF the table: the key chooses which, it is not where the value comes from. Only the table
568
684
  // flows to the result, so a constant table gives a constant result however tainted the key is.
@@ -611,6 +727,10 @@ class FnLowerer {
611
727
  if (IO_NAMES.has(f.name)) this.sawIO = true;
612
728
  } else {
613
729
  hs.status = 'unknown'; hs.reason = r.reason || 'unresolved'; hs.effect = 'unknown';
730
+ // An import that two modules both satisfy (the Posix and Windows variants of one module behind a CPP conditional, two re-exports of
731
+ // one name) is a call to ONE of them, which one is not decided here. Each candidate is referenced, so none of them reads as a
732
+ // function nothing in the project uses.
733
+ if (r.reason === 'ambiguous-import') for (const q of r.candidates || []) this.noteRef(q, 'ambiguous-import', ln);
614
734
  }
615
735
  if (hs.status === 'resolved' || hs.status === 'typeclass') {
616
736
  const ctxTargets = args.map((a) => argTargets(a));
@@ -635,6 +755,39 @@ class FnLowerer {
635
755
  return { kind: 'ident', name: t, hs: { sourceTemp: call.callee } };
636
756
  }
637
757
 
758
+ // Apply a point-free alias (see aliasBodyOf) as the function(s) it names. The names are looked up where the alias was DEFINED; a
759
+ // local binding of one of them at the use site would capture it, so that case is left to the ordinary (unresolved) call.
760
+ applyAlias(r, argAsts, ln, via) {
761
+ let alias = null; let calleeMod = this.mod;
762
+ if (r.kind === 'local' && r.alias) alias = r.alias;
763
+ else if (r.kind === 'def' && r.def.kind === 'fun' && r.def.arity === 0) {
764
+ calleeMod = this.proj.modules.get(r.mod);
765
+ alias = calleeMod ? aliasBodyOf(calleeMod.groups.funs.find((f) => f.name === r.name)) : null;
766
+ }
767
+ if (!alias || (this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
768
+ if (alias.names.some((n) => this.lookupLocal(n))) return null;
769
+ const savedMod = this.mod;
770
+ // Only an alias of LIBRARY functions is expanded. A project function keeps its own summary and call-graph node, and
771
+ // expanding it here would replace the edge to it with edges to what it calls.
772
+ this.mod = calleeMod;
773
+ const libraryOnly = alias.names.every((n) => { const k = this.resolve(n, null).kind; return k === 'external' || k === 'builtin'; });
774
+ this.mod = savedMod;
775
+ if (!libraryOnly) return null;
776
+ // Arguments belong to the CALLER: lower them here, once, then hand them over as already-lowered expressions.
777
+ const lowered = argAsts.map((a) => ({ t: 'lowered', expr: this.lowerExpr(a, ln) }));
778
+ const savedOverride = this.lineOverride;
779
+ this.lineOverride = this.lineOverride || ln;
780
+ this.inlineDepth = (this.inlineDepth || 0) + 1;
781
+ this.mod = calleeMod;
782
+ // The alias is still CALLED here: keep its call-graph edge, exactly as an inlined function body does.
783
+ if (r.kind === 'def') (this.inlinedCalls ||= []).push({ site: null, callee: this.canonical(r), args: [], line: ln, hs: { status: 'resolved', target: r.qid, effect: 'unknown', inlined: true, ...(via ? { via } : {}) } });
784
+ try { return this.lowerApp(alias.body, lowered, ln); } finally {
785
+ this.mod = savedMod;
786
+ this.lineOverride = savedOverride;
787
+ this.inlineDepth -= 1;
788
+ }
789
+ }
790
+
638
791
  // Substitute lowered arguments into a small pure callee body. Returns null when the callee is not inlinable.
639
792
  tryInline(r, loweredArgs, ln, via) {
640
793
  const calleeMod = this.proj.modules.get(r.mod);
@@ -673,19 +826,31 @@ class FnLowerer {
673
826
  // Bounded collection/traversal summaries. Returns null to fall back to a plain call.
674
827
  lowerHof(name, argAsts, ln) {
675
828
  const spec = HOF[name];
676
- const need = Math.max(spec.fn === undefined ? 0 : spec.fn, spec.coll) + 1;
829
+ const need = Math.max(spec.fn === undefined ? 0 : spec.fn, spec.coll, spec.coll2 === undefined ? 0 : spec.coll2) + 1;
677
830
  if (argAsts.length < need || (spec.seed !== undefined && argAsts.length < 3)) return null;
678
831
  const coll = this.lowerExpr(argAsts[spec.coll], ln);
679
832
  const elem = { kind: 'member', object: coll, prop: '[]' };
833
+ // a function of two collections (`zipWithM_ f xs ys`): it is applied to an element of each
834
+ const el2 = spec.coll2 !== undefined && argAsts[spec.coll2] ? { t: 'lowered', expr: { kind: 'member', object: this.lowerExpr(argAsts[spec.coll2], ln), prop: '[]' } } : null;
680
835
  if (spec.kind === 'sequence') { this.sawIO = true; return coll; }
681
836
  const fnAst = argAsts[spec.fn];
682
837
  const el = { t: 'lowered', expr: elem };
683
838
  if (spec.kind === 'fold') {
684
839
  const seed = this.lowerExpr(argAsts[spec.seed], ln);
685
840
  const step = this.lowerApp(fnAst, [spec.fn === 0 && (name === 'foldr') ? el : { t: 'lowered', expr: seed }, name === 'foldr' ? { t: 'lowered', expr: seed } : el], ln, name);
841
+ if (spec.monadic) this.sawIO = true;
686
842
  return mkUnion(seed, step);
687
843
  }
688
- const res = this.lowerApp(fnAst, [el], ln, name);
844
+ const res = this.lowerApp(fnAst, el2 ? [el, el2] : [el], ln, name);
845
+ // `filter isAlphaNum s` keeps only letters and digits: no quote, separator, dot or markup character survives, so nothing in the result
846
+ // can change the meaning of a command, a query, a path or a page. A predicate that merely drops some characters (`/= ';'`) is not that.
847
+ if (spec.kind === 'filter') {
848
+ const pf = unparen(fnAst);
849
+ if (pf && pf.t === 'var') {
850
+ const pr = this.resolve(pf.name, pf.qual);
851
+ if ((pr.kind === 'external' || pr.kind === 'builtin') && CHAR_CLASS.has(libraryName(pr, pf.name, pf.qual).name)) return { kind: 'literal', value: '', hs: { charClassFilter: pf.name } };
852
+ }
853
+ }
689
854
  if (spec.kind === 'filter') {
690
855
  this.cfg.add('if', ln, { cond: res, hs: { via: 'filter' } });
691
856
  return coll;
@@ -920,6 +1085,14 @@ class FnLowerer {
920
1085
  processDecls(decls, bodyAsts, ln) {
921
1086
  if (!decls.length) return;
922
1087
  const g = groupDecls(decls);
1088
+ // `let run = \x -> ...` is a function of one parameter written in lambda form: treat it as `run x = ...` so a call `run c` is resolved and analysed
1089
+ // for the value it is given, instead of being an unknown call of a local value.
1090
+ g.funs = g.funs.map((f) => {
1091
+ if (f.clauses.length !== 1) return f;
1092
+ const c = f.clauses[0];
1093
+ const body = c.pats.length === 0 && c.rhs && !(c.rhs.guards && c.rhs.guards.length) && !(c.rhs.where && c.rhs.where.length) ? unparen(c.rhs.body) : null;
1094
+ return body && body.t === 'lam' && body.params.every((p) => p && p.t === 'pvar') ? { ...f, clauses: [{ ...c, pats: body.params, rhs: { ...c.rhs, body: body.body } }] } : f;
1095
+ });
923
1096
  const scope = this.scopes[this.scopes.length - 1];
924
1097
  const parent = this.info.parentName || this.info.name;
925
1098
  for (const f of g.funs) {
@@ -940,6 +1113,9 @@ class FnLowerer {
940
1113
  const rhsAsts = f.clauses.map((c) => c.rhs);
941
1114
  const others = siblingAsts.filter((a) => a && !rhsAsts.includes(a));
942
1115
  this.bindLazy([f.name], () => this.lowerRhs(f.clauses[0].rhs, f.line || ln), bodyAsts, others, strict, f.line || ln);
1116
+ // bindLazy declares a plain local; a point-free alias keeps what it names so applying it resolves to that function.
1117
+ const alias = aliasBodyOf(f);
1118
+ if (alias && !alias.names.some((n) => this.lookupLocal(n))) scope.set(f.name, { kind: 'local', alias });
943
1119
  }
944
1120
  for (const pb of g.patbinds) {
945
1121
  const names = patternVars(pb.pat);
@@ -1162,8 +1338,11 @@ function lowerTopFunction(proj, mod, fun, def) {
1162
1338
  const kind = def.arity > 0 ? 'function' : (sig && sig.returnsIO) || fun.name === 'main' ? 'io-action' : 'value';
1163
1339
  const exported = !mod.exports || mod.exports.some((x) => x.kind === 'var' && x.name === fun.name || x.kind === 'module' && x.name === mod.name);
1164
1340
  const fn = L.finishFn({ kind, arity: def.arity, effect: sig ? (sig.returnsIO ? 'io' : 'pure') : (L.sawIO ? 'io' : 'unknown'), sig: sig ? sig.text : null, exported });
1165
- const ann = callerControlledParams(fn, sig, exported);
1341
+ const ann = callerControlledParams(fn, sig, exported, mod.imports.map((i) => i.module));
1342
+ for (const a of servantEndpointParams(mod, fn, sig, exported)) if (!ann.some((x) => x.name === a.name)) ann.push(a);
1166
1343
  fn.hs.textParams = ann.map((a) => a.name);
1344
+ fn.hs.paramSources = ann.map((a) => ({ name: a.name, decorator: a.decorator }));
1345
+ fn.hs.inputParams = inputParams(fn, sig, exported);
1167
1346
  // Parameters of an exported function whose type is a record DECLARED IN THE PROJECT: the lineage view treats each such
1168
1347
  // parameter as a source of that record's fields (a customer record handed to an entry point), field by field.
1169
1348
  const recTypes = proj.recordTypes();
@@ -1282,7 +1461,13 @@ function buildHaskellIRUncached(fileContents) {
1282
1461
  const reached = new Set();
1283
1462
  for (const e of callGraph.edges || []) if (e && e.callee) reached.add(e.callee);
1284
1463
  for (const fn of callGraph.functions.values()) for (const r of (fn.hs && fn.hs.refs) || []) if (r && r.target) reached.add(r.target);
1285
- for (const fn of callGraph.functions.values()) if (reached.has(fn.qid)) { if (fn.paramAnnotations) delete fn.paramAnnotations; if (fn.hs && fn.hs.recordParams) fn.hs.recordParams = []; }
1464
+ assignEntryRoles(proj, callGraph);
1465
+ for (const fn of callGraph.functions.values()) {
1466
+ // `reached`: something in the analysed code calls or references this function, so its parameters take their values from THOSE callers
1467
+ // (which the taint engine follows) and not from an unknown outside one. Rules that reason about "caller-supplied" text read it.
1468
+ if (fn.hs) fn.hs.reached = reached.has(fn.qid);
1469
+ if (reached.has(fn.qid)) { if (fn.paramAnnotations) delete fn.paramAnnotations; if (fn.hs && fn.hs.recordParams) fn.hs.recordParams = []; }
1470
+ }
1286
1471
  return { perFile, callGraph, modules: proj.modules, diagnostics: proj.diagnostics, project: proj };
1287
1472
  }
1288
1473
 
@@ -1296,6 +1481,36 @@ function incomplete(proj, mod, f, err) {
1296
1481
  proj.diagnostics.push({ file: mod.file, line: f.line, kind: 'ir-lowering-failure', detail: String(err && err.message) });
1297
1482
  }
1298
1483
 
1484
+ // ── entry roles ──────────────────────────────────────────────────────────────
1485
+ // Who is on the other end of the program's inputs depends on what kind of program the function belongs to. A function reachable from a
1486
+ // `main` whose reachable modules import no server framework or socket library belongs to a command-line program: whoever runs it supplies its
1487
+ // arguments, environment, standard input and the files it is pointed at. One reachable from a `main` that imports a server framework (a WAI,
1488
+ // Scotty, Servant, Yesod, Snap, Happstack or Spock application, a socket listener) belongs to a service, whose inputs come from its clients.
1489
+ // A function no `main` reaches is `library`: its callers are unknown. The call graph is incomplete (an unresolved call ends a path), so a
1490
+ // function the walk does not reach is `library`, never `cli`.
1491
+ const SERVER_IMPORT = /^(?:Network\.Wai|Web\.Scotty|Servant|Yesod|Snap|Happstack\.Server|Web\.Spock)(?:\.|$)|^Network\.(?:Socket|Simple\.TCP|Run\.TCP|HTTP\.Server)$/;
1492
+ export function assignEntryRoles(proj, callGraph) {
1493
+ const out = new Map();
1494
+ for (const e of callGraph.edges || []) if (e && e.callee && e.caller) { if (!out.has(e.caller)) out.set(e.caller, []); out.get(e.caller).push(e.callee); }
1495
+ const mains = [];
1496
+ for (const fn of callGraph.functions.values()) if (fn.hs && fn.hs.module && fn.name === `${fn.hs.module}.main`) mains.push(fn);
1497
+ const cli = new Set(); const service = new Set();
1498
+ for (const m of mains) {
1499
+ const seen = new Set([m.qid]);
1500
+ const todo = [m.qid];
1501
+ while (todo.length) { const q = todo.pop(); for (const c of out.get(q) || []) if (!seen.has(c)) { seen.add(c); todo.push(c); } }
1502
+ const mods = new Set();
1503
+ for (const q of seen) { const f = callGraph.functions.get(q); if (f && f.hs && f.hs.module) mods.add(f.hs.module); }
1504
+ let isService = false;
1505
+ for (const name of mods) { const mi = proj.modules.get(name); if (mi && (mi.imports || []).some((i) => SERVER_IMPORT.test(i.module))) { isService = true; break; } }
1506
+ for (const q of seen) (isService ? service : cli).add(q);
1507
+ }
1508
+ for (const fn of callGraph.functions.values()) {
1509
+ if (!fn.hs) continue;
1510
+ fn.hs.entryRole = service.has(fn.qid) ? 'service' : (cli.has(fn.qid) ? 'cli' : 'library');
1511
+ }
1512
+ }
1513
+
1299
1514
  // ── call graph ───────────────────────────────────────────────────────────────
1300
1515
  function buildHaskellCallGraph(perFile, proj) {
1301
1516
  const functions = new Map();