@intentius/chant 0.41.19 → 0.42.1

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 (150) hide show
  1. package/dist/cli/build-params-cli.d.ts +7 -4
  2. package/dist/cli/build-params-cli.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/carve-apply.d.ts +9 -0
  5. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  6. package/dist/cli/commands/carve-bridge.d.ts +7 -0
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-emit.d.ts +7 -0
  9. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  10. package/dist/cli/commands/import.d.ts +16 -0
  11. package/dist/cli/commands/import.d.ts.map +1 -1
  12. package/dist/cli/commands/init.d.ts +2 -0
  13. package/dist/cli/commands/init.d.ts.map +1 -1
  14. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  15. package/dist/cli/handlers/components.d.ts.map +1 -1
  16. package/dist/cli/handlers/explain.d.ts +11 -0
  17. package/dist/cli/handlers/explain.d.ts.map +1 -0
  18. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  19. package/dist/cli/handlers/misc.d.ts.map +1 -1
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  22. package/dist/cli/registry.d.ts +4 -0
  23. package/dist/cli/registry.d.ts.map +1 -1
  24. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  25. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  26. package/dist/codegen/okf-lexicon.d.ts +36 -0
  27. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  28. package/dist/codegen/package.d.ts +2 -1
  29. package/dist/codegen/package.d.ts.map +1 -1
  30. package/dist/components/deploy-units.d.ts.map +1 -1
  31. package/dist/components/verbs/sbom.d.ts +1 -1
  32. package/dist/components/verbs/sbom.d.ts.map +1 -1
  33. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  34. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  35. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  36. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  37. package/dist/config.d.ts +12 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/deep-observation.d.ts +13 -0
  40. package/dist/deep-observation.d.ts.map +1 -1
  41. package/dist/governance.d.ts +37 -0
  42. package/dist/governance.d.ts.map +1 -0
  43. package/dist/lexicon.d.ts +3 -0
  44. package/dist/lexicon.d.ts.map +1 -1
  45. package/dist/lifecycle/observe.d.ts.map +1 -1
  46. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  47. package/dist/okf.d.ts +57 -0
  48. package/dist/okf.d.ts.map +1 -0
  49. package/dist/reconcile.d.ts +15 -0
  50. package/dist/reconcile.d.ts.map +1 -1
  51. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  52. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  53. package/dist/terraform/adopt-state.d.ts +33 -1
  54. package/dist/terraform/adopt-state.d.ts.map +1 -1
  55. package/dist/terraform/bridge.d.ts +7 -1
  56. package/dist/terraform/bridge.d.ts.map +1 -1
  57. package/dist/terraform/carve.d.ts +12 -0
  58. package/dist/terraform/carve.d.ts.map +1 -1
  59. package/dist/terraform/excise.d.ts +31 -0
  60. package/dist/terraform/excise.d.ts.map +1 -0
  61. package/dist/terraform/graduate.d.ts +12 -0
  62. package/dist/terraform/graduate.d.ts.map +1 -1
  63. package/dist/terraform/graph.d.ts +35 -5
  64. package/dist/terraform/graph.d.ts.map +1 -1
  65. package/dist/terraform/manifest.d.ts +89 -0
  66. package/dist/terraform/manifest.d.ts.map +1 -0
  67. package/dist/terraform/parse.d.ts +9 -4
  68. package/dist/terraform/parse.d.ts.map +1 -1
  69. package/dist/terraform/types.d.ts +7 -0
  70. package/dist/terraform/types.d.ts.map +1 -1
  71. package/dist/terraform/unified-diff.d.ts +11 -0
  72. package/dist/terraform/unified-diff.d.ts.map +1 -0
  73. package/package.json +1 -1
  74. package/src/__snapshots__/okf.test.ts.snap +381 -0
  75. package/src/cli/build-params-cli.test.ts +47 -3
  76. package/src/cli/build-params-cli.ts +22 -5
  77. package/src/cli/commands/build.test.ts +44 -0
  78. package/src/cli/commands/build.ts +15 -2
  79. package/src/cli/commands/carve-apply.test.ts +94 -2
  80. package/src/cli/commands/carve-apply.ts +62 -11
  81. package/src/cli/commands/carve-bridge.test.ts +82 -4
  82. package/src/cli/commands/carve-bridge.ts +79 -11
  83. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  84. package/src/cli/commands/carve-emit.ts +184 -8
  85. package/src/cli/commands/import.ts +53 -5
  86. package/src/cli/commands/init.ts +1 -1
  87. package/src/cli/handlers/build.test.ts +3 -1
  88. package/src/cli/handlers/carve-apply.ts +1 -0
  89. package/src/cli/handlers/components.ts +4 -2
  90. package/src/cli/handlers/explain.test.ts +93 -0
  91. package/src/cli/handlers/explain.ts +60 -0
  92. package/src/cli/handlers/lifecycle.ts +9 -7
  93. package/src/cli/handlers/misc.ts +40 -1
  94. package/src/cli/handlers/run.test.ts +3 -1
  95. package/src/cli/main.ts +25 -5
  96. package/src/cli/mcp/server.test.ts +17 -0
  97. package/src/cli/mcp/tools/explain.ts +15 -3
  98. package/src/cli/registry.ts +4 -0
  99. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  100. package/src/codegen/docs-rule-scanning.ts +35 -14
  101. package/src/codegen/okf-lexicon.test.ts +248 -0
  102. package/src/codegen/okf-lexicon.ts +303 -0
  103. package/src/codegen/package.ts +29 -4
  104. package/src/codegen/publish-order.test.ts +1 -1
  105. package/src/codegen/release-wiring.test.ts +7 -3
  106. package/src/components/config-defaults.test.ts +30 -0
  107. package/src/components/deploy-units.ts +3 -0
  108. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  109. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  110. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  111. package/src/components/verbs/sbom.ts +1 -1
  112. package/src/components/verbs/vuln-gate.test.ts +144 -1
  113. package/src/components/verbs/vuln-gate.ts +61 -10
  114. package/src/components/verbs/vuln-scan.test.ts +94 -0
  115. package/src/components/verbs/vuln-scan.ts +44 -9
  116. package/src/config.test.ts +21 -0
  117. package/src/config.ts +18 -0
  118. package/src/deep-observation.test.ts +19 -0
  119. package/src/deep-observation.ts +32 -13
  120. package/src/governance.test.ts +72 -0
  121. package/src/governance.ts +54 -0
  122. package/src/lexicon.ts +3 -0
  123. package/src/lifecycle/observe.test.ts +18 -0
  124. package/src/lifecycle/observe.ts +10 -3
  125. package/src/lifecycle/snapshot.ts +5 -4
  126. package/src/okf.test.ts +169 -0
  127. package/src/okf.ts +308 -0
  128. package/src/reconcile.ts +32 -1
  129. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  130. package/src/terraform/adopt-state.test.ts +34 -1
  131. package/src/terraform/adopt-state.ts +69 -2
  132. package/src/terraform/bridge.test.ts +38 -14
  133. package/src/terraform/bridge.ts +48 -14
  134. package/src/terraform/carve.test.ts +9 -7
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/excise.test.ts +92 -0
  137. package/src/terraform/excise.ts +139 -0
  138. package/src/terraform/graduate.test.ts +73 -6
  139. package/src/terraform/graduate.ts +49 -0
  140. package/src/terraform/graph.test.ts +56 -10
  141. package/src/terraform/graph.ts +112 -45
  142. package/src/terraform/manifest.test.ts +105 -0
  143. package/src/terraform/manifest.ts +162 -0
  144. package/src/terraform/parse.test.ts +39 -1
  145. package/src/terraform/parse.ts +36 -9
  146. package/src/terraform/score.test.ts +8 -8
  147. package/src/terraform/state.test.ts +4 -4
  148. package/src/terraform/types.ts +7 -0
  149. package/src/terraform/unified-diff.test.ts +77 -0
  150. package/src/terraform/unified-diff.ts +146 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Contract test (#1464): exploitability data (KEV/EPSS, from #1462/#1463)
3
+ * survives the trip from scanner output to the gate's policy evaluation via
4
+ * BOTH routes a finding can take there:
5
+ *
6
+ * 1. `scan-vulnerabilities` step -> `ScanVulnerabilitiesOutput.findings` ->
7
+ * `vuln-gate`'s `findings` input (the cross-capability seam where an
8
+ * intermediate mapping could drop a field unnoticed), and
9
+ * 2. `vuln-gate` with no `findings`, scanning the SBOM itself through the
10
+ * injected `VulnScanner`.
11
+ *
12
+ * All hermetic: real captured scanner stdout from __fixtures__ plus inline
13
+ * fake scanners — no live scanner, network, or vuln DB. Gate *behavior* on
14
+ * these fields is #1465; this file only proves the data arrives intact.
15
+ */
16
+
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { describe, test, expect } from "vitest";
20
+ import {
21
+ createScanVulnerabilitiesCapability,
22
+ parseGrypeOutput,
23
+ parseTrivyOutput,
24
+ type VulnFinding,
25
+ type VulnScanner,
26
+ } from "./vuln-scan";
27
+ import { createVulnGateCapability, VulnGateFailedError } from "./vuln-gate";
28
+ import { applyVex } from "./vex";
29
+ import type { SbomDocument } from "./sbom-generator";
30
+
31
+ const ctx = { env: "prod", component: "search-service" };
32
+ const FIXTURES_DIR = join(import.meta.dirname, "__fixtures__");
33
+
34
+ const SBOM: SbomDocument = {
35
+ format: "cyclonedx",
36
+ mediaType: "application/vnd.cyclonedx+json",
37
+ bytes: JSON.stringify({ components: [{ name: "log4j-core", version: "2.14.1" }] }),
38
+ generator: "lockfile",
39
+ };
40
+
41
+ const EXPLOITABILITY_FIELDS = ["epss", "epssPercentile", "inKev", "kevDateAdded", "kevDueDate", "kevRansomware"] as const;
42
+
43
+ /** The real grype capture from #1463 — findings with and without exploitability data in one document. */
44
+ const grypeFindings = () => parseGrypeOutput(readFileSync(join(FIXTURES_DIR, "grype-with-kev-epss.json"), "utf8"));
45
+
46
+ const kevFinding = (findings: VulnFinding[]) => findings.find((f) => f.inKev === true);
47
+
48
+ describe("exploitability survives scan -> gate (#1464)", () => {
49
+ test("path 1: scan-vulnerabilities output carries every exploitability field into vuln-gate's findings input", async () => {
50
+ const scanner: VulnScanner = {
51
+ async scan() {
52
+ return grypeFindings();
53
+ },
54
+ };
55
+ const scanned = await createScanVulnerabilitiesCapability(scanner).run(ctx, { sbom: SBOM, digest: "sha256:abc" });
56
+ const kev = kevFinding(scanned.findings);
57
+ expect(kev).toBeDefined();
58
+ expect(kev!.epss).toBeTypeOf("number");
59
+ expect(kev!.epssPercentile).toBeTypeOf("number");
60
+ expect(kev!.kevDateAdded).toBeTypeOf("string");
61
+ expect(kev!.kevDueDate).toBeTypeOf("string");
62
+ expect(kev!.kevRansomware).toBe(true);
63
+
64
+ // Hand the scan step's output to the gate the way a composition wires it,
65
+ // with VEX suppressing every critical (the capture has two KEV-listed
66
+ // ones) so the gate passes and reports them — the suppression report is
67
+ // our window onto what the gate actually saw.
68
+ const vex = JSON.stringify({
69
+ statements: scanned.findings
70
+ .filter((f) => f.severity === "critical")
71
+ .map((f) => ({ vulnerability: f.cveId, status: "not_affected", justification: "not reachable" })),
72
+ });
73
+ const out = await createVulnGateCapability().run(ctx, {
74
+ sbom: SBOM,
75
+ findings: scanned.findings,
76
+ vex: [vex],
77
+ policy: { failSeverity: "critical", fixableOnly: false },
78
+ });
79
+
80
+ const seen = out.suppressed.find((s) => s.finding.cveId === kev!.cveId)!.finding;
81
+ for (const field of EXPLOITABILITY_FIELDS) expect(seen[field]).toBe(kev![field]);
82
+ });
83
+
84
+ test("path 1: a KEV finding that BLOCKS reaches the error's blocking list with exploitability intact", async () => {
85
+ const scanner: VulnScanner = {
86
+ async scan() {
87
+ return grypeFindings();
88
+ },
89
+ };
90
+ const scanned = await createScanVulnerabilitiesCapability(scanner).run(ctx, { sbom: SBOM });
91
+ const kev = kevFinding(scanned.findings)!;
92
+ let err: unknown;
93
+ try {
94
+ await createVulnGateCapability().run(ctx, { sbom: SBOM, findings: scanned.findings });
95
+ } catch (e) {
96
+ err = e;
97
+ }
98
+ expect(err).toBeInstanceOf(VulnGateFailedError);
99
+ const blocked = (err as VulnGateFailedError).blocking.find((b) => b.finding.cveId === kev.cveId);
100
+ expect(blocked).toBeDefined();
101
+ // Blocked for severity, not KEV — exploitability changes no outcome yet
102
+ // (#1465) — but the data rode along to the decision point untouched.
103
+ expect(blocked!.reason).toBe("severity-threshold");
104
+ for (const field of EXPLOITABILITY_FIELDS) expect(blocked!.finding[field]).toBe(kev[field]);
105
+ });
106
+
107
+ test("path 2: the gate-scans-for-you route delivers the same fields intact", async () => {
108
+ const kev = kevFinding(grypeFindings())!;
109
+ const fake: VulnScanner = {
110
+ async scan() {
111
+ return [kev];
112
+ },
113
+ };
114
+ const vex = JSON.stringify({ statements: [{ vulnerability: kev.cveId, status: "not_affected" }] });
115
+ const out = await createVulnGateCapability(fake).run(ctx, { sbom: SBOM, vex: [vex] });
116
+ const seen = out.suppressed[0].finding;
117
+ expect(seen).toBe(kev); // same object — nothing rebuilt it on the way through
118
+ expect(seen.inKev).toBe(true);
119
+ expect(seen.epss).toBe(kev.epss);
120
+ expect(seen.epssPercentile).toBe(kev.epssPercentile);
121
+ });
122
+
123
+ test("warnings surface the finding object intact, exploitability included", async () => {
124
+ const kevHigh: VulnFinding = {
125
+ cveId: "CVE-2021-45046",
126
+ severity: "high",
127
+ package: "log4j-core",
128
+ installedVersion: "2.14.1",
129
+ fixedVersion: "2.16.0",
130
+ fixable: true,
131
+ epss: 0.99977,
132
+ epssPercentile: 0.9998,
133
+ inKev: true,
134
+ kevDateAdded: "2023-05-01",
135
+ kevDueDate: "2023-05-22",
136
+ kevRansomware: true,
137
+ };
138
+ const out = await createVulnGateCapability().run(ctx, { sbom: SBOM, findings: [kevHigh] });
139
+ expect(out.passed).toBe(true);
140
+ expect(out.warnings).toEqual([kevHigh]);
141
+ expect(out.warnings[0].inKev).toBe(true);
142
+ expect(out.warnings[0].epss).toBe(0.99977);
143
+ });
144
+
145
+ test("a scanner reporting no exploitability data reaches the gate with all six fields undefined — not false, not 0", async () => {
146
+ // The real trivy capture from #1463: same CVEs as the grype fixture, zero
147
+ // exploitability data — trivy's JSON output has no KEV/EPSS fields at all.
148
+ const trivyFindings = parseTrivyOutput(readFileSync(join(FIXTURES_DIR, "trivy-with-kev-epss.json"), "utf8"));
149
+ const scanner: VulnScanner = {
150
+ async scan() {
151
+ return trivyFindings;
152
+ },
153
+ };
154
+ const scanned = await createScanVulnerabilitiesCapability(scanner).run(ctx, { sbom: SBOM });
155
+ let err: unknown;
156
+ try {
157
+ await createVulnGateCapability().run(ctx, { sbom: SBOM, findings: scanned.findings });
158
+ } catch (e) {
159
+ err = e;
160
+ }
161
+ // The capture's fixable criticals block on severity as before; inspect the
162
+ // findings as the gate saw them at the decision point.
163
+ expect(err).toBeInstanceOf(VulnGateFailedError);
164
+ const seen = (err as VulnGateFailedError).blocking.map((b) => b.finding);
165
+ expect(seen.length).toBeGreaterThan(0);
166
+ for (const f of seen) {
167
+ expect(f.inKev).toBeUndefined();
168
+ expect(f.inKev).not.toBe(false); // undefined !== false: "not reported" is not "reported absent"
169
+ expect(f.epss).toBeUndefined();
170
+ expect(f.epss).not.toBe(0);
171
+ expect(f.epssPercentile).toBeUndefined();
172
+ expect(f.kevDateAdded).toBeUndefined();
173
+ expect(f.kevDueDate).toBeUndefined();
174
+ expect(f.kevRansomware).toBeUndefined();
175
+ }
176
+ });
177
+
178
+ test("applyVex preserves exploitability on findings it does NOT suppress (no rebuild, no dropped keys)", () => {
179
+ const findings = grypeFindings();
180
+ const kev = kevFinding(findings)!;
181
+ const other = findings.find((f) => f.inKev === undefined && f.cveId !== kev.cveId)!;
182
+ const { gating, suppressed } = applyVex(findings, [
183
+ { cveId: other.cveId, status: "not_affected", justification: "not reachable" },
184
+ ]);
185
+ expect(suppressed.map((s) => s.finding.cveId)).toEqual([other.cveId]);
186
+ const survived = gating.find((f) => f.cveId === kev.cveId)!;
187
+ expect(survived).toBe(kev); // pass-through by reference, not a rebuilt object
188
+ for (const field of EXPLOITABILITY_FIELDS) expect(survived[field]).toBe(kev[field]);
189
+ });
190
+ });
@@ -148,5 +148,5 @@ export function createGenerateSbomCapability(
148
148
  };
