diff --git a/README.md b/README.md index 571a005e..5c9bc2ad 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,16 @@ After submitting the request, you will be redirected to the Report page. Once th **Note:** There is a configurable pool of concurrent requests. Any request that is submitted when the pool is full will be queued. If after a certain time a callback response is not received, the report will be _expired_ (failed). +### CVE Details Page + +By clicking on the CVE link: + +![cve_click.png](./docs/images/cve_click.png) + +you will navigate to the CVE Details page where you can find details about a specific CVE. + +![cve_details_page.png](./docs/images/cve_details_page.png) + ### Reports Page On this page, you will find a table containing all reports. @@ -82,11 +92,10 @@ You will be able to sort, filter, and organize the reports to quickly locate spe ![report](./docs/images/report.png) - ### Download Feature A blue **Download** button is available on the repository report page, providing access to download either the VEX (Vulnerability Exploitability eXchange) data or the complete report as JSON files. The VEX option is only available when the component is in a vulnerable status and is automatically disabled otherwise. ![download_button](./docs/images/download_button.png) -![download_open](./docs/images/download_open.png) \ No newline at end of file +![download_open](./docs/images/download_open.png) diff --git a/docs/images/cve_click.png b/docs/images/cve_click.png new file mode 100644 index 00000000..04b49eca Binary files /dev/null and b/docs/images/cve_click.png differ diff --git a/docs/images/cve_details_page.png b/docs/images/cve_details_page.png new file mode 100644 index 00000000..0f204dd1 Binary files /dev/null and b/docs/images/cve_details_page.png differ diff --git a/openspec/specs/cve-details-page/spec.md b/openspec/specs/cve-details-page/spec.md new file mode 100644 index 00000000..55ddb7ec --- /dev/null +++ b/openspec/specs/cve-details-page/spec.md @@ -0,0 +1,100 @@ +# cve-details-page Specification + +## Purpose + +The CVE Details page displays comprehensive information about a specific CVE (Common Vulnerabilities and Exposures) identifier, including description, metadata, vulnerable packages, and references. Users can access this page by clicking on a CVE link from the repository report page. + +## Requirements + +### Requirement: CVE Details Page Route + +The application SHALL provide routes to display CVE details for a specific CVE ID with support for proper breadcrumb navigation and efficient data fetching. + +#### Scenario: Navigate to CVE Details page with product and report + +- **WHEN** a user navigates to `/reports/product/cve/:productId/:cveId/:reportId` where: + - `:productId` is a product identifier + - `:cveId` is a CVE identifier (e.g., "cve-2024-0987") + - `:reportId` is a report identifier +- **THEN** the CVE Details page displays with the CVE ID from the route parameter +- **AND** the page fetches data directly from the specified report (more efficient than filtering) + +#### Scenario: Navigate to CVE Details page with product only + +- **WHEN** a user navigates to `/reports/product/cve/:productId/:cveId` where: + - `:productId` is a product identifier + - `:cveId` is a CVE identifier (e.g., "cve-2024-0987") +- **THEN** the CVE Details page displays with the CVE ID from the route parameter +- **AND** the page fetches data by filtering reports by CVE ID + +#### Scenario: Navigate to CVE Details page for component route + +- **WHEN** a user navigates to `/reports/component/cve/:productId/:cveId/:reportId` where: + - `:productId` is a product identifier (for component routes) + - `:cveId` is a CVE identifier (e.g., "cve-2024-0987") + - `:reportId` is a report identifier +- **THEN** the CVE Details page displays with the CVE ID from the route parameter +- **AND** the page fetches data directly from the specified report + +#### Scenario: Breadcrumb navigation displays full path + +- **WHEN** a user navigates to the CVE Details page with route parameters +- **THEN** a breadcrumb navigation is displayed showing: Reports > ProductId/CVE > Report > CVE Details +- **AND** breadcrumb items are clickable links that navigate to their respective pages, except the last "CVE Details" item which is non-clickable +- **AND** the breadcrumb path is preserved even after page refresh (no dependency on location state) + +### Requirement: CVE Details Page Content Structure + +The CVE Details page SHALL display content in a structured layout with four cards arranged in a 2x2 grid layout. + +#### Scenario: Page displays four cards in grid layout + +- **WHEN** a user views the CVE Details page +- **THEN** the page displays four cards organized in a 2x2 grid layout using PatternFly `Grid` and `GridItem` components: + - Top left: "Description" card + - Top right: "Metadata" card + - Bottom left: "Vulnerable Packages" card + - Bottom right: "References" card +- **AND** the Description and Metadata cards have matching heights using flexbox layout (`height: 100%` and `flex: 1` on CardBody) + +#### Scenario: Description card + +- **WHEN** a user views the CVE Details page with CVE data loaded +- **THEN** the Description card displays description field +- **AND** the description text is extracted from `info.intel[0].nvd.cve_description` if available and not empty +- **AND** if `nvd.cve_description` is empty or missing, the description is extracted from `info.intel[0].ghsa.description` as a fallback +- **AND** if the description source is `ghsa.description`, the markdown text is rendered as formatted HTML using a markdown rendering library (e.g., `react-markdown`) +- **AND** if the description source is `nvd.cve_description`, the plain text is displayed as-is +- **AND** if no description is available from either source, the card displays "Not Available" using the `NotAvailable` component + +#### Scenario: Metadata card + +- **WHEN** a user views the CVE Details page with CVE data loaded +- **THEN** the Metadata card displays CVE metadata using PatternFly `DescriptionList` component with the following fields: + - **CVSS Score**: Displays CVSS severity and score with priority icon using the `CvssBanner` component, which shows severity, icon, and score in the format "Severity Score" (e.g., "High 8.2") where severity is determined from the CVSS score (None, Low, Medium, High, Critical), and the appropriate severity icon is displayed next to the text + - **EPSS Score**: Displays EPSS percentage in the format "X.XXX%" (e.g., "0.025%") calculated by multiplying `epss.percentage` by 100 + - **CWE**: Displays CWE identifier as a string in the format "CWE-XXX" (e.g., "CWE-22") from `ghsa.cwes[0].cwe_id` + - **Published**: Displays published date using `FormattedTimestamp` component from `ghsa.published_at` field + - **Updated**: Displays updated date using `FormattedTimestamp` component from `ghsa.updated_at` field + - **Credits**: Displays credits as a clickable link where the link text is `credits[0].user.login` and the link URL is `credits[0].user.html_url`, opening in a new tab +- **AND** missing or null data fields display "Not Available" using the `NotAvailable` component + +#### Scenario: Vulnerable Packages card + +- **WHEN** a user views the CVE Details page with CVE data loaded +- **THEN** the Vulnerable Packages card displays a list of vulnerable packages from `info.intel[0].ghsa.vulnerabilities` array +- **AND** each vulnerable package displays the following information using PatternFly `DescriptionList` component: + - **Package Name**: Displays `package.name` in bold font (using PatternFly `Title` component with `strong` tag) + - **Ecosystem**: Displays `package.ecosystem` (e.g., "npm", "pypi", "maven") as a field label and value pair + - **Vulnerable Version**: Displays `vulnerable_version_range` (e.g., "< 7.5.7") as a field label and value pair + - **First patched Version**: Displays `first_patched_version` (e.g., "7.5.7") as a field label and value pair +- **AND** if no vulnerable packages are available or the vulnerabilities array is empty, the card displays "Not Available" using the `NotAvailable` component +- **AND** the card supports displaying 0, 1, or more vulnerable packages + +#### Scenario: References card + +- **WHEN** a user views the CVE Details page with CVE data loaded +- **THEN** the References card displays a list of reference URLs from `info.intel[0].ghsa.references` array +- **AND** each reference URL is displayed as a clickable link using PatternFly `List` and `ListItem` components +- **AND** each reference link opens in a new tab when clicked (using `target="_blank"` and `rel="noreferrer"`) +- **AND** if no references are available or the references array is empty, the card displays "Not Available" using the `NotAvailable` component diff --git a/src/main/webui/package-lock.json b/src/main/webui/package-lock.json index 9b0f175c..e47436af 100644 --- a/src/main/webui/package-lock.json +++ b/src/main/webui/package-lock.json @@ -16,6 +16,7 @@ "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", "react-router": "^7.1.1", "react-router-dom": "^7.1.1", "victory": "^37.3.6" @@ -5058,4 +5059,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/main/webui/package.json b/src/main/webui/package.json index 0c6aef04..0573c335 100644 --- a/src/main/webui/package.json +++ b/src/main/webui/package.json @@ -23,6 +23,7 @@ "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", "react-router": "^7.1.1", "react-router-dom": "^7.1.1", "victory": "^37.3.6" diff --git a/src/main/webui/src/App.tsx b/src/main/webui/src/App.tsx index 4dba58a0..db958ff6 100644 --- a/src/main/webui/src/App.tsx +++ b/src/main/webui/src/App.tsx @@ -5,6 +5,7 @@ import HomePage from "./pages/HomePage"; import ReportsPage from "./pages/ReportsPage"; import ReportPage from "./pages/ReportPage"; import RepositoryReportPage from "./pages/RepositoryReportPage"; +import CveDetailsPage from "./pages/CveDetailsPage"; /** * App component - provides router context and defines all application routes @@ -20,11 +21,26 @@ const App: React.FC = () => { path="/reports/product/:productId/:cveId/:reportId" element={} /> - } /> + } + /> } /> + } + /> + } + /> + } + /> } /> diff --git a/src/main/webui/src/components/CveDescriptionCard.tsx b/src/main/webui/src/components/CveDescriptionCard.tsx new file mode 100644 index 00000000..9b252019 --- /dev/null +++ b/src/main/webui/src/components/CveDescriptionCard.tsx @@ -0,0 +1,45 @@ +import ReactMarkdown from "react-markdown"; +import { Content, EmptyState, EmptyStateBody } from "@patternfly/react-core"; +import { SearchIcon } from "@patternfly/react-icons"; +import type { CveMetadata } from "../hooks/useCveDetails"; + +interface CveDescriptionCardProps { + metadata: CveMetadata | null; +} + +/** + * Component to display CVE description with markdown rendering support + * Content from PatternFly automatically applies styling to standard HTML elements + * (h1-h6, p, ul, ol, blockquote) and overrides the base CSS reset. + */ +const CveDescriptionCard: React.FC = ({ + metadata, +}) => { + const description = metadata?.description; + const descriptionSource = metadata?.descriptionSource; + + if (!description) { + return ( + + No description available for this CVE. + + ); + } + + // Render markdown if source is GHSA, otherwise render as plain text + if (descriptionSource === "ghsa") { + return ( + + {description} + + ); + } + + return {description}; +}; + +export default CveDescriptionCard; diff --git a/src/main/webui/src/components/CveMetadataCard.tsx b/src/main/webui/src/components/CveMetadataCard.tsx new file mode 100644 index 00000000..f947c01e --- /dev/null +++ b/src/main/webui/src/components/CveMetadataCard.tsx @@ -0,0 +1,90 @@ +import { + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, +} from "@patternfly/react-core"; +import type { CveMetadata } from "../hooks/useCveDetails"; +import CvssBanner from "./CvssBanner"; +import FormattedTimestamp from "./FormattedTimestamp"; +import NotAvailable from "./NotAvailable"; + +interface CveMetadataCardProps { + metadata: CveMetadata | null; +} + +/** + * Component to display CVE metadata in a DescriptionList format + */ +const CveMetadataCard: React.FC = ({ metadata }) => { + // Convert numeric CVSS score to Cvss object format for CvssBanner component + const cvssDisplay = + metadata?.cvssScore !== undefined + ? { + score: metadata.cvssScore.toString(), + } + : null; + + // Format EPSS score (multiply by 100 and add %) + const epssDisplay = + metadata?.epssPercentage !== undefined + ? `${(metadata.epssPercentage * 100).toFixed(3)}%` + : null; + + return ( + + + CVSS Score + + {cvssDisplay ? : } + + + + EPSS Score + + {epssDisplay || } + + + + CWE + + {metadata?.cwe || } + + + + Published + + {metadata?.publishedAt ? ( + + ) : ( + + )} + + + + Updated + + {metadata?.updatedAt ? ( + + ) : ( + + )} + + + + Credits + + {metadata?.credits ? ( + + {metadata.credits.login} + + ) : ( + + )} + + + + ); +}; + +export default CveMetadataCard; diff --git a/src/main/webui/src/components/CveReferencesCard.tsx b/src/main/webui/src/components/CveReferencesCard.tsx new file mode 100644 index 00000000..d64c008e --- /dev/null +++ b/src/main/webui/src/components/CveReferencesCard.tsx @@ -0,0 +1,45 @@ +import { + List, + ListItem, + EmptyState, + EmptyStateBody, +} from "@patternfly/react-core"; +import { SearchIcon } from "@patternfly/react-icons"; +import type { CveMetadata } from "../hooks/useCveDetails"; + +interface CveReferencesCardProps { + metadata: CveMetadata | null; +} + +/** + * Component to display CVE references as a list of clickable links + */ +const CveReferencesCard: React.FC = ({ metadata }) => { + const references = metadata?.references; + + if (!references || references.length === 0) { + return ( + + No references available for this CVE. + + ); + } + + return ( + + {references.map((reference, index) => ( + + + {reference} + + + ))} + + ); +}; + +export default CveReferencesCard; diff --git a/src/main/webui/src/components/CveStatus.tsx b/src/main/webui/src/components/CveStatus.tsx index 0b5262cb..4bf1451f 100644 --- a/src/main/webui/src/components/CveStatus.tsx +++ b/src/main/webui/src/components/CveStatus.tsx @@ -7,7 +7,7 @@ interface CveStatusProps { /** * Component to display CVE status based on justification */ -const CveStatus: React.FC = ({ status }) => { +const CveStatus: React.FC = ({ status }) => { const getColor = ( status: string diff --git a/src/main/webui/src/components/CveVulnerablePackagesCard.tsx b/src/main/webui/src/components/CveVulnerablePackagesCard.tsx new file mode 100644 index 00000000..76638221 --- /dev/null +++ b/src/main/webui/src/components/CveVulnerablePackagesCard.tsx @@ -0,0 +1,65 @@ +import { + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + Title, +} from "@patternfly/react-core"; +import type { CveMetadata } from "../hooks/useCveDetails"; +import NotAvailable from "./NotAvailable"; + +interface CveVulnerablePackagesCardProps { + metadata: CveMetadata | null; +} + +/** + * Component to display CVE vulnerable packages with their details + */ +const CveVulnerablePackagesCard: React.FC = ({ + metadata, +}) => { + const vulnerablePackages = metadata?.vulnerablePackages; + + if (!vulnerablePackages || vulnerablePackages.length === 0) { + return ; + } + + return ( + <> + {vulnerablePackages.map((pkg, index) => ( +
+ + <strong>{pkg.name}</strong> + + + + Ecosystem + + {pkg.ecosystem || } + + + + Vulnerable Version + + {pkg.vulnerableVersionRange || } + + + + First patched Version + + {pkg.firstPatchedVersion || } + + + +
+ ))} + + ); +}; + +export default CveVulnerablePackagesCard; diff --git a/src/main/webui/src/components/CvssBanner.tsx b/src/main/webui/src/components/CvssBanner.tsx index 0b28df6e..46cda9d4 100644 --- a/src/main/webui/src/components/CvssBanner.tsx +++ b/src/main/webui/src/components/CvssBanner.tsx @@ -6,10 +6,10 @@ import { SeverityModerateIcon, SeverityNoneIcon, } from "@patternfly/react-icons"; -import t_global_icon_color_status_danger_default from "@patternfly/react-tokens/dist/esm/t_global_icon_color_status_danger_default"; -import t_global_icon_color_status_warning_default from "@patternfly/react-tokens/dist/esm/t_global_icon_color_status_warning_default"; import t_global_icon_color_status_info_default from "@patternfly/react-tokens/dist/esm/t_global_icon_color_status_info_default"; import t_global_icon_color_status_success_default from "@patternfly/react-tokens/dist/esm/t_global_icon_color_status_success_default"; +import t_global_icon_color_status_warning_default from "@patternfly/react-tokens/dist/esm/t_global_icon_color_status_warning_default"; +import t_global_icon_color_status_danger_default from "@patternfly/react-tokens/dist/esm/t_global_icon_color_status_danger_default"; import type { Cvss } from "../types/FullReport"; import NotAvailable from "./NotAvailable"; @@ -18,18 +18,14 @@ interface CvssBannerProps { } /** - * Component to display CVSS score with severity icon and text + * Shared utility function to get CVSS severity, icon, and color from a numeric score + * Can be used by both CvssBanner and other components */ -const CvssBanner: React.FC = ({ cvss }) => { - if (cvss === null || cvss === undefined || cvss.score === "") { - return ; - } - - const score = parseFloat(cvss.score); - if (isNaN(score)) { - return {cvss.score}; - } - +export function getCvssSeverityIconAndColor(score: number): { + severity: string; + Icon: React.ComponentType<{ color?: string }> | null; + color: string; +} { let severity = ""; let Icon: React.ComponentType<{ color?: string }> | null = null; let color: string = t_global_icon_color_status_info_default.var; // Default fallback @@ -56,6 +52,28 @@ const CvssBanner: React.FC = ({ cvss }) => { color = t_global_icon_color_status_danger_default.var; } + return { + severity, + Icon, + color, + }; +} + +/** + * Component to display CVSS score with severity icon and text + */ +const CvssBanner: React.FC = ({ cvss }) => { + if (cvss === null || cvss === undefined || cvss.score === "") { + return ; + } + + const score = parseFloat(cvss.score); + if (isNaN(score)) { + return {cvss.score}; + } + + const { severity, Icon, color } = getCvssSeverityIconAndColor(score); + if (Icon) { return ( @@ -76,4 +94,3 @@ const CvssBanner: React.FC = ({ cvss }) => { }; export default CvssBanner; - diff --git a/src/main/webui/src/components/DetailsCard.tsx b/src/main/webui/src/components/DetailsCard.tsx index 361c50fb..93aad913 100644 --- a/src/main/webui/src/components/DetailsCard.tsx +++ b/src/main/webui/src/components/DetailsCard.tsx @@ -10,6 +10,7 @@ import { Flex, FlexItem, } from "@patternfly/react-core"; +import { Link, useParams } from "react-router"; import type { FullReport } from "../types/FullReport"; import CvssBanner from "./CvssBanner"; import CveStatus from "./CveStatus"; @@ -27,7 +28,7 @@ interface DetailsCardProps { const DetailsCard: React.FC = ({ report, cveId, - analysisState + analysisState, }) => { const image = report.input?.image; const sourceInfo = image?.source_info || []; @@ -38,7 +39,16 @@ const DetailsCard: React.FC = ({ const codeTag = codeSource?.ref; const output = report.output?.analysis || []; const vuln = report.input?.scan?.vulns?.find((v) => v.vuln_id === cveId); - const outputVuln = output.find((v) => v.vuln_id === cveId);`` + const outputVuln = output.find((v) => v.vuln_id === cveId); + + const params = useParams<{ + sbomReportId?: string; + productId?: string; + reportId?: string; + }>(); + const { sbomReportId, productId, reportId } = params; + const isComponentRoute = !sbomReportId && !productId; + const effectiveProductId = sbomReportId || productId; return ( @@ -61,7 +71,21 @@ const DetailsCard: React.FC = ({ - {vuln.vuln_id} + + {vuln.vuln_id} + {outputVuln?.justification?.status ? ( @@ -77,11 +101,7 @@ const DetailsCard: React.FC = ({ Repository {codeRepository ? ( - + {codeRepository} ) : ( @@ -141,4 +161,3 @@ const DetailsCard: React.FC = ({ }; export default DetailsCard; - diff --git a/src/main/webui/src/components/ReportDetails.tsx b/src/main/webui/src/components/ReportDetails.tsx index 164f3ddf..34547dec 100644 --- a/src/main/webui/src/components/ReportDetails.tsx +++ b/src/main/webui/src/components/ReportDetails.tsx @@ -10,6 +10,7 @@ import { GridItem, Title, } from "@patternfly/react-core"; +import { Link, useParams } from "react-router"; import type { ProductSummary } from "../generated-client/models/ProductSummary"; interface ReportDetailsProps { @@ -17,12 +18,12 @@ interface ReportDetailsProps { cveId: string; } -const ReportDetails: React.FC = ({ - product, - cveId, -}) => { +const ReportDetails: React.FC = ({ product, cveId }) => { const name = product.data?.name || ""; - const repositoriesAnalyzed = product.summary?.statusCounts?.["completed"]?.toString() || "0"; + const repositoriesAnalyzed = + product.summary?.statusCounts?.["completed"]?.toString() || "0"; + const params = useParams<{ productId?: string }>(); + const { productId } = params; return ( @@ -37,7 +38,15 @@ const ReportDetails: React.FC = ({ CVE Analyzed - {cveId} + + {productId ? ( + + {cveId} + + ) : ( + cveId + )} + Report name @@ -64,4 +73,3 @@ const ReportDetails: React.FC = ({ }; export default ReportDetails; - diff --git a/src/main/webui/src/hooks/useCveDetails.ts b/src/main/webui/src/hooks/useCveDetails.ts new file mode 100644 index 00000000..4eaa4d38 --- /dev/null +++ b/src/main/webui/src/hooks/useCveDetails.ts @@ -0,0 +1,299 @@ +import { useMemo } from "react"; +import { usePaginatedApi } from "./usePaginatedApi"; +import { useApi } from "./useApi"; +import { getRepositoryReport } from "../utils/reportApi"; +import type { FullReport } from "../types/FullReport"; +import type { Report } from "../generated-client/models/Report"; +import type { ReportWithStatus } from "../generated-client"; + +export interface VulnerablePackage { + name: string; + ecosystem: string; + vulnerableVersionRange: string; + firstPatchedVersion: string; +} + +export interface CveMetadata { + cvssScore?: number; + epssPercentage?: number; + cwe?: string; + publishedAt?: string; + updatedAt?: string; + credits?: { + login: string; + htmlUrl: string; + }; + references?: string[]; + vulnerablePackages?: VulnerablePackage[]; + description?: string; + descriptionSource?: "nvd" | "ghsa"; +} + +export interface UseCveDetailsResult { + metadata: CveMetadata | null; + loading: boolean; + error: Error | null; +} + +/** + * Pure function to extract CVE metadata from FullReport info.intel structure + */ +export function extractCveMetadata( + report: FullReport | null, + cveId: string +): CveMetadata | null { + if (!report?.info) { + return null; + } + + const info = report.info as { + intel?: Array<{ + vuln_id?: string; + nvd?: { + cve_description?: string; + }; + ghsa?: { + cvss?: { + score?: number; + }; + cwes?: Array<{ + cwe_id?: string; + }>; + published_at?: string; + updated_at?: string; + credits?: Array<{ + user?: { + login?: string; + html_url?: string; + }; + }>; + references?: string[]; + vulnerabilities?: Array<{ + package?: { + name?: string; + ecosystem?: string; + }; + vulnerable_version_range?: string; + first_patched_version?: string; + }>; + description?: string; + }; + epss?: { + percentage?: number; + }; + }>; + }; + + const intel = info.intel; + if (!intel || !Array.isArray(intel) || intel.length === 0) { + return null; + } + + // Find the intel entry matching the CVE ID + const intelEntry = intel.find((entry) => entry.vuln_id === cveId); + if (!intelEntry?.ghsa) { + return null; + } + + const ghsa = intelEntry.ghsa; + const metadata: CveMetadata = {}; + + // Extract CVSS score + if (ghsa.cvss?.score !== undefined) { + metadata.cvssScore = ghsa.cvss.score; + } + + // Extract EPSS percentage + if (intelEntry.epss?.percentage !== undefined) { + metadata.epssPercentage = intelEntry.epss.percentage; + } + + // Extract CWE (first one) + if (ghsa.cwes && ghsa.cwes.length > 0 && ghsa.cwes[0]?.cwe_id) { + metadata.cwe = ghsa.cwes[0].cwe_id; + } + + // Extract published date + if (ghsa.published_at) { + metadata.publishedAt = ghsa.published_at; + } + + // Extract updated date + if (ghsa.updated_at) { + metadata.updatedAt = ghsa.updated_at; + } + + // Extract credits (first one) + if (ghsa.credits && ghsa.credits.length > 0) { + const credit = ghsa.credits[0]; + if (credit && credit.user?.login && credit.user?.html_url) { + metadata.credits = { + login: credit.user.login, + htmlUrl: credit.user.html_url, + }; + } + } + + // Extract references + if ( + ghsa.references && + Array.isArray(ghsa.references) && + ghsa.references.length > 0 + ) { + metadata.references = ghsa.references.filter( + (ref): ref is string => typeof ref === "string" && ref.length > 0 + ); + } + + // Extract vulnerable packages + if ( + ghsa.vulnerabilities && + Array.isArray(ghsa.vulnerabilities) && + ghsa.vulnerabilities.length > 0 + ) { + metadata.vulnerablePackages = ghsa.vulnerabilities + .filter((vuln) => vuln.package?.name && vuln.package?.ecosystem) + .map((vuln) => ({ + name: vuln.package!.name!, + ecosystem: vuln.package!.ecosystem!, + vulnerableVersionRange: vuln.vulnerable_version_range || "", + firstPatchedVersion: vuln.first_patched_version || "", + })); + } + + // Extract description with fallback: nvd.cve_description -> ghsa.description + const nvdDescription = intelEntry.nvd?.cve_description; + if ( + nvdDescription && + typeof nvdDescription === "string" && + nvdDescription.trim().length > 0 + ) { + metadata.description = nvdDescription.trim(); + metadata.descriptionSource = "nvd"; + } else if ( + ghsa.description && + typeof ghsa.description === "string" && + ghsa.description.trim().length > 0 + ) { + metadata.description = ghsa.description.trim(); + metadata.descriptionSource = "ghsa"; + } + + return metadata; +} + +/** + * Hook to fetch CVE metadata from a specific report or by filtering reports + * If reportId is provided, fetches that specific report directly + * Otherwise, fetches reports filtered by CVE ID and uses the first result + * + * @param cveId - The CVE ID to fetch metadata for + * @param reportId - Optional report ID to fetch directly + * @returns Object with metadata, loading, and error states + */ +export function useCveDetails( + cveId: string, + reportId?: string +): UseCveDetailsResult { + // If reportId is provided, fetch that specific report directly + const { + data: directReportWithStatus, + loading: directReportLoading, + error: directReportError, + } = useApi( + () => { + if (!reportId) { + return Promise.resolve(null); + } + return getRepositoryReport(reportId); + }, + { + deps: [reportId], + } + ); + + // Fallback: If no reportId provided, fetch reports filtered by CVE ID + const { + data: reports, + loading: reportsLoading, + error: reportsError, + } = usePaginatedApi>( + () => { + if (reportId) { + // Skip fetching if we have reportId (will use direct fetch) + return { + method: "GET" as const, + url: "/api/v1/reports", + query: {}, + }; + } + return { + method: "GET" as const, + url: "/api/v1/reports", + query: { + page: 0, + pageSize: 1, // We only need the first report + vulnId: cveId, + }, + }; + }, + { + deps: [cveId, reportId], + } + ); + + // Get the first report ID from filtered results if no direct reportId + const fallbackReportId = useMemo(() => { + if (reportId || !reports || reports.length === 0) { + return null; + } + return reports[0]?.id || null; + }, [reportId, reports]); + + // Fetch the full report data using fallback report ID if needed + const { + data: fallbackReportWithStatus, + loading: fallbackReportLoading, + error: fallbackReportError, + } = useApi( + () => { + if (reportId || !fallbackReportId) { + return Promise.resolve(null); + } + return getRepositoryReport(fallbackReportId); + }, + { + deps: [fallbackReportId, reportId], + } + ); + + // Determine which report data to use + const fullReport = useMemo(() => { + if (directReportWithStatus?.report) { + return directReportWithStatus.report; + } + if (fallbackReportWithStatus?.report) { + return fallbackReportWithStatus.report; + } + return null; + }, [directReportWithStatus, fallbackReportWithStatus]); + + // Extract metadata from the full report + const metadata = useMemo(() => { + return extractCveMetadata(fullReport, cveId); + }, [fullReport, cveId]); + + // Determine loading and error states + const loading = reportId + ? directReportLoading + : reportsLoading || fallbackReportLoading; + const error = reportId + ? directReportError + : reportsError || fallbackReportError; + + return { + metadata, + loading, + error, + }; +} diff --git a/src/main/webui/src/hooks/useReport.ts b/src/main/webui/src/hooks/useReport.ts index 1cf546a9..262b5461 100644 --- a/src/main/webui/src/hooks/useReport.ts +++ b/src/main/webui/src/hooks/useReport.ts @@ -62,4 +62,3 @@ export function useReport(productId: string | undefined): UseReportResult { return { data: data || null, loading, error }; } - diff --git a/src/main/webui/src/mocks/handlers.ts b/src/main/webui/src/mocks/handlers.ts index af6e058b..7e3576d0 100644 --- a/src/main/webui/src/mocks/handlers.ts +++ b/src/main/webui/src/mocks/handlers.ts @@ -971,4 +971,4 @@ export const handlers = [ { status: 501 } ); }), -]; +]; \ No newline at end of file diff --git a/src/main/webui/src/pages/CveDetailsPage.tsx b/src/main/webui/src/pages/CveDetailsPage.tsx new file mode 100644 index 00000000..9c85945c --- /dev/null +++ b/src/main/webui/src/pages/CveDetailsPage.tsx @@ -0,0 +1,238 @@ +import { useParams, Link } from "react-router"; +import { + Breadcrumb, + BreadcrumbItem, + PageSection, + Title, + Grid, + GridItem, + Card, + CardTitle, + CardBody, + EmptyState, + EmptyStateBody, +} from "@patternfly/react-core"; +import { ExclamationCircleIcon } from "@patternfly/react-icons"; +import { useCveDetails } from "../hooks/useCveDetails"; +import CveMetadataCard from "../components/CveMetadataCard"; +import CveReferencesCard from "../components/CveReferencesCard"; +import CveVulnerablePackagesCard from "../components/CveVulnerablePackagesCard"; +import CveDescriptionCard from "../components/CveDescriptionCard"; +import SkeletonCard from "../components/SkeletonCard"; + +interface CveDetailsPageErrorProps { + title: string; + message: string | React.ReactNode; +} + +const CveDetailsPageError: React.FC = ({ + title, + message, +}) => { + return ( + + + {message} + + + ); +}; + +const CveDetailsPage: React.FC = () => { + const params = useParams<{ + productId?: string; + cveId: string; + reportId?: string; + }>(); + const { productId, cveId, reportId } = params; + + if (!cveId) { + return ( + + ); + } + + const cveIdDisplay = cveId.toUpperCase(); + const isComponentRoute = !productId; + + const buildBreadcrumb = () => { + return ( + + + Reports + + {productId && ( + + + {productId}/{cveId} + + + )} + {reportId && ( + + + Report {reportId.substring(0, 8)}... + + + )} + CVE Details + + ); + }; + + const { metadata, loading, error } = useCveDetails(cveId, reportId); + + if (loading) { + return ( + <> + + + {buildBreadcrumb()} + + + <strong>{cveIdDisplay}</strong> + + + + + + + + + + + + + + + + + + + + + + ); + } + + if (error) { + return ( + + ); + } + + return ( + <> + + + {buildBreadcrumb()} + + + <strong>{cveIdDisplay}</strong> + + + + + + + + + + + Description + + + + + + + + + + + + Metadata + + + + + + + + + + + + Vulnerable Packages + + + + + + + + + + + + References + + + + + + + + + + + ); +}; + +export default CveDetailsPage; diff --git a/src/main/webui/src/pages/RepositoryReportPage.tsx b/src/main/webui/src/pages/RepositoryReportPage.tsx index f3250fac..10761dea 100644 --- a/src/main/webui/src/pages/RepositoryReportPage.tsx +++ b/src/main/webui/src/pages/RepositoryReportPage.tsx @@ -31,7 +31,11 @@ const RepositoryReportPageError: React.FC = ({ }) => { return ( - + {message} @@ -43,10 +47,9 @@ interface RepositoryReportPageApiErrorProps { reportId: string; } -const RepositoryReportPageApiError: React.FC = ({ - error, - reportId, -}) => { +const RepositoryReportPageApiError: React.FC< + RepositoryReportPageApiErrorProps +> = ({ error, reportId }) => { const errorStatus = (error as { status?: number })?.status; const title = errorStatus === 404 @@ -67,7 +70,6 @@ const RepositoryReportPageApiError: React.FC return ; }; - const RepositoryReportPage: React.FC = () => { // Support both new routes: /reports/product/:productId/:cveId/:reportId and /reports/component/:cveId/:reportId // Also support legacy route: /reports/:productId/:cveId/:reportId @@ -76,15 +78,22 @@ const RepositoryReportPage: React.FC = () => { cveId: string; reportId: string; }>(); - + const { productId, cveId, reportId } = params; - const { data: report, status, loading, error } = useRepositoryReport(reportId || ""); + const { + data: report, + status, + loading, + error, + } = useRepositoryReport(reportId || ""); if (!cveId) { - return ; + return ( + + ); } if (loading) { @@ -92,7 +101,9 @@ const RepositoryReportPage: React.FC = () => { } if (error) { - return ; + return ( + + ); } if (!report) { @@ -118,44 +129,69 @@ const RepositoryReportPage: React.FC = () => { } const reportIdDisplay = vuln.vuln_id - ? `${vuln.vuln_id} | ${image?.name || ""} | ${image?.tag || ""}` : "" + ? `${vuln.vuln_id} | ${image?.name || ""} | ${image?.tag || ""}` + : ""; // Extract product name from metadata, fallback to productId const productName = report?.metadata?.product_id; - const productCveBreadcrumbText = `${productName}/${cveId || ""}`; const output = report.output?.analysis || []; const outputVuln = output.find((v) => v.vuln_id === cveId); - + const showReport = () => { return ( - + CVE Repository Report:{" "} <span style={{ fontSize: "var(--pf-t--global--font--size--heading--h6)", + wordBreak: "break-word", + overflowWrap: "break-word", + display: "inline-block", + maxWidth: "100%", }} > - {reportIdDisplay} + <Link + to={ + productId + ? `/reports/product/cve/${productId}/${cveId}/${reportId}` + : `/reports/component/cve/${cveId}/${cveId}/${reportId}` + } + > + {cveId} + </Link> + {" | "} + {image?.name || ""} | {image?.tag || ""} </span> - + - + @@ -177,7 +213,23 @@ const RepositoryReportPage: React.FC = () => { {productId && ( - {productCveBreadcrumbText} + {productName} + + + )} + {productId && ( + + + {cveId} + + + )} + {!productId && ( + + + {cveId} )} diff --git a/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/ReportUploadEndpointTest.java b/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/ReportUploadEndpointTest.java index b2cd1e4b..3bc09733 100644 --- a/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/ReportUploadEndpointTest.java +++ b/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/ReportUploadEndpointTest.java @@ -390,4 +390,3 @@ void testUpload_MissingCveId_NoProductCreated() { } } - diff --git a/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportsEndpointTest.java b/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportsEndpointTest.java new file mode 100644 index 00000000..01be8d48 --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportsEndpointTest.java @@ -0,0 +1,196 @@ +package com.redhat.ecosystemappeng.morpheus.rest; + +import static org.hamcrest.Matchers.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.Assertions; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +/** + * End-to-end test for the SBOM reports API endpoint. + * + * This test assumes the service is running in a separate process. + * Set the BASE_URL environment variable to point to the running service, + * e.g., BASE_URL=http://localhost:8080 + * + * If BASE_URL is not set, tests will be skipped. + */ +@EnabledIfEnvironmentVariable(named = "BASE_URL", matches = ".*") +class SbomReportsEndpointTest { + + private static final String BASE_URL = System.getenv("BASE_URL"); + private static final String API_BASE = BASE_URL != null ? BASE_URL : "http://localhost:8080"; + + @Test + void testGetSbomReports_ReturnsExpectedStructure() { + + RestAssured.given() + .when() + .queryParam("sortField", "submittedAt") + .queryParam("sortDirection", "DESC") + .queryParam("page", 0) + .queryParam("pageSize", 100) + .get("/api/v1/sbom-reports") + .then() + .body("[0].sbomReportId", equalTo("product-4")) + .body("[0].sbomName", equalTo("Product_4")) + .body("[0].cveId", equalTo("CVE-2024-1485")) + .body("[0].cveStatusCounts", equalTo(java.util.Map.of("FALSE", 1, "UNKNOWN", 1))) + .body("[0].statusCounts", equalTo(java.util.Map.of("completed", 2))) + .body("[0].completedAt", equalTo("2025-02-24T07:12:15.038386")) + .body("[0].submittedAt", equalTo("2025-02-24T07:11:41.123Z")) + .body("[0].numReports", equalTo(2)) + .body("[0].firstReportId", is(notNullValue())); + + } + + @Test + void testGetSbomReports_WithSortBySubmittedAt() { + RestAssured.baseURI = API_BASE; + + // Test sorting by submittedAt ASC + var ascResults = RestAssured.given() + .when() + .queryParam("sortField", "submittedAt") + .queryParam("sortDirection", "ASC") + .queryParam("pageSize", 100) + .get("/api/v1/sbom-reports") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("$", isA(java.util.List.class)) + .extract() + .jsonPath() + .getList("submittedAt", String.class); + + // Verify ASC sorting: each submittedAt should be <= the next one (or nulls at the end) + if (ascResults != null && ascResults.size() > 1) { + for (int i = 0; i < ascResults.size() - 1; i++) { + String current = ascResults.get(i); + String next = ascResults.get(i + 1); + if (current != null && next != null) { + Assertions.assertTrue( + current.compareTo(next) <= 0, + String.format("ASC sort failed: %s should be <= %s at index %d", current, next, i) + ); + } + } + } + + // Test sorting by submittedAt DESC + var descResults = RestAssured.given() + .when() + .queryParam("sortField", "submittedAt") + .queryParam("sortDirection", "DESC") + .queryParam("pageSize", 100) + .get("/api/v1/sbom-reports") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("$", isA(java.util.List.class)) + .extract() + .jsonPath() + .getList("submittedAt", String.class); + // Verify DESC sorting: each submittedAt should be >= the next one (or nulls at the end) + Assertions.assertNotNull(descResults, "DESC results should not be null"); + Assertions.assertTrue(descResults.size() > 1, "DESC results should have at least 2 items"); + for (int i = 0; i < descResults.size() - 1; i++) { + String current = descResults.get(i); + String next = descResults.get(i + 1); + if (current != null && next != null) { + Assertions.assertTrue( + current.compareTo(next) >= 0, + String.format("DESC sort failed: %s should be >= %s at index %d", current, next, i) + ); + } + } + + } + + + @Test + void testGetSbomReports_WithPagination() { + // Act & Assert - Test pagination + RestAssured.baseURI = API_BASE; + RestAssured.given() + .when() + .queryParam("page", 0) + .queryParam("pageSize", 5) + .get("/api/v1/sbom-reports") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .header("X-Total-Pages", notNullValue()) + .header("X-Total-Elements", notNullValue()) + .body("$", isA(java.util.List.class)) + .body("size()", lessThanOrEqualTo(5)); + } + + + @Test + void testGetSbomReportById_ReturnsExpectedStructure() { + // Arrange - First get a SBOM report ID from the list + String sbomReportId = "product-1"; + + RestAssured.given() + .when() + .get("/api/v1/sbom-reports/{sbomReportId}", sbomReportId) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("sbomReportId", equalTo(sbomReportId)) + .body("sbomName", equalTo("test-sbom-product-1")) + .body("cveId", equalTo("CVE-2024-12345")) + .body("cveStatusCounts", equalTo(java.util.Map.of("FALSE", 1, "TRUE", 1))) + .body("statusCounts", equalTo(java.util.Map.of("completed", 2))) + .body("completedAt", equalTo("2026-01-26T11:05:00.000000") ) + .body("submittedAt", equalTo("2025-01-15T09:00:00Z")) + .body("numReports", equalTo(2)) + .body("firstReportId", is(notNullValue())); + + } + + @Test + void testGetSbomReportById_NotFound() { + // Act & Assert - Test getting a non-existent SBOM report + RestAssured.baseURI = API_BASE; + RestAssured.given() + .when() + .get("/api/v1/sbom-reports/nonexistent-sbom-report-id") + .then() + .statusCode(404); + } + + @Test + void testFirstReportId_CanBeRetrievedFromDatabase() { + // Arrange - Get a SBOM report with firstReportId + RestAssured.baseURI = API_BASE; + var firstReportId = RestAssured.given() + .when() + .queryParam("page", 0) + .queryParam("pageSize", 1) + .get("/api/v1/sbom-reports") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("$", isA(java.util.List.class)) + .extract() + .path("[0].firstReportId"); + + Assertions.assertNotNull(firstReportId, "First report ID should not be null"); + System.out.println("First report ID: " + firstReportId); + // Act & Assert - Verify firstReportId can be used to fetch the report from database + RestAssured.given() + .when() + .get("/api/v1/reports/" + firstReportId) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body(notNullValue()); + + } +} + diff --git a/src/test/resources/devservices/reports/test-sbom-report-9-report-1.json b/src/test/resources/devservices/reports/test-sbom-report-9-report-1.json index 3f56bf2e..c419cada 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-9-report-1.json +++ b/src/test/resources/devservices/reports/test-sbom-report-9-report-1.json @@ -3137,7 +3137,7 @@ }, "nvd": { "cve_id": "CVE-2024-0406", - "cve_description": "A flaw was discovered in the mholt/archiver package. This flaw allows an attacker to create a specially crafted tar file, which, when unpacked, may allow access to restricted files or directories. This issue can allow the creation or overwriting of files with the user's or application's privileges using the library.", + "cve_description": "", "cvss_vector": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:N", "cvss_base_score": 6.1, "cvss_severity": "MEDIUM",