@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.
- package/CHANGELOG.md +26 -0
- package/dist/{851efefbfcfa2a9aa691.json → 13e6d1463f0aec440afa.json} +3 -3
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +13 -3
- package/src/language/haskell-fix.js +359 -7
- package/src/language/haskell-ir.js +135 -8
- package/src/language/haskell-models.js +9 -2
- package/src/language/haskell-security-rules.js +126 -8
- package/src/language/support-registry.js +2 -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
|
}
|
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
411
|
-
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);
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|