@clear-capabilities/agentic-security-scanner 0.195.14 → 0.195.16

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.
@@ -599,7 +599,14 @@ class FnLowerer {
599
599
  // `read s :: Int`: the value is a number, so it is a clean constant for the taint engine. The lowered inner expression rides on `hs.inner`, which the rule and web
600
600
  // analyses read (an unsafe coercion in it, the id it names), exactly as they read `hs.selector`. (A sanitizer call would be wrong here: the engine's sanitizer label is per expression, so a
601
601
  // number in `"kill " ++ show n ++ name` would mark the tainted `name` sanitized too.)
602
- case 'typed': return { kind: 'literal', value: 0, hs: { numeric: e.type, inner: this.lowerExpr(e.e, ln) } };
602
+ case 'typed': {
603
+ if (e.digest) { // `hash x :: Digest MD5`: the call itself, stamped with the algorithm the annotation selects
604
+ const inner = this.lowerExpr(e.e, ln);
605
+ if (inner && inner.kind === 'call') inner.hs = { ...(inner.hs || {}), digest: e.digest };
606
+ return inner;
607
+ }
608
+ return { kind: 'literal', value: 0, hs: { numeric: e.type, inner: this.lowerExpr(e.e, ln) } };
609
+ }
603
610
  case 'tuple': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { tuple: true } };
604
611
  case 'list': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)) };
605
612
  case 'range': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { range: true } };
@@ -49,6 +49,10 @@ export const BOUNDARIES = Object.freeze({
49
49
 
50
50
  const WEAK_HASH_CALLS = new Set(['Crypto.Hash.MD5.hash', 'Crypto.Hash.MD5.hashlazy', 'Crypto.Hash.SHA1.hash', 'Crypto.Hash.SHA1.hashlazy', 'Data.Digest.Pure.MD5.md5', 'Data.Digest.Pure.MD5.md5DigestBytes', 'Data.Digest.Pure.SHA.sha1', 'Data.Digest.Pure.SHA.sha1Incremental']);
51
51
  const WEAK_ALGO_CONS = new Set(['MD5', 'MD4', 'MD2', 'SHA1']);
52
+ // Partial list functions that are total on a list with at least one element (see nonEmptyList), and the length calls a size test may name.
53
+ const NONEMPTY_TOTAL = /(?:^|\.)(?:head|last|init|tail|maximum|minimum|foldr1|foldl1|maximumBy|minimumBy|cycle)$/;
54
+ const LENGTH_CALL = /(?:^|\.)(?:length|genericLength|size)$/;
55
+ const COND_COMBINATOR = /(?:^|\.)(?:when|unless|guard)$/;
52
56
  const ANY_HASH_CALLS = new Set([...WEAK_HASH_CALLS, 'Crypto.Hash.hash', 'Crypto.Hash.hashlazy', 'Crypto.Hash.hashWith', 'Crypto.Hash.SHA256.hash', 'Crypto.Hash.SHA256.hashlazy', 'Crypto.Hash.SHA512.hash', 'Crypto.Hash.SHA512.hashlazy', 'Data.Digest.Pure.SHA.sha256', 'Data.Digest.Pure.SHA.sha512']);
53
57
  const WEAK_CIPHER_MODULES = new Set(['Crypto.Cipher.DES', 'Crypto.Cipher.RC4', 'Crypto.Cipher.RC2', 'Crypto.Cipher.Blowfish']);
54
58
  const ECB_CALLS = new Set(['Crypto.Cipher.Types.ecbEncrypt', 'Crypto.Cipher.Types.ecbDecrypt']);
@@ -547,7 +551,40 @@ export function analyzeHaskellRules(files, opts = {}) {
547
551
  if (x.kind === 'binary' && ['==', '/=', '!='].includes(x.op) && [x.left, x.right].some((side) => side && side.kind === 'array' && side.elements.length === 0)) for (const nm of directNames(x)) testedVars.add(nm);
548
552
  }
549
553
  }
554
+ // `case length xs of 3 -> ..`, `if length xs == 3`: a comparison of the list's LENGTH with a number is a test of the list (the IR lowers the
555
+ // case alternative to an equality on the scrutinee variable, so the length call sits behind an assignment).
556
+ const lengthVars = new Set();
557
+ for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source && n.source.kind === 'call' && typeof n.source.callee === 'string' && LENGTH_CALL.test(n.source.callee)) lengthVars.add(n.target);
558
+ // The conditions this function tests: every `if`, plus the first argument of `when` / `unless` / `guard` wherever the call sits
559
+ // (`when (length xs > 2) (putStrLn (xs !! 2))` is a guard written as a combinator, not an `if` node).
560
+ const condExprs = [];
561
+ for (const n of fnNodes(fn)) {
562
+ if (n.kind === 'if' && n.cond) condExprs.push(n.cond);
563
+ for (const ex of [n.value, n.source, ...(n.args || [])]) if (ex) for (const x of walk(ex)) if (x.kind === 'call' && typeof x.callee === 'string' && COND_COMBINATOR.test(x.callee) && x.args && x.args[0]) condExprs.push(x.args[0]);
564
+ }
565
+ for (const cond of condExprs) {
566
+ for (const x of walk(cond)) {
567
+ if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) {
568
+ for (const nm of directNames(x)) testedVars.add(nm);
569
+ for (const s of splitsIn(x)) testedSplits.add(s);
570
+ // `case length xs of 3 -> ..`: the match is on a scratch variable holding the length; the list behind it is what was tested
571
+ for (const a of x.args || []) if (a && a.kind === 'ident' && lengthVars.has(a.name)) for (const nm of assignSources.get(a.name) || []) testedVars.add(nm);
572
+ }
573
+ if (x.kind !== 'binary' || !['==', '/=', '!='].includes(x.op)) continue;
574
+ for (const side of [x.left, x.right]) {
575
+ if (side && side.kind === 'call' && typeof side.callee === 'string' && LENGTH_CALL.test(side.callee)) for (const nm of directNames(side)) testedVars.add(nm);
576
+ if (side && side.kind === 'ident' && lengthVars.has(side.name)) for (const nm of assignSources.get(side.name) || []) testedVars.add(nm);
577
+ }
578
+ }
579
+ }
550
580
  const testedAll = withOrigins(testedVars);