149
149
  }
150
150
 
151
- /** Default `generate-sbom` capability, backed by the process-wide default `SbomGenerator` (throws until a real backend is injected see ./sbom-generator.ts's `notImplementedSbomGenerator`). */
151
+ /** Default `generate-sbom` capability, backed by the process-wide default `SbomGenerator` — the hermetic lockfile backend (#630), so `dir`/`zip`/`jar` work with no tool installed; only `forImage` throws until `toolSbomGenerator` is injected. See ./sbom-generator.ts's `defaultSbomGenerator`. */
152
152
  export const generateSbomCapability: Capability<GenerateSbomInput, GenerateSbomOutput> = createGenerateSbomCapability();
@@ -9,7 +9,7 @@
9
9
  import { describe, test, expect } from "vitest";
10
10
  import { parseOpenVex, parseCycloneDxVex, parseVexDocument, applyVex } from "./vex";
11
11
  import { extractLicenses, evaluateLicensePolicy } from "./license-policy";
12
- import { createVulnGateCapability, VulnGateFailedError, type VulnGateInput } from "./vuln-gate";
12
+ import { createVulnGateCapability, DEFAULT_VULN_POLICY, VulnGateFailedError, type VulnGateInput } from "./vuln-gate";
13
13
  import type { VulnFinding, VulnScanner } from "./vuln-scan";
