@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.
- package/CHANGELOG.md +32 -0
- package/dist/{312ef9976a988b322ade.json → 2bbc3428df6b8d66516c.json} +3 -3
- package/dist/agentic-security.mjs +4 -4
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog-expanded.js +5 -1
- package/src/dataflow/engine.js +7 -0
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +7 -3
- package/src/language/haskell-fix.js +108 -2
- package/src/language/haskell-guards.js +10 -0
- package/src/language/haskell-ir.js +253 -10
- package/src/language/haskell-models.js +14 -2
- package/src/language/haskell-parser.js +4 -1
- package/src/language/haskell-security-rules.js +82 -7
- package/src/language/haskell-syntax.js +34 -6
- package/src/posture/corpus-match.js +12 -0
|
@@ -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)
|
|
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
|
|
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; }
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
const
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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 });
|