/** * Contract test (#1464): exploitability data (KEV/EPSS, from #1462/#1463) * survives the trip from scanner output to the gate's policy evaluation via * BOTH routes a finding can take there: * * 1. `scan-vulnerabilities` step -> `ScanVulnerabilitiesOutput.findings` -> * `vuln-gate`'s `findings` input (the cross-capability seam where an * intermediate mapping could drop a field unnoticed), and * 2. `vuln-gate` with no `findings`, scanning the SBOM itself through the * injected `VulnScanner`. * * All hermetic: real captured scanner stdout from __fixtures__ plus inline * fake scanners — no live scanner, network, or vuln DB. Gate *behavior* on * these fields is #1465; this file only proves the data arrives intact. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, test, expect } from "vitest"; import { createScanVulnerabilitiesCapability, parseGrypeOutput, parseTrivyOutput, type VulnFinding, type VulnScanner, } from "./vuln-scan"; import { createVulnGateCapability, VulnGateFailedError } from "./vuln-gate"; import { applyVex } from "./vex"; import type { SbomDocument } from "./sbom-generator"; const ctx = { env: "prod", component: "search-service" }; const FIXTURES_DIR = join(import.meta.dirname, "__fixtures__"); const SBOM: SbomDocument = { format: "cyclonedx", mediaType: "application/vnd.cyclonedx+json", bytes: JSON.stringify({ components: [{ name: "log4j-core", version: "2.14.1" }] }), generator: "lockfile", }; const EXPLOITABILITY_FIELDS = ["epss", "epssPercentile", "inKev", "kevDateAdded", "kevDueDate", "kevRansomware"] as const; /** The real grype capture from #1463 — findings with and without exploitability data in one document. */ const grypeFindings = () => parseGrypeOutput(readFileSync(join(FIXTURES_DIR, "grype-with-kev-epss.json"), "utf8")); const kevFinding = (findings: VulnFinding[]) => findings.find((f) => f.inKev === true); describe("exploitability survives scan -> gate (#1464)", () => { test("path 1: scan-vulnerabilities output carries every exploitability field into vuln-gate's findings input", async () => { const scanner: VulnScanner = { async scan() { return grypeFindings(); }, }; const scanned = await createScanVulnerabilitiesCapability(scanner).run(ctx, { sbom: SBOM, digest: "sha256:abc" }); const kev = kevFinding(scanned.findings); expect(kev).toBeDefined(); expect(kev!.epss).toBeTypeOf("number"); expect(kev!.epssPercentile).toBeTypeOf("number"); expect(kev!.kevDateAdded).toBeTypeOf("string"); expect(kev!.kevDueDate).toBeTypeOf("string"); expect(kev!.kevRansomware).toBe(true); // Hand the scan step's output to the gate the way a composition wires it, // with VEX suppressing every critical (the capture has two KEV-listed // ones) so the gate passes and reports them — the suppression report is // our window onto what the gate actually saw. const vex = JSON.stringify({ statements: scanned.findings .filter((f) => f.severity === "critical") .map((f) => ({ vulnerability: f.cveId, status: "not_affected", justification: "not reachable" })), }); const out = await createVulnGateCapability().run(ctx, { sbom: SBOM, findings: scanned.findings, vex: [vex], policy: { failSeverity: "critical", fixableOnly: false }, }); const seen = out.suppressed.find((s) => s.finding.cveId === kev!.cveId)!.finding; for (const field of EXPLOITABILITY_FIELDS) expect(seen[field]).toBe(kev![field]); }); test("path 1: a KEV finding that BLOCKS reaches the error's blocking list with exploitability intact", async () => { const scanner: VulnScanner = { async scan() { return grypeFindings(); }, }; const scanned = await createScanVulnerabilitiesCapability(scanner).run(ctx, { sbom: SBOM }); const kev = kevFinding(scanned.findings)!; let err: unknown; try { await createVulnGateCapability().run(ctx, { sbom: SBOM, findings: scanned.findings }); } catch (e) { err = e; } expect(err).toBeInstanceOf(VulnGateFailedError); const blocked = (err as VulnGateFailedError).blocking.find((b) => b.finding.cveId === kev.cveId); expect(blocked).toBeDefined(); // Blocked for severity, not KEV — exploitability changes no outcome yet // (#1465) — but the data rode along to the decision point untouched. expect(blocked!.reason).toBe("severity-threshold"); for (const field of EXPLOITABILITY_FIELDS) expect(blocked!.finding[field]).toBe(kev[field]); }); test("path 2: the gate-scans-for-you route delivers the same fields intact", async () => { const kev = kevFinding(grypeFindings())!; const fake: VulnScanner = { async scan() { return [kev]; }, }; const vex = JSON.stringify({ statements: [{ vulnerability: kev.cveId, status: "not_affected" }] }); const out = await createVulnGateCapability(fake).run(ctx, { sbom: SBOM, vex: [vex] }); const seen = out.suppressed[0].finding; expect(seen).toBe(kev); // same object — nothing rebuilt it on the way through expect(seen.inKev).toBe(true); expect(seen.epss).toBe(kev.epss); expect(seen.epssPercentile).toBe(kev.epssPercentile); }); test("warnings surface the finding object intact, exploitability included", async () => { const kevHigh: VulnFinding = { cveId: "CVE-2021-45046", severity: "high", package: "log4j-core", installedVersion: "2.14.1", fixedVersion: "2.16.0", fixable: true, epss: 0.99977, epssPercentile: 0.9998, inKev: true, kevDateAdded: "2023-05-01", kevDueDate: "2023-05-22", kevRansomware: true, }; const out = await createVulnGateCapability().run(ctx, { sbom: SBOM, findings: [kevHigh] }); expect(out.passed).toBe(true); expect(out.warnings).toEqual([kevHigh]); expect(out.warnings[0].inKev).toBe(true); expect(out.warnings[0].epss).toBe(0.99977); }); test("a scanner reporting no exploitability data reaches the gate with all six fields undefined — not false, not 0", async () => { // The real trivy capture from #1463: same CVEs as the grype fixture, zero // exploitability data — trivy's JSON output has no KEV/EPSS fields at all. const trivyFindings = parseTrivyOutput(readFileSync(join(FIXTURES_DIR, "trivy-with-kev-epss.json"), "utf8")); const scanner: VulnScanner = { async scan() { return trivyFindings; }, }; const scanned = await createScanVulnerabilitiesCapability(scanner).run(ctx, { sbom: SBOM }); let err: unknown; try { await createVulnGateCapability().run(ctx, { sbom: SBOM, findings: scanned.findings }); } catch (e) { err = e; } // The capture's fixable criticals block on severity as before; inspect the // findings as the gate saw them at the decision point. expect(err).toBeInstanceOf(VulnGateFailedError); const seen = (err as VulnGateFailedError).blocking.map((b) => b.finding); expect(seen.length).toBeGreaterThan(0); for (const f of seen) { expect(f.inKev).toBeUndefined(); expect(f.inKev).not.toBe(false); // undefined !== false: "not reported" is not "reported absent" expect(f.epss).toBeUndefined(); expect(f.epss).not.toBe(0); expect(f.epssPercentile).toBeUndefined(); expect(f.kevDateAdded).toBeUndefined(); expect(f.kevDueDate).toBeUndefined(); expect(f.kevRansomware).toBeUndefined(); } }); test("applyVex preserves exploitability on findings it does NOT suppress (no rebuild, no dropped keys)", () => { const findings = grypeFindings(); const kev = kevFinding(findings)!; const other = findings.find((f) => f.inKev === undefined && f.cveId !== kev.cveId)!; const { gating, suppressed } = applyVex(findings, [ { cveId: other.cveId, status: "not_affected", justification: "not reachable" }, ]); expect(suppressed.map((s) => s.finding.cveId)).toEqual([other.cveId]); const survived = gating.find((f) => f.cveId === kev.cveId)!; expect(survived).toBe(kev); // pass-through by reference, not a rebuilt object for (const field of EXPLOITABILITY_FIELDS) expect(survived[field]).toBe(kev[field]); }); });