14
14
  import type { SbomDocument } from "./sbom-generator";
15
15
  import { resolveVulnPolicy } from "../../config";
@@ -186,6 +186,131 @@ describe("audit hardening (#628)", () => {
186
186
  });
187
187
  });
188
188
 
189
+ // ── exploitability shape (#1462, phase 0 of epic #1461) ──────────────────────
190
+
191
+ describe("exploitability policy shape (#1462)", () => {
192
+ test("DEFAULT_VULN_POLICY does not gate on KEV — flipping failOnKev is a deliberate release decision (epic #1461), not a drive-by", () => {
193
+ expect(DEFAULT_VULN_POLICY.failOnKev).toBe(false);
194
+ expect(DEFAULT_VULN_POLICY.exploitabilityFixableOnly).toBe(true);
195
+ expect(DEFAULT_VULN_POLICY.failEpssAtOrAbove).toBeUndefined();
196
+ expect(DEFAULT_VULN_POLICY.warnEpssAtOrAbove).toBeUndefined();
197
+ });
198
+
199
+ test("a finding carrying exploitability fields passes through the gate with today's outcomes (evaluation is #1465)", async () => {
200
+ const kevHigh: VulnFinding = {
201
+ ...HIGH_FIXABLE,
202
+ epss: 0.42,
203
+ epssPercentile: 0.97,
204
+ inKev: true,
205
+ kevDateAdded: "2026-01-15",
206
+ kevDueDate: "2026-02-05",
207
+ kevRansomware: true,
208
+ };
209
+ // KEV membership changes no outcome in this phase: still a warning under the default policy.
210
+ const out = await gate({ sbom: SBOM_SPDX, findings: [kevHigh] });
211
+ expect(out.passed).toBe(true);
212
+ expect(out.warnings).toEqual([kevHigh]);
213
+ });
214
+
215
+ test("a finding constructed without exploitability fields still typechecks and gates as before", async () => {
216
+ await expect(gate({ sbom: SBOM_SPDX, findings: [CRIT_FIXABLE], policy: { failOnKev: false, exploitabilityFixableOnly: true } })).rejects.toBeInstanceOf(
217
+ VulnGateFailedError,
218
+ );
219
+ });
220
+ });
221
+
222
+ // ── exploitability evaluation (#1465, phase 2 of epic #1461) ─────────────────
223
+
224
+ describe("exploitability evaluation (#1465)", () => {
225
+ const MEDIUM_KEV_FIXABLE: VulnFinding = {
226
+ cveId: "CVE-K1", severity: "medium", package: "libfoo", installedVersion: "1", fixedVersion: "2", fixable: true,
227
+ inKev: true, kevDateAdded: "2024-03-11", kevRansomware: true,
228
+ };
229
+ const LOW_HIGH_EPSS: VulnFinding = {
230
+ cveId: "CVE-E1", severity: "low", package: "libbar", installedVersion: "1", fixedVersion: "2", fixable: true,
231
+ epss: 0.42, epssPercentile: 0.97,
232
+ };
233
+
234
+ async function blockingOf(input: VulnGateInput) {
235
+ try {
236
+ await gate(input);
237
+ } catch (e) {
238
+ expect(e).toBeInstanceOf(VulnGateFailedError);
239
+ return (e as VulnGateFailedError).blocking;
240
+ }
241
+ throw new Error("expected the gate to block");
242
+ }
243
+
244
+ test("failOnKev blocks a fixable KEV finding BELOW the fail severity, reason kev — the headline case that passes today", async () => {
245
+ const blocking = await blockingOf({ sbom: SBOM_SPDX, findings: [MEDIUM_KEV_FIXABLE], policy: { failOnKev: true } });
246
+ expect(blocking).toEqual([{ finding: MEDIUM_KEV_FIXABLE, reason: "kev" }]);
247
+ });
248
+
249
+ test("an UNFIXABLE KEV finding warns instead of blocking under exploitabilityFixableOnly (default)", async () => {
250
+ const unfixable: VulnFinding = { ...MEDIUM_KEV_FIXABLE, fixedVersion: undefined, fixable: false };
251
+ const out = await gate({ sbom: SBOM_SPDX, findings: [unfixable], policy: { failOnKev: true } });
252
+ expect(out.passed).toBe(true);
253
+ expect(out.warnings).toEqual([unfixable]);
254
+ });
255
+
256
+ test("exploitabilityFixableOnly: false blocks the unfixable KEV finding too", async () => {
257
+ const unfixable: VulnFinding = { ...MEDIUM_KEV_FIXABLE, fixedVersion: undefined, fixable: false };
258
+ const blocking = await blockingOf({ sbom: SBOM_SPDX, findings: [unfixable], policy: { failOnKev: true, exploitabilityFixableOnly: false } });
259
+ expect(blocking[0].reason).toBe("kev");
260
+ });
261
+
262
+ test("failEpssAtOrAbove blocks a low-severity finding at/above the threshold, reason epss-threshold", async () => {
263
+ const blocking = await blockingOf({ sbom: SBOM_SPDX, findings: [LOW_HIGH_EPSS], policy: { failEpssAtOrAbove: 0.1 } });
264
+ expect(blocking).toEqual([{ finding: LOW_HIGH_EPSS, reason: "epss-threshold" }]);
265
+ });
266
+
267
+ test("absent EPSS never matches a threshold — undefined is not zero", async () => {
268
+ const unscored: VulnFinding = { ...LOW_HIGH_EPSS, epss: undefined, epssPercentile: undefined };
269
+ const out = await gate({ sbom: SBOM_SPDX, findings: [unscored], policy: { failEpssAtOrAbove: 0, warnEpssAtOrAbove: 0 } });
270
+ expect(out.passed).toBe(true);
271
+ expect(out.warnings).toEqual([]);
272
+ });
273
+
274
+ test("warnEpssAtOrAbove warns without blocking", async () => {
275
+ const out = await gate({ sbom: SBOM_SPDX, findings: [LOW_HIGH_EPSS], policy: { warnEpssAtOrAbove: 0.1 } });
276
+ expect(out.passed).toBe(true);
277
+ expect(out.warnings).toEqual([LOW_HIGH_EPSS]);
278
+ });
279
+
280
+ test("VEX suppression outranks KEV — a suppressed KEV finding does not block and lands in suppressed", async () => {
281
+ const vex = JSON.stringify({ statements: [{ vulnerability: "CVE-K1", status: "not_affected", justification: "vulnerable code not in execute path" }] });
282
+ const out = await gate({ sbom: SBOM_SPDX, findings: [MEDIUM_KEV_FIXABLE], vex: [vex], policy: { failOnKev: true } });
283
+ expect(out.passed).toBe(true);
284
+ expect(out.suppressed.map((s) => s.finding.cveId)).toEqual(["CVE-K1"]);
285
+ });
286
+
287
+ test("exploitability escalates, never de-escalates: a severity block with low EPSS and no KEV still blocks", async () => {
288
+ const dullCritical: VulnFinding = { ...CRIT_FIXABLE, epss: 0.001, inKev: undefined };
289
+ const blocking = await blockingOf({ sbom: SBOM_SPDX, findings: [dullCritical], policy: { failOnKev: true, failEpssAtOrAbove: 0.5 } });
290
+ expect(blocking).toEqual([{ finding: dullCritical, reason: "severity-threshold" }]);
291
+ });
292
+
293
+ test("a finding blocked for several reasons is reported once, under the most specific: kev > epss-threshold > severity-threshold", async () => {
294
+ const everything: VulnFinding = { ...CRIT_FIXABLE, epss: 0.9, inKev: true, kevDateAdded: "2024-03-11" };
295
+ const blocking = await blockingOf({ sbom: SBOM_SPDX, findings: [everything], policy: { failOnKev: true, failEpssAtOrAbove: 0.1 } });
296
+ expect(blocking).toEqual([{ finding: everything, reason: "kev" }]);
297
+ });
298
+
299
+ test("the error message names the rule that fired for each finding", async () => {
300
+ const err = await gate({
301
+ sbom: SBOM_SPDX,
302
+ findings: [MEDIUM_KEV_FIXABLE, LOW_HIGH_EPSS, CRIT_FIXABLE],
303
+ policy: { failOnKev: true, failEpssAtOrAbove: 0.1 },
304
+ }).then(
305
+ () => { throw new Error("expected the gate to block"); },
306
+ (e) => e as VulnGateFailedError,
307
+ );
308
+ expect(err.message).toContain("CVE-K1 (medium, libfoo) — in CISA KEV since 2024-03-11, known ransomware use");
309
+ expect(err.message).toContain("CVE-E1 (low, libbar) — EPSS 0.42 at/above the fail threshold");
310
+ expect(err.message).toContain("CVE-1 (critical, a) — severity at/above the fail threshold, fixable");
311
+ });
312
+ });
313
+
189
314
  // ── config resolver ──────────────────────────────────────────────────────────
