@clear-capabilities/agentic-security-scanner 0.186.1 → 0.187.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 (36) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/bin/agentic-security.js +42 -17
  3. package/dist/{2066.index.js → 1602.index.js} +477 -9
  4. package/dist/9560.index.js +62 -5
  5. package/dist/agentic-security.mjs +2 -2
  6. package/dist/agentic-security.mjs.sha256 +1 -1
  7. package/dist/{ca62ee93079be4c24eee.json → deabc47d0e27e21bf99b.json} +5 -5
  8. package/package.json +5 -5
  9. package/src/capabilities/decide.js +7 -1
  10. package/src/dataflow/CLAUDE.md +2 -0
  11. package/src/dataflow/catalog-haskell.js +7 -1
  12. package/src/dataflow/engine.js +16 -0
  13. package/src/dataflow/privacy-catalog-haskell.js +2 -1
  14. package/src/language/CLAUDE.md +19 -0
  15. package/src/language/compliance-map.js +6 -0
  16. package/src/language/context.js +20 -0
  17. package/src/language/haskell-disclosure.js +16 -4
  18. package/src/language/haskell-guards.js +130 -3
  19. package/src/language/haskell-ir.js +182 -10
  20. package/src/language/haskell-models.js +93 -4
  21. package/src/language/haskell-security-rules.js +112 -34
  22. package/src/language/haskell-syntax.js +10 -1
  23. package/src/language/haskell-web.js +44 -8
  24. package/src/language/support-registry.js +3 -2
  25. package/src/mcp/tools.js +54 -3
  26. package/src/posture/attack-taxonomy.js +10 -0
  27. package/src/posture/entrypoint-inventory.js +6 -2
  28. package/src/posture/proof-fabric/cli.js +1 -1
  29. package/src/posture/proof-fabric/container.js +2 -2
  30. package/src/posture/proof-fabric/generate-haskell.js +454 -0
  31. package/src/posture/proof-fabric/generate.js +15 -5
  32. package/src/posture/proof-fabric/oracles.js +21 -0
  33. package/src/posture/proof-fabric/reproducer.js +4 -2
  34. package/src/posture/proof-fabric/runner.js +4 -4
  35. package/src/posture/stable-id.js +2 -1
  36. package/src/report/index.js +3 -0
@@ -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)) {
@@ -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
 
@@ -363,6 +430,25 @@ function inlinableBody(fnAst) {
363
430
  return acc.ok ? { params: c.pats.map((p) => p.name), body: c.rhs.body } : null;
364
431
  }
365
432
 
433
+ // A point-free alias: `tidy = takeFileName`, `clean = trim . takeFileName`. A zero-argument binding whose whole right-hand side is a
434
+ // function name or a composition of function names IS the function it names, so applying it is applying them. Without this the
435
+ // application is an unresolved call, and a sanitizer (or a sink) reached through the alias loses its identity.
436
+ // Returns the right-hand side AST and the unqualified names it mentions, or null for anything else (guards, where, any other form).
437
+ function aliasBodyOf(fnAst) {
438
+ if (!fnAst || fnAst.clauses.length !== 1) return null;
439
+ const c = fnAst.clauses[0];
440
+ 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;
441
+ const names = [];
442
+ const ok = (e) => {
443
+ const x = unparen(e);
444
+ if (!x) return false;
445
+ if (x.t === 'var') { if (!x.qual) names.push(x.name); return x.name !== fnAst.name; }
446
+ if (x.t === 'op' && x.op === '.' && !x.qual) return ok(x.l) && ok(x.r);
447
+ return false;
448
+ };
449
+ return ok(c.rhs.body) ? { body: c.rhs.body, names } : null;
450
+ }
451
+
366
452
  // ── per-function lowering ────────────────────────────────────────────────────
367
453
  class FnLowerer {
368
454
  constructor(proj, mod, fnInfo, scopes) {
@@ -429,6 +515,10 @@ class FnLowerer {
429
515
  case 'app': return this.lowerApp(e.f, e.args, ln);
430
516
  case 'op': return this.lowerOp(e, ln);
431
517
  case 'neg': return { kind: 'binary', op: '-', left: { kind: 'literal', value: 0 }, right: this.lowerExpr(e.e, ln) };
518
+ // `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
519
+ // 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
520
+ // number in `"kill " ++ show n ++ name` would mark the tainted `name` sanitized too.)
521
+ case 'typed': return { kind: 'literal', value: 0, hs: { numeric: e.type, inner: this.lowerExpr(e.e, ln) } };
432
522
  case 'tuple': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { tuple: true } };
433
523
  case 'list': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)) };