581
+ // `head (x : xs)`, `last (xs ++ [x])`, `head [a, b]`: the list has at least one element, so the call is total (real code: xmonad's `last (["Root"] ++ ..)`).
582
+ const nonEmptyList = (e) => {
583
+ if (!e || typeof e !== 'object') return false;
584
+ if (e.kind === 'array') return !!(e.hs && e.hs.cons) || (!(e.hs && (e.hs.tuple || e.hs.con || e.hs.range)) && (e.elements || []).length > 0);
585
+ if (e.kind === 'binary' && e.hs && e.hs.concat) return nonEmptyList(e.left) || nonEmptyList(e.right);
586
+ return false;
587
+ };
551
588
  const isTested = (args) => {
552
589
  if (args.some((a) => splitsIn(a).some((s) => testedSplits.has(s)))) return true;
553
590
  const mine = withOrigins([...args.flatMap((a) => [...directNames(a)])]);
@@ -686,7 +723,9 @@ export function analyzeHaskellRules(files, opts = {}) {
686
723
  // a signing/encryption secret that is a constant in the source
687
724
  if (SECRET_KEY_CALLS.has(c) && args[0] && isLiteralLike(args[0]) && !(args[0].kind === 'literal' && String(args[0].value).length < 8)) emit('hs-hardcoded-secret', file, line, { detail: `${c} given a constant`, confidence: 0.85, evidence: { call: c } });
688
725
  // weak hash
689
- if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value))) {
726
+ // `hash x :: Digest MD5` / `hashlazy x :: Digest SHA1`: the digest is chosen by the type annotation (haskell-syntax.js keeps a
727
+ // `Digest <algo>` annotation as a `typed` node; haskell-ir.js stamps `hs.digest` on the call).
728
+ if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value)) || (ANY_HASH_CALLS.has(c) && e.hs && e.hs.digest && WEAK_ALGO_CONS.has(e.hs.digest))) {
690
729
  const pw = args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n)));
691
730
  // MD5 and SHA-1 are broken for collision and preimage resistance, which only matters where an attacker gains from a
692
731
  // collision: a digest that is VERIFIED (compared with an expected value), that authenticates or signs, that hashes a
@@ -732,7 +771,7 @@ export function analyzeHaskellRules(files, opts = {}) {
732
771
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
733
772
  }
734
773
  // a partial function applied to caller text: one malformed value raises an uncaught exception
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 });
774
+ if (PARTIALS.has(c) && !(NONEMPTY_TOTAL.test(c) && nonEmptyList(args[0])) && 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 });
736
775
  // a session/token cookie assembled without HttpOnly or Secure
737
776
  if (c === 'Web.Cookie.defaultSetCookie') { /* handled on the record update below */ }
738
777
  // unbounded body
@@ -23,6 +23,7 @@ const TH_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bTemplateHaskell\b/;
23
23
  const RECORD_DOT_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bOverloadedRecordDot\b/;
24
24
  const LABELS_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bOverloadedLabels\b/;
25
25
  const RECDO_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bRecursiveDo\b/;
26
+ const DIGEST_TYPE = /^\(?\s*Digest\s+([A-Za-z][A-Za-z0-9_]*)\s*\)?$/;
26
27
  const RESERVED_OPS = new Set(['=', '|', '->', '<-', '::', '=>', '\\', '@', '~', '..']);
27
28
  const SYM = new Set('!#$%&*+./<=>?@\\^|-~:'.split(''));
28
29
  const OPEN = { '(': ')', '[': ']', '{': '}' };
@@ -459,6 +460,8 @@ export function parseSyntax(src, opts = {}) {
459
460
  // lowers to a constant (no caller text survives such a type). Every other annotation is dropped as before.
460
461
  const typeText = T.slice(from, pos).map((x) => x.v).join(' ');
461
462
  if (NUMERIC_TYPE.test(typeText)) e = { t: 'typed', e, type: typeText, line: e && e.line };
463
+ // `hash x :: Digest MD5`: the annotation chooses the digest algorithm (type-directed selection); kept for the weak-hash rule.
464
+ else { const m = DIGEST_TYPE.exec(typeText); if (m) e = { t: 'typed', e, type: typeText, digest: m[1], line: e && e.line }; }
462
465
  }
463
466
  return e;
464
467
  } finally { depth--; }