190
315
 
191
316
  describe("resolveVulnPolicy", () => {
@@ -198,4 +323,22 @@ describe("resolveVulnPolicy", () => {
198
323
  license: { deny: ["GPL-3.0"] },
199
324
  });
200
325
  });
326
+
327
+ test("passes through the exploitability fields (#1466)", () => {
328
+ expect(resolveVulnPolicy({ vulnPolicy: { failOnKev: true, failEpssAtOrAbove: 0.1, warnEpssAtOrAbove: 0.01, exploitabilityFixableOnly: false } })).toEqual({
329
+ failOnKev: true,
330
+ failEpssAtOrAbove: 0.1,
331
+ warnEpssAtOrAbove: 0.01,
332
+ exploitabilityFixableOnly: false,
333
+ });
334
+ });
335
+
336
+ test("failEpssAtOrAbove: 0 survives — a meaningful value, not falsy noise", () => {
337
+ expect(resolveVulnPolicy({ vulnPolicy: { failEpssAtOrAbove: 0, warnEpssAtOrAbove: 0 } })).toEqual({ failEpssAtOrAbove: 0, warnEpssAtOrAbove: 0 });
338
+ });
339
+
340
+ test("an explicit failOnKev: false is distinguishable from unset", () => {
341
+ expect(resolveVulnPolicy({ vulnPolicy: { failOnKev: false } })).toEqual({ failOnKev: false });
342
+ expect(resolveVulnPolicy({ vulnPolicy: {} })).toEqual({});
343
+ });
201
344
  });