434
524
  case 'range': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { range: true } };
@@ -526,7 +616,9 @@ class FnLowerer {
526
616
  }
527
617
  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
618
  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);
619
+ if (f.t === 'lam') return this.betaReduce(f, argAsts, ln, via);
620
+ // `m >>= \case ...`: a \case applied to its argument is a case on that argument, run here (not a call of an unknown lifted function)
621
+ 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
622
  if (f.t === 'var') return this.applyVar(f, argAsts, ln, via);
531
623
  if (f.t === 'con') {
532
624
  const cons = this.mod.cons.get(f.name) || this.proj.findCon(f.name);
@@ -539,11 +631,14 @@ class FnLowerer {
539
631
  return { kind: 'call', callee: '<indirect>', args: [fv, ...argAsts.map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'computed-function', via } };
540
632
  }
541
633
 
542
- betaReduce(lam, argAsts, ln) {
634
+ betaReduce(lam, argAsts, ln, via) {
543
635
  const n = Math.min(lam.params.length, argAsts.length);
544
636
  this.push();
545
637
  try {
546
- for (let i = 0; i < n; i++) this.bindPattern(lam.params[i], this.lowerExpr(argAsts[i], ln), ln);
638
+ // `action >>= \x -> k` and `f <$> action` RUN the action and hand its result to the lambda, even when the lambda ignores it (`\_ -> k`):
639
+ // the action is executed where it is sequenced, not only where its result is used.
640
+ const runs = via === 'bind' || via === 'fmap' || via === 'applicative';
641
+ for (let i = 0; i < n; i++) this.bindPattern(lam.params[i], this.lowerExpr(argAsts[i], ln), ln, runs ? { executed: true } : undefined);
547
642
  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
643
  const body = this.lowerExpr(lam.body, ln);
549
644
  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 +653,30 @@ class FnLowerer {
558
653
  const out = this.lowerHof(f.name, argAsts, ln);
559
654
  if (out) return out;
560
655
  }
656
+ // `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
657
+ // (`maybe (pure ()) callCommand mc`) is reached by the value. The result is whichever branch ran.
658
+ if ((r.kind === 'builtin' || r.kind === 'external') && ((f.name === 'maybe' && argAsts.length >= 3) || (f.name === 'either' && argAsts.length >= 3))) {
659
+ // The scrutinee is examined whichever branch runs, so it is the condition of a two-way branch (an ignored payload, `\\_ -> ..`, still reads it).
660
+ const scrut = this.lowerExpr(argAsts[2], ln);
661
+ const inner = { t: 'lowered', expr: scrut };
662
+ return f.name === 'maybe'
663
+ ? this.branch(scrut, ln, () => this.lowerApp(argAsts[1], [inner], ln, f.name), () => this.lowerExpr(argAsts[0], ln))
664
+ : this.branch(scrut, ln, () => this.lowerApp(argAsts[1], [inner], ln, f.name), () => this.lowerApp(argAsts[0], [inner], ln, f.name));
665
+ }
666
+ // 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`.
667
+ if ((r.kind === 'builtin' || r.kind === 'external') && !f.qual) {
668
+ const tup = (a) => { const u = unparen(a); return u && u.t === 'tuple' && u.items.length === 2 ? u.items : null; };
669
+ 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);
670
+ if (f.name === 'const' && argAsts.length === 2) { this.lowerExpr(argAsts[1], ln); return this.lowerExpr(argAsts[0], ln); }
671
+ if (f.name === 'uncurry' && argAsts.length === 2 && tup(argAsts[1])) return this.lowerApp(argAsts[0], tup(argAsts[1]), ln, via);
672
+ }
561
673
  if (r.kind === 'def' && r.def.kind === 'field' && argAsts.length >= 1) {
562
674
  const obj = this.lowerExpr(argAsts[0], ln);
563
675
  const mem = { kind: 'member', object: obj, prop: r.name };
564
676
  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
677
  }
678
+ const viaAlias = this.applyAlias(r, argAsts, ln, via);
679
+ if (viaAlias) return viaAlias;
566
680
  let args = argAsts.map((a) => this.lowerExpr(a, ln));
567
681
  // `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
682
  // flows to the result, so a constant table gives a constant result however tainted the key is.
@@ -635,6 +749,39 @@ class FnLowerer {
635
749
  return { kind: 'ident', name: t, hs: { sourceTemp: call.callee } };
636
750
  }
637
751
 
752
+ // Apply a point-free alias (see aliasBodyOf) as the function(s) it names. The names are looked up where the alias was DEFINED; a
753
+ // local binding of one of them at the use site would capture it, so that case is left to the ordinary (unresolved) call.
754
+ applyAlias(r, argAsts, ln, via) {
755
+ let alias = null; let calleeMod = this.mod;
756
+ if (r.kind === 'local' && r.alias) alias = r.alias;
757
+ else if (r.kind === 'def' && r.def.kind === 'fun' && r.def.arity === 0) {
758
+ calleeMod = this.proj.modules.get(r.mod);
759
+ alias = calleeMod ? aliasBodyOf(calleeMod.groups.funs.find((f) => f.name === r.name)) : null;
760
+ }
761
+ if (!alias || (this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
762
+ if (alias.names.some((n) => this.lookupLocal(n))) return null;
763
+ const savedMod = this.mod;
764
+ // Only an alias of LIBRARY functions is expanded. A project function keeps its own summary and call-graph node, and
765
+ // expanding it here would replace the edge to it with edges to what it calls.
766
+ this.mod = calleeMod;
767
+ const libraryOnly = alias.names.every((n) => { const k = this.resolve(n, null).kind; return k === 'external' || k === 'builtin'; });
768
+ this.mod = savedMod;
769
+ if (!libraryOnly) return null;
770
+ // Arguments belong to the CALLER: lower them here, once, then hand them over as already-lowered expressions.
771
+ const lowered = argAsts.map((a) => ({ t: 'lowered', expr: this.lowerExpr(a, ln) }));
772
+ const savedOverride = this.lineOverride;
773
+ this.lineOverride = this.lineOverride || ln;
774
+ this.inlineDepth = (this.inlineDepth || 0) + 1;
775
+ this.mod = calleeMod;
776
+ // The alias is still CALLED here: keep its call-graph edge, exactly as an inlined function body does.
777
+ 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 } : {}) } });
778
+ try { return this.lowerApp(alias.body, lowered, ln); } finally {
779
+ this.mod = savedMod;
780
+ this.lineOverride = savedOverride;
781
+ this.inlineDepth -= 1;
782
+ }
783
+ }
784
+
638
785
  // Substitute lowered arguments into a small pure callee body. Returns null when the callee is not inlinable.
