@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.0

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.
Files changed (136) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bin/agentic-security.js +131 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/1919.index.js +445 -0
  6. package/dist/2862.index.js +325 -0
  7. package/dist/3499.index.js +5 -1
  8. package/dist/3531.index.js +261 -0
  9. package/dist/3596.index.js +103 -0
  10. package/dist/4113.index.js +15 -3
  11. package/dist/4171.index.js +319 -0
  12. package/dist/5637.index.js +10 -4
  13. package/dist/6359.index.js +433 -0
  14. package/dist/7838.index.js +15 -3
  15. package/dist/8551.index.js +1192 -0
  16. package/dist/8587.index.js +410 -0
  17. package/dist/8714.index.js +98 -0
  18. package/dist/8798.index.js +218 -0
  19. package/dist/9220.index.js +35 -3
  20. package/dist/9560.index.js +26 -3
  21. package/dist/agentic-security.mjs +51 -51
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/package.json +20 -3
  24. package/src/dataflow/CLAUDE.md +9 -0
  25. package/src/dataflow/catalog-haskell.js +37 -0
  26. package/src/dataflow/catalog.js +4 -0
  27. package/src/dataflow/engine.js +33 -3
  28. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  29. package/src/dataflow/privacy-catalog.js +5 -1
  30. package/src/discovery/CLAUDE.md +6 -0
  31. package/src/discovery/index.js +12 -3
  32. package/src/discovery/lenses.js +4 -1
  33. package/src/discovery/partition.js +28 -0
  34. package/src/egress/redact.js +8 -0
  35. package/src/engine.js +125 -14
  36. package/src/ir/index.js +39 -2
  37. package/src/language/CLAUDE.md +74 -0
  38. package/src/language/accuracy.js +90 -0
  39. package/src/language/aibom.js +307 -0
  40. package/src/language/assurance.js +193 -0
  41. package/src/language/bom-validate.js +227 -0
  42. package/src/language/bom.js +236 -0
  43. package/src/language/bridges.js +392 -0
  44. package/src/language/compliance-map.js +61 -0
  45. package/src/language/context.js +199 -0
  46. package/src/language/contracts.js +339 -0
  47. package/src/language/discovery.js +359 -0
  48. package/src/language/engine-pass.js +120 -0
  49. package/src/language/fix-lifecycle.js +120 -0
  50. package/src/language/haskell-adapter.js +51 -0
  51. package/src/language/haskell-disclosure.js +170 -0
  52. package/src/language/haskell-findings.js +44 -0
  53. package/src/language/haskell-fix.js +409 -0
  54. package/src/language/haskell-grammar.js +78 -0
  55. package/src/language/haskell-guards.js +382 -0
  56. package/src/language/haskell-ir.js +1377 -0
  57. package/src/language/haskell-llm.js +128 -0
  58. package/src/language/haskell-manifests.js +1290 -0
  59. package/src/language/haskell-models.js +278 -0
  60. package/src/language/haskell-parser.js +1073 -0
  61. package/src/language/haskell-resolved-graph.js +414 -0
  62. package/src/language/haskell-sca.js +387 -0
  63. package/src/language/haskell-security-rules.js +332 -0
  64. package/src/language/haskell-supply.js +140 -0
  65. package/src/language/haskell-sweep.js +85 -0
  66. package/src/language/haskell-syntax.js +895 -0
  67. package/src/language/haskell-web.js +749 -0
  68. package/src/language/nix-adapter.js +42 -0
  69. package/src/language/nix-agents.js +155 -0
  70. package/src/language/nix-build-trust.js +790 -0
  71. package/src/language/nix-closure.js +331 -0
  72. package/src/language/nix-eval-isolation.js +274 -0
  73. package/src/language/nix-fix.js +268 -0
  74. package/src/language/nix-grammar.js +97 -0
  75. package/src/language/nix-inventory.js +379 -0
  76. package/src/language/nix-ir.js +642 -0
  77. package/src/language/nix-parser.js +908 -0
  78. package/src/language/nix-privacy.js +114 -0
  79. package/src/language/nix-sca.js +358 -0
  80. package/src/language/nix-script-taint.js +885 -0
  81. package/src/language/nix-secrets.js +396 -0
  82. package/src/language/nixos-hardening.js +564 -0
  83. package/src/language/nixos-module-resolver.js +595 -0
  84. package/src/language/nixos-option-catalog.js +96 -0
  85. package/src/language/pragma.js +150 -0
  86. package/src/language/resolved-pass.js +174 -0
  87. package/src/language/secrets.js +323 -0
  88. package/src/language/state-artifacts.js +78 -0
  89. package/src/language/support-registry.js +289 -0
  90. package/src/language/trusted-inputs.js +38 -0
  91. package/src/language/witness.js +185 -0
  92. package/src/lineage/CLAUDE.md +8 -0
  93. package/src/lineage/coverage.js +2 -0
  94. package/src/lineage/cross-repo-link.js +22 -0
  95. package/src/lineage/graph-builder.js +5 -1
  96. package/src/lineage/haskell-view.js +319 -0
  97. package/src/lineage/index.js +8 -1
  98. package/src/lineage/nix-view.js +198 -0
  99. package/src/lineage/sink-registry.js +10 -1
  100. package/src/lineage/source-registry.js +3 -0
  101. package/src/lineage/transform-catalog.js +12 -0
  102. package/src/llm-validator/index.js +16 -2
  103. package/src/lsp/server.js +69 -9
  104. package/src/mcp/CLAUDE.md +7 -0
  105. package/src/mcp/tools.js +26 -3
  106. package/src/pipeline/coverage-ledger.js +0 -0
  107. package/src/pipeline/scan-health.js +19 -1
  108. package/src/posture/accuracy-scorecard.js +51 -0
  109. package/src/posture/aibom.js +61 -3
  110. package/src/posture/artifact-registry.js +12 -0
  111. package/src/posture/auditor-walkthrough.js +41 -0
  112. package/src/posture/clustering.js +2 -2
  113. package/src/posture/evidence-bundle.js +7 -1
  114. package/src/posture/execution-proof.js +15 -0
  115. package/src/posture/falsification.js +7 -0
  116. package/src/posture/family-resolve.js +7 -0
  117. package/src/posture/fix-history.js +40 -0
  118. package/src/posture/fix-verify.js +15 -3
  119. package/src/posture/provenance/git-evidence.js +24 -0
  120. package/src/posture/provenance/origin-resolver.js +14 -2
  121. package/src/posture/provenance/predicate-replay.js +11 -2
  122. package/src/posture/provenance/sca-origin.js +20 -3
  123. package/src/posture/sbom-diff.js +7 -6
  124. package/src/posture/sbom.js +85 -32
  125. package/src/posture/sca-policy.js +10 -1
  126. package/src/posture/sca-verdict.js +8 -1
  127. package/src/posture/secret-history.js +62 -18
  128. package/src/posture/stable-id.js +21 -2
  129. package/src/posture/state-dir.js +21 -0
  130. package/src/posture/threat-model-auto.js +4 -4
  131. package/src/posture/triage.js +18 -1
  132. package/src/posture/watch-mode.js +34 -3
  133. package/src/pr-delta.js +9 -4
  134. package/src/report/index.js +35 -9
  135. package/src/runScan.js +56 -5
  136. package/src/sast/pipeline.js +34 -1
