@clear-capabilities/agentic-security-scanner 0.195.9 → 0.195.11

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
  }
@@ -590,6 +619,11 @@ class FnLowerer {
590
619
  const r = this.resolve(e.name, e.qual);
591
620
  if (r.kind === 'alias') return r.expr;
592
621
  if (r.kind === 'def' && r.def.kind === 'field') return { kind: 'ident', name: e.name, hs: { fieldSelector: r.name } };
622
+ if (r.kind === 'def' && r.def.kind === 'fun' && isKnownApi(r.mod, r.name)) {
623
+ // a modelled name the project itself defines (see applyVar): the registry's meaning, the definition still referenced
624
+ this.noteRef(r.qid, 'modelled-definition', ln);
625
+ return { kind: 'ident', name: `${r.mod}.${r.name}`, hs: { resolution: 'external', certain: true, modelledDefinition: r.qid } };
626
+ }
593
627
  if (r.kind === 'def' || r.kind === 'localfn') {
594
628
  this.noteRef(r.qid, 'reference', ln);
595
629
  return { kind: 'ident', name: this.canonical(r), hs: { functionRef: r.qid } };
@@ -683,6 +717,16 @@ class FnLowerer {
683
717
  if (f.name === 'uncurry' && argAsts.length === 2 && tup(argAsts[1])) return this.lowerApp(argAsts[0], tup(argAsts[1]), ln, via);
684
718
  }
685
719
  if (r.kind === 'def' && r.def.kind === 'field' && argAsts.length >= 1) {
720
+ // `exec ops x` where `ops` is a top-level constant record whose `exec` field holds a function: the application is of THAT function. The
721
+ // held function is found in the constant's record syntax (`Ops { exec = \x -> .. }` or `Ops { exec = pingHost }`); a positional
722
+ // construction, a field set by an update, or a record built at run time stay an indirect call.
723
+ const held = argAsts.length > 1 ? this.heldFieldFunction(r, argAsts[0], ln) : null;
724
+ if (held) {
725
+ const lowered = argAsts.slice(1).map((a) => ({ t: 'lowered', expr: this.lowerExpr(a, ln) }));
726
+ const savedMod = this.mod; const savedOverride = this.lineOverride;
727
+ this.lineOverride = this.lineOverride || ln; this.inlineDepth = (this.inlineDepth || 0) + 1; this.mod = held.mod;
728
+ try { return this.lowerApp(held.expr, lowered, ln, via); } finally { this.mod = savedMod; this.lineOverride = savedOverride; this.inlineDepth -= 1; }
729
+ }
686
730
  const obj = this.lowerExpr(argAsts[0], ln);
687
731
  const mem = { kind: 'member', object: obj, prop: r.name };
688
732
  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' } };
@@ -700,7 +744,18 @@ class FnLowerer {
700
744
  if (selector) hs.selector = selector;
701
745
  let callee = f.qual ? `${f.qual}.${f.name}` : f.name;
702
746
  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') {
747
+ if (r.kind === 'def' && r.def.kind === 'fun' && isKnownApi(r.mod, r.name)) {
748
+ // The project defines a module the model registry models: the project IS the framework (a framework repository scanning itself) or
749
+ // carries a copy of it. The registry says what the name means to its caller (a request read, a sink, a sanitizer), which the
750
+ // definition's body, written in terms the registry does not model (an implicit request parameter, a parser over it), does not show.
751
+ // The call is lowered as the library call; the definition is still referenced, so it is never an uncalled entry point, and its own
752
+ // body is still analysed as a function of the project. Pinned both ways in test/haskell/haskell-taint-shapes.test.js.
753
+ this.noteRef(r.qid, 'modelled-definition', ln);
754
+ callee = `${r.mod}.${r.name}`;
755
+ hs.status = 'external'; hs.certain = true; hs.modelledDefinition = r.qid;
756
+ hs.effect = r.def.sig ? (r.def.sig.returnsIO ? 'io' : 'pure') : (IO_NAMES.has(f.name) ? 'io' : 'unknown');
757
+ if (hs.effect === 'io') this.sawIO = true;
758
+ } else if ((r.kind === 'def' && r.def.kind === 'fun') || r.kind === 'localfn') {
704
759
  callee = this.canonical(r);
705
760
  hs.status = 'resolved'; hs.target = r.qid;
706
761
  if (r.kind === 'def' && arity === args.length && arity > 0 && (this.inlineDepth || 0) < INLINE_MAX_DEPTH) {
@@ -722,6 +777,10 @@ class FnLowerer {
722
777
  hs.status = 'typeclass'; hs.cls = r.def.cls; hs.method = r.name; hs.target = r.qid;
723
778
  hs.candidates = this.proj.instancesOf(r.def.cls, r.name);
724
779
  hs.effect = 'unknown';
780
+ // `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
781
+ // 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.
782
+ const picked = this.dispatchInstance(r.def.cls, r.name, args);
783
+ if (picked) { callee = picked.name; hs.status = 'resolved'; hs.target = picked.qid; hs.dispatchedOn = picked.type; delete hs.candidates; }
725
784
  } else if (r.kind === 'param') {
726
785
  hs.status = 'param'; hs.paramIndex = r.index; hs.effect = 'unknown';
727
786
  } else if (r.kind === 'local') {
@@ -747,6 +806,10 @@ class FnLowerer {
747
806
  if (ctxTargets.some((t) => t.length)) hs.argTargets = ctxTargets;
748
807
  }
749
808
  const call = { kind: 'call', callee, args, line: ln, hs };
809
+ if (hs.status === 'external' || hs.status === 'builtin') {
810
+ const cell = this.lowerCellOp(callee, args, argAsts, ln, call);
811
+ if (cell) return cell;
812
+ }
750
813
  // `setRequestBodyJSON body req` returns the REQUEST with a body: the body's taint is a property of the body, and
751
814
  // must not make the request's URL tainted. The call still gets its own node so a sink on the body argument fires.
752
815
  if ((hs.status === 'external' || hs.status === 'builtin') && BODY_SETTER.test(callee) && args.length >= 2) {
@@ -757,6 +820,64 @@ class FnLowerer {
757
820
  return (hs.status === 'external' || hs.status === 'builtin') && isSourceApi(callee) ? this.sourceValue(call, ln) : call;
758
821
  }
759
822
 
823
+ // The instance a class-method call dispatches to, when an argument is a constructor (nullary, positional or record) of a type that has
824
+ // exactly one visible instance of the class. Several candidate types, or no constructor argument, is null (the candidates stay).
825
+ dispatchInstance(cls, method, args) {
826
+ const cands = this.proj.instancesOf(cls, method);
827
+ if (!cands.length) return null;
828
+ const types = new Set();
829
+ for (const a of args) {
830
+ const con = a && a.hs && a.hs.con;
831
+ if (!con) continue;
832
+ const c = this.mod.cons.get(con) || this.proj.findCon(con);
833
+ if (c && c.type) types.add(c.type);
834
+ }
835
+ if (types.size !== 1) return null;
836
+ const [type] = types;
837
+ const hits = cands.filter((q) => { const m = this.proj.instanceMeta.get(q); return m && m.type === type; });
838
+ if (hits.length !== 1) return null;
839
+ return { qid: hits[0], name: this.proj.instanceMeta.get(hits[0]).name, type };
840
+ }
841
+
842
+ // The function a record field of a top-level constant holds: `ops = Ops { exec = \x -> .. }` for `exec ops`. Returns the field's expression
843
+ // and the module it was written in, or null (the constant is not a single record literal, or the field is not set there).
844
+ heldFieldFunction(r, objAst, ln) {
845
+ if ((this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
846
+ const u = unparen(objAst);
847
+ if (!u || u.t !== 'var') return null;
848
+ const r0 = this.resolve(u.name, u.qual);
849
+ if (r0.kind !== 'def' || r0.def.kind !== 'fun' || r0.def.arity !== 0) return null;
850
+ const mod = this.proj.modules.get(r0.mod);
851
+ const fnAst = mod && mod.groups.funs.find((f) => f.name === r0.name);
852
+ if (!fnAst || fnAst.clauses.length !== 1) return null;
853
+ const c = fnAst.clauses[0];
854
+ 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;
855
+ const body = unparen(c.rhs.body);
856
+ if (!body || body.t !== 'rec' || !body.con || body.con.name !== r.def.con) return null;
857
+ const fld = (body.fields || []).find((f) => !f.wildcard && f.name === r.name && f.e);
858
+ if (!fld) return null;
859
+ this.noteRef(r0.qid, 'reference', ln);
860
+ return { mod, expr: fld.e };
861
+ }
862
+
863
+ // 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
864
+ // the function applied to it. Anything else (a cell that is not a local variable, a missing argument) is the plain call.
865
+ lowerCellOp(callee, args, argAsts, ln, call) {
866
+ const op = CELL_OPS[callee];
867
+ if (!op) return null;
868
+ const ref = args[0];
869
+ if (!ref || ref.kind !== 'ident' || !ref.name || ref.hs) return null;
870
+ this.sawIO = true;
871
+ if (op === 'read') return { kind: 'ident', name: ref.name };
872
+ if (op === 'write' && args.length >= 2) { this.cfg.add('assign', ln, { target: ref.name, source: args[1], hs: { demand: 'executed', cellWrite: callee } }); return call; }
873
+ if (op === 'modify' && argAsts.length >= 2) {
874
+ const applied = this.lowerApp(argAsts[1], [{ t: 'lowered', expr: { kind: 'ident', name: ref.name } }], ln, callee);
875
+ this.cfg.add('assign', ln, { target: ref.name, source: applied, hs: { demand: 'executed', cellWrite: callee } });
876
+ return call;
877
+ }
878
+ return null;
879
+ }
880
+
760
881
  // A modelled source read gets its own assignment node, so the evidence chain names the exact line the
761
882
  // untrusted data entered, even when the read sits inside a larger expression (`f =<< getLine`).
762
883
  sourceValue(call, ln) {
@@ -813,7 +934,8 @@ class FnLowerer {
813
934
  try {
814
935
  inl.params.forEach((name, i) => this.declare(name, { kind: 'alias', expr: loweredArgs[i] }));
815
936
  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 } : {}) } });
937
+ // the lowered arguments are kept on the edge: a rule asking what a helper's parameters receive reads them (haskell-security-rules.js requestTextParams)
938
+ (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
939
  const out = this.lowerExpr(inl.body, ln);
818
940
  return out && typeof out === 'object' ? { ...out, hs: { ...(out.hs || {}), inlinedFrom: r.qid } } : out;
819
941
  } finally {
@@ -1297,6 +1419,7 @@ class Project {
1297
1419
  this.fns = [];
1298
1420
  this.byFile = new Map();
1299
1421
  this.instanceIndex = new Map(); // `${cls}.${method}` -> qids
1422
+ this.instanceMeta = new Map(); // instance-method qid -> { type, name }: the type the instance is for and the function's call-graph name
1300
1423
  this.diagnostics = [];
1301
1424
  }
1302
1425
 
@@ -1467,7 +1590,11 @@ function buildHaskellIRUncached(fileContents) {
1467
1590
  const key = `${inst.name}.${f.name}`;
1468
1591
  if (!proj.instanceIndex.has(key)) proj.instanceIndex.set(key, []);
1469
1592
  proj.instanceIndex.get(key).push(qid);
1470
- instQids.push({ mod, inst, f, qid });
1593
+ // 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
1594
+ const type = instanceTypeOf(inst);
1595
+ const name = `${mod.name}.$inst.${inst.name}${type ? `.${type}` : ''}.${f.name}`;
1596
+ proj.instanceMeta.set(qid, { type, name });
1597
+ instQids.push({ mod, inst, f, qid, name });
1471
1598
  }
1472
1599
  }
1473
1600
  }
@@ -1481,8 +1608,8 @@ function buildHaskellIRUncached(fileContents) {
1481
1608
  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
1609
  }
1483
1610
  }
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}` };
1611
+ for (const { mod, inst, f, qid, name } of instQids) {
1612
+ const info = { qid, name, line: f.line, parentName: name };
1486
1613
  try {
1487
1614
  const L = new FnLowerer(proj, mod, info, []);
1488
1615
  L.lowerClauses(f.clauses, f.line);
@@ -398,6 +398,13 @@ export const HS_RULE_APIS = Object.freeze([
398
398
  // mwc-random and splitmix are statistical generators, not CSPRNGs (createSystemRandom only seeds from the OS)
399
399
  ...E('System.Random.MWC', ['uniformR', 'uniform', 'uniformRM', 'uniformM', 'createSystemRandom', 'create', 'initialize', 'withSystemRandom', 'withSystemRandomST']),
400
400
  ...E('System.Random.SplitMix', ['nextWord64', 'nextInt', 'nextDouble', 'mkSMGen', 'newSMGen', 'initSMGen']),
401
+ // mutable cells (haskell-ir.js CELL_OPS): named here so the IR qualifies them whichever wildcard imports a module has
402
+ ...E('Data.IORef', ['newIORef', 'readIORef', 'writeIORef', 'modifyIORef', "modifyIORef'", 'atomicWriteIORef']),
403
+ ...E('Control.Concurrent.MVar', ['newMVar', 'newEmptyMVar', 'putMVar', 'takeMVar', 'readMVar', 'modifyMVar_', 'swapMVar']),
404
+ ...E('Control.Concurrent', ['newMVar', 'newEmptyMVar', 'putMVar', 'takeMVar', 'readMVar', 'modifyMVar_']),
405
+ ...E('Control.Concurrent.STM', ['atomically', 'newTVar', 'newTVarIO', 'readTVar', 'readTVarIO', 'writeTVar', 'modifyTVar', "modifyTVar'"]),
406
+ ...E('Control.Concurrent.STM.TVar', ['newTVar', 'newTVarIO', 'readTVar', 'readTVarIO', 'writeTVar', 'modifyTVar', "modifyTVar'"]),
407
+ ...E('Data.STRef', ['newSTRef', 'readSTRef', 'writeSTRef', 'modifySTRef', "modifySTRef'"]),
401
408
  ...E('Crypto.Random', ['getRandomBytes']), ...E('System.Entropy', ['getEntropy']),
402
409
  ...E('Network.Wai', ['strictRequestBody', 'lazyRequestBody']),
403
410
  ...E('Data.Time.Clock.POSIX', ['getPOSIXTime']), ...E('Data.Time.Clock', ['getCurrentTime']), ...E('System.CPUTime', ['getCPUTime']),
@@ -441,8 +448,8 @@ export const HS_WEB_FRAMEWORKS = Object.freeze({
441
448
  yesod: { modules: ['Yesod', 'Yesod.Core', 'Yesod.Auth'], package: 'yesod', tested: ['1.6'] },
442
449
  });
443
450
  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']),
451
+ ...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']),
452
+ ...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
453
  ...E('Network.Wai', ['pathInfo', 'requestMethod', 'requestHeaders', 'responseLBS', 'queryString', 'rawPathInfo']),
447
454
  ...E('Network.Wai.Handler.Warp', ['run', 'runSettings']),
448
455
  ...E('Network.HTTP.Types', ['status200', 'status201', 'status400', 'status401', 'status403', 'status404', 'unauthorized401', 'forbidden403', 'methodGet', 'methodPost', 'methodPut', 'methodDelete', 'hAuthorization', 'hCookie']),
@@ -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)$/;
@@ -331,6 +379,44 @@ function weakDigestUse(fn, node, hashCall, hashArgs, fnBase) {
331
379
  return reasons;
332
380
  }
333
381
 
382
+ /**
383
+ * The migration shape: a fast password hash kept ONLY to verify hashes stored before a password KDF was introduced. True when every
384
+ * use of the function, in every file, is an operand of an equality (nothing new is stored with it) and a function that compares with
385
+ * it also computes a password KDF, directly or through one project function (`verify stored salt pw = if legacy stored then stored ==
386
+ * oldHash salt pw else stored == kdfHash salt pw`). The weak hash is still computed, so the finding is kept at `info` with this reason;
387
+ * a function that is also used to STORE a hash, or whose comparisons sit beside no KDF, is the full finding. Decided from the IR only,
388
+ * never from a name (`hashPasswordLegacy` is not evidence; the comparison and the KDF are).
389
+ */
390
+ function legacyVerifierOnly(fn, ir, memo) {
391
+ if (memo.has(fn.qid)) return memo.get(fn.qid);
392
+ const allFns = Object.values(ir.perFile).flatMap((x) => x.functions || []);
393
+ const byQid = new Map(allFns.map((x) => [x.qid, x]));
394
+ const usesF = (e) => !!e.hs && ((e.kind === 'call' && e.hs.target === fn.qid) || e.hs.inlinedFrom === fn.qid);
395
+ const kdfCall = (c) => typeof c === 'string' && (STRONG_KDF.test(c) || PBKDF2_CALLS.has(c));
396
+ const hasKdf = (g, depth) => (g.calls || []).some((c) => kdfCall(c.callee) || (depth < 1 && c.hs && c.hs.target && c.hs.target !== g.qid && byQid.has(c.hs.target) && hasKdf(byQid.get(c.hs.target), depth + 1)));
397
+ let uses = 0; let compared = 0; let besideKdf = false;
398
+ const visit = (e, parent, g, depth) => {
399
+ if (!e || typeof e !== 'object' || depth > 60) return;
400
+ if (usesF(e)) {
401
+ uses++;
402
+ if (parent && parent.kind === 'binary' && EQUALITY_OPS.has(parent.op)) { compared++; if (hasKdf(g, 0)) besideKdf = true; }
403
+ return; // under an inlined root the nodes are the helper's own body, not further uses of it
404
+ }
405
+ for (const k of Object.keys(e)) {
406
+ if (k === 'hs') continue;
407
+ const v = e[k];
408
+ if (Array.isArray(v)) { for (const x of v) visit(x, e, g, depth + 1); } else if (v && typeof v === 'object') visit(v, e, g, depth + 1);
409
+ }
410
+ };
411
+ for (const g of allFns) {
412
+ if (g.qid === fn.qid) continue;
413
+ for (const n of Object.values(g.cfg.nodes)) for (const root of [n.source, n.value, n.cond, ...(n.args || [])]) visit(root, null, g, 0);
414
+ }
415
+ const r = uses > 0 && compared === uses && besideKdf;
416
+ memo.set(fn.qid, r);
417
+ return r;
418
+ }
419
+
334
420
  /**
335
421
  * @param {Record<string,string>} files path -> source (only Haskell files are read)
336
422
  * @param {object} [opts]
@@ -353,7 +439,22 @@ export function analyzeHaskellRules(files, opts = {}) {
353
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; }
354
440
  }
355
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
+
356
456
  let currentFn = null;
457
+ const legacyMemo = new Map();
357
458
  const emit = (rule, file, line, extra = {}) => {
358
459
  const r = RULES[rule];
359
460
  const sev = extra.severity || r.severity;
@@ -407,8 +508,10 @@ export function analyzeHaskellRules(files, opts = {}) {
407
508
  // A value READ from the request inside the function (`raw <- param "page"`, `body <- strictRequestBody req`, `lookup h (requestHeaders req)`)
408
509
  // is caller-supplied text wherever the function sits: the framework's own read says who supplied it, so no parameter label is needed.
409
510
  const requestRead = (c) => { const s = sourceInfo(c.callee); return !!s && REQUEST_PROVENANCE.has(s.provenance); };
410
- const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
411
- 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);
412
515
  for (let round = 0; round < 8; round++) {
413
516
  let grew = false;
414
517
  for (const n of fnNodes(fn)) {
@@ -553,9 +656,14 @@ export function analyzeHaskellRules(files, opts = {}) {
553
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' } });
554
657
  }
555
658
  // `xs !! n` raises on a short list: a partial function, written as an operator, applied to caller-supplied text
556
- if (e.kind === 'binary' && e.op === '!!' && e.left && mentionsText(e.left) && !isTested([e.left])) {
557
- emit('hs-partial-function-on-input', file, e.line || node.line, { detail: '!! on a caller-supplied list', evidence: { partial: '!!' } });
558
- 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
+ }
559
667
  }
560
668
  if (e.kind !== 'call' || typeof e.callee !== 'string') continue;
561
669
  const c = e.callee;
@@ -595,7 +703,11 @@ export function analyzeHaskellRules(files, opts = {}) {
595
703
  // password hashed with a fast hash
596
704
  // (a hash call the IR inlined from a project helper, `digestFor (credPassword creds)` at the comparison, is reported once, in the helper)
597
705
  if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && !(e.hs && e.hs.inlinedFrom) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
598
- emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value`, inlineCtx: e.hs && e.hs.inlineCtx });
706
+ if (!(e.hs && e.hs.inlineCtx) && legacyVerifierOnly(fn, ir, legacyMemo)) {
707
+ emit('hs-fast-password-hash', file, line, { severity: 'info', confidence: 0.5, detail: `${c} applied to a password-shaped value, in a function used only to verify hashes made before a migration (every use of it is an equality, beside a password KDF): re-hash with the KDF at the next successful login and remove it once no stored hash needs it`, inlineCtx: e.hs && e.hs.inlineCtx, evidence: { legacyVerifier: true } });
708
+ } else {
709
+ emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value`, inlineCtx: e.hs && e.hs.inlineCtx });
710
+ }
599
711
  }
600
712
  if (PBKDF2_CALLS.has(c)) {
601
713
  for (const a of args) for (const x of walk(a)) if (x.kind === 'object') for (const p of x.props || []) if (p.key === 'iterCounts' && p.value && p.value.kind === 'literal' && typeof p.value.value === 'number' && p.value.value < 10000) {
@@ -620,12 +732,18 @@ export function analyzeHaskellRules(files, opts = {}) {
620
732
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
621
733
  }
622
734
  // a partial function applied to caller text: one malformed value raises an uncaught exception
623
- 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 });
624
736
  // a session/token cookie assembled without HttpOnly or Secure
625
737
  if (c === 'Web.Cookie.defaultSetCookie') { /* handled on the record update below */ }
626
738
  // unbounded body
627
739
  if (BODY_CALLS.has(c) && !sizeChecked && !(c === 'Network.Wai.Conduit.sourceRequestBody' && bounded)) {
628
- 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) {
629
747
  controls.push({ kind: 'request-size-limit', file: requestLimit.file, line: requestLimit.line, appliesTo: `${file}:${line}`, verified: false });
630
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` }] });
631
749
  } else emit('hs-unbounded-request-body', file, line, { detail: c });
@@ -243,6 +243,8 @@ export function evaluateSupport(language, input) {
243
243
  for (const [cap, req] of Object.entries(REQUIRES_TOOL[language] || {})) {
244
244
  const r = out.rows[cap];
245
245
  if (r && !tools[req.tool]) out.rows[cap] = { ...r, status: 'blocked', reasons: [`${req.tool} is not available on the measuring host: ${req.why}`, ...r.reasons] };
246
+ // a GHC other than the recorded toolchain compiles the fixtures against an empty package set: not a verdict on the engine
247
+ else if (r && req.tool === 'ghc' && tools.ghcMatchesRecorded === false && r.status !== 'supported') out.rows[cap] = { ...r, status: 'blocked', reasons: [`ghc ${tools.ghcVersion} on the measuring host is not the recorded toolchain (${tools.ghcRecorded}); the criterion that needs it could not be judged`, ...r.reasons] };
246
248
  }
247
249
  const rows = Object.values(out.rows);
248
250
  out.summary = { required: rows.length, supported: rows.filter((r) => r.status === 'supported').length, notSupported: rows.filter((r) => r.status !== 'supported').map((r) => `${r.capability}:${r.status}`), dataOk: dataOk.ok };