@@ -42,6 +42,14 @@ export interface VulnPolicy {
42
42
  failOnLicense: boolean;
43
43
  /** Block on an `unknown`-severity finding (a scanner that didn't report a severity chant could map). Default `false` — but such a finding is ALWAYS at least warned, never silently dropped, regardless of this flag. Set true for a strict shop that won't ship an unclassifiable finding. */
44
44
  failOnUnknownSeverity: boolean;
45
+ /** Block any finding in the CISA KEV catalog, regardless of severity. Default `false` — see epic #1461's open decision before flipping. */
46
+ failOnKev: boolean;
47
+ /** Block when EPSS is at or above this (0.0–1.0). Omit to ignore EPSS entirely. */
48
+ failEpssAtOrAbove?: number;
49
+ /** Warn (not block) at or above this EPSS. Omit to ignore. */
50
+ warnEpssAtOrAbove?: number;
51
+ /** Apply `fixableOnly` to exploitability blocks too — an unfixable KEV finding warns rather than blocks. Default `true`. */
52
+ exploitabilityFixableOnly: boolean;
45
53
  }
46
54
 
47
55
  /** Beginner-safe defaults, also encoded in `resolveVulnPolicy` (../../config.ts). */
@@ -51,6 +59,8 @@ export const DEFAULT_VULN_POLICY: VulnPolicy = {
51
59
  warnSeverity: "high",
52
60
  failOnLicense: false,
53
61
  failOnUnknownSeverity: false,
62
+ failOnKev: false,
63
+ exploitabilityFixableOnly: true,
54
64
  };