@@ -0,0 +1,332 @@
1
+ // Haskell crypto, randomness, resource, XML and sensitive-logging rules (HS-004).
2
+ //
3
+ // Structural, API-aware rules over the Haskell IR. Every callee is the IMPORT-QUALIFIED name the IR
4
+ // resolved (`Crypto.Hash.hashWith`), so an unrelated user function with the same bare name never
5
+ // matches. A rule fires only for the API behaviour it describes:
6
+ //
7
+ // - a generic `decode`, `read`, `random` or `hash` identifier is NOT evidence of anything;
8
+ // - a randomness call is a finding only when its result is BOUND to something security-shaped;
9
+ // - a hash is a password-storage finding only when a password-shaped value is hashed with a fast hash;
10
+ // - CWE-502 is not claimed: no modelled Haskell API here executes code on decode, and `read`/`readMaybe`
11
+ // are parsers, not code execution (the modelled boundaries are listed in `BOUNDARIES`);
12
+ // - type-directed selection (`hash x :: Digest MD5`) is NOT resolved: it is disclosed, never guessed.
13
+ //
14
+ // Nothing here executes code, evaluates a type, or reads a path outside the files it is handed.
15
+
16
+ import { buildHaskellIR } from './haskell-ir.js';
17
+ import { HS_LOG_SINKS, HS_MODEL_VERSION } from './haskell-models.js';
18
+
19
+ export const HS_RULES_VERSION = 'haskell-security-rules/1';
20
+
21
+ export const RULES = Object.freeze({
22
+ 'hs-weak-hash': { cwe: 'CWE-328', severity: 'medium', family: 'weak-hash', vuln: 'Weak hash function (MD5/SHA-1)', remediation: 'Use SHA-256 or stronger (or BLAKE2) for integrity; use a password KDF for secrets.' },
23
+ 'hs-fast-password-hash': { cwe: 'CWE-916', severity: 'high', family: 'password-hashing', vuln: 'Password hashed with a fast general-purpose hash', remediation: 'Hash passwords with a memory-hard or iterated KDF (Argon2id, scrypt, bcrypt, PBKDF2 with a high iteration count) and a per-user salt.' },
24
+ 'hs-pbkdf2-low-iterations': { cwe: 'CWE-916', severity: 'medium', family: 'password-hashing', vuln: 'PBKDF2 with too few iterations', remediation: 'Use at least 600000 iterations for PBKDF2-HMAC-SHA256 (OWASP), or prefer Argon2id.' },
25
+ 'hs-weak-cipher': { cwe: 'CWE-327', severity: 'high', family: 'weak-crypto', vuln: 'Broken or obsolete cipher', remediation: 'Use AES-GCM or ChaCha20-Poly1305 (an AEAD).' },
26
+ 'hs-ecb-mode': { cwe: 'CWE-327', severity: 'high', family: 'weak-crypto', vuln: 'ECB block cipher mode', remediation: 'ECB leaks plaintext structure. Use an AEAD mode (AES-GCM) with a fresh nonce.' },
27
+ 'hs-static-iv': { cwe: 'CWE-329', severity: 'high', family: 'weak-crypto', vuln: 'Static IV or nonce', remediation: 'Generate a fresh random IV/nonce per message with `getRandomBytes`; never reuse one with the same key.' },
28
+ 'hs-predictable-random': { cwe: 'CWE-338', severity: 'high', family: 'weak-randomness', vuln: 'Predictable random number generator used for a security value', remediation: 'Use `Crypto.Random.getRandomBytes` or `System.Entropy.getEntropy` (OS CSPRNG). `System.Random` is a deterministic PRNG.' },
29
+ 'hs-unbounded-request-body': { cwe: 'CWE-400', severity: 'medium', family: 'resource-exhaustion', vuln: 'Request body read without a size limit', remediation: 'Apply `requestSizeLimitMiddleware` (wai-extra) or read a bounded number of chunks with `getRequestBodyChunk`.' },
30
+ 'hs-xml-entity-substitution': { cwe: 'CWE-611', severity: 'medium', family: 'xxe', vuln: 'XML parser configured to substitute DTD entities', remediation: 'Disable DTD entity substitution (`withSubstDTDEntities no`) for untrusted XML.' },
31
+ 'hs-sensitive-logging': { cwe: 'CWE-532', severity: 'high', family: 'sensitive-logging', vuln: 'Sensitive value written to a log or trace', remediation: 'Do not log credentials or secrets. Log an identifier, or redact the value before it reaches the logger.' },
32
+ 'hs-insecure-cookie': { cwe: 'CWE-1004', severity: 'medium', family: 'cookie-attributes', vuln: 'Session cookie without HttpOnly or Secure', remediation: 'Set `setCookieHttpOnly = True`, `setCookieSecure = True` and `setCookieSameSite = Just sameSiteStrict` (or Lax) on a session or token cookie.' },
33
+ 'hs-unbounded-input-read': { cwe: 'CWE-770', severity: 'medium', family: 'resource-exhaustion', vuln: 'Whole input read into memory without a size limit', remediation: 'Bound the read (`BL.take n`, `hGetSome`, a request-size limit) before the data is consumed.' },
34
+ 'hs-unbounded-allocation': { cwe: 'CWE-770', severity: 'medium', family: 'resource-exhaustion', vuln: 'Allocation sized by parsed caller text without an upper bound', remediation: 'Clamp the size (`min limit n`) before allocating.' },
35
+ 'hs-partial-function-on-input': { cwe: 'CWE-248', severity: 'low', family: 'unhandled-exception', vuln: 'Partial function applied to caller-supplied text', remediation: 'Use the total variant (`readMaybe`, `listToMaybe`, pattern matching) and handle the failure.' },
36
+ 'hs-unsafe-io-boundary': { cwe: 'CWE-676', severity: 'low', family: 'unsafe-boundary', vuln: 'Unsafe IO / coercion boundary', remediation: 'Confine `unsafePerformIO`/`unsafeCoerce` to audited, well-tested wrappers.' },
37
+ });
38
+
39
+ // Modelled boundaries and what each does and does NOT claim. Reported verbatim on the finding.
40
+ export const BOUNDARIES = Object.freeze({
41
+ unsafePerformIO: 'Runs an IO action as a pure value. Modelled as a code-quality/safety boundary only; no vulnerability class (including CWE-502) is claimed.',
42
+ unsafeInterleaveIO: 'Defers an IO action until demanded. Modelled as a boundary only.',
43
+ unsafeDupablePerformIO: 'Like unsafePerformIO without the thunk lock. Modelled as a boundary only.',
44
+ unsafeCoerce: 'Reinterprets a value as another type without a check. Modelled as a boundary only.',
45
+ });
46
+
47
+ 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.SHA.sha1']);
48
+ const WEAK_ALGO_CONS = new Set(['MD5', 'MD4', 'MD2', 'SHA1']);
49
+ 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']);
50
+ const WEAK_CIPHER_MODULES = new Set(['Crypto.Cipher.DES', 'Crypto.Cipher.RC4', 'Crypto.Cipher.RC2', 'Crypto.Cipher.Blowfish']);
51
+ const ECB_CALLS = new Set(['Crypto.Cipher.Types.ecbEncrypt', 'Crypto.Cipher.Types.ecbDecrypt']);
52
+ const IV_CALLS = new Set(['Crypto.Cipher.Types.makeIV', 'Crypto.Cipher.ChaChaPoly1305.nonce12', 'Crypto.Cipher.ChaChaPoly1305.nonce8']);
53
+ const RANDOM_CALLS = new Set(['System.Random.randomRIO', 'System.Random.randomIO', 'System.Random.newStdGen', 'System.Random.getStdGen', 'System.Random.mkStdGen', 'System.Random.randomR', 'System.Random.random', 'System.Random.randoms', 'System.Random.randomRs']);
54
+ const BODY_CALLS = new Set(['Network.Wai.strictRequestBody', 'Network.Wai.lazyRequestBody', 'Web.Scotty.body', 'Web.Scotty.Trans.body']);
55
+ const PBKDF2_CALLS = new Set(['Crypto.KDF.PBKDF2.generate', 'Crypto.KDF.PBKDF2.fastPBKDF2_SHA1', 'Crypto.KDF.PBKDF2.fastPBKDF2_SHA256', 'Crypto.KDF.PBKDF2.fastPBKDF2_SHA512']);
56
+ const STRONG_KDF = /^(?:Crypto\.KDF\.BCrypt\.|Crypto\.Argon2\.|Crypto\.Scrypt\.|Crypto\.KDF\.Scrypt\.|Crypto\.PasswordStore\.|Crypto\.BCrypt\.)/;
57
+ const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'System.IO.Unsafe.unsafeInterleaveIO': 'unsafeInterleaveIO', 'System.IO.Unsafe.unsafeDupablePerformIO': 'unsafeDupablePerformIO', 'Unsafe.Coerce.unsafeCoerce': 'unsafeCoerce' };
58
+
59
+ const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
60
+ const SECURITY_FN = /(?:token|nonce|secret|otp|csrf|session|salt|apikey|resetcode|passcode|verification)/i;
61
+ const INPUT_READS = new Set(['Prelude.getContents', 'System.IO.getContents', 'System.IO.hGetContents', 'Data.ByteString.getContents', 'Data.ByteString.hGetContents', 'Data.ByteString.Char8.getContents', 'Data.ByteString.Char8.hGetContents', 'Data.ByteString.Lazy.getContents', 'Data.ByteString.Lazy.hGetContents', 'Data.ByteString.Lazy.Char8.getContents', 'Data.ByteString.Lazy.Char8.hGetContents', 'Data.Text.IO.getContents', 'Data.Text.IO.hGetContents', 'Data.Text.Lazy.IO.getContents', 'Data.Text.Lazy.IO.hGetContents']);
62
+ const BOUNDING = /(?:^|\.)(?:take|hGetSome|hGet|splitAt|limit|limitedRead)$/;
63
+ 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']);
64
+ const CLAMPS = /^(?:(?:Prelude|Data\.Ord)\.)?(?:min|clamp)$/;
65
+ const PARTIALS = new Set(['read', 'Prelude.read', 'head', 'Prelude.head', 'Data.List.head', 'tail', 'Prelude.tail', 'Data.List.tail', 'last', 'Prelude.last', 'Data.List.last', 'init', 'Prelude.init', 'Data.List.init', 'Data.Maybe.fromJust', 'fromJust']);
66
+ const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secret)/i;
67
+
68
+ // What makes a bound variable security-shaped, and what makes a value sensitive for logging.
69
+ const SECURITY_VAR = /^(?:.*(?:token|secret|nonce|salt|otp|csrf|apikey|api_?key|sessionid|session_?key|sessiontoken|resettoken)[a-z0-9_']*|key|iv|sessionId|sid)$/i;
70
+ const PASSWORD_VAR = /(?:^|[^a-z])(?:pass(?:word|wd|phrase)?|pwd|pw)(?:[^a-z]|$)|password/i;
71
+ const LOW_ENTROPY = /(?:pass(?:word|wd|phrase)?|pwd|ssn|creditcard|cardnumber|cvv|\bpin\b)/i;
72
+ export const SENSITIVE = /(?:pass(?:word|wd|phrase)?|pwd|(?:^|[^a-z])pw(?:[^a-z]|$)|secret|token|api_?key|ssn|credit_?card|card_?number|cvv|authorization|bearer|private_?key|session_?id)/i;
73
+ const REDACTOR = /(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor|length|null)$|(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor)/i;
74
+
75
+ const kids = (e) => {
76
+ const out = [];
77
+ for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') out.push(e[k]);
78
+ for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) out.push(...e[k]);
79
+ if (Array.isArray(e.props)) for (const p of e.props) if (p && p.value) out.push(p.value);
80
+ return out;
81
+ };
82
+
83
+ function* walk(e, depth = 0) {
84
+ if (!e || typeof e !== 'object' || depth > 80) return;
85
+ yield e;
86
+ for (const c of kids(e)) yield* walk(c, depth + 1);
87
+ }
88
+
89
+ function names(e, out = new Set()) {
90
+ for (const x of walk(e)) {
91
+ if (x.kind === 'ident' && x.name && !(x.hs && x.hs.functionRef)) out.add(x.name);
92
+ if (x.kind === 'member' && x.prop) out.add(`.${x.prop}`);
93
+ }
94
+ return out;
95
+ }
96
+
97
+ const isLiteralLike = (e, depth = 0) => {
98
+ if (!e || depth > 8) return false;
99
+ if (e.kind === 'literal') return true;
100
+ if (e.kind === 'array') return e.elements.length > 0 && e.elements.every((x) => isLiteralLike(x, depth + 1));
101
+ // B.pack "lit", fromString "lit", replicate n lit, T.encodeUtf8 "lit": a conversion of constants is a constant
102
+ if (e.kind === 'call' && /(?:^|\.)(?:pack|fromString|replicate|encodeUtf8|fromStrict|toStrict|singleton)$/.test(e.callee || '')) return (e.args || []).length > 0 && e.args.every((x) => isLiteralLike(x, depth + 1));
103
+ return false;
104
+ };
105
+
106
+ function fnNodes(fn) { return Object.values(fn.cfg.nodes); }
107
+
108
+ function snippetOf(text, line) {
109
+ if (!text || !Number.isInteger(line) || line < 1) return '';
110
+ const l = text.split('\n')[line - 1];
111
+ return typeof l === 'string' ? l.trim().slice(0, 240) : '';
112
+ }
113
+
114
+ /**
115
+ * @param {Record<string,string>} files path -> source (only Haskell files are read)
116
+ * @param {object} [opts]
117
+ * @param {Record<string,string>} [opts.packageVersions] package name -> resolved version, when known
118
+ * @returns {{findings: object[], controls: object[], notes: object[]}}
119
+ */
120
+ export function analyzeHaskellRules(files, opts = {}) {
121
+ const hs = {};
122
+ for (const [f, t] of Object.entries(files || {})) if (/\.hs$/i.test(f) && typeof t === 'string') hs[f] = t;
123
+ const findings = [];
124
+ const controls = [];
125
+ const notes = [];
126
+ if (!Object.keys(hs).length) return { findings, controls, notes };
127
+
128
+ const ir = buildHaskellIR(hs);
129
+
130
+ // project-level facts
131
+ let requestLimit = null;
132
+ for (const [file, text] of Object.entries(hs)) {
133
+ 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; }
134
+ }
135
+
136
+ const emit = (rule, file, line, extra = {}) => {
137
+ const r = RULES[rule];
138
+ const sev = extra.severity || r.severity;
139
+ const f = {
140
+ id: `hs-rule:${rule}:${file}:${line}`, severity: sev, file, line, vuln: extra.vuln || r.vuln, cwe: r.cwe,
141
+ description: `${extra.vuln || r.vuln}${extra.detail ? `: ${extra.detail}` : ''}`,
142
+ remediation: r.remediation, parser: 'HS-RULES', family: r.family,
143
+ language: 'haskell', capability: 'sast', analysisKind: 'application', evidenceKind: 'source',
144
+ originalLocation: { file, line, column: 0 }, snippet: snippetOf(hs[file], line),
145
+ confidence: extra.confidence ?? 0.8, rule, ruleVersion: 1, rulesetVersion: HS_RULES_VERSION, modelVersion: HS_MODEL_VERSION,
146
+ ...(extra.uncertainty ? { uncertainty: extra.uncertainty } : {}),
147
+ ...(extra.modelNote ? { modelNote: extra.modelNote } : {}),
148
+ ...(extra.evidence ? { evidence: extra.evidence } : {}),
149
+ };
150
+ findings.push(f);
151
+ return f;
152
+ };
153
+
154
+ for (const [file, fir] of Object.entries(ir.perFile)) {
155
+ // imports of obsolete primitives (type-directed use is not resolved: disclosed on the finding)
156
+ for (const imp of fir.imports || []) {
157
+ if (WEAK_CIPHER_MODULES.has(imp.module)) {
158
+ emit('hs-weak-cipher', file, imp.line, { vuln: `Broken or obsolete cipher module imported (${imp.module})`, confidence: 0.6, detail: 'the cipher type is selected by type, which is not resolved: the import is the evidence', evidence: { module: imp.module } });
159
+ }
160
+ }
161
+ for (const fn of fir.functions) {
162
+ if (fn.name.endsWith('.<module>')) continue;
163
+ const bound = new Map(); // target var -> {callee, line}
164
+ const sensitive = new Map(); // var -> {class:'low'|'high', hashed:boolean, line}
165
+
166
+ const fnBase = fn.name.replace(/^.*\./, '');
167
+ // text-typed parameters of an exported function, and every local value derived from them
168
+ const derivedText = new Set((fn.hs && fn.hs.textParams) || []);
169
+ if (derivedText.size) {
170
+ for (let round = 0; round < 8; round++) {
171
+ let grew = false;
172
+ for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source && !derivedText.has(n.target) && [...names(n.source)].some((x) => derivedText.has(x))) { derivedText.add(n.target); grew = true; }
173
+ if (!grew) break;
174
+ }
175
+ }
176
+ const mentionsText = (e) => [...names(e)].some((x) => derivedText.has(x));
177
+ const callsIn = (e) => { const out = []; for (const x of walk(e)) if (x.kind === 'call' && typeof x.callee === 'string') out.push(x); return out; };
178
+ const fnReturns = fnNodes(fn).filter((n) => n.kind === 'return' && n.value);
179
+ const bounded = fnNodes(fn).some((n) => [n.value, n.source, ...(n.args || [])].some((e) => e && callsIn(e).some((c) => BOUNDING.test(c.callee))));
180
+
181
+ const seeds = (e) => { for (const n of names(e)) if (SENSITIVE.test(n.replace(/^\./, ''))) return n.replace(/^\./, ''); return null; };
182
+
183
+ // pass over nodes in program order
184
+ for (const node of fnNodes(fn)) {
185
+ const exprs = [];
186
+ if (node.kind === 'assign' && node.source) exprs.push(node.source);
187
+ if (node.kind === 'return' && node.value) exprs.push(node.value);
188
+ if (node.kind === 'call') exprs.push({ kind: 'call', callee: node.callee, args: node.args || [], line: node.line });
189
+ if (node.kind === 'if' && node.cond) exprs.push(node.cond);
190
+
191
+ for (const root of exprs) {
192
+ for (const e of walk(root)) {
193
+ if (e.kind === 'ident') {
194
+ if (e.name === 'Crypto.Cipher.Types.nullIV') emit('hs-static-iv', file, node.line, { vuln: 'Static IV (nullIV)', detail: 'nullIV is a constant all-zero IV' });
195
+ // a clock reading used as the value of a security-shaped function (a "nonce" that is the time)
196
+ if (TIME_SOURCES.has(e.name) && SECURITY_FN.test(fnBase)) emit('hs-predictable-random', file, node.line, { vuln: 'Clock value used as a security value', detail: `${e.name} feeds \`${fnBase}\``, evidence: { function: fnBase, source: e.name } });
197
+ if (INPUT_READS.has(e.name) && !bounded) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
198
+ continue;
199
+ }
200
+ if (e.kind === 'object' && e.hs && e.hs.update && Array.isArray(e.props)) {
201
+ const prop = (k) => e.props.find((p) => p && p.key === k);
202
+ if (prop('setCookieName')) {
203
+ const nm = prop('setCookieName').value;
204
+ const sessionLike = !nm || nm.kind !== 'literal' || SESSION_COOKIE.test(String(nm.value));
205
+ const isTrue = (p) => p && p.value && ((p.value.kind === 'literal' && /^true$/i.test(String(p.value.value))) || (p.value.kind === 'ident' && /\.True$/.test(p.value.name)));
206
+ const httpOnly = isTrue(prop('setCookieHttpOnly')); const secure = isTrue(prop('setCookieSecure'));
207
+ if (sessionLike && (!httpOnly || !secure)) {
208
+ emit('hs-insecure-cookie', file, node.line, { detail: `${!httpOnly ? 'HttpOnly missing' : ''}${!httpOnly && !secure ? ', ' : ''}${!secure ? 'Secure missing' : ''}`, evidence: { httpOnly, secure, sameSite: !!prop('setCookieSameSite') } });
209
+ }
210
+ }
211
+ continue;
212
+ }
213
+ if (e.kind !== 'call' || typeof e.callee !== 'string') continue;
214
+ const c = e.callee;
215
+ const line = e.line || node.line;
216
+ const args = e.args || [];
217
+
218
+ // weak hash
219
+ if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value))) {
220
+ const pw = args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n)));
221
+ if (!pw) emit('hs-weak-hash', file, line, { detail: c });
222
+ }
223
+ // password hashed with a fast hash
224
+ if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
225
+ emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value` });
226
+ }
227
+ if (PBKDF2_CALLS.has(c)) {
228
+ 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) {
229
+ emit('hs-pbkdf2-low-iterations', file, line, { detail: `iterCounts = ${p.value.value}`, evidence: { iterations: p.value.value, threshold: 10000 } });
230
+ }
231
+ }
232
+ // cipher mode / IV
233
+ if (ECB_CALLS.has(c)) emit('hs-ecb-mode', file, line, { detail: c });
234
+ if (IV_CALLS.has(c) && args[0] && isLiteralLike(args[0])) emit('hs-static-iv', file, line, { detail: `${c} of a constant value` });
235
+ // randomness bound to a security-shaped name
236
+ if (RANDOM_CALLS.has(c) && node.kind === 'assign' && SECURITY_VAR.test(node.target || '')) {
237
+ emit('hs-predictable-random', file, line, { detail: `${c} result bound to \`${node.target}\``, evidence: { binding: node.target } });
238
+ }
239
+ if (RANDOM_CALLS.has(c) && SECURITY_FN.test(fnBase) && node.kind !== 'assign') {
240
+ emit('hs-predictable-random', file, line, { detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
241
+ }
242
+ if (TIME_SOURCES.has(c) && SECURITY_FN.test(fnBase)) emit('hs-predictable-random', file, line, { vuln: 'Clock value used as a security value', detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
243
+ if (INPUT_READS.has(c) && !bounded) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
244
+ // an allocation whose size is parsed caller text and is not clamped
245
+ if (ALLOCATORS.has(c) && args[0] && mentionsText(args[0]) && callsIn(args[0]).some((x) => /(?:^|\.)(?:read|readMaybe|readEither)$/.test(x.callee)) && !callsIn(args[0]).some((x) => CLAMPS.test(x.callee))) {
246
+ emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
247
+ }
248
+ // a partial function applied to caller text: one malformed value raises an uncaught exception
249
+ if (PARTIALS.has(c) && args.some((a) => mentionsText(a))) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied text`, evidence: { partial: c } });
250
+ // a session/token cookie assembled without HttpOnly or Secure
251
+ if (c === 'Web.Cookie.defaultSetCookie') { /* handled on the record update below */ }
252
+ // unbounded body
253
+ if (BODY_CALLS.has(c)) {
254
+ if (requestLimit) {
255
+ controls.push({ kind: 'request-size-limit', file: requestLimit.file, line: requestLimit.line, appliesTo: `${file}:${line}`, verified: false });
256
+ 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` }] });
257
+ } else emit('hs-unbounded-request-body', file, line, { detail: c });
258
+ }
259
+ // XML entity substitution (explicit opt-in only)
260
+ if (c === 'Text.XML.HXT.Core.withSubstDTDEntities' && args[0]) {
261
+ const a = args[0];
262
+ const yes = (a.kind === 'ident' && /\.yes$/.test(a.name)) || (a.kind === 'literal' && /^true$/i.test(String(a.value)));
263
+ if (yes) emit('hs-xml-entity-substitution', file, line, { detail: 'withSubstDTDEntities yes' });
264
+ }
265
+ // boundaries
266
+ if (UNSAFE_CALLS[c]) {
267
+ const b = UNSAFE_CALLS[c];
268
+ emit('hs-unsafe-io-boundary', file, line, { vuln: `Unsafe boundary: ${b}`, confidence: 0.5, modelNote: BOUNDARIES[b], detail: BOUNDARIES[b] });
269
+ }
270
+ }
271
+ }
272
+
273
+ // ── sensitive logging: forward propagation through assignments and conversions ──
274
+ if (node.kind === 'assign' && node.source) {
275
+ const src = node.source;
276
+ const srcNames = names(src);
277
+ let cls = null; let hashed = false;
278
+ const seed = seeds(src);
279
+ if (seed) cls = { name: seed, low: LOW_ENTROPY.test(seed) };
280
+ for (const n of srcNames) if (sensitive.has(n)) { const s = sensitive.get(n); cls = { name: s.name, low: s.low }; hashed = hashed || s.hashed; }
281
+ if (cls) {
282
+ let cleared = false;
283
+ for (const x of walk(src)) {
284
+ if (x.kind !== 'call' || typeof x.callee !== 'string') continue;
285
+ if (REDACTOR.test(x.callee)) cleared = true;
286
+ if (STRONG_KDF.test(x.callee)) cleared = true;
287
+ if (ANY_HASH_CALLS.has(x.callee) && !STRONG_KDF.test(x.callee)) { if (cls.low) hashed = true; else cleared = true; }
288
+ if (x.callee === 'const') cleared = true;
289
+ }
290
+ if (!cleared) sensitive.set(node.target, { name: cls.name, low: cls.low, hashed, line: node.line });
291
+ else sensitive.delete(node.target);
292
+ } else if (sensitive.has(node.target)) sensitive.delete(node.target);
293
+ }
294
+ for (const root of exprs) {
295
+ for (const e of walk(root)) {
296
+ if (e.kind !== 'call' || typeof e.callee !== 'string') continue;
297
+ const sink = HS_LOG_SINKS.find((s) => `${s.module}.${s.name}` === e.callee);
298
+ if (!sink) continue;
299
+ const arg = (e.args || [])[sink.argIndex];
300
+ if (!arg) continue;
301
+ // a file append is a log only when its path says so (a log, audit or trace file)
302
+ if (sink.channel === 'file') { const p = (e.args || [])[0]; if (!(p && p.kind === 'literal' && /(?:log|audit|trace|journal)/i.test(String(p.value)))) continue; }
303
+ let hit = null;
304
+ for (const n of names(arg)) {
305
+ const bare = n.replace(/^\./, '');
306
+ if (sensitive.has(n)) hit = { ...sensitive.get(n), var: n };
307
+ else if (SENSITIVE.test(bare) && !/^[A-Z][A-Za-z0-9_.]*\./.test(n)) hit = { name: bare, low: LOW_ENTROPY.test(bare), hashed: false, var: n };
308
+ if (hit) break;
309
+ }
310
+ // redaction applied inline at the call site
311
+ let inlineCleared = false;
312
+ for (const x of walk(arg)) if (x.kind === 'call' && typeof x.callee === 'string' && (REDACTOR.test(x.callee) || STRONG_KDF.test(x.callee) || x.callee === 'const')) inlineCleared = true;
313
+ if (hit && !inlineCleared) {
314
+ emit('hs-sensitive-logging', file, e.line || node.line, {
315
+ severity: hit.hashed ? 'low' : 'high',
316
+ detail: hit.hashed ? `a fast hash of low-entropy \`${hit.name}\` reaches ${e.callee}` : `\`${hit.name}\` reaches ${e.callee}`,
317
+ evidence: { value: hit.name, via: hit.var, channel: sink.channel, hashed: hit.hashed },
318
+ });
319
+ }
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+
326
+ // de-duplicate (a call visited through both a node and its parent expression)
327
+ const seen = new Set();
328
+ const out = [];
329
+ for (const f of findings) { if (seen.has(f.id)) continue; seen.add(f.id); out.push(f); }
330
+ void notes; void opts;
331
+ return { findings: out, controls, notes };
332
+ }
@@ -0,0 +1,140 @@
1
+ // Haskell supply-chain orchestration for a project (HS-009): manifests -> components -> advisories + policy.
2
+ // The pure logic lives in haskell-sca.js; this module only gathers inputs and shapes supply-chain entries.
3
+
4
+ import { resolveOperatorSnapshot, IGNORED_NOTE } from './trusted-inputs.js';
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { analyzeHaskellManifests } from './haskell-manifests.js';
8
+ import { buildHaskellIR } from './haskell-ir.js';
9
+ import { AdvisoryDb, loadAdvisorySnapshot, evaluateComponents, reachability, nearNameCandidates, sourceIntegrity, lifecycle, licensePolicy, hackagePurl, GHC_BOOT_PACKAGES } from './haskell-sca.js';
10
+
11
+ const MANIFEST = /(?:^|\/)(?:[^/]+\.cabal|cabal\.project(?:\.[a-z]+)?|package\.yaml|stack\.yaml|stack\.yaml\.lock)$/;
12
+ const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
13
+
14
+ export function manifestFiles(files) {
15
+ return Object.entries(files).filter(([p, t]) => MANIFEST.test(p) && typeof t === 'string').map(([path, text]) => ({ path, text }));
16
+ }
17
+
18
+ /** Components from declared dependencies, each with its RESOLVED version when a freeze file or an exact pin gives one. */
19
+ export function hackageComponents(files) {
20
+ const mf = manifestFiles(files);
21
+ if (!mf.length) return { components: [], manifests: null };
22
+ const manifests = analyzeHaskellManifests(mf);
23
+ const locked = new Map();
24
+ const lockedAt = new Map();
25
+ for (const l of manifests.lockedPackages || []) { locked.set(l.name, l.version); if (l.version && l.file) lockedAt.set(l.name, { file: l.file, line: l.line || null }); }
26
+ const seen = new Map();
27
+ for (const d of manifests.dependencies || []) {
28
+ const resolved = locked.get(d.name) || d.exactPin || null;
29
+ const key = `${d.name}@${d.scope || 'runtime'}`;
30
+ if (seen.has(key)) continue;
31
+ seen.set(key, {
32
+ ecosystem: 'hackage', name: d.name, version: resolved, declaredRange: d.declaredRange || null,
33
+ resolution: locked.has(d.name) ? 'freeze' : (d.exactPin ? 'exact-pin' : 'unresolved'),
34
+ scope: d.scope || 'runtime', engineScope: SCOPE[d.scope] || 'required', target: d.component || null, componentKind: d.componentKind || null,
35
+ manifest: d.manifest, line: d.line, ghcComponent: GHC_BOOT_PACKAGES.has(d.name),
36
+ versionSource: locked.has(d.name) && lockedAt.has(d.name) ? lockedAt.get(d.name) : null,
37
+ });
38
+ }
39
+ return { components: [...seen.values()], manifests };
40
+ }
41
+
42
+ /**
43
+ * Folds an explicit resolved graph (a fresh cabal plan or Stack export) into the declared inventory: a declared dependency gets the
44
+ * exact version the plan chose, and a transitive package nobody declared is added with its own scope. Nothing is guessed: when the
45
+ * graph is stale or absent the caller passes none and the declared inventory stands.
46
+ */
47
+ function mergeResolved(declared, resolved) {
48
+ if (!resolved || !resolved.components || !resolved.components.length) return declared;
49
+ const byName = new Map(declared.map((c) => [c.name, c]));
50
+ const out = declared.map((c) => {
51
+ const r = resolved.components.find((x) => x.name === c.name);
52
+ return r && r.version ? { ...c, version: r.version, resolution: 'plan', versionSource: r.versionSource, unitId: r.unitId } : c;
53
+ });
54
+ const seen = new Set(out.map((c) => `${c.name}@${c.version || ''}`));
55
+ for (const r of resolved.components) {
56
+ if (byName.has(r.name) || seen.has(`${r.name}@${r.version}`)) continue;
57
+ seen.add(`${r.name}@${r.version}`);
58
+ out.push({ ...r, transitive: true });
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Advisory data configured for this scan: a hash-pinned snapshot from the environment or the project's state dir. */
64
+ export function configuredAdvisoryDb(root, env = process.env) {
65
+ const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_HACKAGE_ADVISORIES', fileName: 'hackage-advisories.json', root, env });
66
+ if (!sel.path) return { db: null, reason: `no advisory snapshot configured (set AGENTIC_SECURITY_HACKAGE_ADVISORIES, or place hackage-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? IGNORED_NOTE('hackage-advisories.json') : ''}`, projectLocalIgnored: !!sel.projectLocalIgnored };
67
+ let snap;
68
+ try { snap = JSON.parse(readFileSync(sel.path, 'utf8')); } catch (e) { return { db: null, reason: `advisory snapshot unreadable: ${e.code || e.message}` }; }
69
+ const r = loadAdvisorySnapshot(snap, { pinnedSha256: env.AGENTIC_SECURITY_HACKAGE_ADVISORIES_SHA256 || null });
70
+ return r.ok ? { db: r.db, reason: null, source: sel.source } : { db: null, reason: `advisory snapshot refused: ${r.reason}` };
71
+ }
72
+
73
+ /** Imports and import-qualified callees across the project's Haskell sources. */
74
+ export function collectUsage(files) {
75
+ const hs = {};
76
+ for (const [f, t] of Object.entries(files)) if (/\.hs$/i.test(f) && typeof t === 'string') hs[f] = t;
77
+ if (!Object.keys(hs).length) return { imports: [], callees: new Set() };
78
+ const ir = buildHaskellIR(hs);
79
+ const imports = [];
80
+ const callees = new Set();
81
+ for (const f of Object.values(ir.perFile)) {
82
+ for (const i of f.imports || []) imports.push({ module: i.module, items: i.names && i.names.length ? i.names : null, file: f.file, line: i.line });
83
+ for (const fn of f.functions) for (const c of fn.calls || []) if (typeof c.callee === 'string') callees.add(c.callee);
84
+ }
85
+ return { imports, callees };
86
+ }
87
+
88
+ /**
89
+ * @returns {{supplyChain:object[], statuses:object[], feed:object, policy:object, gaps:object[], components:object[]}}
90
+ */
91
+ export function analyzeHaskellSupply(files, { db = undefined, root = null, env = process.env, kev = null, epss = null, metadata = null, registry = null, symbols = {}, resolved = null } = {}) {
92
+ const hc = hackageComponents(files);
93
+ const { manifests } = hc;
94
+ const components = mergeResolved(hc.components, resolved);
95
+ const out = { supplyChain: [], statuses: [], feed: null, policy: {}, gaps: [...((resolved && resolved.gaps) || []).map((g) => ({ ...g, source: 'haskell' }))], components, resolved: resolved ? resolved.summary : null };
96
+ if (!components.length) return out;
97
+ let feedReason = null;
98
+ if (db === undefined) { const c = configuredAdvisoryDb(root, env); db = c.db; feedReason = c.reason; }
99
+ const ev = evaluateComponents(components, db, { kev, epss });
100
+ out.statuses = ev.statuses;
101
+ out.feed = ev.feed;
102
+ if (!db) out.gaps.push({ kind: 'advisory-feed-unavailable', detail: `${feedReason || ev.feed.detail}. ${components.length} Hackage dependencies were NOT checked against any advisory: the absence of findings is not a clean result.` });
103
+ else if (ev.feed.status === 'stale-cache') out.gaps.push({ kind: 'advisory-feed-stale', detail: ev.feed.detail });
104
+ const unresolved = components.filter((c) => !c.version);
105
+ if (unresolved.length) out.gaps.push({ kind: 'unresolved-dependency-versions', detail: `${unresolved.length} dependency version(s) are only declared ranges (${unresolved.slice(0, 5).map((c) => c.name).join(', ')}${unresolved.length > 5 ? ', ...' : ''}); generate a Cabal plan or a Stack export to resolve them` });
106
+
107
+ // reachability, per finding
108
+ const usage = collectUsage(files);
109
+ for (const f of ev.findings) {
110
+ const r = reachability(f.name, usage.imports, usage.callees, (symbols && symbols[f.osvId]) || null);
111
+ f.reachability = r;
112
+ f.functionReachable = r.function === 'reachable' ? 'reachable' : (r.function === 'not-imported' ? 'unreachable' : 'unknown');
113
+ f.reachabilityTier = r.import === 'imported' ? (r.function === 'reachable' ? 'function-reachable' : 'import-reachable') : (r.import === 'not-imported' ? 'not-imported' : 'unknown');
114
+ out.supplyChain.push({ ...f, file: f.file, line: f.line });
115
+ }
116
+ // policy
117
+ const si = sourceIntegrity(manifests || {});
118
+ for (const s of si.filter((x) => x.finding)) {
119
+ out.supplyChain.push({
120
+ type: 'source_integrity', ecosystem: 'hackage', name: s.location, version: s.tag || null, severity: s.severity, file: s.file, line: s.line,
121
+ vuln: 'Source repository dependency is not pinned to a commit', cwe: 'CWE-829', description: `source-repository-package ${s.location}: ${s.reason}`,
122
+ remediation: 'Pin `tag:` to a full commit hash (and add `--sha256` where supported).', parser: 'HS-SUPPLY', family: 'source-integrity',
123
+ language: 'haskell', capability: 'sca', analysisKind: 'application', evidenceKind: 'manifest', dataSource: { manifest: s.file },
124
+ });
125
+ }
126
+ out.policy.sourceIntegrity = si;
127
+ out.policy.nearName = nearNameCandidates(components, { registry });
128
+ out.policy.lifecycle = lifecycle(components, metadata);
129
+ out.policy.license = licensePolicy(components, metadata);
130
+ for (const n of out.policy.nearName) {
131
+ out.supplyChain.push({
132
+ type: 'dep_confusion_candidate', ecosystem: 'hackage', name: n.name, version: null, severity: 'info', file: components.find((c) => c.name === n.name).manifest, line: components.find((c) => c.name === n.name).line,
133
+ vuln: 'Dependency name resembles a popular package', cwe: 'CWE-1357', description: `${n.name} is one edit from ${n.similarTo}. ${n.note}; registry evidence: ${n.status}.`,
134
+ remediation: 'Confirm the package name is intended.', parser: 'HS-SUPPLY', family: 'dependency-confusion', verdict: 'candidate', malicious: false,
135
+ language: 'haskell', capability: 'sca', analysisKind: 'application', evidenceKind: 'manifest', confidence: 0.2,
136
+ });
137
+ }
138
+ void hackagePurl; void AdvisoryDb;
139
+ return out;
140
+ }
@@ -0,0 +1,85 @@
1
+ // Root-cause sweep for Haskell (X-008). The generic sweep anchors on a `callee(args)` text shape, which Haskell's
2
+ // juxtaposition syntax never produces, so a confirmed Haskell finding swept nothing. This sweep works on the IR: for
3
+ // each confirmed finding it takes the RESOLVED sink callee from the model catalog and lists every other call of that
4
+ // same callee in the project, then accounts for all of them:
5
+ //
6
+ // found === candidates + mitigated
7
+ //
8
+ // `found` is every other call site of the callee (the finding's own site, and the sites of other findings in the same
9
+ // group, are the origins and are excluded). `mitigated` is a site where a detector already produced a finding for a
10
+ // different reason, `candidates` is the rest. A candidate whose argument is a literal constant is flagged
11
+ // (`constantArgument`) and stays a candidate: the flag is information, not a verdict. Nothing is dropped.
12
+ //
13
+ // Resolution is by import-qualified callee, so an unrelated function that merely shares a name is not a sibling.
14
+
15
+ import { HASKELL_CATALOG } from '../dataflow/catalog-haskell.js';
16
+ import { exprRoots, walkExpr } from '../lineage/source-seeding.js';
17
+
18
+ export const HS_SWEEP_VERSION = 'haskell-sweep/1';
19
+ const SAMPLE_LIMIT = 100;
20
+ const isHs = (f) => /\.l?hs$/i.test(f || '');
21
+ const BY_ID = new Map(HASKELL_CATALOG.filter((e) => e.kind === 'sink').map((e) => [e.id, e]));
22
+
23
+ const allConstant = (e) => {
24
+ let constant = true; let sawLiteral = false;
25
+ walkExpr(e, (x) => {
26
+ if (x.kind === 'literal') sawLiteral = true;
27
+ else if (x.kind === 'ident' && !(x.hs && x.hs.functionRef)) constant = false;
28
+ else if (x.kind === 'member') constant = false;
29
+ });
30
+ return constant && sawLiteral;
31
+ };
32
+
33
+ /**
34
+ * @param {object[]} findings confirmed findings (only Haskell IR-taint findings are swept)
35
+ * @param {{functions: Map}} callGraph the project call graph (ir/index.js buildProjectIR)
36
+ */
37
+ export function sweepHaskellSiblings(findings, callGraph) {
38
+ const groups = new Map();
39
+ for (const f of findings || []) {
40
+ if (!f || !isHs(f.file) || f.parser !== 'IR-TAINT' || !f.sink || !f.sink.label) continue;
41
+ const entry = BY_ID.get(f.sink.label);
42
+ if (!entry || !entry.match || !entry.match.callee) continue;
43
+ const key = entry.match.callee;
44
+ if (!groups.has(key)) groups.set(key, { callee: key, entry, findings: [] });
45
+ groups.get(key).findings.push(f);
46
+ }
47
+ const sites = new Map(); // callee -> [{file, line, constantArgument}]
48
+ const wanted = new Set(groups.keys());
49
+ for (const fn of callGraph && callGraph.functions ? callGraph.functions.values() : []) {
50
+ if (!isHs(fn.file) || !fn.cfg) continue;
51
+ for (const node of Object.values(fn.cfg.nodes)) {
52
+ const visit = (e) => {
53
+ if (e.kind === 'call' && typeof e.callee === 'string' && wanted.has(e.callee)) {
54
+ const entry = groups.get(e.callee).entry;
55
+ const arg = (e.args || [])[entry.argIndex || 0];
56
+ if (!sites.has(e.callee)) sites.set(e.callee, []);
57
+ sites.get(e.callee).push({ file: fn.file, line: e.line || node.line || null, constantArgument: arg ? allConstant(arg) : false });
58
+ }
59
+ };
60
+ if (node.kind === 'call' && typeof node.callee === 'string' && wanted.has(node.callee)) visit(node);
61
+ for (const root of exprRoots(node)) walkExpr(root, visit);
62
+ }
63
+ }
64
+ const covered = new Map(); // "file:line" -> true, any finding at that site
65
+ for (const f of findings || []) if (f && f.file && Number.isInteger(f.line)) covered.set(`${f.file}:${f.line}`, true);
66
+ const sweeps = [];
67
+ const totals = { found: 0, candidates: 0, mitigated: 0 };
68
+ for (const [callee, g] of [...groups.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
69
+ const origin = new Set(g.findings.map((f) => `${f.file}:${f.line}`));
70
+ const seen = new Set(); const all = [];
71
+ for (const s of sites.get(callee) || []) { const k = `${s.file}:${s.line}`; if (origin.has(k) || seen.has(k)) continue; seen.add(k); all.push(s); }
72
+ all.sort((a, b) => (a.file + String(a.line)).localeCompare(b.file + String(b.line), undefined, { numeric: true }));
73
+ const instances = all.map((s) => ({ ...s, status: covered.has(`${s.file}:${s.line}`) ? 'mitigated' : 'candidate' }));
74
+ const mitigated = instances.filter((i) => i.status === 'mitigated').length;
75
+ const candidates = instances.length - mitigated;
76
+ sweeps.push({
77
+ callee, cwe: g.entry.vuln.cwe, family: g.entry.hs && g.entry.hs.family, origins: [...origin].sort(), findingIds: g.findings.map((f) => f.id).sort(),
78
+ found: instances.length, candidates, mitigated, constantArgumentCandidates: instances.filter((i) => i.status === 'candidate' && i.constantArgument).length,
79
+ instances: instances.slice(0, SAMPLE_LIMIT), instancesTruncated: instances.length > SAMPLE_LIMIT, remaining: candidates,
80
+ note: 'siblings are other calls of the same import-qualified sink; a candidate is unreviewed, not vulnerable',
81
+ });
82
+ totals.found += instances.length; totals.candidates += candidates; totals.mitigated += mitigated;
83
+ }
84
+ return { version: HS_SWEEP_VERSION, sweeps, totals };
85
+ }