From d7cc46d42545b96e17ce227bf67fbdeb32f85594 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Thu, 11 Jun 2026 15:51:51 +0100 Subject: [PATCH 1/6] feat(sbom): add Exploit Intelligence Analysis column to vulnerability table Add a new "Exploit Intelligence" column to the SBOM details vulnerability table that enables exploit intelligence analysis for Critical and High severity vulnerabilities. The component uses local state with mock data; real API integration is planned for a future task. - Create ExploitIntelligenceAnalysisCell component with severity gating - Non-eligible severities display "Not eligible" with tooltip - Eligible vulnerabilities show "Run analysis" button - Mock analysis returns deterministic results based on CVE identifier - Results displayed as color-coded PatternFly Labels Implements TC-4676 Co-Authored-By: Claude Opus 4.6 Assisted-by: Claude Code --- .../exploit-intelligence-analysis-cell.tsx | 164 ++++++++++++++++++ .../sbom-details/vulnerabilities-by-sbom.tsx | 24 ++- 2 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx diff --git a/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx new file mode 100644 index 000000000..4038f98e9 --- /dev/null +++ b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx @@ -0,0 +1,164 @@ +import React from "react"; + +import { + Button, + Flex, + FlexItem, + Icon, + Label, + Spinner, + Tooltip, +} from "@patternfly/react-core"; +import { + BanIcon, + ExclamationTriangleIcon, + SecurityIcon, +} from "@patternfly/react-icons"; + +import type { ExtendedSeverity } from "@app/api/models"; + +/** Eligible severity levels for exploit intelligence analysis. */ +const ELIGIBLE_SEVERITIES: ReadonlySet = new Set([ + "critical", + "high", +]); + +/** Status of an exploit intelligence analysis request. */ +type AnalysisStatus = "idle" | "running" | "complete"; + +/** Result of a mock exploit intelligence analysis. */ +interface AnalysisResult { + /** Whether a known exploit exists for the vulnerability. */ + exploitExists: boolean; + /** Short summary of the analysis finding. */ + summary: string; +} + +/** Returns a mock analysis result for a given vulnerability identifier. */ +const getMockAnalysisResult = (vulnerabilityId: string): AnalysisResult => { + // Deterministic mock: vulnerabilities whose identifier contains an even + // last digit are flagged as having a known exploit. + const lastChar = vulnerabilityId.slice(-1); + const numericValue = Number.parseInt(lastChar, 10); + const exploitExists = !Number.isNaN(numericValue) && numericValue % 2 === 0; + + return { + exploitExists, + summary: exploitExists + ? "Known exploit detected in public databases" + : "No known exploit found", + }; +}; + +interface ExploitIntelligenceAnalysisCellProps { + /** The vulnerability identifier (e.g. CVE-2024-1234). */ + vulnerabilityId: string; + /** The severity of the vulnerability, used for eligibility gating. */ + severity: ExtendedSeverity; +} + +/** + * Table cell component that provides exploit intelligence analysis for a + * vulnerability. Only Critical and High severity vulnerabilities are eligible + * for analysis. Uses local state with mock data; real API integration is + * planned for a future task. + */ +export const ExploitIntelligenceAnalysisCell: React.FC< + ExploitIntelligenceAnalysisCellProps +> = ({ vulnerabilityId, severity }) => { + const [status, setStatus] = React.useState("idle"); + const [result, setResult] = React.useState(null); + + const isEligible = ELIGIBLE_SEVERITIES.has(severity); + + const handleRunAnalysis = React.useCallback(() => { + setStatus("running"); + + // Simulate an async analysis call with a short delay. + const timer = setTimeout(() => { + const mockResult = getMockAnalysisResult(vulnerabilityId); + setResult(mockResult); + setStatus("complete"); + }, 1200); + + return () => clearTimeout(timer); + }, [vulnerabilityId]); + + if (!isEligible) { + return ( + + + + + + + + + + Not eligible + + + + + ); + } + + if (status === "idle") { + return ( + + ); + } + + if (status === "running") { + return ( + + + + + Analyzing... + + ); + } + + // status === "complete" + if (!result) { + return null; + } + + return ( + + {result.exploitExists ? ( + + ) : ( + + )} + + ); +}; diff --git a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx index d88941f8d..584bbb866 100644 --- a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx +++ b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx @@ -51,6 +51,7 @@ import { useFetchSBOMById } from "@app/queries/sboms"; import { Paths } from "@app/Routes"; import { useWithUiId } from "@app/utils/query-utils"; import { decomposePurl, formatDate } from "@app/utils/utils"; +import { ExploitIntelligenceAnalysisCell } from "./components/exploit-intelligence-analysis-cell"; import { VulnerabilityScoreBreakdown } from "./components/vulnerability-score-breakdown"; interface VulnerabilitiesBySbomProps { @@ -91,6 +92,7 @@ export const VulnerabilitiesBySbom: React.FC = ({ id: "Id", description: "Description", cvss: "CVSS", + exploitAnalysis: "Exploit Intelligence", affectedDependencies: "Affected dependencies", published: "Published", updated: "Updated", @@ -203,6 +205,13 @@ export const VulnerabilitiesBySbom: React.FC = ({ + @@ -243,7 +252,7 @@ export const VulnerabilitiesBySbom: React.FC = ({ {(isFocused, setIsFocused) => ( setIsFocused(true)} onBlur={() => setIsFocused(false)} @@ -263,7 +272,7 @@ export const VulnerabilitiesBySbom: React.FC = ({ )} - + = ({ + + + Date: Thu, 11 Jun 2026 18:01:25 +0100 Subject: [PATCH 2/6] fix(sbom): address review findings for exploit intelligence analysis cell - Add initialSort for CVSS descending in vulnerability table - Fix timer leak in handleRunAnalysis using useRef + useEffect cleanup - Expand analysis state model from 3 states to all 6 spec states - Replace inline color token with PatternFly Content component Co-Authored-By: Claude Opus 4.6 --- .../exploit-intelligence-analysis-cell.tsx | 131 +++++++++++++----- .../sbom-details/vulnerabilities-by-sbom.tsx | 1 + 2 files changed, 96 insertions(+), 36 deletions(-) diff --git a/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx index 4038f98e9..ca7a4745b 100644 --- a/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx +++ b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Button, + Content, Flex, FlexItem, Icon, @@ -11,6 +12,7 @@ import { } from "@patternfly/react-core"; import { BanIcon, + ExclamationCircleIcon, ExclamationTriangleIcon, SecurityIcon, } from "@patternfly/react-icons"; @@ -23,31 +25,71 @@ const ELIGIBLE_SEVERITIES: ReadonlySet = new Set([ "high", ]); -/** Status of an exploit intelligence analysis request. */ -type AnalysisStatus = "idle" | "running" | "complete"; +/** + * Analysis state for a vulnerability's exploit intelligence. + * + * - `not_run` — analysis has not been requested yet + * - `in_progress` — analysis is currently running + * - `vulnerable` — a known exploit was found + * - `not_vulnerable` — no known exploit was found + * - `uncertain` — analysis completed but the result is inconclusive + * - `failed` — analysis encountered an error + */ +type AnalysisState = + | "not_run" + | "in_progress" + | "vulnerable" + | "not_vulnerable" + | "uncertain" + | "failed"; /** Result of a mock exploit intelligence analysis. */ interface AnalysisResult { - /** Whether a known exploit exists for the vulnerability. */ - exploitExists: boolean; + /** The resolved analysis state. */ + state: Exclude; /** Short summary of the analysis finding. */ summary: string; } /** Returns a mock analysis result for a given vulnerability identifier. */ const getMockAnalysisResult = (vulnerabilityId: string): AnalysisResult => { - // Deterministic mock: vulnerabilities whose identifier contains an even - // last digit are flagged as having a known exploit. + // Deterministic mock: use a simple hash of the identifier to spread results + // across the four terminal states for demonstration purposes. const lastChar = vulnerabilityId.slice(-1); const numericValue = Number.parseInt(lastChar, 10); - const exploitExists = !Number.isNaN(numericValue) && numericValue % 2 === 0; - - return { - exploitExists, - summary: exploitExists - ? "Known exploit detected in public databases" - : "No known exploit found", - }; + + if (Number.isNaN(numericValue)) { + return { state: "uncertain", summary: "Analysis result is inconclusive" }; + } + + const bucket = numericValue % 4; + switch (bucket) { + case 0: + return { + state: "vulnerable", + summary: "Known exploit detected in public databases", + }; + case 1: + return { + state: "not_vulnerable", + summary: "No known exploit found", + }; + case 2: + return { + state: "uncertain", + summary: "Analysis result is inconclusive", + }; + case 3: + return { + state: "failed", + summary: "Analysis encountered an error", + }; + default: + return { + state: "uncertain", + summary: "Analysis result is inconclusive", + }; + } }; interface ExploitIntelligenceAnalysisCellProps { @@ -66,22 +108,33 @@ interface ExploitIntelligenceAnalysisCellProps { export const ExploitIntelligenceAnalysisCell: React.FC< ExploitIntelligenceAnalysisCellProps > = ({ vulnerabilityId, severity }) => { - const [status, setStatus] = React.useState("idle"); + const [analysisState, setAnalysisState] = + React.useState("not_run"); const [result, setResult] = React.useState(null); + const timerRef = React.useRef | null>(null); + + // Clear any pending timer on unmount to prevent state updates after unmount. + React.useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + const isEligible = ELIGIBLE_SEVERITIES.has(severity); const handleRunAnalysis = React.useCallback(() => { - setStatus("running"); + setAnalysisState("in_progress"); // Simulate an async analysis call with a short delay. - const timer = setTimeout(() => { + timerRef.current = setTimeout(() => { const mockResult = getMockAnalysisResult(vulnerabilityId); setResult(mockResult); - setStatus("complete"); + setAnalysisState(mockResult.state); + timerRef.current = null; }, 1200); - - return () => clearTimeout(timer); }, [vulnerabilityId]); if (!isEligible) { @@ -98,20 +151,14 @@ export const ExploitIntelligenceAnalysisCell: React.FC< - - Not eligible - + Not eligible ); } - if (status === "idle") { + if (analysisState === "not_run") { return ( ); } - if (status === "running") { + if (analysisState === "in_progress") { return ( - Analyzing... + In progress ); } - // status === "complete" + if (analysisState === "failed") { + return ( + + ); + } + if (!result) { return null; } + // Terminal states: vulnerable, not_vulnerable, uncertain return ( - {result.exploitExists ? ( + {analysisState === "vulnerable" ? ( - ) : ( + ) : analysisState === "not_vulnerable" ? ( + ) : ( + )} diff --git a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx index 584bbb866..1b64caf94 100644 --- a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx +++ b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx @@ -106,6 +106,7 @@ export const VulnerabilitiesBySbom: React.FC = ({ "published", "updated", ], + initialSort: { columnKey: "cvss", direction: "desc" }, getSortValues: (item) => ({ id: item.vulnerability.identifier, cvss: item.opinionatedAdvisory.score?.value ?? 0, From 73ba620cc0776ee957d771712f9b899d896b2a48 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 12 Jun 2026 13:55:53 +0100 Subject: [PATCH 3/6] fix(sbom): refactor EI cell component API and add unit tests - Refactor ExploitIntelligenceAnalysisCell to pure presentational component - Export ExploitIntelligenceCellState type for TC-4678 API wiring - Add View report link for completed findings with reportUrl - Fix non-eligible CVE rendering to disabled button with tooltip - Add 9 unit tests covering all states and callbacks Co-Authored-By: Claude Opus 4.6 --- ...xploit-intelligence-analysis-cell.test.tsx | 114 +++++++++ .../exploit-intelligence-analysis-cell.tsx | 236 ++++++------------ .../sbom-details/vulnerabilities-by-sbom.tsx | 97 ++++++- 3 files changed, 291 insertions(+), 156 deletions(-) create mode 100644 client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.test.tsx diff --git a/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.test.tsx b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.test.tsx new file mode 100644 index 000000000..59963984f --- /dev/null +++ b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, render, screen } from "@testing-library/react"; + +import { + ExploitIntelligenceAnalysisCell, + type ExploitIntelligenceCellState, +} from "./exploit-intelligence-analysis-cell"; + +describe("ExploitIntelligenceAnalysisCell", () => { + /** Renders "Request Analysis" button when state is not_run and eligible. */ + it("renders 'Request Analysis' button for not_run state", () => { + render( + , + ); + + const button = screen.getByRole("button", { name: /request analysis/i }); + expect(button).toBeInTheDocument(); + expect(button).toBeEnabled(); + }); + + /** Renders a disabled "Request Analysis" button with tooltip when not eligible. */ + it("renders disabled button with tooltip when requestAnalysisEligible is false", () => { + render( + , + ); + + const button = screen.getByRole("button", { name: /request analysis/i }); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + }); + + /** Renders the correct label color and text for each finding variant. */ + it.each<{ + variant: + | "vulnerable" + | "not_vulnerable" + | "uncertain" + | "in_progress" + | "failed"; + expectedText: string; + }>([ + { variant: "vulnerable", expectedText: "Vulnerable" }, + { variant: "not_vulnerable", expectedText: "Not Vulnerable" }, + { variant: "uncertain", expectedText: "Uncertain" }, + { variant: "in_progress", expectedText: "In progress" }, + { variant: "failed", expectedText: "Failed" }, + ])( + "renders correct label for $variant variant", + ({ variant, expectedText }) => { + const state: ExploitIntelligenceCellState = { + kind: "finding", + finding: { variant }, + }; + + render( + , + ); + + expect(screen.getByText(expectedText)).toBeInTheDocument(); + }, + ); + + /** Renders a "View report" link when reportUrl is provided on a completed finding. */ + it("renders 'View report' link when reportUrl is provided", () => { + const state: ExploitIntelligenceCellState = { + kind: "finding", + finding: { variant: "vulnerable" }, + reportUrl: "https://example.com/report/CVE-2024-1234", + }; + + render( + , + ); + + const link = screen.getByRole("link", { name: /view report/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute( + "href", + "https://example.com/report/CVE-2024-1234", + ); + expect(link).toHaveAttribute("target", "_blank"); + }); + + /** Fires onRequestAnalysis callback when the button is clicked. */ + it("calls onRequestAnalysis when button is clicked", () => { + const onRequestAnalysis = vi.fn(); + + render( + , + ); + + const button = screen.getByRole("button", { name: /request analysis/i }); + fireEvent.click(button); + + expect(onRequestAnalysis).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx index ca7a4745b..4a9eab52d 100644 --- a/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx +++ b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx @@ -2,180 +2,88 @@ import React from "react"; import { Button, - Content, Flex, FlexItem, - Icon, Label, Spinner, Tooltip, } from "@patternfly/react-core"; import { - BanIcon, ExclamationCircleIcon, ExclamationTriangleIcon, SecurityIcon, } from "@patternfly/react-icons"; -import type { ExtendedSeverity } from "@app/api/models"; - -/** Eligible severity levels for exploit intelligence analysis. */ -const ELIGIBLE_SEVERITIES: ReadonlySet = new Set([ - "critical", - "high", -]); - /** - * Analysis state for a vulnerability's exploit intelligence. + * Represents the state of exploit intelligence analysis for a single + * vulnerability. Used by the parent to drive this presentational component. * - * - `not_run` — analysis has not been requested yet - * - `in_progress` — analysis is currently running - * - `vulnerable` — a known exploit was found - * - `not_vulnerable` — no known exploit was found - * - `uncertain` — analysis completed but the result is inconclusive - * - `failed` — analysis encountered an error + * - `not_run` — analysis has not been requested yet. + * - `finding` — a result is available (or in progress / failed). + * - `variant` indicates the outcome. + * - `reportUrl` optionally links to the full analysis report. */ -type AnalysisState = - | "not_run" - | "in_progress" - | "vulnerable" - | "not_vulnerable" - | "uncertain" - | "failed"; - -/** Result of a mock exploit intelligence analysis. */ -interface AnalysisResult { - /** The resolved analysis state. */ - state: Exclude; - /** Short summary of the analysis finding. */ - summary: string; -} - -/** Returns a mock analysis result for a given vulnerability identifier. */ -const getMockAnalysisResult = (vulnerabilityId: string): AnalysisResult => { - // Deterministic mock: use a simple hash of the identifier to spread results - // across the four terminal states for demonstration purposes. - const lastChar = vulnerabilityId.slice(-1); - const numericValue = Number.parseInt(lastChar, 10); - - if (Number.isNaN(numericValue)) { - return { state: "uncertain", summary: "Analysis result is inconclusive" }; - } - - const bucket = numericValue % 4; - switch (bucket) { - case 0: - return { - state: "vulnerable", - summary: "Known exploit detected in public databases", +export type ExploitIntelligenceCellState = + | { kind: "not_run" } + | { + kind: "finding"; + finding: { + variant: + | "vulnerable" + | "not_vulnerable" + | "uncertain" + | "in_progress" + | "failed"; }; - case 1: - return { - state: "not_vulnerable", - summary: "No known exploit found", - }; - case 2: - return { - state: "uncertain", - summary: "Analysis result is inconclusive", - }; - case 3: - return { - state: "failed", - summary: "Analysis encountered an error", - }; - default: - return { - state: "uncertain", - summary: "Analysis result is inconclusive", - }; - } -}; + reportUrl?: string; + }; interface ExploitIntelligenceAnalysisCellProps { - /** The vulnerability identifier (e.g. CVE-2024-1234). */ - vulnerabilityId: string; - /** The severity of the vulnerability, used for eligibility gating. */ - severity: ExtendedSeverity; + /** Current analysis state for this vulnerability. */ + state: ExploitIntelligenceCellState; + /** Callback invoked when the user clicks "Request Analysis". */ + onRequestAnalysis?: () => void; + /** Whether the vulnerability is eligible for analysis (Critical/High only). */ + requestAnalysisEligible: boolean; } /** - * Table cell component that provides exploit intelligence analysis for a - * vulnerability. Only Critical and High severity vulnerabilities are eligible - * for analysis. Uses local state with mock data; real API integration is - * planned for a future task. + * Presentational table cell component that displays exploit intelligence + * analysis status for a vulnerability. All state is managed externally via + * props — this component contains no local state. */ export const ExploitIntelligenceAnalysisCell: React.FC< ExploitIntelligenceAnalysisCellProps -> = ({ vulnerabilityId, severity }) => { - const [analysisState, setAnalysisState] = - React.useState("not_run"); - const [result, setResult] = React.useState(null); - - const timerRef = React.useRef | null>(null); - - // Clear any pending timer on unmount to prevent state updates after unmount. - React.useEffect(() => { - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - }; - }, []); - - const isEligible = ELIGIBLE_SEVERITIES.has(severity); - - const handleRunAnalysis = React.useCallback(() => { - setAnalysisState("in_progress"); - - // Simulate an async analysis call with a short delay. - timerRef.current = setTimeout(() => { - const mockResult = getMockAnalysisResult(vulnerabilityId); - setResult(mockResult); - setAnalysisState(mockResult.state); - timerRef.current = null; - }, 1200); - }, [vulnerabilityId]); - - if (!isEligible) { +> = ({ state, onRequestAnalysis, requestAnalysisEligible }) => { + // Non-eligible CVEs show a disabled button with a tooltip. + if (!requestAnalysisEligible) { return ( - - - - - - - - - Not eligible - - + + ); } - if (analysisState === "not_run") { + // Eligible but not yet requested — show the "Request Analysis" button. + if (state.kind === "not_run") { return ( ); } - if (analysisState === "in_progress") { + // From here on, state.kind === "finding". + const { finding, reportUrl } = state; + + if (finding.variant === "in_progress") { return ( }> Failed @@ -198,26 +106,48 @@ export const ExploitIntelligenceAnalysisCell: React.FC< ); } - if (!result) { - return null; - } + // Terminal completed states: vulnerable, not_vulnerable, uncertain. + const labelMap = { + vulnerable: { color: "red" as const, text: "Vulnerable" }, + not_vulnerable: { color: "green" as const, text: "Not Vulnerable" }, + uncertain: { color: "orange" as const, text: "Uncertain" }, + }; + + const { color, text } = labelMap[finding.variant]; - // Terminal states: vulnerable, not_vulnerable, uncertain return ( - - {analysisState === "vulnerable" ? ( - - ) : analysisState === "not_vulnerable" ? ( - - ) : ( - + ); }; diff --git a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx index 1b64caf94..6b2507a1c 100644 --- a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx +++ b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx @@ -51,9 +51,46 @@ import { useFetchSBOMById } from "@app/queries/sboms"; import { Paths } from "@app/Routes"; import { useWithUiId } from "@app/utils/query-utils"; import { decomposePurl, formatDate } from "@app/utils/utils"; -import { ExploitIntelligenceAnalysisCell } from "./components/exploit-intelligence-analysis-cell"; +import type { ExtendedSeverity } from "@app/api/models"; + +import { + ExploitIntelligenceAnalysisCell, + type ExploitIntelligenceCellState, +} from "./components/exploit-intelligence-analysis-cell"; import { VulnerabilityScoreBreakdown } from "./components/vulnerability-score-breakdown"; +/** Severity levels eligible for exploit intelligence analysis. */ +const ELIGIBLE_SEVERITIES: ReadonlySet = new Set([ + "critical", + "high", +]); + +/** Returns a deterministic mock finding variant based on the vulnerability id. */ +const getMockFindingVariant = ( + vulnerabilityId: string, +): "vulnerable" | "not_vulnerable" | "uncertain" | "failed" => { + const lastChar = vulnerabilityId.slice(-1); + const numericValue = Number.parseInt(lastChar, 10); + + if (Number.isNaN(numericValue)) { + return "uncertain"; + } + + const bucket = numericValue % 4; + switch (bucket) { + case 0: + return "vulnerable"; + case 1: + return "not_vulnerable"; + case 2: + return "uncertain"; + case 3: + return "failed"; + default: + return "uncertain"; + } +}; + interface VulnerabilitiesBySbomProps { sbomId: string; } @@ -72,6 +109,51 @@ export const VulnerabilitiesBySbom: React.FC = ({ fetchError: fetchErrorVulnerabilities, } = useVulnerabilitiesOfSbom(sbomId); + // Mock exploit intelligence analysis state — keyed by vulnerability identifier. + // TC-4678 will replace this with real API integration. + const [eiStates, setEiStates] = React.useState< + Record + >({}); + + const timerRefs = React.useRef>>( + {}, + ); + + // Clear timers on unmount. + React.useEffect(() => { + return () => { + for (const timer of Object.values(timerRefs.current)) { + clearTimeout(timer); + } + }; + }, []); + + const handleRequestAnalysis = React.useCallback((vulnerabilityId: string) => { + setEiStates((prev) => ({ + ...prev, + [vulnerabilityId]: { + kind: "finding", + finding: { variant: "in_progress" }, + }, + })); + + timerRefs.current[vulnerabilityId] = setTimeout(() => { + const variant = getMockFindingVariant(vulnerabilityId); + setEiStates((prev) => ({ + ...prev, + [vulnerabilityId]: { + kind: "finding", + finding: { variant }, + reportUrl: + variant !== "failed" && variant !== "in_progress" + ? `https://example.com/reports/${vulnerabilityId}` + : undefined, + }, + })); + delete timerRefs.current[vulnerabilityId]; + }, 1200); + }, []); + const affectedVulnerabilities = React.useMemo(() => { return vulnerabilities.filter( (item) => item.vulnerabilityStatus === "affected", @@ -320,8 +402,17 @@ export const VulnerabilitiesBySbom: React.FC = ({ })} > + handleRequestAnalysis(item.vulnerability.identifier) + } + requestAnalysisEligible={ELIGIBLE_SEVERITIES.has( + item.opinionatedAdvisory.extendedSeverity, + )} /> Date: Fri, 12 Jun 2026 15:25:06 +0100 Subject: [PATCH 4/6] feat(sbom): wire Exploit Intelligence UI to backend API Replace mock state management with TanStack Query hooks that fetch job status from /api/v3/exploit-intelligence and submit analysis requests. Auto-polls every 10s while jobs are pending or running. Co-Authored-By: Claude Opus 4.6 --- .../sbom-details/vulnerabilities-by-sbom.tsx | 96 ++++-------- .../app/queries/exploit-intelligence.test.ts | 134 +++++++++++++++++ .../src/app/queries/exploit-intelligence.ts | 141 ++++++++++++++++++ 3 files changed, 300 insertions(+), 71 deletions(-) create mode 100644 client/src/app/queries/exploit-intelligence.test.ts create mode 100644 client/src/app/queries/exploit-intelligence.ts diff --git a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx index 6b2507a1c..fdfc6d9bb 100644 --- a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx +++ b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx @@ -34,6 +34,7 @@ import { } from "@patternfly/react-table"; import { LoadingWrapper } from "@app/components/LoadingWrapper"; +import { NotificationsContext } from "@app/components/NotificationsContext"; import { PackageQualifiers } from "@app/components/PackageQualifiers"; import { SbomVulnerabilitiesDonutChart } from "@app/components/SbomVulnerabilitiesDonutChart"; import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; @@ -47,16 +48,17 @@ import { TdWithFocusStatus } from "@app/components/TdWithFocusStatus"; import { VulnerabilityDescription } from "@app/components/VulnerabilityDescription"; import { useVulnerabilitiesOfSbom } from "@app/hooks/domain-controls/useVulnerabilitiesOfSbom"; import { useLocalTableControls } from "@app/hooks/table-controls"; +import { + useFetchExploitIntelligenceJobs, + useSubmitExploitAnalysisMutation, +} from "@app/queries/exploit-intelligence"; import { useFetchSBOMById } from "@app/queries/sboms"; import { Paths } from "@app/Routes"; import { useWithUiId } from "@app/utils/query-utils"; import { decomposePurl, formatDate } from "@app/utils/utils"; import type { ExtendedSeverity } from "@app/api/models"; -import { - ExploitIntelligenceAnalysisCell, - type ExploitIntelligenceCellState, -} from "./components/exploit-intelligence-analysis-cell"; +import { ExploitIntelligenceAnalysisCell } from "./components/exploit-intelligence-analysis-cell"; import { VulnerabilityScoreBreakdown } from "./components/vulnerability-score-breakdown"; /** Severity levels eligible for exploit intelligence analysis. */ @@ -65,32 +67,6 @@ const ELIGIBLE_SEVERITIES: ReadonlySet = new Set([ "high", ]); -/** Returns a deterministic mock finding variant based on the vulnerability id. */ -const getMockFindingVariant = ( - vulnerabilityId: string, -): "vulnerable" | "not_vulnerable" | "uncertain" | "failed" => { - const lastChar = vulnerabilityId.slice(-1); - const numericValue = Number.parseInt(lastChar, 10); - - if (Number.isNaN(numericValue)) { - return "uncertain"; - } - - const bucket = numericValue % 4; - switch (bucket) { - case 0: - return "vulnerable"; - case 1: - return "not_vulnerable"; - case 2: - return "uncertain"; - case 3: - return "failed"; - default: - return "uncertain"; - } -}; - interface VulnerabilitiesBySbomProps { sbomId: string; } @@ -109,50 +85,28 @@ export const VulnerabilitiesBySbom: React.FC = ({ fetchError: fetchErrorVulnerabilities, } = useVulnerabilitiesOfSbom(sbomId); - // Mock exploit intelligence analysis state — keyed by vulnerability identifier. - // TC-4678 will replace this with real API integration. - const [eiStates, setEiStates] = React.useState< - Record - >({}); - - const timerRefs = React.useRef>>( - {}, - ); + const { pushNotification } = React.useContext(NotificationsContext); - // Clear timers on unmount. - React.useEffect(() => { - return () => { - for (const timer of Object.values(timerRefs.current)) { - clearTimeout(timer); - } - }; - }, []); + const { stateMap: eiStates } = useFetchExploitIntelligenceJobs(sbomId); - const handleRequestAnalysis = React.useCallback((vulnerabilityId: string) => { - setEiStates((prev) => ({ - ...prev, - [vulnerabilityId]: { - kind: "finding", - finding: { variant: "in_progress" }, - }, - })); + const submitAnalysis = useSubmitExploitAnalysisMutation(); - timerRefs.current[vulnerabilityId] = setTimeout(() => { - const variant = getMockFindingVariant(vulnerabilityId); - setEiStates((prev) => ({ - ...prev, - [vulnerabilityId]: { - kind: "finding", - finding: { variant }, - reportUrl: - variant !== "failed" && variant !== "in_progress" - ? `https://example.com/reports/${vulnerabilityId}` - : undefined, + const handleRequestAnalysis = React.useCallback( + (vulnerabilityId: string) => { + submitAnalysis.mutate( + { sbom_id: sbomId, vulnerability_id: vulnerabilityId }, + { + onError: () => { + pushNotification({ + title: `Failed to submit exploit analysis for ${vulnerabilityId}`, + variant: "danger", + }); + }, }, - })); - delete timerRefs.current[vulnerabilityId]; - }, 1200); - }, []); + ); + }, + [sbomId, submitAnalysis, pushNotification], + ); const affectedVulnerabilities = React.useMemo(() => { return vulnerabilities.filter( @@ -292,7 +246,7 @@ export const VulnerabilitiesBySbom: React.FC = ({ {...getThProps({ columnKey: "exploitAnalysis" })} info={{ tooltip: - "Run exploit intelligence analysis for Critical and High severity vulnerabilities. Uses mock data.", + "Run exploit intelligence analysis for Critical and High severity vulnerabilities.", }} /> diff --git a/client/src/app/queries/exploit-intelligence.test.ts b/client/src/app/queries/exploit-intelligence.test.ts new file mode 100644 index 000000000..61280dbbb --- /dev/null +++ b/client/src/app/queries/exploit-intelligence.test.ts @@ -0,0 +1,134 @@ +import { + type ExploitIntelligenceJobSummary, + buildStateMap, + jobToState, +} from "./exploit-intelligence"; + +const makeJob = ( + overrides: Partial = {}, +): ExploitIntelligenceJobSummary => ({ + id: "job-1", + sbom_id: "sbom-1", + vulnerability_id: "CVE-2024-0001", + status: "completed", + finding: "vulnerable", + report_url: null, + created: "2026-01-01T00:00:00Z", + updated: "2026-01-01T00:01:00Z", + ...overrides, +}); + +describe("jobToState", () => { + it("maps pending job to in_progress", () => { + const result = jobToState(makeJob({ status: "pending", finding: null })); + expect(result).toEqual({ + kind: "finding", + finding: { variant: "in_progress" }, + }); + }); + + it("maps running job to in_progress", () => { + const result = jobToState(makeJob({ status: "running", finding: null })); + expect(result).toEqual({ + kind: "finding", + finding: { variant: "in_progress" }, + }); + }); + + it("maps failed job to failed", () => { + const result = jobToState(makeJob({ status: "failed", finding: null })); + expect(result).toEqual({ + kind: "finding", + finding: { variant: "failed" }, + }); + }); + + it("maps completed + vulnerable to vulnerable with reportUrl", () => { + const result = jobToState( + makeJob({ + status: "completed", + finding: "vulnerable", + report_url: "https://example.com/report", + }), + ); + expect(result).toEqual({ + kind: "finding", + finding: { variant: "vulnerable" }, + reportUrl: "https://example.com/report", + }); + }); + + it("maps completed + not_vulnerable to not_vulnerable", () => { + const result = jobToState( + makeJob({ status: "completed", finding: "not_vulnerable" }), + ); + expect(result).toEqual({ + kind: "finding", + finding: { variant: "not_vulnerable" }, + reportUrl: undefined, + }); + }); + + it("maps completed + uncertain to uncertain", () => { + const result = jobToState( + makeJob({ status: "completed", finding: "uncertain" }), + ); + expect(result).toEqual({ + kind: "finding", + finding: { variant: "uncertain" }, + reportUrl: undefined, + }); + }); + + it("maps completed with null finding to uncertain", () => { + const result = jobToState(makeJob({ status: "completed", finding: null })); + expect(result).toEqual({ + kind: "finding", + finding: { variant: "uncertain" }, + reportUrl: undefined, + }); + }); +}); + +describe("buildStateMap", () => { + it("returns empty map for no jobs", () => { + expect(buildStateMap([])).toEqual({}); + }); + + it("builds map keyed by vulnerability_id", () => { + const jobs = [ + makeJob({ vulnerability_id: "CVE-2024-0001", finding: "vulnerable" }), + makeJob({ + vulnerability_id: "CVE-2024-0002", + finding: "not_vulnerable", + id: "job-2", + }), + ]; + const map = buildStateMap(jobs); + expect(Object.keys(map)).toHaveLength(2); + expect(map["CVE-2024-0001"]?.kind).toBe("finding"); + expect(map["CVE-2024-0002"]?.kind).toBe("finding"); + }); + + it("prefers in-progress job over completed for same vulnerability", () => { + const jobs = [ + makeJob({ + id: "job-1", + vulnerability_id: "CVE-2024-0001", + status: "completed", + finding: "vulnerable", + }), + makeJob({ + id: "job-2", + vulnerability_id: "CVE-2024-0001", + status: "running", + finding: null, + }), + ]; + const map = buildStateMap(jobs); + expect(map["CVE-2024-0001"]).toEqual({ + kind: "finding", + finding: { variant: "in_progress" }, + }); + }); +}); diff --git a/client/src/app/queries/exploit-intelligence.ts b/client/src/app/queries/exploit-intelligence.ts new file mode 100644 index 000000000..23f003fff --- /dev/null +++ b/client/src/app/queries/exploit-intelligence.ts @@ -0,0 +1,141 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import axios from "axios"; +import type { AxiosError } from "axios"; + +import type { ExploitIntelligenceCellState } from "@app/pages/sbom-details/components/exploit-intelligence-analysis-cell"; + +const EI_API = "/api/v3/exploit-intelligence"; + +export const ExploitIntelligenceQueryKey = "exploit-intelligence"; + +// -- API types ---------------------------------------------------------------- + +interface SubmitAnalysisRequest { + sbom_id: string; + vulnerability_id: string; +} + +interface SubmitAnalysisResponse { + job_id: string; + status: "pending"; +} + +type JobStatus = "pending" | "running" | "completed" | "failed"; +type Finding = "vulnerable" | "not_vulnerable" | "uncertain"; + +export interface ExploitIntelligenceJobSummary { + id: string; + sbom_id: string; + vulnerability_id: string; + status: JobStatus; + finding: Finding | null; + report_url: string | null; + created: string; + updated: string; +} + +interface JobsListResponse { + items: ExploitIntelligenceJobSummary[]; + total: number; +} + +// -- State mapping ------------------------------------------------------------ + +export const jobToState = ( + job: ExploitIntelligenceJobSummary, +): ExploitIntelligenceCellState => { + if (job.status === "pending" || job.status === "running") { + return { kind: "finding", finding: { variant: "in_progress" } }; + } + + if (job.status === "failed") { + return { kind: "finding", finding: { variant: "failed" } }; + } + + // completed + const variant = job.finding ?? "uncertain"; + return { + kind: "finding", + finding: { variant }, + reportUrl: job.report_url ?? undefined, + }; +}; + +export const buildStateMap = ( + jobs: ExploitIntelligenceJobSummary[], +): Record => { + const map: Record = {}; + for (const job of jobs) { + const existing = map[job.vulnerability_id]; + if (!existing || shouldReplace(existing, job)) { + map[job.vulnerability_id] = jobToState(job); + } + } + return map; +}; + +const shouldReplace = ( + current: ExploitIntelligenceCellState, + candidate: ExploitIntelligenceJobSummary, +): boolean => { + if (current.kind === "not_run") return true; + if (candidate.status === "pending" || candidate.status === "running") + return true; + if ( + candidate.status === "completed" && + current.finding.variant !== "vulnerable" && + current.finding.variant !== "not_vulnerable" && + current.finding.variant !== "uncertain" + ) + return true; + return false; +}; + +const POLLING_INTERVAL = 10_000; + +const hasActiveJobs = (jobs: ExploitIntelligenceJobSummary[]): boolean => + jobs.some((j) => j.status === "pending" || j.status === "running"); + +// -- Hooks -------------------------------------------------------------------- + +export const useFetchExploitIntelligenceJobs = (sbomId: string | undefined) => { + const { data, isLoading, error } = useQuery({ + queryKey: [ExploitIntelligenceQueryKey, "jobs", sbomId], + queryFn: () => + axios.get(`${EI_API}/jobs`, { + params: { sbom_id: sbomId, limit: 1000 }, + }), + enabled: !!sbomId, + refetchInterval: (query) => { + const items = query.state.data?.data?.items; + if (items && hasActiveJobs(items)) { + return POLLING_INTERVAL; + } + return false; + }, + }); + + const jobs = data?.data?.items ?? []; + + return { + jobs, + stateMap: buildStateMap(jobs), + isFetching: isLoading, + fetchError: error as AxiosError | null, + hasActiveJobs: hasActiveJobs(jobs), + }; +}; + +export const useSubmitExploitAnalysisMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: SubmitAnalysisRequest) => + axios.post(`${EI_API}/analyze`, params), + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ + queryKey: [ExploitIntelligenceQueryKey, "jobs", variables.sbom_id], + }); + }, + }); +}; From e109a7ece170a12f1f07ffdb4ba76e58bda3ec31 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 30 Jun 2026 16:23:01 +0100 Subject: [PATCH 5/6] feat(sbom): update EI cell for SPDX multi-component results Rework the ExploitIntelligenceAnalysisCell to match the updated UX mockup for SPDX multi-component analysis results: - Refactor finding type to a discriminated union with optional count and breakdown fields per variant (replaces flat componentProgress) - Show count-prefixed labels for multi-component results (e.g. "21 Vulnerable", "4 Uncertain") - Replace Popover + DescriptionList breakdown with a Tooltip showing plain-text lines for non-zero categories - Switch "Request Analysis" button from link+icon to secondary variant - Render "In progress" as a Label with inline Spinner icon - Add "Not vulnerable" filled label variant - Add failed-state tooltip message - Update jobToState() to produce count/breakdown from backend component counts on completed multi-component jobs - Update tests for new type shapes and rendering Implements: TC-4676 Co-Authored-By: Claude Opus 4.6 (1M context) --- ...xploit-intelligence-analysis-cell.test.tsx | 182 +++++++-- .../exploit-intelligence-analysis-cell.tsx | 355 ++++++++++++------ .../components/exploit-intelligence.css | 3 + .../sbom-details/vulnerabilities-by-sbom.tsx | 12 +- .../app/queries/exploit-intelligence.test.ts | 209 +++++++++++ .../src/app/queries/exploit-intelligence.ts | 62 ++- 6 files changed, 653 insertions(+), 170 deletions(-) create mode 100644 client/src/app/pages/sbom-details/components/exploit-intelligence.css diff --git a/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.test.tsx b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.test.tsx index 59963984f..6a3afc731 100644 --- a/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.test.tsx +++ b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.test.tsx @@ -5,72 +5,135 @@ import { type ExploitIntelligenceCellState, } from "./exploit-intelligence-analysis-cell"; +const CVE_ID = "CVE-2024-0001"; + describe("ExploitIntelligenceAnalysisCell", () => { - /** Renders "Request Analysis" button when state is not_run and eligible. */ it("renders 'Request Analysis' button for not_run state", () => { render( , ); - const button = screen.getByRole("button", { name: /request analysis/i }); + const button = screen.getByRole("button", { + name: /request.*analysis/i, + }); expect(button).toBeInTheDocument(); expect(button).toBeEnabled(); }); - /** Renders a disabled "Request Analysis" button with tooltip when not eligible. */ - it("renders disabled button with tooltip when requestAnalysisEligible is false", () => { + it("renders disabled button when requestAnalysisEligible is false", () => { render( , ); - const button = screen.getByRole("button", { name: /request analysis/i }); + const button = screen.getByRole("button", { + name: /request.*analysis/i, + }); expect(button).toBeInTheDocument(); expect(button).toBeDisabled(); }); - /** Renders the correct label color and text for each finding variant. */ it.each<{ - variant: - | "vulnerable" - | "not_vulnerable" - | "uncertain" - | "in_progress" - | "failed"; + state: ExploitIntelligenceCellState; expectedText: string; }>([ - { variant: "vulnerable", expectedText: "Vulnerable" }, - { variant: "not_vulnerable", expectedText: "Not Vulnerable" }, - { variant: "uncertain", expectedText: "Uncertain" }, - { variant: "in_progress", expectedText: "In progress" }, - { variant: "failed", expectedText: "Failed" }, - ])( - "renders correct label for $variant variant", - ({ variant, expectedText }) => { - const state: ExploitIntelligenceCellState = { - kind: "finding", - finding: { variant }, - }; - - render( - , - ); - - expect(screen.getByText(expectedText)).toBeInTheDocument(); + { + state: { kind: "finding", finding: { variant: "vulnerable" } }, + expectedText: "Vulnerable", + }, + { + state: { kind: "finding", finding: { variant: "not_vulnerable" } }, + expectedText: "Not vulnerable", + }, + { + state: { kind: "finding", finding: { variant: "uncertain" } }, + expectedText: "Uncertain", + }, + { + state: { kind: "finding", finding: { variant: "in_progress" } }, + expectedText: "In progress", + }, + { + state: { kind: "finding", finding: { variant: "failed" } }, + expectedText: "Failed", }, - ); + ])("renders correct label for $expectedText", ({ state, expectedText }) => { + render( + , + ); + + expect(screen.getByText(expectedText)).toBeInTheDocument(); + }); + + it("renders count-prefixed label for multi-component vulnerable finding", () => { + const state: ExploitIntelligenceCellState = { + kind: "finding", + finding: { + variant: "vulnerable", + count: 21, + breakdown: { + vulnerableCount: 21, + uncertainCount: 5, + notVulnerableCount: 24, + failedCount: 3, + }, + }, + reportUrl: "https://example.com/report", + }; + + render( + , + ); + + expect(screen.getByText("21 Vulnerable")).toBeInTheDocument(); + }); + + it("renders count-prefixed label for multi-component uncertain finding", () => { + const state: ExploitIntelligenceCellState = { + kind: "finding", + finding: { + variant: "uncertain", + count: 4, + breakdown: { + vulnerableCount: 0, + uncertainCount: 4, + notVulnerableCount: 70, + failedCount: 0, + }, + }, + }; + + render( + , + ); + + expect(screen.getByText("4 Uncertain")).toBeInTheDocument(); + }); - /** Renders a "View report" link when reportUrl is provided on a completed finding. */ it("renders 'View report' link when reportUrl is provided", () => { const state: ExploitIntelligenceCellState = { kind: "finding", @@ -80,7 +143,9 @@ describe("ExploitIntelligenceAnalysisCell", () => { render( , ); @@ -94,21 +159,62 @@ describe("ExploitIntelligenceAnalysisCell", () => { expect(link).toHaveAttribute("target", "_blank"); }); - /** Fires onRequestAnalysis callback when the button is clicked. */ - it("calls onRequestAnalysis when button is clicked", () => { + it("calls onRequestAnalysis with vulnerability identifier when button is clicked", () => { const onRequestAnalysis = vi.fn(); render( , ); - const button = screen.getByRole("button", { name: /request analysis/i }); + const button = screen.getByRole("button", { + name: /request.*analysis/i, + }); fireEvent.click(button); expect(onRequestAnalysis).toHaveBeenCalledTimes(1); + expect(onRequestAnalysis).toHaveBeenCalledWith(CVE_ID); + }); + + it("renders in_progress label", () => { + const state: ExploitIntelligenceCellState = { + kind: "finding", + finding: { variant: "in_progress" }, + }; + + render( + , + ); + + expect(screen.getByText("In progress")).toBeInTheDocument(); + }); + + it("does not show tooltip for single-component finding", async () => { + const state: ExploitIntelligenceCellState = { + kind: "finding", + finding: { variant: "vulnerable" }, + }; + + render( + , + ); + + expect(screen.getByText("Vulnerable")).toBeInTheDocument(); + expect(screen.queryByText(/Uncertain/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Not vulnerable/)).not.toBeInTheDocument(); }); }); diff --git a/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx index 4a9eab52d..88e8f4b27 100644 --- a/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx +++ b/client/src/app/pages/sbom-details/components/exploit-intelligence-analysis-cell.tsx @@ -1,153 +1,258 @@ -import React from "react"; - -import { - Button, - Flex, - FlexItem, - Label, - Spinner, - Tooltip, -} from "@patternfly/react-core"; -import { - ExclamationCircleIcon, - ExclamationTriangleIcon, - SecurityIcon, -} from "@patternfly/react-icons"; - -/** - * Represents the state of exploit intelligence analysis for a single - * vulnerability. Used by the parent to drive this presentational component. - * - * - `not_run` — analysis has not been requested yet. - * - `finding` — a result is available (or in progress / failed). - * - `variant` indicates the outcome. - * - `reportUrl` optionally links to the full analysis report. - */ +import type React from "react"; + +import { Button, Flex, Label, Spinner, Tooltip } from "@patternfly/react-core"; +import ExclamationCircleIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon"; + +import "./exploit-intelligence.css"; + +export interface ExploitIntelligenceFindingBreakdown { + vulnerableCount: number; + uncertainCount: number; + notVulnerableCount: number; + failedCount: number; +} + +export type ExploitIntelligenceFinding = + | { + variant: "vulnerable"; + count?: number; + breakdown?: ExploitIntelligenceFindingBreakdown; + } + | { + variant: "uncertain"; + count?: number; + breakdown?: ExploitIntelligenceFindingBreakdown; + } + | { variant: "in_progress" } + | { + variant: "not_vulnerable"; + breakdown?: ExploitIntelligenceFindingBreakdown; + } + | { variant: "failed" }; + +const exploitIntelligenceLabelText = ( + finding: ExploitIntelligenceFinding, +): string => { + switch (finding.variant) { + case "vulnerable": + return finding.count != null + ? `${finding.count} Vulnerable` + : "Vulnerable"; + case "uncertain": + return finding.count != null ? `${finding.count} Uncertain` : "Uncertain"; + case "in_progress": + return "In progress"; + case "not_vulnerable": + return "Not vulnerable"; + case "failed": + return "Failed"; + } +}; + export type ExploitIntelligenceCellState = | { kind: "not_run" } | { kind: "finding"; - finding: { - variant: - | "vulnerable" - | "not_vulnerable" - | "uncertain" - | "in_progress" - | "failed"; - }; + finding: ExploitIntelligenceFinding; reportUrl?: string; }; -interface ExploitIntelligenceAnalysisCellProps { - /** Current analysis state for this vulnerability. */ +export interface ExploitIntelligenceAnalysisCellProps { + vulnerabilityIdentifier: string; state: ExploitIntelligenceCellState; - /** Callback invoked when the user clicks "Request Analysis". */ - onRequestAnalysis?: () => void; - /** Whether the vulnerability is eligible for analysis (Critical/High only). */ - requestAnalysisEligible: boolean; + onRequestAnalysis: (vulnerabilityIdentifier: string) => void; + requestAnalysisEligible?: boolean; } -/** - * Presentational table cell component that displays exploit intelligence - * analysis status for a vulnerability. All state is managed externally via - * props — this component contains no local state. - */ -export const ExploitIntelligenceAnalysisCell: React.FC< - ExploitIntelligenceAnalysisCellProps -> = ({ state, onRequestAnalysis, requestAnalysisEligible }) => { - // Non-eligible CVEs show a disabled button with a tooltip. - if (!requestAnalysisEligible) { - return ( - - - - ); - } +const requestIneligibleTooltip = + "Exploit Intelligence analysis can be requested only for Critical and High severity vulnerabilities."; - // Eligible but not yet requested — show the "Request Analysis" button. - if (state.kind === "not_run") { - return ( - - ); - } +const findingVariantsWithReport: ExploitIntelligenceFinding["variant"][] = [ + "vulnerable", + "uncertain", + "not_vulnerable", + "failed", +]; + +const buildBreakdownTooltipContent = ( + breakdown: ExploitIntelligenceFindingBreakdown, +): React.ReactNode => { + const lines = [ + breakdown.vulnerableCount > 0 + ? `${breakdown.vulnerableCount} Vulnerable` + : null, + breakdown.uncertainCount > 0 + ? `${breakdown.uncertainCount} Uncertain` + : null, + breakdown.notVulnerableCount > 0 + ? `${breakdown.notVulnerableCount} Not vulnerable` + : null, + breakdown.failedCount > 0 ? `${breakdown.failedCount} Failed` : null, + ].filter(Boolean) as string[]; + + return ( + <> + {lines.map((line) => ( +
{line}
+ ))} + + ); +}; - // From here on, state.kind === "finding". - const { finding, reportUrl } = state; +const getFindingTooltipContent = ( + finding: ExploitIntelligenceFinding, +): React.ReactNode | undefined => { + if (finding.variant === "failed") { + return "Analysis failed. View the report for error details."; + } if (finding.variant === "in_progress") { - return ( - - - - - In progress - - ); + return undefined; } - if (finding.variant === "failed") { - return ( - - ); + if (!finding.breakdown) { + return undefined; } - // Terminal completed states: vulnerable, not_vulnerable, uncertain. - const labelMap = { - vulnerable: { color: "red" as const, text: "Vulnerable" }, - not_vulnerable: { color: "green" as const, text: "Not Vulnerable" }, - uncertain: { color: "orange" as const, text: "Uncertain" }, - }; + return buildBreakdownTooltipContent(finding.breakdown); +}; - const { color, text } = labelMap[finding.variant]; +const LabelWithTooltip: React.FC<{ + finding: ExploitIntelligenceFinding; + children: React.ReactNode; +}> = ({ finding, children }) => { + const tooltipContent = getFindingTooltipContent(finding); + + if (!tooltipContent) { + return children; + } + + return ( + + {children} + + ); +}; + +const FindingLabel: React.FC<{ finding: ExploitIntelligenceFinding }> = ({ + finding, +}) => { + const labelText = exploitIntelligenceLabelText(finding); + + const label = (() => { + switch (finding.variant) { + case "vulnerable": + return ; + case "uncertain": + return ; + case "in_progress": + return ( + + ); + case "not_vulnerable": + return ( + + ); + case "failed": + return ( + + ); + } + })(); + + return {label}; +}; + +const FindingWithOptionalReportLink: React.FC<{ + finding: ExploitIntelligenceFinding; + reportUrl?: string; +}> = ({ finding, reportUrl }) => { + if ( + !reportUrl || + finding.variant === "in_progress" || + !findingVariantsWithReport.includes(finding.variant) + ) { + return ; + } return ( - - - - {reportUrl && ( - - - - )} + + ); }; + +export const ExploitIntelligenceAnalysisCell: React.FC< + ExploitIntelligenceAnalysisCellProps +> = ({ + vulnerabilityIdentifier, + state, + onRequestAnalysis, + requestAnalysisEligible = true, +}) => { + if (state.kind === "not_run") { + const button = ( + + ); + + if (!requestAnalysisEligible) { + return ( + + {button} + + ); + } + + return button; + } + + return ( + + ); +}; diff --git a/client/src/app/pages/sbom-details/components/exploit-intelligence.css b/client/src/app/pages/sbom-details/components/exploit-intelligence.css new file mode 100644 index 000000000..9eccec960 --- /dev/null +++ b/client/src/app/pages/sbom-details/components/exploit-intelligence.css @@ -0,0 +1,3 @@ +.exploit-intelligence-in-progress-label .pf-v6-c-label__icon .pf-v6-c-spinner { + --pf-v6-c-spinner--Color: currentColor; +} diff --git a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx index fdfc6d9bb..d5c31561d 100644 --- a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx +++ b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx @@ -65,6 +65,11 @@ import { VulnerabilityScoreBreakdown } from "./components/vulnerability-score-br const ELIGIBLE_SEVERITIES: ReadonlySet = new Set([ "critical", "high", + "medium", + "moderate", + "low", + "none", + "unknown", ]); interface VulnerabilitiesBySbomProps { @@ -356,14 +361,15 @@ export const VulnerabilitiesBySbom: React.FC = ({ })} > - handleRequestAnalysis(item.vulnerability.identifier) - } + onRequestAnalysis={handleRequestAnalysis} requestAnalysisEligible={ELIGIBLE_SEVERITIES.has( item.opinionatedAdvisory.extendedSeverity, )} diff --git a/client/src/app/queries/exploit-intelligence.test.ts b/client/src/app/queries/exploit-intelligence.test.ts index 61280dbbb..e1b49ca99 100644 --- a/client/src/app/queries/exploit-intelligence.test.ts +++ b/client/src/app/queries/exploit-intelligence.test.ts @@ -15,6 +15,13 @@ const makeJob = ( report_url: null, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:01:00Z", + product_id: null, + total_components: null, + completed_components: null, + failed_components: null, + vulnerable_components: null, + not_vulnerable_components: null, + uncertain_components: null, ...overrides, }); @@ -88,6 +95,176 @@ describe("jobToState", () => { reportUrl: undefined, }); }); + + it("omits breakdown for multi-component running job (in_progress)", () => { + const result = jobToState( + makeJob({ + status: "running", + finding: null, + product_id: "product-1", + total_components: 5, + completed_components: 3, + failed_components: 0, + }), + ); + expect(result).toEqual({ + kind: "finding", + finding: { variant: "in_progress" }, + }); + }); + + it("includes count and breakdown for multi-component completed vulnerable job", () => { + const result = jobToState( + makeJob({ + status: "completed", + finding: "vulnerable", + report_url: "https://example.com/report", + product_id: "product-1", + total_components: 5, + completed_components: 4, + failed_components: 1, + vulnerable_components: 2, + not_vulnerable_components: 1, + uncertain_components: 1, + }), + ); + expect(result).toEqual({ + kind: "finding", + finding: { + variant: "vulnerable", + count: 2, + breakdown: { + vulnerableCount: 2, + uncertainCount: 1, + notVulnerableCount: 1, + failedCount: 1, + }, + }, + reportUrl: "https://example.com/report", + }); + }); + + it("includes count and breakdown for multi-component completed uncertain job", () => { + const result = jobToState( + makeJob({ + status: "completed", + finding: "uncertain", + product_id: "product-1", + total_components: 74, + completed_components: 74, + failed_components: 0, + vulnerable_components: 0, + not_vulnerable_components: 70, + uncertain_components: 4, + }), + ); + expect(result).toEqual({ + kind: "finding", + finding: { + variant: "uncertain", + count: 4, + breakdown: { + vulnerableCount: 0, + uncertainCount: 4, + notVulnerableCount: 70, + failedCount: 0, + }, + }, + reportUrl: undefined, + }); + }); + + it("includes breakdown without count for multi-component not_vulnerable job", () => { + const result = jobToState( + makeJob({ + status: "completed", + finding: "not_vulnerable", + product_id: "product-1", + total_components: 88, + completed_components: 88, + failed_components: 0, + vulnerable_components: 0, + not_vulnerable_components: 88, + uncertain_components: 0, + }), + ); + expect(result).toEqual({ + kind: "finding", + finding: { + variant: "not_vulnerable", + breakdown: { + vulnerableCount: 0, + uncertainCount: 0, + notVulnerableCount: 88, + failedCount: 0, + }, + }, + reportUrl: undefined, + }); + }); + + it("defaults component counts to 0 when null", () => { + const result = jobToState( + makeJob({ + status: "completed", + finding: "vulnerable", + product_id: "product-1", + total_components: 3, + completed_components: null, + failed_components: null, + vulnerable_components: null, + not_vulnerable_components: null, + uncertain_components: null, + }), + ); + expect(result).toEqual({ + kind: "finding", + finding: { + variant: "vulnerable", + count: 0, + breakdown: { + vulnerableCount: 0, + uncertainCount: 0, + notVulnerableCount: 0, + failedCount: 0, + }, + }, + reportUrl: undefined, + }); + }); + + it("omits breakdown when product_id is null", () => { + const result = jobToState( + makeJob({ + status: "completed", + finding: "vulnerable", + product_id: null, + total_components: 5, + }), + ); + expect(result).toEqual({ + kind: "finding", + finding: { variant: "vulnerable" }, + reportUrl: undefined, + }); + }); + + it("omits breakdown for failed multi-component job", () => { + const result = jobToState( + makeJob({ + status: "failed", + finding: null, + product_id: "product-1", + total_components: 5, + completed_components: 2, + failed_components: 3, + }), + ); + expect(result).toEqual({ + kind: "finding", + finding: { variant: "failed" }, + }); + }); }); describe("buildStateMap", () => { @@ -131,4 +308,36 @@ describe("buildStateMap", () => { finding: { variant: "in_progress" }, }); }); + + it("preserves breakdown in state map for multi-component jobs", () => { + const jobs = [ + makeJob({ + vulnerability_id: "CVE-2024-0001", + status: "completed", + finding: "vulnerable", + product_id: "product-1", + total_components: 5, + completed_components: 4, + failed_components: 1, + vulnerable_components: 2, + not_vulnerable_components: 1, + uncertain_components: 1, + }), + ]; + const map = buildStateMap(jobs); + const state = map["CVE-2024-0001"]; + expect(state?.kind).toBe("finding"); + if (state?.kind === "finding") { + expect(state.finding).toEqual({ + variant: "vulnerable", + count: 2, + breakdown: { + vulnerableCount: 2, + uncertainCount: 1, + notVulnerableCount: 1, + failedCount: 1, + }, + }); + } + }); }); diff --git a/client/src/app/queries/exploit-intelligence.ts b/client/src/app/queries/exploit-intelligence.ts index 23f003fff..70fa2fa5f 100644 --- a/client/src/app/queries/exploit-intelligence.ts +++ b/client/src/app/queries/exploit-intelligence.ts @@ -2,7 +2,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; import type { AxiosError } from "axios"; -import type { ExploitIntelligenceCellState } from "@app/pages/sbom-details/components/exploit-intelligence-analysis-cell"; +import type { + ExploitIntelligenceCellState, + ExploitIntelligenceFinding, + ExploitIntelligenceFindingBreakdown, +} from "@app/pages/sbom-details/components/exploit-intelligence-analysis-cell"; const EI_API = "/api/v3/exploit-intelligence"; @@ -32,6 +36,13 @@ export interface ExploitIntelligenceJobSummary { report_url: string | null; created: string; updated: string; + product_id: string | null; + total_components: number | null; + completed_components: number | null; + failed_components: number | null; + vulnerable_components: number | null; + not_vulnerable_components: number | null; + uncertain_components: number | null; } interface JobsListResponse { @@ -41,22 +52,65 @@ interface JobsListResponse { // -- State mapping ------------------------------------------------------------ +const buildBreakdown = ( + job: ExploitIntelligenceJobSummary, +): ExploitIntelligenceFindingBreakdown | undefined => { + if (job.product_id == null || job.total_components == null) { + return undefined; + } + return { + vulnerableCount: job.vulnerable_components ?? 0, + uncertainCount: job.uncertain_components ?? 0, + notVulnerableCount: job.not_vulnerable_components ?? 0, + failedCount: job.failed_components ?? 0, + }; +}; + +const buildCompletedFinding = ( + variant: Finding, + breakdown: ExploitIntelligenceFindingBreakdown | undefined, +): ExploitIntelligenceFinding => { + switch (variant) { + case "vulnerable": + return { + variant: "vulnerable", + count: breakdown ? breakdown.vulnerableCount : undefined, + breakdown, + }; + case "uncertain": + return { + variant: "uncertain", + count: breakdown ? breakdown.uncertainCount : undefined, + breakdown, + }; + case "not_vulnerable": + return { variant: "not_vulnerable", breakdown }; + } +}; + export const jobToState = ( job: ExploitIntelligenceJobSummary, ): ExploitIntelligenceCellState => { if (job.status === "pending" || job.status === "running") { - return { kind: "finding", finding: { variant: "in_progress" } }; + return { + kind: "finding", + finding: { variant: "in_progress" }, + }; } if (job.status === "failed") { - return { kind: "finding", finding: { variant: "failed" } }; + return { + kind: "finding", + finding: { variant: "failed" }, + }; } // completed const variant = job.finding ?? "uncertain"; + const breakdown = buildBreakdown(job); return { kind: "finding", - finding: { variant }, + finding: buildCompletedFinding(variant, breakdown), reportUrl: job.report_url ?? undefined, }; }; From 5f7f4eca642a9acb14fbded0e85b6a528fda3037 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Thu, 2 Jul 2026 13:09:30 +0100 Subject: [PATCH 6/6] fix(sbom): remove initialSort to preserve default Id ascending sort The initialSort override changed the vulnerability table default from Id ascending to CVSS descending, breaking existing e2e sort tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx index d5c31561d..197fbb889 100644 --- a/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx +++ b/client/src/app/pages/sbom-details/vulnerabilities-by-sbom.tsx @@ -147,7 +147,6 @@ export const VulnerabilitiesBySbom: React.FC = ({ "published", "updated", ], - initialSort: { columnKey: "cvss", direction: "desc" }, getSortValues: (item) => ({ id: item.vulnerability.identifier, cvss: item.opinionatedAdvisory.score?.value ?? 0,