@clear-capabilities/agentic-security-scanner 0.195.10 → 0.195.12

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.
@@ -43,6 +43,26 @@ const IO_NAMES = new Set(['putStr', 'putStrLn', 'print', 'getLine', 'getContents
43
43
 
44
44
  // name -> positions of the function, the collection and (folds) the seed.
45
45
  const BODY_SETTER = /^Network\.HTTP\.(?:Simple|Conduit|Client)\.setRequestBody(?:JSON|LBS|BS|URLEncoded|File)$/;
46
+ // A mutable cell carries what was written to it: `writeIORef ref v` assigns the cell's variable, `readIORef ref` reads it, `modifyIORef ref f`
47
+ // is `ref = f ref`. The same for MVar, TVar and STRef. Only a cell held in a LOCAL variable of the function that reads it is modelled
48
+ // (created there, or handed in as a parameter): a cell written in one function and read in another is not followed (see the guide's limits).
49
+ const CELL_OPS = Object.freeze({
50
+ 'Data.IORef.writeIORef': 'write', 'Data.IORef.readIORef': 'read', 'Data.IORef.modifyIORef': 'modify', "Data.IORef.modifyIORef'": 'modify', 'Data.IORef.atomicWriteIORef': 'write',
51
+ 'Control.Concurrent.MVar.putMVar': 'write', 'Control.Concurrent.MVar.takeMVar': 'read', 'Control.Concurrent.MVar.readMVar': 'read', 'Control.Concurrent.MVar.modifyMVar_': 'modify', 'Control.Concurrent.MVar.swapMVar': 'write',
52
+ 'Control.Concurrent.putMVar': 'write', 'Control.Concurrent.takeMVar': 'read', 'Control.Concurrent.readMVar': 'read', 'Control.Concurrent.modifyMVar_': 'modify',
53
+ 'Control.Concurrent.STM.writeTVar': 'write', 'Control.Concurrent.STM.readTVar': 'read', 'Control.Concurrent.STM.readTVarIO': 'read', 'Control.Concurrent.STM.modifyTVar': 'modify', "Control.Concurrent.STM.modifyTVar'": 'modify',
54
+ 'Control.Concurrent.STM.TVar.writeTVar': 'write', 'Control.Concurrent.STM.TVar.readTVar': 'read', 'Control.Concurrent.STM.TVar.readTVarIO': 'read', 'Control.Concurrent.STM.TVar.modifyTVar': 'modify', "Control.Concurrent.STM.TVar.modifyTVar'": 'modify',
55
+ 'Data.STRef.writeSTRef': 'write', 'Data.STRef.readSTRef': 'read', 'Data.STRef.modifySTRef': 'modify', "Data.STRef.modifySTRef'": 'modify',
56
+ });
57
+ // `instance Runner Shell`, `instance Show a => Runner (Box a)`: the type constructor the instance is for (the first constructor after the class name).
58
+ function instanceTypeOf(inst) {
59
+ const head = String((inst && inst.headText) || '').replace(/^.*?=>\s*/, '');
60
+ const cls = inst && inst.name ? String(inst.name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : null;
61
+ if (!cls) return null;
62
+ const after = head.replace(new RegExp(`^\\s*(?:[A-Z][\\w']*\\.)*${cls}(?![\\w'])`), '');
63
+ const m = /^[\s([]*(?:[A-Z][\w']*\.)*([A-Z][\w']*)/.exec(after);
64
+ return m ? m[1] : null;
65
+ }
46
66
  const HOF = {
47
67
  map: { fn: 0, coll: 1, kind: 'map' }, fmap: { fn: 0, coll: 1, kind: 'map' }, concatMap: { fn: 0, coll: 1, kind: 'map' },
48
68
  traverse: { fn: 0, coll: 1, kind: 'traverse' }, mapM: { fn: 0, coll: 1, kind: 'traverse' },
@@ -232,6 +252,11 @@ function exportedLookup(proj, target, name, depth, seen) {
232
252
  if (depth > HS_IR_LIMITS.maxResolveDepth) return { kind: 'unknown', reason: 'resolution-depth-cap' };
233
253
  const next = new Set(seen); next.add(key);
234
254
  if (!target.exports) return target.defs.has(name) ? defResult(target, name) : null;
255
+ // A wildcard re-export of a module the project does not contain (`module CorePrelude` over `import CorePrelude hiding (..)`) can only say the
256
+ // name MIGHT come from there. It is kept as the fallback while the rest of the export list is searched: a definition the project holds, or a
257
+ // name an import lists, is the answer when one exists. IHP's preludes re-export `CorePrelude` first and `IHP.Controller.Param` later, and
258
+ // returning the possibility first made every `param` an unknown external name instead of the in-tree request read.
259
+ let fallback = null;
235
260
  for (const ex of target.exports) {
236
261
  if (ex.kind === 'var' && ex.name === name) return resolveGlobal(proj, target, name, ex.qual || null, depth + 1, next);
237
262
  if (ex.kind === 'type' && ex.all) {
@@ -245,11 +270,13 @@ function exportedLookup(proj, target, name, depth, seen) {
245
270
  if (imp.items && (imp.hiding ? imp.items.includes(name) : !imp.items.includes(name))) continue;
246
271
  const t2 = proj.modules.get(imp.module);
247
272
  const r = t2 ? exportedLookup(proj, t2, name, depth + 1, next) : { kind: 'external', module: imp.module, name, certain: !!(imp.items && !imp.hiding && imp.items.includes(name)) };
248
- if (r) return r;
273
+ if (!r) continue;
274
+ if (r.kind === 'external' && !r.certain) { if (!fallback) fallback = r; continue; }
275
+ return r;
249
276
  }
250
277
  }
251
278
  }
252
- return null;
279
+ return fallback;
253
280
  }
254
281
 
255
282
  // A top-level resolution (depth 0, nothing seen yet) is a pure function of the module table, which is complete before any function
@@ -285,7 +312,9 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
285
312
  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;
286
313
  if (!owner || !(imp.typeAll || []).includes(owner)) continue;
287
314
  }
288
- if (r.kind === 'unknown') { unknownReason = r.reason; if (r.candidates) unknownCandidates = r.candidates; } else hits.push(r);
315
+ if (r.kind === 'unknown') { unknownReason = r.reason; if (r.candidates) unknownCandidates = r.candidates; }
316
+ else if (r.kind === 'external' && !r.certain) wildcard.push(r.module || imp.module); // a re-export chain's "might come from there" is a possibility, not a hit that makes a definition ambiguous
317
+ else hits.push(r);
289
318
  } else if (qual || (imp.items && !imp.hiding && imp.items.includes(name))) hits.push({ kind: 'external', module: imp.module, name, certain: true });
290
319
  else wildcard.push(imp.module); // includes `import M (T(..))` of a module we cannot read: possible, never certain
291
320
  }
@@ -560,6 +589,9 @@ class FnLowerer {
560
589
  for (const r of refs) this.noteRef(r, 'section', ln);
561
590
  return { kind: 'array', elements: [this.lowerExpr(e.e, ln)], hs: { section: e.op, partial: { remaining: 1 } } };
562
591
  }
592
+ // A Template Haskell splice in expression position: generated code the analysis never sees. The parser has already disclosed the
593
+ // `th-splice` boundary for the module, so this is not a lowering gap of the IR; the value is unknown.
594
+ case 'splice': return unknownExpr({ splice: true });
563
595
  default: this.diagnostics.push({ kind: 'lowering-gap', detail: e.t || 'unknown' }); return unknownExpr({ ast: e.t || 'unknown' });
564
596
  }
565
597
  }
@@ -590,6 +622,11 @@ class FnLowerer {
590
622
  const r = this.resolve(e.name, e.qual);
591
623
  if (r.kind === 'alias') return r.expr;
592
624
  if (r.kind === 'def' && r.def.kind === 'field') return { kind: 'ident', name: e.name, hs: { fieldSelector: r.name } };
625
+ if (r.kind === 'def' && r.def.kind === 'fun' && isKnownApi(r.mod, r.name)) {
626
+ // a modelled name the project itself defines (see applyVar): the registry's meaning, the definition still referenced
627
+ this.noteRef(r.qid, 'modelled-definition', ln);
628
+ return { kind: 'ident', name: `${r.mod}.${r.name}`, hs: { resolution: 'external', certain: true, modelledDefinition: r.qid } };
629
+ }
593
630
  if (r.kind === 'def' || r.kind === 'localfn') {
594
631
  this.noteRef(r.qid, 'reference', ln);
595
632
  return { kind: 'ident', name: this.canonical(r), hs: { functionRef: r.qid } };
@@ -683,10 +720,29 @@ class FnLowerer {
683
720
  if (f.name === 'uncurry' && argAsts.length === 2 && tup(argAsts[1])) return this.lowerApp(argAsts[0], tup(argAsts[1]), ln, via);
684
721
  }
685
722
  if (r.kind === 'def' && r.def.kind === 'field' && argAsts.length >= 1) {
723
+ // `exec ops x` where `ops` is a top-level constant record whose `exec` field holds a function: the application is of THAT function. The
724
+ // held function is found in the constant's record syntax (`Ops { exec = \x -> .. }` or `Ops { exec = pingHost }`); a positional
725
+ // construction, a field set by an update, or a record built at run time stay an indirect call.
726
+ const held = argAsts.length > 1 ? this.heldFieldFunction(r, argAsts[0], ln) : null;
727
+ if (held) {
728
+ const lowered = argAsts.slice(1).map((a) => ({ t: 'lowered', expr: this.lowerExpr(a, ln) }));
729
+ const savedMod = this.mod; const savedOverride = this.lineOverride;
730
+ this.lineOverride = this.lineOverride || ln; this.inlineDepth = (this.inlineDepth || 0) + 1; this.mod = held.mod;
731
+ try { return this.lowerApp(held.expr, lowered, ln, via); } finally { this.mod = savedMod; this.lineOverride = savedOverride; this.inlineDepth -= 1; }
732
+ }
686
733
  const obj = this.lowerExpr(argAsts[0], ln);
734
+ if (argAsts.length > 1) {
735
+ const viaField = this.applyFieldCandidates(r.def.con, r.name, argAsts.slice(1), ln, via);
736
+ if (viaField) return viaField;
737
+ }
687
738
  const mem = { kind: 'member', object: obj, prop: r.name };
688
739
  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' } };
689
740
  }
741
+ // `psUnkownHost host` where `psUnkownHost` was bound by a record pattern: the functions the project stores in that field.
742
+ if (r.kind === 'local' && r.field && argAsts.length) {
743
+ const viaField = this.applyFieldCandidates(r.field.con, r.field.name, argAsts, ln, via);
744
+ if (viaField) return viaField;
745
+ }
690
746
  const viaAlias = this.applyAlias(r, argAsts, ln, via);
691
747
  if (viaAlias) return viaAlias;
692
748
  let args = argAsts.map((a) => this.lowerExpr(a, ln));
@@ -700,7 +756,18 @@ class FnLowerer {
700
756
  if (selector) hs.selector = selector;
701
757
  let callee = f.qual ? `${f.qual}.${f.name}` : f.name;
702
758
  const arity = (r.kind === 'def' && r.def.kind === 'fun') ? r.def.arity : (r.kind === 'localfn' ? r.arity : -1);
703
- if ((r.kind === 'def' && r.def.kind === 'fun') || r.kind === 'localfn') {
759
+ if (r.kind === 'def' && r.def.kind === 'fun' && isKnownApi(r.mod, r.name)) {
760
+ // The project defines a module the model registry models: the project IS the framework (a framework repository scanning itself) or
761
+ // carries a copy of it. The registry says what the name means to its caller (a request read, a sink, a sanitizer), which the
762
+ // definition's body, written in terms the registry does not model (an implicit request parameter, a parser over it), does not show.
763
+ // The call is lowered as the library call; the definition is still referenced, so it is never an uncalled entry point, and its own
764
+ // body is still analysed as a function of the project. Pinned both ways in test/haskell/haskell-taint-shapes.test.js.
765
+ this.noteRef(r.qid, 'modelled-definition', ln);
766
+ callee = `${r.mod}.${r.name}`;
767
+ hs.status = 'external'; hs.certain = true; hs.modelledDefinition = r.qid;
768
+ hs.effect = r.def.sig ? (r.def.sig.returnsIO ? 'io' : 'pure') : (IO_NAMES.has(f.name) ? 'io' : 'unknown');
769
+ if (hs.effect === 'io') this.sawIO = true;
770
+ } else if ((r.kind === 'def' && r.def.kind === 'fun') || r.kind === 'localfn') {
704
771
  callee = this.canonical(r);
705
772
  hs.status = 'resolved'; hs.target = r.qid;
706
773
  if (r.kind === 'def' && arity === args.length && arity > 0 && (this.inlineDepth || 0) < INLINE_MAX_DEPTH) {
@@ -722,6 +789,10 @@ class FnLowerer {
722
789
  hs.status = 'typeclass'; hs.cls = r.def.cls; hs.method = r.name; hs.target = r.qid;
723
790
  hs.candidates = this.proj.instancesOf(r.def.cls, r.name);
724
791
  hs.effect = 'unknown';
792
+ // `run Shell x`: an argument that is a constructor of a type with exactly one visible instance of the class names the instance, so the
793
+ // call is a call of that instance's method (a resolved edge, not a set of candidates). An argument of unknown type keeps the candidates.
794
+ const picked = this.dispatchInstance(r.def.cls, r.name, args);
795
+ if (picked) { callee = picked.name; hs.status = 'resolved'; hs.target = picked.qid; hs.dispatchedOn = picked.type; delete hs.candidates; }
725
796
  } else if (r.kind === 'param') {
726
797
  hs.status = 'param'; hs.paramIndex = r.index; hs.effect = 'unknown';
727
798
  } else if (r.kind === 'local') {
@@ -747,6 +818,10 @@ class FnLowerer {
747
818
  if (ctxTargets.some((t) => t.length)) hs.argTargets = ctxTargets;
748
819
  }
749
820
  const call = { kind: 'call', callee, args, line: ln, hs };
821
+ if (hs.status === 'external' || hs.status === 'builtin') {
822
+ const cell = this.lowerCellOp(callee, args, argAsts, ln, call);
823
+ if (cell) return cell;
824
+ }
750
825
  // `setRequestBodyJSON body req` returns the REQUEST with a body: the body's taint is a property of the body, and
751
826
  // must not make the request's URL tainted. The call still gets its own node so a sink on the body argument fires.
752
827
  if ((hs.status === 'external' || hs.status === 'builtin') && BODY_SETTER.test(callee) && args.length >= 2) {
@@ -757,6 +832,89 @@ class FnLowerer {
757
832
  return (hs.status === 'external' || hs.status === 'builtin') && isSourceApi(callee) ? this.sourceValue(call, ln) : call;
758
833
  }
759
834
 
835
+ // The instance a class-method call dispatches to, when an argument is a constructor (nullary, positional or record) of a type that has
836
+ // exactly one visible instance of the class. Several candidate types, or no constructor argument, is null (the candidates stay).
837
+ dispatchInstance(cls, method, args) {
838
+ const cands = this.proj.instancesOf(cls, method);
839
+ if (!cands.length) return null;
840
+ const types = new Set();
841
+ for (const a of args) {
842
+ const con = a && a.hs && a.hs.con;
843
+ if (!con) continue;
844
+ const c = this.mod.cons.get(con) || this.proj.findCon(con);
845
+ if (c && c.type) types.add(c.type);
846
+ }
847
+ if (types.size !== 1) return null;
848
+ const [type] = types;
849
+ const hits = cands.filter((q) => { const m = this.proj.instanceMeta.get(q); return m && m.type === type; });
850
+ if (hits.length !== 1) return null;
851
+ return { qid: hits[0], name: this.proj.instanceMeta.get(hits[0]).name, type };
852
+ }
853
+
854
+ // The function a record field of a top-level constant holds: `ops = Ops { exec = \x -> .. }` for `exec ops`. Returns the field's expression
855
+ // and the module it was written in, or null (the constant is not a single record literal, or the field is not set there).
856
+ heldFieldFunction(r, objAst, ln) {
857
+ if ((this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
858
+ const u = unparen(objAst);
859
+ if (!u || u.t !== 'var') return null;
860
+ const r0 = this.resolve(u.name, u.qual);
861
+ if (r0.kind !== 'def' || r0.def.kind !== 'fun' || r0.def.arity !== 0) return null;
862
+ const mod = this.proj.modules.get(r0.mod);
863
+ const fnAst = mod && mod.groups.funs.find((f) => f.name === r0.name);
864
+ if (!fnAst || fnAst.clauses.length !== 1) return null;
865
+ const c = fnAst.clauses[0];
866
+ 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;
867
+ const body = unparen(c.rhs.body);
868
+ if (!body || body.t !== 'rec' || !body.con || body.con.name !== r.def.con) return null;
869
+ const fld = (body.fields || []).find((f) => !f.wildcard && f.name === r.name && f.e);
870
+ if (!fld) return null;
871
+ this.noteRef(r0.qid, 'reference', ln);
872
+ return { mod, expr: fld.e };
873
+ }
874
+
875
+ // A function held in a record field whose record is NOT a top-level constant (a parameter bound by `Con {..}`, a record built at run time):
876
+ // the application is the union of the functions the project stores in that field, found in every construction of the constructor
877
+ // (`Con { f = g }`, or `Con{..}` with `f <- pure g` / `f = g` in the building function; a `case`/`if` chooses among them; `pure`/`return` is
878
+ // peeled; `const <$> e` / `const e` is a function that ignores its argument). Any construction whose value cannot be classified (a lambda
879
+ // with captured locals, an arbitrary expression) leaves the call unresolved as before: a partial answer would hide a sink or invent one.
880
+ applyFieldCandidates(con, field, argAsts, ln, via) {
881
+ if (!con || !field || (this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
882
+ const cands = this.proj.fieldFunctionCandidates(con, field);
883
+ if (!cands.length || cands.some((c) => c.kind === 'opaque')) return null;
884
+ const lowered = argAsts.map((a) => ({ t: 'lowered', expr: this.lowerExpr(a, ln) }));
885
+ let out = null;
886
+ for (const c of cands) {
887
+ let v;
888
+ if (c.kind === 'ignores') v = unknownExpr({ fieldFunction: `${con}.${field}`, ignoresArguments: true });
889
+ else {
890
+ const savedMod = this.mod; const savedOverride = this.lineOverride;
891
+ this.lineOverride = this.lineOverride || ln; this.inlineDepth = (this.inlineDepth || 0) + 1; this.mod = c.mod;
892
+ try { v = this.lowerApp(c.expr, lowered, ln, via); } finally { this.mod = savedMod; this.lineOverride = savedOverride; this.inlineDepth -= 1; }
893
+ }
894
+ out = mkUnion(out, v);
895
+ }
896
+ if (out) out.hs = { ...(out.hs || {}), fieldFunction: `${con}.${field}`, candidates: cands.length };
897
+ return out;
898
+ }
899
+
900
+ // A mutable cell held in a local variable (see CELL_OPS): a write is an assignment of the cell's variable, a read is that variable, a modify is
901
+ // the function applied to it. Anything else (a cell that is not a local variable, a missing argument) is the plain call.
902
+ lowerCellOp(callee, args, argAsts, ln, call) {
903
+ const op = CELL_OPS[callee];
904
+ if (!op) return null;
905
+ const ref = args[0];
906
+ if (!ref || ref.kind !== 'ident' || !ref.name || ref.hs) return null;
907
+ this.sawIO = true;
908
+ if (op === 'read') return { kind: 'ident', name: ref.name };
909
+ if (op === 'write' && args.length >= 2) { this.cfg.add('assign', ln, { target: ref.name, source: args[1], hs: { demand: 'executed', cellWrite: callee } }); return call; }
910
+ if (op === 'modify' && argAsts.length >= 2) {
911
+ const applied = this.lowerApp(argAsts[1], [{ t: 'lowered', expr: { kind: 'ident', name: ref.name } }], ln, callee);
912
+ this.cfg.add('assign', ln, { target: ref.name, source: applied, hs: { demand: 'executed', cellWrite: callee } });
913
+ return call;
914
+ }
915
+ return null;
916
+ }
917
+
760
918
  // A modelled source read gets its own assignment node, so the evidence chain names the exact line the
761
919
  // untrusted data entered, even when the read sits inside a larger expression (`f =<< getLine`).
762
920
  sourceValue(call, ln) {
@@ -813,7 +971,8 @@ class FnLowerer {
813
971
  try {
814
972
  inl.params.forEach((name, i) => this.declare(name, { kind: 'alias', expr: loweredArgs[i] }));
815
973
  this.mod = calleeMod;
816
- (this.inlinedCalls ||= []).push({ site: null, callee: this.canonical(r), args: [], line: ln, hs: { status: 'resolved', target: r.qid, effect: 'unknown', inlined: true, ...(via ? { via } : {}) } });
974
+ // the lowered arguments are kept on the edge: a rule asking what a helper's parameters receive reads them (haskell-security-rules.js requestTextParams)
975
+ (this.inlinedCalls ||= []).push({ site: null, callee: this.canonical(r), args: loweredArgs, line: ln, hs: { status: 'resolved', target: r.qid, effect: 'unknown', inlined: true, ...(via ? { via } : {}) } });
817
976
  const out = this.lowerExpr(inl.body, ln);
818
977
  return out && typeof out === 'object' ? { ...out, hs: { ...(out.hs || {}), inlinedFrom: r.qid } } : out;
819
978
  } finally {
@@ -1080,8 +1239,10 @@ class FnLowerer {
1080
1239
  const info = this.mod.cons.get(pat.con) || this.proj.findCon(pat.con);
1081
1240
  pat.args.forEach((x, i) => walk(x, { kind: 'member', object: s, prop: info && info.fields && info.fields[i] ? info.fields[i] : `${pat.con}.${i}` }));
1082
1241
  for (const f of pat.fields) {
1083
- if (f.wildcard) { for (const fld of (info && info.fields) || []) if (!pat.fields.some((g) => g.name === fld)) emit(fld, { kind: 'member', object: s, prop: fld }); }
1084
- else walk(f.pat, { kind: 'member', object: s, prop: f.name });
1242
+ // A variable bound to a record field (`MkProxySettings {..}`, `Ops { exec = run }`) remembers which field it is, so applying it can be
1243
+ // resolved to the functions the project stores in that field (see applyFieldCandidates).
1244
+ if (f.wildcard) { for (const fld of (info && info.fields) || []) if (!pat.fields.some((g) => g.name === fld)) { emit(fld, { kind: 'member', object: s, prop: fld }); this.declare(fld, { kind: 'local', field: { con: pat.con, name: fld } }); } }
1245
+ else { walk(f.pat, { kind: 'member', object: s, prop: f.name }); if (f.pat && f.pat.t === 'pvar') this.declare(f.pat.name, { kind: 'local', field: { con: pat.con, name: f.name } }); }
1085
1246
  }
1086
1247
  break;
1087
1248
  }
@@ -1297,6 +1458,7 @@ class Project {
1297
1458
  this.fns = [];
1298
1459
  this.byFile = new Map();
1299
1460
  this.instanceIndex = new Map(); // `${cls}.${method}` -> qids
1461
+ this.instanceMeta = new Map(); // instance-method qid -> { type, name }: the type the instance is for and the function's call-graph name
1300
1462
  this.diagnostics = [];
1301
1463
  }
1302
1464
 
@@ -1328,6 +1490,83 @@ class Project {
1328
1490
  return this._recTypes;
1329
1491
  }
1330
1492
 
1493
+ // The function values the project stores in field `field` of constructor `con`, over every construction in every module (see
1494
+ // applyFieldCandidates). Each candidate is `{kind:'name', mod, expr}` (a name resolved where the construction is), `{kind:'ignores'}`
1495
+ // (`const <$> e`, `fmap const e`, `const e`: the function ignores its argument) or `{kind:'opaque'}`. Bounded by a node budget per module.
1496
+ fieldFunctionCandidates(con, field) {
1497
+ const key = `${con}.${field}`;
1498
+ if (!this._fieldCands) this._fieldCands = new Map();
1499
+ if (this._fieldCands.has(key)) return this._fieldCands.get(key);
1500
+ const out = [];
1501
+ const walkAst = (n, visit, budget) => {
1502
+ if (!n || typeof n !== 'object' || budget.n-- <= 0) return;
1503
+ if (Array.isArray(n)) { for (const x of n) walkAst(x, visit, budget); return; }
1504
+ if (typeof n.t === 'string') visit(n);
1505
+ for (const k of Object.keys(n)) { const v = n[k]; if (v && typeof v === 'object') walkAst(v, visit, budget); }
1506
+ };
1507
+ // The expression bound to `name` inside the clause: a do-bind (`name <- e`), a `let`/`where` clause without parameters, or a pattern binding.
1508
+ const bindingOf = (clause, name) => {
1509
+ let found = null;
1510
+ walkAst(clause, (n) => {
1511
+ if (found) return;
1512
+ if (n.t === 'sbind' && n.pat && n.pat.t === 'pvar' && n.pat.name === name) found = { e: n.e, bind: true };
1513
+ else if (n.t === 'clause' && n.name === name && n.clause && !n.clause.pats.length && n.clause.rhs && n.clause.rhs.body && !(n.clause.rhs.guards && n.clause.rhs.guards.length)) found = { e: n.clause.rhs.body };
1514
+ else if (n.t === 'patbind' && n.pat && n.pat.t === 'pvar' && n.pat.name === name && n.rhs && n.rhs.body) found = { e: n.rhs.body };
1515
+ }, { n: 20000 });
1516
+ return found;
1517
+ };
1518
+ const localsOf = (clause) => {
1519
+ const names = new Set();
1520
+ for (const p of clause.pats || []) for (const v of patternVars(p)) names.add(v);
1521
+ walkAst(clause, (n) => {
1522
+ if (n.t === 'sbind' && n.pat) for (const v of patternVars(n.pat)) names.add(v);
1523
+ else if (n.t === 'clause' && n.name) names.add(n.name);
1524
+ else if (n.t === 'lam') for (const p of n.params || []) for (const v of patternVars(p)) names.add(v);
1525
+ else if (n.t === 'patbind' && n.pat) for (const v of patternVars(n.pat)) names.add(v);
1526
+ }, { n: 20000 });
1527
+ return names;
1528
+ };
1529
+ const isVar = (e, name) => { const u = unparen(e); return u && u.t === 'var' && !u.qual && u.name === name; };
1530
+ const classify = (e, mod, locals, depth) => {
1531
+ const u = unparen(e);
1532
+ if (!u || depth > 6) { out.push({ kind: 'opaque' }); return; }
1533
+ if (u.t === 'var') {
1534
+ if (locals.has(u.name) && !u.qual) { out.push({ kind: 'opaque' }); return; }
1535
+ const r = resolveGlobal(this, mod, u.name, u.qual || null);
1536
+ if ((r.kind === 'def' && r.def.kind === 'fun') || r.kind === 'external' || r.kind === 'builtin') out.push({ kind: 'name', mod, expr: u });
1537
+ else out.push({ kind: 'opaque' });
1538
+ return;
1539
+ }
1540
+ if (u.t === 'op' && u.op === '$') { classify({ t: 'app', f: u.l, args: [u.r], line: u.line }, mod, locals, depth + 1); return; }
1541
+ if (u.t === 'op' && u.op === '<$>' && isVar(u.l, 'const')) { out.push({ kind: 'ignores' }); return; }
1542
+ if (u.t === 'app' && u.args.length === 1 && (isVar(u.f, 'pure') || isVar(u.f, 'return'))) { classify(u.args[0], mod, locals, depth + 1); return; }
1543
+ if (u.t === 'app' && isVar(u.f, 'fmap') && u.args.length === 2 && isVar(u.args[0], 'const')) { out.push({ kind: 'ignores' }); return; }
1544
+ if (u.t === 'app' && isVar(u.f, 'const') && u.args.length === 1) { out.push({ kind: 'ignores' }); return; }
1545
+ if (u.t === 'case') { for (const a of u.alts || []) { if (a.rhs && a.rhs.guards && a.rhs.guards.length) for (const g of a.rhs.guards) classify(g.body, mod, locals, depth + 1); else classify(a.rhs && a.rhs.body, mod, locals, depth + 1); } return; }
1546
+ if (u.t === 'if') { classify(u.a, mod, locals, depth + 1); classify(u.b, mod, locals, depth + 1); return; }
1547
+ out.push({ kind: 'opaque' });
1548
+ };
1549
+ for (const mod of this.modules.values()) {
1550
+ for (const f of (mod.groups && mod.groups.funs) || []) {
1551
+ for (const c of f.clauses || []) {
1552
+ const recs = [];
1553
+ walkAst(c, (n) => { if (n.t === 'rec' && n.con && n.con.name === con) recs.push(n); }, { n: 20000 });
1554
+ if (!recs.length) continue;
1555
+ const locals = localsOf(c);
1556
+ for (const r of recs) {
1557
+ const explicit = (r.fields || []).find((x) => !x.wildcard && x.name === field);
1558
+ if (explicit) { classify(explicit.e, mod, locals, 0); continue; }
1559
+ if (!(r.fields || []).some((x) => x.wildcard)) continue; // the field is not set here (a partial record): nothing is known about it
1560
+ const b = bindingOf(c, field);
1561
+ if (b) classify(b.e, mod, locals, 0); else out.push({ kind: 'opaque' });
1562
+ }
1563
+ }
1564
+ }
1565
+ }
1566
+ this._fieldCands.set(key, out);
1567
+ return out;
1568
+ }
1569
+
1331
1570
  // A definition by its qid (the last one in module order wins, as the linear scan it replaces did).
1332
1571
  defByQid(qid) {
1333
1572
  if (!this._defIndex) {
@@ -1467,7 +1706,11 @@ function buildHaskellIRUncached(fileContents) {
1467
1706
  const key = `${inst.name}.${f.name}`;
1468
1707
  if (!proj.instanceIndex.has(key)) proj.instanceIndex.set(key, []);
1469
1708
  proj.instanceIndex.get(key).push(qid);
1470
- instQids.push({ mod, inst, f, qid });
1709
+ // the call-graph name carries the instance's type when it is known, so two instances of one class resolve to different functions by name
1710
+ const type = instanceTypeOf(inst);
1711
+ const name = `${mod.name}.$inst.${inst.name}${type ? `.${type}` : ''}.${f.name}`;
1712
+ proj.instanceMeta.set(qid, { type, name });
1713
+ instQids.push({ mod, inst, f, qid, name });
1471
1714
  }
1472
1715
  }
1473
1716
  }
@@ -1481,8 +1724,8 @@ function buildHaskellIRUncached(fileContents) {
1481
1724
  try { proj.addFn(lowerPatbind(proj, mod, pb)); } catch (err) { proj.diagnostics.push({ file: mod.file, line: pb.line, kind: 'ir-lowering-failure', detail: String(err && err.message) }); }
1482
1725
  }
1483
1726
  }
1484
- for (const { mod, inst, f, qid } of instQids) {
1485
- const info = { qid, name: `${mod.name}.$inst.${inst.name}.${f.name}`, line: f.line, parentName: `${mod.name}.$inst.${inst.name}.${f.name}` };
1727
+ for (const { mod, inst, f, qid, name } of instQids) {
1728
+ const info = { qid, name, line: f.line, parentName: name };
1486
1729
  try {
1487
1730
  const L = new FnLowerer(proj, mod, info, []);
1488
1731
  L.lowerClauses(f.clauses, f.line);
@@ -42,6 +42,7 @@ export const HS_PACKAGES = Object.freeze({
42
42
  'http-conduit': { modules: ['Network.HTTP.Simple', 'Network.HTTP.Conduit'], tested: ['2.3'] },
43
43
  wreq: { modules: ['Network.Wreq', 'Network.Wreq.Session'], tested: ['0.5'] },
44
44
  'blaze-html': { modules: ['Text.Blaze', 'Text.Blaze.Html', 'Text.Blaze.Html5', 'Text.Blaze.Internal'], tested: ['0.9'] },
45
+ 'blaze-builder': { modules: ['Blaze.ByteString.Builder.Html.Utf8', 'Blaze.ByteString.Builder.Html.Word'], tested: ['0.4.4'] },
45
46
  lucid: { modules: ['Lucid', 'Lucid.Base'], tested: ['2.11'] },
46
47
  shakespeare: { modules: ['Text.Hamlet', 'Text.Shakespeare'], tested: ['2.0'] },
47
48
  text: { modules: ['Data.Text', 'Data.Text.IO', 'Data.Text.Lazy', 'Data.Text.Lazy.IO', 'Data.Text.Encoding'], tested: ['2.0'] },
@@ -347,6 +348,10 @@ san('Text.Blaze', ['toMarkup', 'toValue', 'text', 'string', 'lazyText'], ['xss']
347
348
  // Text.Blaze.Html5 and Html4 re-export the escaping combinators, and most files import the element module rather than Text.Blaze.Html
348
349
  for (const mod of ['Text.Blaze.Html5', 'Text.Blaze.Html4.Strict', 'Text.Blaze.Html4.Transitional', 'Text.Blaze.Html4.FrameSet', 'Text.Blaze.XHtml5', 'Text.Blaze.XHtml1.Strict']) san(mod, ['toHtml', 'toMarkup', 'toValue', 'text', 'string', 'lazyText', 'textValue', 'stringValue'], ['xss'], 'escapes &, <, >, quotes for HTML text/attribute context');
349
350
  san('Lucid', ['toHtml', 'toHtmlRaw_'], ['xss'], 'Lucid.toHtml escapes');
351
+ // blaze-builder's HTML escapers (names read from the 0.4.4.1 Haddock index): a raw WAI body built from them is escaped text. keter's fix for
352
+ // its reflected XSS (HSEC-2024-0001) is `toByteString . fromHtmlEscapedByteString` over the Host header.
353
+ san('Blaze.ByteString.Builder.Html.Utf8', ['fromHtmlEscapedChar', 'fromHtmlEscapedString', 'fromHtmlEscapedShow', 'fromHtmlEscapedText', 'fromHtmlEscapedLazyText'], ['xss'], 'escapes &, <, >, quotes for HTML text/attribute context', { package: 'blaze-builder' });
354
+ san('Blaze.ByteString.Builder.Html.Word', ['fromHtmlEscapedWord', 'fromHtmlEscapedWordList', 'fromHtmlEscapedByteString', 'fromHtmlEscapedLazyByteString'], ['xss'], 'escapes &, <, >, quotes for HTML text/attribute context', { package: 'blaze-builder' });
350
355
  san('System.FilePath', ['takeFileName', 'takeBaseName'], ['path'], 'drops every directory component');
351
356
  san('System.FilePath.Posix', ['takeFileName', 'takeBaseName'], ['path'], 'drops every directory component');
352
357
  // A function whose result is a number or a boolean cannot carry caller text: nothing of the argument survives (`show (length x)`).
@@ -398,6 +403,13 @@ export const HS_RULE_APIS = Object.freeze([
398
403
  // mwc-random and splitmix are statistical generators, not CSPRNGs (createSystemRandom only seeds from the OS)
399
404
  ...E('System.Random.MWC', ['uniformR', 'uniform', 'uniformRM', 'uniformM', 'createSystemRandom', 'create', 'initialize', 'withSystemRandom', 'withSystemRandomST']),
400
405
  ...E('System.Random.SplitMix', ['nextWord64', 'nextInt', 'nextDouble', 'mkSMGen', 'newSMGen', 'initSMGen']),
406
+ // mutable cells (haskell-ir.js CELL_OPS): named here so the IR qualifies them whichever wildcard imports a module has
407
+ ...E('Data.IORef', ['newIORef', 'readIORef', 'writeIORef', 'modifyIORef', "modifyIORef'", 'atomicWriteIORef']),
408
+ ...E('Control.Concurrent.MVar', ['newMVar', 'newEmptyMVar', 'putMVar', 'takeMVar', 'readMVar', 'modifyMVar_', 'swapMVar']),
409
+ ...E('Control.Concurrent', ['newMVar', 'newEmptyMVar', 'putMVar', 'takeMVar', 'readMVar', 'modifyMVar_']),
410
+ ...E('Control.Concurrent.STM', ['atomically', 'newTVar', 'newTVarIO', 'readTVar', 'readTVarIO', 'writeTVar', 'modifyTVar', "modifyTVar'"]),
411
+ ...E('Control.Concurrent.STM.TVar', ['newTVar', 'newTVarIO', 'readTVar', 'readTVarIO', 'writeTVar', 'modifyTVar', "modifyTVar'"]),
412
+ ...E('Data.STRef', ['newSTRef', 'readSTRef', 'writeSTRef', 'modifySTRef', "modifySTRef'"]),
401
413
  ...E('Crypto.Random', ['getRandomBytes']), ...E('System.Entropy', ['getEntropy']),
402
414
  ...E('Network.Wai', ['strictRequestBody', 'lazyRequestBody']),
403
415
  ...E('Data.Time.Clock.POSIX', ['getPOSIXTime']), ...E('Data.Time.Clock', ['getCurrentTime']), ...E('System.CPUTime', ['getCPUTime']),
@@ -441,8 +453,8 @@ export const HS_WEB_FRAMEWORKS = Object.freeze({
441
453
  yesod: { modules: ['Yesod', 'Yesod.Core', 'Yesod.Auth'], package: 'yesod', tested: ['1.6'] },
442
454
  });
443
455
  export const HS_WEB_APIS = Object.freeze([
444
- ...E('Web.Scotty', ['get', 'post', 'put', 'delete', 'patch', 'options', 'addroute', 'matchAny', 'middleware', 'scotty', 'scottyApp', 'status', 'finish', 'raise', 'raiseStatus', 'redirect', 'text', 'json', 'html', 'file', 'setHeader', 'header', 'headers', 'param', 'params', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData', 'files', 'rescue', 'liftIO', 'notFound']),
445
- ...E('Web.Scotty.Trans', ['get', 'post', 'put', 'delete', 'patch', 'middleware', 'scottyT', 'status', 'finish', 'raise', 'raiseStatus', 'redirect', 'text', 'json', 'html', 'header', 'param', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData']),
456
+ ...E('Web.Scotty', ['get', 'post', 'put', 'delete', 'patch', 'options', 'addroute', 'matchAny', 'middleware', 'scotty', 'scottyApp', 'status', 'finish', 'raise', 'raiseStatus', 'redirect', 'text', 'json', 'html', 'file', 'setHeader', 'header', 'headers', 'param', 'params', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData', 'files', 'rescue', 'liftIO', 'notFound', 'setMaxRequestBodySize']),
457
+ ...E('Web.Scotty.Trans', ['get', 'post', 'put', 'delete', 'patch', 'middleware', 'scottyT', 'status', 'finish', 'raise', 'raiseStatus', 'redirect', 'text', 'json', 'html', 'header', 'param', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData', 'setMaxRequestBodySize']),
446
458
  ...E('Network.Wai', ['pathInfo', 'requestMethod', 'requestHeaders', 'responseLBS', 'queryString', 'rawPathInfo']),
447
459
  ...E('Network.Wai.Handler.Warp', ['run', 'runSettings']),
448
460
  ...E('Network.HTTP.Types', ['status200', 'status201', 'status400', 'status401', 'status403', 'status404', 'unauthorized401', 'forbidden403', 'methodGet', 'methodPost', 'methodPut', 'methodDelete', 'hAuthorization', 'hCookie']),
@@ -1131,7 +1131,10 @@ function analyze(source, file, dialect, ctx, res) {
1131
1131
  put(it.exprStart, seg.slice(it.exprStart, it.close));
1132
1132
  put(it.close, ')');
1133
1133
  }
1134
- put(interps[interps.length - 1].close + 1, ')'); // closes the application; the char after the last `}` is template text or the `|` of `|]`
1134
+ // Closes the application at the `|` of the closing `|]`: always inside the segment and never a newline. (It used to go at the character
1135
+ // after the last `}`, which is a newline when the interpolation ends its line, so the paren was dropped and the enclosing function failed
1136
+ // to parse: every hledger-web handler whose last `^{widget}` ended a line lost its calls.)
1137
+ put(seg.length - 2, ')');
1135
1138
  const text = chars.join('');
1136
1139
  if (text.length !== seg.length) continue;
1137
1140
  b.kind = 'quasiquote-hamlet';
@@ -87,6 +87,54 @@ const BOUNDING = /(?:^|\.)(?:take|takeE|isolate|hGetSome|hGet|splitAt|limit|limi
87
87
  const REQUEST_PROVENANCE = new Set(['url-param', 'path-param', 'http-body', 'header', 'cookie', 'upload']);
88
88
  // wai-extra's `requestSizeCheck (Just n) req` wraps the request so the body read after it fails past n bytes: a bound on every body read of the function.
89
89
  const WAI_SIZE_CHECK = /(?:^|\.)requestSizeCheck$/;
90
+ // scotty's global body limit: `setMaxRequestBodySize kb` in the ScottyM block answers every body read of the application it builds with a 413.
91
+ const SCOTTY_BODY_LIMIT = /^Web\.Scotty(?:\.Trans)?\.setMaxRequestBodySize$/;
92
+
93
+ // Request text handed to a project helper: in `deleteEnum tableId list`, where the caller's `tableId` was read from the request, the helper's
94
+ // first parameter carries caller text for the partial-function, allocation and bounding rules (which do not follow calls themselves). Decided
95
+ // from the IR's RESOLVED call targets and the caller's own request reads, one hop per round, bounded rounds; a parameter the IR could not name
96
+ // (`$arg0`, a pattern) is skipped. Returns qid -> Set of parameter names.
97
+ function requestTextParams(ir) {
98
+ const byQid = new Map();
99
+ for (const fir of Object.values(ir.perFile)) for (const fn of fir.functions) byQid.set(fn.qid, fn);
100
+ const out = new Map();
101
+ const requestRead = (c) => { const s = sourceInfo(c.callee); return !!s && REQUEST_PROVENANCE.has(s.provenance); };
102
+ const callsIn = (e) => { const r = []; for (const x of walk(e)) if (x.kind === 'call' && typeof x.callee === 'string') r.push(x); return r; };
103
+ for (let round = 0; round < 4; round++) {
104
+ let grew = false;
105
+ for (const fn of byQid.values()) {
106
+ const from = new Set(out.get(fn.qid) || []);
107
+ for (let k = 0; k < 8; k++) {
108
+ let g = false;
109
+ for (const n of fnNodes(fn)) {
110
+ if (n.kind !== 'assign' || !n.target || !n.source) continue;
111
+ if (!from.has(n.target) && (callsIn(n.source).some(requestRead) || [...names(n.source)].some((x) => from.has(x)))) { from.add(n.target); g = true; }
112
+ }
113
+ if (!g) break;
114
+ }
115
+ const derived = (e) => [...names(e)].some((x) => from.has(x)) || callsIn(e).some(requestRead);
116
+ const mark = (x) => {
117
+ if (!x || !x.hs) return;
118
+ // a call with every argument, or a partial application (`updateSchema (deleteEnum tableId)`: the IR keeps the supplied arguments as elements)
119
+ const target = x.kind === 'call' && x.hs.status === 'resolved' ? x.hs.target : (x.kind === 'array' && x.hs.partial ? x.hs.partial.target : null);
120
+ if (!target) return;
121
+ const callee = byQid.get(target);
122
+ if (!callee || callee === fn || !Array.isArray(callee.params)) return;
123
+ ((x.kind === 'call' ? x.args : x.elements) || []).forEach((a, i) => {
124
+ const p = callee.params[i];
125
+ if (!p || typeof p !== 'string' || p.startsWith('$') || !derived(a)) return;
126
+ if (!out.has(callee.qid)) out.set(callee.qid, new Set());
127
+ if (!out.get(callee.qid).has(p)) { out.get(callee.qid).add(p); grew = true; }
128
+ });
129
+ };
130
+ for (const n of fnNodes(fn)) for (const ex of [n.value, n.source, n.cond, ...(n.args || [])]) if (ex) for (const x of walk(ex)) mark(x);
131
+ // a helper the IR inlined at its call site keeps its edge, with the arguments, on `fn.calls`
132
+ for (const c of fn.calls || []) if (c && c.hs && c.hs.inlined) mark(c);
133
+ }
134
+ if (!grew) break;
135
+ }
136
+ return out;
137
+ }
90
138
  // `replicateM n getLine` reads n lines: the count is an allocation (and an input loop) sized by whoever supplied n.
91
139
  const ALLOCATORS = new Set(['replicate', 'Prelude.replicate', 'Data.List.replicate', 'Data.ByteString.replicate', 'Data.ByteString.Char8.replicate', 'Data.ByteString.Lazy.replicate', 'Data.Text.replicate', 'Data.Vector.replicate', 'Control.Monad.replicateM', 'Control.Monad.replicateM_', 'Data.Vector.replicateM', 'Data.List.genericReplicate']);
92
140
  const CLAMPS = /^(?:(?:Prelude|Data\.Ord)\.)?(?:min|clamp)$/;
@@ -391,6 +439,20 @@ export function analyzeHaskellRules(files, opts = {}) {
391
439
  if (/import\s+(?:qualified\s+)?Network\.Wai\.Middleware\.RequestSizeLimit\b/.test(text) || /\brequestSizeLimitMiddleware\b/.test(text)) { requestLimit = { file, line: (text.split('\n').findIndex((l) => /RequestSizeLimit|requestSizeLimitMiddleware/.test(l)) + 1) || 1 }; break; }
392
440
  }
393
441
 
442
+ const requestParams = requestTextParams(ir);
443
+ // A ScottyM block that calls setMaxRequestBodySize bounds every body read of the application it builds: certain for a body read in the SAME
444
+ // function (an inline route) and for a handler the block references; a block elsewhere in the project is a control whose reach is not verified
445
+ // (the finding stays, at low, as for the wai middleware).
446
+ const scottyLimitFns = [];
447
+ for (const fir of Object.values(ir.perFile)) for (const fn of fir.functions) if ((fn.calls || []).some((c) => typeof c.callee === 'string' && SCOTTY_BODY_LIMIT.test(c.callee))) scottyLimitFns.push(fn);
448
+ const scottyLimitFor = (fn) => {
449
+ if (!scottyLimitFns.length) return null;
450
+ if (scottyLimitFns.includes(fn)) return { certain: true, fn };
451
+ const refs = (g) => new Set([...((g.hs && g.hs.refs) || []).map((r) => r.target), ...(g.calls || []).map((c) => c.hs && c.hs.target).filter(Boolean)]);
452
+ const g = scottyLimitFns.find((x) => refs(x).has(fn.qid));
453
+ return g ? { certain: true, fn: g } : { certain: false, fn: scottyLimitFns[0] };
454
+ };
455
+
394
456
  let currentFn = null;
395
457
  const legacyMemo = new Map();
396
458
  const emit = (rule, file, line, extra = {}) => {
@@ -446,8 +508,10 @@ export function analyzeHaskellRules(files, opts = {}) {
446
508
  // A value READ from the request inside the function (`raw <- param "page"`, `body <- strictRequestBody req`, `lookup h (requestHeaders req)`)
447
509
  // is caller-supplied text wherever the function sits: the framework's own read says who supplied it, so no parameter label is needed.
448
510
  const requestRead = (c) => { const s = sourceInfo(c.callee); return !!s && REQUEST_PROVENANCE.has(s.provenance); };
449
- const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
450
- const fromRequest = new Set();
511
+ // a parameter a caller fills with request-derived text (`requestTextParams`) is caller text here too, whatever this function's own label
512
+ const helperParams = requestParams.get(fn.qid) || new Set();
513
+ const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : []), ...helperParams]);
514
+ const fromRequest = new Set(helperParams);
451
515
  for (let round = 0; round < 8; round++) {
452
516
  let grew = false;
453
517
  for (const n of fnNodes(fn)) {
@@ -592,9 +656,14 @@ export function analyzeHaskellRules(files, opts = {}) {
592
656
  if (e.hs.con === 'VerifyNone' && e.kind === 'literal' && importsFrom(['OpenSSL.Session'])) emit('hs-tls-validation-disabled', file, e.line || node.line, { detail: 'OpenSSL verification mode VerifyNone', evidence: { mode: 'VerifyNone' } });
593
657
  }
594
658
  // `xs !! n` raises on a short list: a partial function, written as an operator, applied to caller-supplied text
595
- if (e.kind === 'binary' && e.op === '!!' && e.left && mentionsText(e.left) && !isTested([e.left])) {
596
- emit('hs-partial-function-on-input', file, e.line || node.line, { detail: '!! on a caller-supplied list', evidence: { partial: '!!' } });
597
- continue;
659
+ // (an INDEX from caller text is the same flaw, `list !! tableId`: out of range raises; a relational test on the index in this function covers it)
660
+ if (e.kind === 'binary' && e.op === '!!' && e.left && e.right) {
661
+ const listText = mentionsText(e.left) && !isTested([e.left]);
662
+ const indexText = !listText && mentionsText(e.right) && !isTested([e.right]) && ![...names(e.right)].some((v) => sizeTestedVars.has(v));
663
+ if (listText || indexText) {
664
+ emit('hs-partial-function-on-input', file, e.line || node.line, { detail: listText ? '!! on a caller-supplied list' : '!! with a caller-supplied index', evidence: { partial: '!!' }, inlineCtx: e.hs && e.hs.inlineCtx });
665
+ continue;
666
+ }
598
667
  }
599
668
  if (e.kind !== 'call' || typeof e.callee !== 'string') continue;
600
669
  const c = e.callee;
@@ -663,12 +732,18 @@ export function analyzeHaskellRules(files, opts = {}) {
663
732
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
664
733
  }
665
734
  // a partial function applied to caller text: one malformed value raises an uncaught exception
666
- if (PARTIALS.has(c) && args.some((a) => mentionsText(a)) && !isTested(args)) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied input`, evidence: { partial: c } });
735
+ if (PARTIALS.has(c) && args.some((a) => mentionsText(a)) && !isTested(args)) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied input`, evidence: { partial: c }, inlineCtx: e.hs && e.hs.inlineCtx });
667
736
  // a session/token cookie assembled without HttpOnly or Secure
668
737
  if (c === 'Web.Cookie.defaultSetCookie') { /* handled on the record update below */ }
669
738
  // unbounded body
670
739
  if (BODY_CALLS.has(c) && !sizeChecked && !(c === 'Network.Wai.Conduit.sourceRequestBody' && bounded)) {
671
- if (requestLimit) {
740
+ const scottyLimit = /^Web\.Scotty/.test(c) ? scottyLimitFor(fn) : null;
741
+ if (scottyLimit && scottyLimit.certain) {
742
+ controls.push({ kind: 'scotty-body-limit', file: scottyLimit.fn.file, line: scottyLimit.fn.line, appliesTo: `${file}:${line}`, verified: true });
743
+ } else if (scottyLimit) {
744
+ controls.push({ kind: 'scotty-body-limit', file: scottyLimit.fn.file, line: scottyLimit.fn.line, appliesTo: `${file}:${line}`, verified: false });
745
+ emit('hs-unbounded-request-body', file, line, { severity: 'low', confidence: 0.4, detail: c, uncertainty: [{ kind: 'unresolved-import', detail: `a scotty body limit is set (${scottyLimit.fn.file}:${scottyLimit.fn.line}); that this handler is registered in that application is not verified` }] });
746
+ } else if (requestLimit) {
672
747
  controls.push({ kind: 'request-size-limit', file: requestLimit.file, line: requestLimit.line, appliesTo: `${file}:${line}`, verified: false });
673
748
  emit('hs-unbounded-request-body', file, line, { severity: 'low', confidence: 0.4, detail: c, uncertainty: [{ kind: 'unresolved-import', detail: `a request-size-limit middleware exists (${requestLimit.file}:${requestLimit.line}); that it wraps this handler is not verified` }] });
674
749
  } else emit('hs-unbounded-request-body', file, line, { detail: c });