639
786
  tryInline(r, loweredArgs, ln, via) {
640
787
  const calleeMod = this.proj.modules.get(r.mod);
@@ -673,19 +820,31 @@ class FnLowerer {
673
820
  // Bounded collection/traversal summaries. Returns null to fall back to a plain call.
674
821
  lowerHof(name, argAsts, ln) {
675
822
  const spec = HOF[name];
676
- const need = Math.max(spec.fn === undefined ? 0 : spec.fn, spec.coll) + 1;
823
+ const need = Math.max(spec.fn === undefined ? 0 : spec.fn, spec.coll, spec.coll2 === undefined ? 0 : spec.coll2) + 1;
677
824
  if (argAsts.length < need || (spec.seed !== undefined && argAsts.length < 3)) return null;
678
825
  const coll = this.lowerExpr(argAsts[spec.coll], ln);
679
826
  const elem = { kind: 'member', object: coll, prop: '[]' };
827
+ // a function of two collections (`zipWithM_ f xs ys`): it is applied to an element of each
828
+ const el2 = spec.coll2 !== undefined && argAsts[spec.coll2] ? { t: 'lowered', expr: { kind: 'member', object: this.lowerExpr(argAsts[spec.coll2], ln), prop: '[]' } } : null;
680
829
  if (spec.kind === 'sequence') { this.sawIO = true; return coll; }
681
830
  const fnAst = argAsts[spec.fn];
682
831
  const el = { t: 'lowered', expr: elem };
683
832
  if (spec.kind === 'fold') {
684
833
  const seed = this.lowerExpr(argAsts[spec.seed], ln);
685
834
  const step = this.lowerApp(fnAst, [spec.fn === 0 && (name === 'foldr') ? el : { t: 'lowered', expr: seed }, name === 'foldr' ? { t: 'lowered', expr: seed } : el], ln, name);
835
+ if (spec.monadic) this.sawIO = true;
686
836
  return mkUnion(seed, step);
687
837
  }
688
- const res = this.lowerApp(fnAst, [el], ln, name);
838
+ const res = this.lowerApp(fnAst, el2 ? [el, el2] : [el], ln, name);
839
+ // `filter isAlphaNum s` keeps only letters and digits: no quote, separator, dot or markup character survives, so nothing in the result
840
+ // can change the meaning of a command, a query, a path or a page. A predicate that merely drops some characters (`/= ';'`) is not that.
841
+ if (spec.kind === 'filter') {
842
+ const pf = unparen(fnAst);
843
+ if (pf && pf.t === 'var') {
844
+ const pr = this.resolve(pf.name, pf.qual);
845
+ 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 } };
846
+ }
847
+ }
689
848
  if (spec.kind === 'filter') {
690
849
  this.cfg.add('if', ln, { cond: res, hs: { via: 'filter' } });
691
850
  return coll;
@@ -920,6 +1079,14 @@ class FnLowerer {
920
1079
  processDecls(decls, bodyAsts, ln) {
921
1080
  if (!decls.length) return;
922
1081
  const g = groupDecls(decls);
1082
+ // `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
1083
+ // for the value it is given, instead of being an unknown call of a local value.
1084
+ g.funs = g.funs.map((f) => {
1085
+ if (f.clauses.length !== 1) return f;
1086
+ const c = f.clauses[0];
1087
+ 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;
1088
+ 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;
1089
+ });
923
1090
  const scope = this.scopes[this.scopes.length - 1];
924
1091
  const parent = this.info.parentName || this.info.name;
925
1092
  for (const f of g.funs) {
@@ -940,6 +1107,9 @@ class FnLowerer {
940
1107
  const rhsAsts = f.clauses.map((c) => c.rhs);
941
1108
  const others = siblingAsts.filter((a) => a && !rhsAsts.includes(a));
942
1109
  this.bindLazy([f.name], () => this.lowerRhs(f.clauses[0].rhs, f.line || ln), bodyAsts, others, strict, f.line || ln);
1110
+ // bindLazy declares a plain local; a point-free alias keeps what it names so applying it resolves to that function.
1111
+ const alias = aliasBodyOf(f);
1112
+ if (alias && !alias.names.some((n) => this.lookupLocal(n))) scope.set(f.name, { kind: 'local', alias });
943
1113
  }
944
1114
  for (const pb of g.patbinds) {
945
1115
  const names = patternVars(pb.pat);
@@ -1162,8 +1332,10 @@ function lowerTopFunction(proj, mod, fun, def) {
1162
1332
  const kind = def.arity > 0 ? 'function' : (sig && sig.returnsIO) || fun.name === 'main' ? 'io-action' : 'value';
1163
1333
  const exported = !mod.exports || mod.exports.some((x) => x.kind === 'var' && x.name === fun.name || x.kind === 'module' && x.name === mod.name);
1164
1334
  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);
1335
+ const ann = callerControlledParams(fn, sig, exported, mod.imports.map((i) => i.module));
1336
+ for (const a of servantEndpointParams(mod, fn, sig, exported)) if (!ann.some((x) => x.name === a.name)) ann.push(a);
1166
1337
  fn.hs.textParams = ann.map((a) => a.name);
1338
+ fn.hs.inputParams = inputParams(fn, sig, exported);
1167
1339
  // Parameters of an exported function whose type is a record DECLARED IN THE PROJECT: the lineage view treats each such
1168
1340
  // parameter as a source of that record's fields (a customer record handed to an entry point), field by field.
1169
1341
  const recTypes = proj.recordTypes();