@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.
- package/CHANGELOG.md +35 -0
- package/dist/{4fe5c024ca0626112826.json → 2f0e6869229198301b40.json} +3 -3
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/language/CLAUDE.md +3 -0
- package/src/language/haskell-fix.js +271 -20
- package/src/language/haskell-ir.js +8 -1
- package/src/language/haskell-security-rules.js +41 -2
- package/src/language/haskell-syntax.js +3 -0
|
@@ -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':
|
|
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
|
-
|
|
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--; }
|