55
65
 
56
66
  export interface VulnGateInput {
@@ -69,7 +79,7 @@ export interface VulnGateInput {
69
79
  /** A finding that fails the gate, with why. */
70
80
  export interface BlockingFinding {
71
81
  finding: VulnFinding;
72
- reason: "severity-threshold" | "unknown-severity";
82
+ reason: "severity-threshold" | "unknown-severity" | "kev" | "epss-threshold";
73
83
  }
74
84
 
75
85
  export interface VulnGateOutput {
@@ -95,16 +105,35 @@ export class VulnGateFailedError extends Error {
95
105
  public readonly blocking: BlockingFinding[],
96
106
  public readonly blockingLicenses: LicenseViolation[],
97
107
  ) {
98
- const cveList = blocking.map((b) => `${b.finding.cveId} (${b.finding.severity}, ${b.finding.package})`).join(", ");
99
108
  const licList = blockingLicenses.map((l) => `${l.license} in ${l.package}`).join(", ");
100
109
  const parts: string[] = [];
101
- if (blocking.length) parts.push(`${blocking.length} vulnerability finding(s): ${cveList}`);
110
+ if (blocking.length) {
111
+ const lines = blocking.map((b) => ` ${b.finding.cveId} (${b.finding.severity}, ${b.finding.package}) — ${describeBlockReason(b)}`);
112
+ parts.push(`${blocking.length} vulnerability finding(s):\n${lines.join("\n")}`);
113
+ }
102
114
  if (blockingLicenses.length) parts.push(`${blockingLicenses.length} license violation(s): ${licList}`);
103
- super(`vuln-gate blocked the deploy — ${parts.join("; ")}. Fix the dependency, or record a VEX statement if it is not exploitable.`);
115
+ super(`vuln-gate blocked the deploy — ${parts.join("\n")}\nFix the dependency, or record a VEX statement if it is not exploitable.`);
104
116
  this.name = "VulnGateFailedError";
105
117
  }
106
118
  }
107
119
 
120
+ /** Which rule fired, human-readably — naming the rule is the difference between a gate people tune and a gate people disable. */
121
+ function describeBlockReason(b: BlockingFinding): string {
122
+ const f = b.finding;
123
+ switch (b.reason) {
124
+ case "kev": {
125
+ const since = f.kevDateAdded ? `in CISA KEV since ${f.kevDateAdded}` : "in CISA KEV";
126
+ return f.kevRansomware ? `${since}, known ransomware use` : since;
127
+ }
128
+ case "epss-threshold":
129
+ return `EPSS ${f.epss} at/above the fail threshold`;
130
+ case "unknown-severity":
131
+ return "severity unreported by the scanner (failOnUnknownSeverity)";
132
+ case "severity-threshold":
133
+ return `severity at/above the fail threshold${f.fixable ? ", fixable" : ""}`;
134
+ }
135
+ }
136
+
108
137
  /** Parse every supplied VEX document into a flat statement list (ignoring any that fail to parse into a known shape). */
109
138
  function collectVex(docs: string[] | undefined): VexStatement[] {
110
139
  const out: VexStatement[] = [];
@@ -116,10 +145,13 @@ function collectVex(docs: string[] | undefined): VexStatement[] {
116
145
  * Build the `vuln-gate` capability. Scans `sbom` (or uses supplied `findings`),
117
146
  * applies VEX, evaluates the license policy, and classifies every gating
118
147
  * finding: it BLOCKS (throws `VulnGateFailedError`) any finding at/above
119
- * `failSeverity` that satisfies `fixableOnly`, plus license violations when
148
+ * `failSeverity` that satisfies `fixableOnly`, any KEV finding when
149
+ * `failOnKev`, any finding at/above `failEpssAtOrAbove` (exploitability
150
+ * blocks honor `exploitabilityFixableOnly`), plus license violations when
120
151
  * `failOnLicense`; everything at/above `warnSeverity` below the fail bar is a
121
- * warning. On a clean pass it returns the warnings/suppressed/license report
122
- * for logging.
152
+ * warning, as are exploitability hits kept from blocking only by fixability
153
+ * and findings at/above `warnEpssAtOrAbove`. On a clean pass it returns the
154
+ * warnings/suppressed/license report for logging.
123
155
  */
124
156
  export function createVulnGateCapability(
125
157
  scanner: VulnScanner = defaultVulnScanner(),
@@ -133,14 +165,30 @@ export function createVulnGateCapability(
133
165
  // 1. VEX suppression.
134
166
  const { gating, suppressed } = applyVex(rawFindings, collectVex(input.vex));
135
167
 
136
- // 2. Classify gating findings against the severity policy.
168
+ // 2. Classify gating findings against the severity + exploitability
169
+ // policy. Exploitability (KEV, EPSS) can only ESCALATE — the rules are
170
+ // independent block reasons OR'd together, never a replacement scoring
171
+ // system that could exempt a severity block. A finding blocked for more
172
+ // than one reason is reported once, under the most specific reason
173
+ // (kev > epss-threshold > severity-threshold). VEX suppression (step 1)
174
+ // outranks all of it, KEV included: a VEX statement is a claim about
175
+ // this artifact as built, a catalog entry is about the CVE somewhere.
137
176
  const failRank = SEVERITY_RANK[policy.failSeverity];
138
177
  const warnRank = SEVERITY_RANK[policy.warnSeverity];
139
178
  const blocking: BlockingFinding[] = [];
140
179
  const warnings: VulnFinding[] = [];
141
180
  for (const f of gating) {
142
181
  const rank = SEVERITY_RANK[f.severity];
143
- if (rank >= failRank && (!policy.fixableOnly || f.fixable)) {
182
+ // Absent EPSS is "not scored", never zero — it can't match a threshold.
183
+ const epssFailHit = policy.failEpssAtOrAbove !== undefined && f.epss !== undefined && f.epss >= policy.failEpssAtOrAbove;
184
+ const epssWarnHit = policy.warnEpssAtOrAbove !== undefined && f.epss !== undefined && f.epss >= policy.warnEpssAtOrAbove;
185
+ const kevHit = policy.failOnKev && f.inKev === true;
186
+ const exploitActionable = !policy.exploitabilityFixableOnly || f.fixable;
187
+ if (kevHit && exploitActionable) {
188
+ blocking.push({ finding: f, reason: "kev" });
189
+ } else if (epssFailHit && exploitActionable) {
190
+ blocking.push({ finding: f, reason: "epss-threshold" });
191
+ } else if (rank >= failRank && (!policy.fixableOnly || f.fixable)) {
144
192
  blocking.push({ finding: f, reason: "severity-threshold" });
145
193
  } else if (f.severity === "unknown") {
146
194
  // Never silently pass an unclassifiable finding — a real critical
@@ -148,7 +196,10 @@ export function createVulnGateCapability(
148
196
  // it always; block it when the policy opts in.
149
197
  if (policy.failOnUnknownSeverity) blocking.push({ finding: f, reason: "unknown-severity" });
150
198
  else warnings.push(f);
151
- } else if (rank >= warnRank) {
199
+ } else if (rank >= warnRank || kevHit || epssFailHit || epssWarnHit) {
200
+ // The exploitability hits landing here were kept from blocking by
201
+ // exploitabilityFixableOnly (or are warn-only EPSS) — an unfixable
202
+ // KEV finding can't be actioned by upgrading, so it warns.
152
203
  warnings.push(f);
153
204
  }
154
205
  }
@@ -5,6 +5,8 @@
5
5
  * `scan-vulnerabilities` capability over an injected scanner.
6
6
  */
7
7
 
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
8
10
  import { describe, test, expect } from "vitest";
9
11
  import {
10
12
  normalizeSeverity,
@@ -22,6 +24,8 @@ import { createMockProcessRunner } from "./__tests__/mock-process-runner";
22
24
 
23
25
  const ctx = { env: "prod", component: "search-service" };
24
26
 
27
+ const FIXTURES_DIR = join(import.meta.dirname, "__fixtures__");
28
+
25
29
  const SBOM: SbomDocument = {
26
30
  format: "spdx",
27
31
  mediaType: "application/spdx+json",
@@ -84,6 +88,96 @@ describe("parseTrivyOutput", () => {
84
88
  });
85
89
  });
86
90
 
91
+ // ── exploitability parsing (#1463, over real captured scanner output) ────────
92
+ // Fixtures are unmodified `grype -o json` (v0.116.1) / `trivy sbom --format
93
+ // json` (v0.73.0) stdout from scanning a CycloneDX SBOM containing
94
+ // log4j-core@2.14.1 — a package with KEV-listed CVEs (Log4Shell).
95
+
96
+ describe("exploitability parsing (#1463)", () => {
97
+ const grypeFixture = readFileSync(join(FIXTURES_DIR, "grype-with-kev-epss.json"), "utf8");
98
+ const trivyFixture = readFileSync(join(FIXTURES_DIR, "trivy-with-kev-epss.json"), "utf8");
99
+
100
+ test("parseGrypeOutput carries KEV membership, dates, ransomware use, and EPSS through to the finding", () => {
101
+ const findings = parseGrypeOutput(grypeFixture);
102
+ const log4shell = findings.find((f) => f.epssPercentile === 1);
103
+ expect(log4shell).toBeDefined();
104
+ expect(log4shell!.inKev).toBe(true);
105
+ expect(log4shell!.kevDateAdded).toBe("2021-12-10");
106
+ expect(log4shell!.kevDueDate).toBe("2021-12-24");
107
+ expect(log4shell!.kevRansomware).toBe(true);
108
+ expect(log4shell!.epss).toBeCloseTo(0.99999, 5);
109
+ // The severity-shaped fields still parse as before on the same match.
110
+ expect(log4shell!.severity).toBe("critical");
111
+ expect(log4shell!.package).toBe("log4j-core");
112
+ expect(log4shell!.fixable).toBe(true);
113
+ });
114
+
115
+ test("grype omitting the KEV annotation leaves inKev undefined — reported-absent and not-reported are different states", () => {
116
+ const findings = parseGrypeOutput(grypeFixture);
117
+ const nonKev = findings.filter((f) => f.inKev === undefined);
118
+ expect(nonKev.length).toBeGreaterThan(0);
119
+ for (const f of nonKev) {
120
+ // undefined !== false: grype said nothing about KEV for these, which is
121
+ // not the same conclusion as grype reporting "not in KEV".
122
+ expect(f.inKev).not.toBe(false);
123
+ expect(f.kevDateAdded).toBeUndefined();
124
+ expect(f.kevDueDate).toBeUndefined();
125
+ expect(f.kevRansomware).toBeUndefined();
126
+ // EPSS is independent of KEV — grype scores these too.
127
+ expect(f.epss).toBeTypeOf("number");
128
+ }
129
+ });
130
+
131
+ test("parseTrivyOutput leaves every exploitability field undefined — trivy reports none of them", () => {
132
+ const findings = parseTrivyOutput(trivyFixture);
133
+ expect(findings.length).toBeGreaterThan(0);
134
+ const log4shell = findings.find((f) => f.cveId === "CVE-2021-44228");
135
+ expect(log4shell).toBeDefined();
136
+ expect(log4shell!.severity).toBe("critical");
137
+ for (const f of findings) {
138
+ expect(f.epss).toBeUndefined();
139
+ expect(f.epssPercentile).toBeUndefined();
140
+ expect(f.inKev).toBeUndefined();
141
+ expect(f.inKev).not.toBe(false);
142
+ expect(f.kevDateAdded).toBeUndefined();
143
+ expect(f.kevDueDate).toBeUndefined();
144
+ expect(f.kevRansomware).toBeUndefined();
145
+ }
146
+ });
147
+
148
+ test("a grype document with no exploitability data parses to findings with all six fields undefined", () => {
149
+ const findings = parseGrypeOutput(GRYPE_JSON);
150
+ for (const f of findings) {
151
+ expect(f.epss).toBeUndefined();
152
+ expect(f.epssPercentile).toBeUndefined();
153
+ expect(f.inKev).toBeUndefined();
154
+ expect(f.kevDateAdded).toBeUndefined();
155
+ expect(f.kevDueDate).toBeUndefined();
156
+ expect(f.kevRansomware).toBeUndefined();
157
+ }
158
+ });
159
+
160
+ test("KEV ransomware 'unknown' stays undefined, not false — KEV's tri-state survives", () => {
161
+ const doc = JSON.stringify({
162
+ matches: [
163
+ {
164
+ vulnerability: {
165
+ id: "CVE-2024-0004",
166
+ severity: "High",
167
+ fix: { versions: ["2.0"], state: "fixed" },
168
+ epss: [{ cve: "CVE-2024-0004", epss: 0.5, percentile: 0.9, date: "2026-08-01" }],
169
+ knownExploited: [{ cve: "CVE-2024-0004", dateAdded: "2026-01-01", dueDate: "2026-01-22", knownRansomwareCampaignUse: "unknown" }],
170
+ },
171
+ artifact: { name: "widget", version: "1.0" },
172
+ },
173
+ ],
174
+ });
175
+ const [f] = parseGrypeOutput(doc);
176
+ expect(f.inKev).toBe(true);
177
+ expect(f.kevRansomware).toBeUndefined();
178
+ });
179
+ });
180
+
87
181
  describe("createToolVulnScanner (grype, via MockProcessRunner)", () => {
88
182
  test("scans the SBOM with `grype sbom:<file>` and parses the result", async () => {
89
183
  const mock = createMockProcessRunner({ tools: { grype: true }, responses: { "grype sbom:": GRYPE_JSON } });