@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,387 @@
1
+ // Hackage advisories, reachability and supply-chain policy (HS-009).
2
+ //
3
+ // Identity. Packages are queried under the exact OSV ecosystem string `Hackage` (the wire value, not the
4
+ // word "Haskell") and named by `pkg:hackage/<name>@<version>`. HSEC advisory ids are preserved even when no
5
+ // CVE alias exists; KEV/EPSS enrichment is keyed on CVE aliases only and an absent value is `unknown`, never
6
+ // zero risk.
7
+ //
8
+ // Versions. Cabal/PVP versions have any number of components and compare with missing trailing components
9
+ // as 0 (1.2 == 1.2.0.0). They are never fed to a SemVer matcher: advisory ranges and declared ranges are both
10
+ // turned into intervals over that ordering and intersected exactly.
11
+ //
12
+ // Honest statuses. A component is `affected` only when its RESOLVED version is inside an affected interval;
13
+ // a declared range that merely overlaps one is `possibly-affected`; no resolved version and no usable range is
14
+ // `unknown`; a missing/stale feed is reported as such and is never read as clean. A withdrawn advisory is
15
+ // listed, never matched. Compiler-provided (GHC boot) packages are a separate scope: their advisories are
16
+ // resolved against the compiler version, not treated as an ordinary dependency.
17
+ //
18
+ // Nothing here uses the network by itself: callers supply records (a pinned snapshot, a cache, or an OSV
19
+ // response). The wire query is built here so its exact shape is testable.
20
+
21
+ import { createHash, createVerify } from 'node:crypto';
22
+ import { parseVersion, compareVersions, parseVersionRange, caretUpperBound } from './haskell-manifests.js';
23
+
24
+ export const HS_SCA_VERSION = 'haskell-sca/1';
25
+ export const OSV_ECOSYSTEM = 'Hackage';
26
+
27
+ /** Packages whose version is fixed by the compiler: advisories on them are GHC-component advisories. */
28
+ export const GHC_BOOT_PACKAGES = Object.freeze(new Set([
29
+ 'base', 'ghc-prim', 'ghc-bignum', 'template-haskell', 'ghc', 'ghc-boot', 'ghc-boot-th', 'integer-gmp', 'rts', 'ghc-heap', 'libiserv', 'ghci',
30
+ 'array', 'binary', 'bytestring', 'containers', 'deepseq', 'directory', 'exceptions', 'filepath', 'haskeline', 'mtl', 'parsec', 'pretty',
31
+ 'process', 'stm', 'text', 'time', 'transformers', 'unix', 'xhtml', 'terminfo', 'Cabal', 'Cabal-syntax',
32
+ ]));
33
+
34
+ export const hackagePurl = (name, version) => `pkg:hackage/${encodeURIComponent(name).replace(/%2D/g, '-')}${version ? `@${encodeURIComponent(version)}` : ''}`;
35
+
36
+ /** The exact JSON body an OSV `/v1/query` takes for one component. */
37
+ export function osvWireQuery(name, version) {
38
+ return { package: { name, ecosystem: OSV_ECOSYSTEM }, ...(version ? { version } : {}) };
39
+ }
40
+
41
+ // ── intervals over the PVP ordering ──────────────────────────────────────────
42
+ // { lo: version|null (-inf), loInc, hi: version|null (+inf), hiInc }
43
+ const ZERO = [0n];
44
+ const iv = (lo, loInc, hi, hiInc) => ({ lo, loInc, hi, hiInc });
45
+ const cmp = (a, b) => compareVersions(a, b);
46
+
47
+ function normalizeIntervals(list) {
48
+ return list.filter((x) => !isEmpty(x));
49
+ }
50
+ function isEmpty(x) {
51
+ if (x.lo === null || x.hi === null) return false;
52
+ const c = cmp(x.lo, x.hi);
53
+ return c > 0 || (c === 0 && !(x.loInc && x.hiInc));
54
+ }
55
+ export function intersectIntervals(a, b) {
56
+ let lo = a.lo, loInc = a.loInc;
57
+ if (b.lo !== null && (lo === null || cmp(b.lo, lo) > 0 || (cmp(b.lo, lo) === 0 && !b.loInc))) { lo = b.lo; loInc = b.loInc; }
58
+ let hi = a.hi, hiInc = a.hiInc;
59
+ if (b.hi !== null && (hi === null || cmp(b.hi, hi) < 0 || (cmp(b.hi, hi) === 0 && !b.hiInc))) { hi = b.hi; hiInc = b.hiInc; }
60
+ return iv(lo, loInc, hi, hiInc);
61
+ }
62
+ export function intervalsOverlap(A, B) {
63
+ for (const a of A) for (const b of B) if (!isEmpty(intersectIntervals(a, b))) return true;
64
+ return false;
65
+ }
66
+ export function intervalsContain(A, version) {
67
+ const v = typeof version === 'string' ? parseVersion(version) : version;
68
+ if (!v) return null;
69
+ return A.some((x) => (x.lo === null || cmp(v, x.lo) > 0 || (cmp(v, x.lo) === 0 && x.loInc)) && (x.hi === null || cmp(v, x.hi) < 0 || (cmp(v, x.hi) === 0 && x.hiInc)));
70
+ }
71
+
72
+ /** Declared Cabal range AST -> intervals. Returns null when the range cannot be understood. */
73
+ export function rangeToIntervals(text) {
74
+ const parsed = parseVersionRange(text);
75
+ if (!parsed.ok) return null;
76
+ const go = (n) => {
77
+ switch (n.t) {
78
+ case 'any': return [iv(null, false, null, false)];
79
+ case 'none': return [];
80
+ case 'and': { const out = []; for (const a of go(n.l)) for (const b of go(n.r)) out.push(intersectIntervals(a, b)); return normalizeIntervals(out); }
81
+ case 'or': return [...go(n.l), ...go(n.r)];
82
+ case 'cmp': {
83
+ const v = n.version.map((x) => BigInt(x));
84
+ switch (n.op) {
85
+ case '>=': return [iv(v, true, null, false)];
86
+ case '>': return [iv(v, false, null, false)];
87
+ case '<': return [iv(ZERO, true, v, false)];
88
+ case '<=': return [iv(ZERO, true, v, true)];
89
+ case '^>=': return [iv(v, true, caretUpperBound(v), false)];
90
+ case '==':
91
+ if (n.wildcard) { const up = [...v]; up[up.length - 1] = up[up.length - 1] + 1n; return [iv(v, true, up, false)]; }
92
+ return [iv(v, true, v, true)];
93
+ default: throw new Error(`unsupported operator ${n.op}`);
94
+ }
95
+ }
96
+ default: throw new Error(`unsupported range node ${n.t}`);
97
+ }
98
+ };
99
+ try { return go(parsed.ast); } catch { return null; }
100
+ }
101
+
102
+ // ── advisory records ─────────────────────────────────────────────────────────
103
+ function eventsToIntervals(events) {
104
+ const out = [];
105
+ let lo = null; let loInc = true;
106
+ for (const e of events || []) {
107
+ if ('introduced' in e) { lo = e.introduced === '0' ? ZERO : parseVersion(e.introduced); loInc = true; if (!lo) return null; }
108
+ else if ('fixed' in e) { const hi = parseVersion(e.fixed); if (!hi || !lo) return null; out.push(iv(lo, loInc, hi, false)); lo = null; }
109
+ else if ('last_affected' in e) { const hi = parseVersion(e.last_affected); if (!hi || !lo) return null; out.push(iv(lo, loInc, hi, true)); lo = null; }
110
+ else if ('limit' in e) { const hi = parseVersion(e.limit); if (!hi || !lo) return null; out.push(iv(lo, loInc, hi, false)); lo = null; }
111
+ }
112
+ if (lo) out.push(iv(lo, loInc, null, false)); // introduced with no fix: open ended
113
+ return normalizeIntervals(out);
114
+ }
115
+
116
+ /**
117
+ * Normalise one OSV record. Unusable pieces are recorded in `problems`, never thrown.
118
+ */
119
+ export function normalizeAdvisory(rec) {
120
+ const problems = [];
121
+ if (!rec || typeof rec !== 'object' || typeof rec.id !== 'string') return { id: null, problems: ['not an OSV record'], affected: [] };
122
+ const aliases = [...new Set((rec.aliases || []).filter((x) => typeof x === 'string'))];
123
+ const ids = [rec.id, ...aliases];
124
+ const canonicalId = ids.find((x) => /^HSEC-/.test(x)) || ids.find((x) => /^GHSA-/.test(x)) || ids.find((x) => /^CVE-/.test(x)) || rec.id;
125
+ const affected = [];
126
+ for (const a of rec.affected || []) {
127
+ if (!a || !a.package || a.package.ecosystem !== OSV_ECOSYSTEM) continue;
128
+ const ranges = (a.ranges || []).filter((r) => r.type === 'ECOSYSTEM');
129
+ let intervals = [];
130
+ let ok = ranges.length > 0;
131
+ for (const r of ranges) { const x = eventsToIntervals(r.events); if (x === null) { ok = false; problems.push(`unparseable range in ${a.package.name}`); } else intervals.push(...x); }
132
+ if (!ranges.length && Array.isArray(a.versions) && a.versions.length) {
133
+ intervals = a.versions.map((v) => { const p = parseVersion(v); return p ? iv(p, true, p, true) : null; }).filter(Boolean);
134
+ ok = intervals.length > 0;
135
+ }
136
+ const unfixed = intervals.some((x) => x.hi === null);
137
+ affected.push({ name: a.package.name, purl: a.package.purl || hackagePurl(a.package.name), intervals, ok, unfixed, fixedIn: fixedVersions(ranges), ghcComponent: GHC_BOOT_PACKAGES.has(a.package.name) });
138
+ }
139
+ return {
140
+ id: rec.id, canonicalId, ids, cveAliases: ids.filter((x) => /^CVE-/.test(x)), ghsaAliases: ids.filter((x) => /^GHSA-/.test(x)),
141
+ summary: rec.summary || '', details: rec.details || '', published: rec.published || null, modified: rec.modified || null,
142
+ withdrawn: rec.withdrawn || null, references: (rec.references || []).map((r) => r.url).filter(Boolean),
143
+ severity: rec.severity || [], affected, problems,
144
+ };
145
+ }
146
+ const fixedVersions = (ranges) => [...new Set((ranges || []).flatMap((r) => (r.events || []).filter((e) => 'fixed' in e).map((e) => e.fixed)))];
147
+
148
+ // ── advisory database (pinned snapshot / records) ────────────────────────────
149
+ export class AdvisoryDb {
150
+ constructor({ records = [], source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, integrity = null } = {}) {
151
+ this.source = source;
152
+ this.generatedAt = generatedAt;
153
+ this.integrity = integrity;
154
+ this.problems = [];
155
+ this.advisories = [];
156
+ this.byPackage = new Map();
157
+ for (const rec of records) {
158
+ const n = normalizeAdvisory(rec);
159
+ if (!n.id) { this.problems.push(...n.problems); continue; }
160
+ this.problems.push(...n.problems.map((p) => `${n.id}: ${p}`));
161
+ this.advisories.push(n);
162
+ for (const a of n.affected) { if (!this.byPackage.has(a.name)) this.byPackage.set(a.name, []); this.byPackage.get(a.name).push({ adv: n, aff: a }); }
163
+ }
164
+ const gen = generatedAt ? Date.parse(generatedAt) : NaN;
165
+ this.ageDays = Number.isFinite(gen) ? (now - gen) / 86400000 : null;
166
+ this.stale = this.ageDays === null ? true : this.ageDays > maxAgeDays;
167
+ this.maxAgeDays = maxAgeDays;
168
+ }
169
+ forPackage(name) { return this.byPackage.get(name) || []; }
170
+ }
171
+
172
+ const sha256 = (b) => createHash('sha256').update(b).digest('hex');
173
+ const canon = (v) => JSON.stringify(v, Object.keys(v).sort());
174
+
175
+ /**
176
+ * A snapshot is `{schema, generatedAt, records:[OSV...], recordHashes:{id:sha256}}`. It is accepted only if
177
+ * every record matches its recorded hash AND (when given) the whole snapshot matches `pinnedSha256` and/or an
178
+ * RSA/Ed25519 signature verifies. Anything else is refused, with the reason.
179
+ */
180
+ export function loadAdvisorySnapshot(snapshot, { pinnedSha256 = null, signature = null, publicKeyPem = null, now = Date.now(), maxAgeDays = 30 } = {}) {
181
+ const refuse = (reason) => ({ ok: false, reason, db: null });
182
+ if (!snapshot || typeof snapshot !== 'object' || !Array.isArray(snapshot.records)) return refuse('not an advisory snapshot');
183
+ for (const rec of snapshot.records) {
184
+ const want = snapshot.recordHashes && snapshot.recordHashes[rec.id];
185
+ if (!want) return refuse(`record ${rec.id} has no recorded hash`);
186
+ if (sha256(JSON.stringify(rec)) !== want) return refuse(`record ${rec.id} does not match its recorded hash`);
187
+ }
188
+ const body = JSON.stringify({ schema: snapshot.schema, generatedAt: snapshot.generatedAt, records: snapshot.records, recordHashes: snapshot.recordHashes });
189
+ const digest = sha256(body);
190
+ if (pinnedSha256 && digest !== pinnedSha256) return refuse('snapshot hash does not match the pinned value');
191
+ let signed = false;
192
+ if (signature || publicKeyPem) {
193
+ if (!(signature && publicKeyPem)) return refuse('signature and public key must be supplied together');
194
+ try {
195
+ const v = createVerify('sha256'); v.update(body);
196
+ if (!v.verify(publicKeyPem, Buffer.from(signature, 'base64'))) return refuse('signature does not verify');
197
+ signed = true;
198
+ } catch (e) { return refuse(`signature check failed: ${e.message}`); }
199
+ }
200
+ const db = new AdvisoryDb({ records: snapshot.records, source: 'snapshot', generatedAt: snapshot.generatedAt, now, maxAgeDays, integrity: { sha256: digest, hashPinned: !!pinnedSha256, signed } });
201
+ return { ok: true, db, digest };
202
+ }
203
+
204
+ export function buildSnapshot(records, generatedAt) {
205
+ const recordHashes = Object.fromEntries(records.map((r) => [r.id, sha256(JSON.stringify(r))]));
206
+ return { schema: 1, generatedAt, records, recordHashes };
207
+ }
208
+ export const snapshotBody = (s) => JSON.stringify({ schema: s.schema, generatedAt: s.generatedAt, records: s.records, recordHashes: s.recordHashes });
209
+
210
+ // ── matching ─────────────────────────────────────────────────────────────────
211
+ /**
212
+ * @param {{name:string, version?:string|null, declaredRange?:string|null}} comp
213
+ * @returns {{status:'affected'|'not-affected'|'possibly-affected'|'unknown', reason:string}}
214
+ */
215
+ export function matchComponent(aff, comp) {
216
+ if (!aff.ok) return { status: 'unknown', reason: 'the advisory range could not be understood' };
217
+ if (comp.version) {
218
+ const inside = intervalsContain(aff.intervals, comp.version);
219
+ if (inside === null) return { status: 'unknown', reason: `"${comp.version}" is not a Cabal version` };
220
+ return inside ? { status: 'affected', reason: 'the resolved version is inside an affected range' } : { status: 'not-affected', reason: 'the resolved version is outside every affected range' };
221
+ }
222
+ if (comp.declaredRange) {
223
+ const declared = rangeToIntervals(comp.declaredRange);
224
+ if (declared === null) return { status: 'unknown', reason: 'the declared range could not be parsed' };
225
+ if (!declared.length) return { status: 'unknown', reason: 'the declared range admits no version' };
226
+ const overlap = intervalsOverlap(declared, aff.intervals);
227
+ if (!overlap) return { status: 'not-affected', reason: 'no version allowed by the declared range is affected' };
228
+ const subset = declared.every((d) => aff.intervals.some((a) => !isEmpty(intersectIntervals(d, a)) && containsInterval(a, d)));
229
+ return subset ? { status: 'affected', reason: 'every version allowed by the declared range is affected' } : { status: 'possibly-affected', reason: 'the declared range allows both affected and unaffected versions; no resolved version is known' };
230
+ }
231
+ return { status: 'unknown', reason: 'neither a resolved version nor a declared range is known' };
232
+ }
233
+ function containsInterval(outer, inner) {
234
+ const loOk = outer.lo === null || (inner.lo !== null && (cmp(inner.lo, outer.lo) > 0 || (cmp(inner.lo, outer.lo) === 0 && (outer.loInc || !inner.loInc))));
235
+ const hiOk = outer.hi === null || (inner.hi !== null && (cmp(inner.hi, outer.hi) < 0 || (cmp(inner.hi, outer.hi) === 0 && (outer.hiInc || !inner.hiInc))));
236
+ return loOk && hiOk;
237
+ }
238
+
239
+ /**
240
+ * Evaluate components against an advisory database.
241
+ * @param {Array<{name, version?, declaredRange?, scope?, target?, manifest?, line?, kind?}>} components
242
+ * @param {AdvisoryDb|null} db null = no feed at all
243
+ * @param {{kev?: Set<string>|null, epss?: Record<string,number>|null, imports?: object}} [opts]
244
+ */
245
+ export function evaluateComponents(components, db, opts = {}) {
246
+ const findings = [];
247
+ const statuses = [];
248
+ const feed = !db ? { status: 'feed-unavailable', detail: 'no advisory data was supplied; absence of findings does NOT mean the dependencies are clean' }
249
+ : db.stale ? { status: 'stale-cache', detail: `advisory data is ${db.ageDays === null ? 'of unknown age' : `${Math.round(db.ageDays)} days old`} (limit ${db.maxAgeDays})` }
250
+ : { status: 'current', detail: `${db.advisories.length} advisories from ${db.source}` };
251
+ for (const comp of components) {
252
+ const base = { name: comp.name, version: comp.version || null, declaredRange: comp.declaredRange || null, scope: comp.scope || null, target: comp.target || null, purl: hackagePurl(comp.name, comp.version || null) };
253
+ if (!db) { statuses.push({ ...base, status: 'feed-unavailable' }); continue; }
254
+ const hits = db.forPackage(comp.name);
255
+ if (!hits.length) { statuses.push({ ...base, status: 'no-advisories', feed: feed.status }); continue; }
256
+ let any = false;
257
+ for (const { adv, aff } of hits) {
258
+ if (adv.withdrawn) { statuses.push({ ...base, status: 'withdrawn', advisory: adv.canonicalId, withdrawn: adv.withdrawn }); continue; }
259
+ const m = matchComponent(aff, comp);
260
+ const ghc = aff.ghcComponent;
261
+ statuses.push({ ...base, status: ghc && m.status !== 'not-affected' ? `ghc-component:${m.status}` : m.status, advisory: adv.canonicalId, reason: m.reason, ghcComponent: ghc });
262
+ if (m.status === 'not-affected') continue;
263
+ any = true;
264
+ const kevHit = adv.cveAliases.length ? (opts.kev ? adv.cveAliases.some((c) => opts.kev.has(c)) : 'unknown') : 'not-applicable';
265
+ const epssVals = adv.cveAliases.map((c) => opts.epss && opts.epss[c]).filter((x) => typeof x === 'number');
266
+ findings.push({
267
+ type: 'vulnerable_dep', ecosystem: 'hackage', name: comp.name, version: comp.version || null, declaredRange: comp.declaredRange || null,
268
+ osvId: adv.canonicalId, ids: adv.ids, cveAliases: adv.cveAliases, ghsaAliases: adv.ghsaAliases,
269
+ summary: adv.summary, fixedIn: aff.fixedIn, unfixed: aff.unfixed, references: adv.references.slice(0, 5),
270
+ severity: 'medium', severityBasis: 'the advisory carries no severity rating',
271
+ matchStatus: m.status, matchReason: m.reason, resolution: comp.version ? 'resolved' : (comp.declaredRange ? 'declared-range' : 'none'),
272
+ scope: comp.scope || null, target: comp.target || null, purl: base.purl, ghcComponent: ghc,
273
+ ...(ghc ? { remediation: `${comp.name} is provided by the compiler: upgrade GHC (the fix is in ${comp.name} ${aff.fixedIn.join(', ') || '(no fixed version published)'}), not a Cabal dependency bound.` } : { remediation: aff.fixedIn.length ? `Upgrade ${comp.name} to ${aff.fixedIn.join(' or ')}.` : `No fixed version of ${comp.name} is published; remove or replace the dependency.` }),
274
+ kev: kevHit, epss: epssVals.length ? Math.max(...epssVals) : 'unknown',
275
+ file: comp.manifest || null, line: comp.line || null, versionSource: comp.versionSource || null,
276
+ dataSource: { feed: db.source, generatedAt: db.generatedAt, integrity: db.integrity, feedStatus: feed.status },
277
+ language: 'haskell', capability: 'sca', analysisKind: 'application', evidenceKind: 'manifest',
278
+ confidence: m.status === 'affected' ? 0.9 : 0.5,
279
+ ...(m.status === 'possibly-affected' ? { uncertainty: [{ kind: 'unresolved-import', detail: 'no resolved version: only a declared range is known (generate a Cabal plan or a Stack export to resolve it)' }] } : {}),
280
+ });
281
+ }
282
+ if (!any && !statuses.some((s) => s.name === comp.name && s.status !== 'not-affected')) statuses.push({ ...base, status: 'not-affected' });
283
+ }
284
+ return { findings, statuses, feed };
285
+ }
286
+
287
+ // ── reachability (imports) ───────────────────────────────────────────────────
288
+ /** Modules each modelled package exposes. A package absent from this table has UNKNOWN import reachability. */
289
+ export const PACKAGE_MODULES = Object.freeze({
290
+ 'xml-conduit': ['Text.XML', 'Text.XML.Cursor', 'Text.XML.Stream.Parse', 'Text.XML.Stream.Render', 'Text.XML.Unresolved'],
291
+ aeson: ['Data.Aeson', 'Data.Aeson.Types', 'Data.Aeson.Parser', 'Data.Aeson.Key', 'Data.Aeson.KeyMap', 'Data.Aeson.Encoding', 'Data.Aeson.TH'],
292
+ pandoc: ['Text.Pandoc', 'Text.Pandoc.Readers', 'Text.Pandoc.Writers', 'Text.Pandoc.Class'],
293
+ 'cmark-gfm': ['CMarkGFM'],
294
+ crypton: ['Crypto.Hash', 'Crypto.Cipher.Types', 'Crypto.Random', 'Crypto.PubKey.RSA', 'Crypto.PubKey.ECC.ECDSA'],
295
+ cryptonite: ['Crypto.Hash', 'Crypto.Cipher.Types', 'Crypto.Random', 'Crypto.PubKey.RSA'],
296
+ 'git-annex': ['Annex'],
297
+ 'hackage-server': ['Distribution.Server'],
298
+ 'cabal-install': ['Distribution.Client'],
299
+ });
300
+
301
+ /**
302
+ * @param {string} pkg
303
+ * @param {Array<{module:string, qualified?:boolean, as?:string, items?:string[]|null, hiding?:boolean}>} imports from the Haskell IR
304
+ * @param {Set<string>} [usedCallees] import-qualified callee names found in the project (`Text.XML.parseLBS`)
305
+ * @param {string[]|null} [symbols] advisory-supplied affected symbols ("Text.XML.Stream.Parse.parseBytes"); null = none given
306
+ */
307
+ export function reachability(pkg, imports, usedCallees = new Set(), symbols = null) {
308
+ const mods = PACKAGE_MODULES[pkg];
309
+ if (!mods) return { import: 'unknown', function: 'unknown', reason: `no module mapping for ${pkg}: import reachability cannot be decided (this is NOT "unreachable")` };
310
+ const hit = (imports || []).filter((i) => mods.includes(i.module) || mods.some((m) => i.module.startsWith(`${m}.`)));
311
+ if (!hit.length) return { import: 'not-imported', function: 'not-imported', reason: `no module of ${pkg} is imported` };
312
+ if (!symbols || !symbols.length) return { import: 'imported', function: 'unknown', modules: [...new Set(hit.map((h) => h.module))], reason: 'the advisory names no affected function, so function-level reachability is unknown' };
313
+ const used = [...usedCallees];
314
+ const called = symbols.filter((s) => used.includes(s));
315
+ if (called.length) return { import: 'imported', function: 'reachable', modules: [...new Set(hit.map((h) => h.module))], called, reason: 'an affected function is called through a resolved import' };
316
+ // No resolved call was found. That is NOT proof of unreachability: a re-export, a wildcard import, a
317
+ // higher-order use or an unmodelled module can still reach the symbol, so the answer stays unknown.
318
+ return { import: 'imported', function: 'unknown', modules: [...new Set(hit.map((h) => h.module))], reason: 'no resolved call to an affected function was found; absence of a resolved call is not proof of unreachability' };
319
+ }
320
+
321
+ // ── supply-chain policy: sources, near names, lifecycle, license ─────────────
322
+ /** Curated, tested registry of very widely used Hackage packages (NOT an npm list). */
323
+ export const POPULAR_PACKAGES = Object.freeze(['aeson', 'text', 'bytestring', 'containers', 'mtl', 'lens', 'conduit', 'http-client', 'http-conduit', 'warp', 'wai', 'scotty', 'servant', 'servant-server', 'yesod', 'persistent', 'postgresql-simple', 'optparse-applicative', 'vector', 'unordered-containers', 'hashable', 'transformers', 'stm', 'async', 'time', 'directory', 'filepath', 'process', 'cryptonite', 'crypton', 'tls', 'pandoc', 'megaparsec', 'attoparsec', 'parsec', 'xml-conduit', 'zlib', 'random', 'QuickCheck', 'hspec', 'tasty']);
324
+
325
+ const lev = (a, b) => {
326
+ const m = a.length, n = b.length; const d = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
327
+ for (let j = 1; j <= n; j++) d[0][j] = j;
328
+ for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
329
+ return d[m][n];
330
+ };
331
+
332
+ /**
333
+ * Name similarity is a CANDIDATE, never a verdict: Hackage has no scopes and legitimate near-names exist.
334
+ * `registry` (optional) maps a package name to known facts ({exists, maintainers, firstUploaded}); without
335
+ * registry evidence the result stays `unknown`.
336
+ */
337
+ export function nearNameCandidates(components, { registry = null, popular = POPULAR_PACKAGES } = {}) {
338
+ const out = [];
339
+ for (const c of components) {
340
+ if (popular.includes(c.name)) continue;
341
+ for (const p of popular) {
342
+ if (Math.abs(p.length - c.name.length) > 1) continue;
343
+ if (lev(c.name.toLowerCase(), p.toLowerCase()) === 1 && p.length > 4) {
344
+ const fact = registry ? registry[c.name] : null;
345
+ out.push({ name: c.name, similarTo: p, status: fact ? (fact.exists ? 'registered-package-near-name' : 'not-on-hackage') : 'unknown', verdict: 'candidate', malicious: false, evidence: fact || null, note: 'edit distance 1 from a popular package; similarity alone is not evidence of malice' });
346
+ break;
347
+ }
348
+ }
349
+ }
350
+ return out;
351
+ }
352
+
353
+ const COMMIT = /^[0-9a-f]{40}$/i;
354
+ /** `source-repository-package` stanzas and Stack `extra-deps` git sources: pinned to a commit, or floating. */
355
+ export function sourceIntegrity(manifests) {
356
+ const out = [];
357
+ for (const r of manifests.sourceRepositories || []) {
358
+ const pinned = typeof r.tag === 'string' && COMMIT.test(r.tag);
359
+ const kind = pinned ? 'pinned-commit' : (r.tag ? 'tag-or-branch' : 'floating');
360
+ out.push({ ...r, integrity: kind, finding: !pinned, severity: !pinned ? (r.tag ? 'low' : 'medium') : null, reason: pinned ? 'pinned to a full commit hash' : (r.tag ? 'a tag or branch can be moved; pin the commit hash' : 'no tag or commit: the default branch head is fetched') });
361
+ }
362
+ return out;
363
+ }
364
+
365
+ /** Lifecycle (deprecated / yanked) is known only from supplied Hackage metadata; absent evidence is `unknown`. */
366
+ export function lifecycle(components, metadata = null) {
367
+ return components.map((c) => {
368
+ const m = metadata && metadata[c.name];
369
+ if (!m) return { name: c.name, version: c.version || null, status: 'unknown', source: null };
370
+ const ver = c.version && m.versions && m.versions[c.version];
371
+ return { name: c.name, version: c.version || null, status: m.deprecated ? 'deprecated' : (ver && ver.deprecated ? 'version-deprecated' : 'active'), supersededBy: m.supersededBy || null, source: 'hackage-metadata' };
372
+ });
373
+ }
374
+
375
+ /** License policy over supplied metadata; a package whose license is not known is `unknown`, not allowed. */
376
+ export function licensePolicy(components, metadata, policy = { deny: ['GPL-3.0-only', 'AGPL-3.0-only'], allow: null }) {
377
+ return components.map((c) => {
378
+ const m = metadata && metadata[c.name];
379
+ const license = m && (c.version && m.versions && m.versions[c.version] && m.versions[c.version].license || m.license) || null;
380
+ if (!license) return { name: c.name, version: c.version || null, license: null, status: 'unknown', reason: 'no license metadata supplied for this package' };
381
+ if (policy.deny.includes(license)) return { name: c.name, version: c.version || null, license, status: 'denied', reason: `${license} is on the deny list` };
382
+ if (policy.allow && !policy.allow.includes(license)) return { name: c.name, version: c.version || null, license, status: 'not-allowed', reason: `${license} is not on the allow list` };
383
+ return { name: c.name, version: c.version || null, license, status: 'allowed', reason: '' };
384
+ });
385
+ }
386
+
387
+ export { sha256 as _sha256, canon as _canon };