From e3a09c683479154a22155bbb7a4dad4fe30f5709 Mon Sep 17 00:00:00 2001 From: rhartuv Date: Mon, 23 Feb 2026 15:12:48 +0200 Subject: [PATCH 1/7] feat: add cve details page and integrate with new ui --- README.md | 17 +- docs/images/cve_click.png | Bin 0 -> 26984 bytes docs/images/cve_details_page.png | Bin 0 -> 142989 bytes openspec/specs/cve-details-page/spec.md | 67 + openspec/specs/request-analysis-modal/spec.md | 6 +- openspec/specs/sbom-reports-api/spec.md | 102 ++ .../morpheus/model/ReportsSummary.java | 18 + .../morpheus/model/SbomReport.java | 30 + .../repository/ReportRepositoryService.java | 1 - .../morpheus/rest/RestApplication.java | 13 + .../morpheus/rest/SbomReportEndpoint.java | 238 ++++ .../service/CveIdValidationException.java | 15 + .../service/FileValidationException.java | 15 + .../morpheus/service/ParsedCycloneDx.java | 23 + .../service/PreProcessingService.java | 57 +- .../morpheus/service/SbomReportsService.java | 338 +++++ .../morpheus/service/ValidationException.java | 20 + src/main/resources/application.properties | 1 - src/main/webui/package-lock.json | 1097 ++++++++++++++++- src/main/webui/package.json | 1 + src/main/webui/scripts/remove-servers.js | 32 + src/main/webui/src/App.tsx | 7 +- .../src/components/CveDescriptionCard.tsx | 41 + .../src/components/CveDetailsPageSkeleton.tsx | 53 + .../webui/src/components/CveMetadataCard.tsx | 109 ++ .../src/components/CveReferencesCard.tsx | 32 + src/main/webui/src/components/CveStatus.tsx | 12 +- .../components/CveVulnerablePackagesCard.tsx | 65 + src/main/webui/src/components/CvssBanner.tsx | 45 +- src/main/webui/src/components/DetailsCard.tsx | 40 +- src/main/webui/src/components/Navigation.tsx | 2 + .../webui/src/components/ReportDetails.tsx | 28 +- .../generated-client/models/ReportsSummary.ts | 26 + .../src/generated-client/models/SbomReport.ts | 46 + .../generated-client/models/Vulnerability.ts | 18 + .../services/SbomReportEndpointService.ts | 155 +++ .../services/VulnerabilityEndpointService.ts | 173 +++ src/main/webui/src/hooks/useCveDetails.ts | 255 ++++ src/main/webui/src/hooks/usePostApi.ts | 100 ++ src/main/webui/src/hooks/useReport.ts | 1 - src/main/webui/src/mocks/handlers.ts | 3 - src/main/webui/src/pages/CveDetailsPage.tsx | 256 ++++ .../webui/src/pages/RepositoryReportPage.tsx | 106 +- .../rest/ReportUploadEndpointTest.java | 1 - .../rest/SbomReportsEndpointTest.java | 196 +++ .../reports/test-sbom-report-1-report-2.json | 2 +- .../reports/test-sbom-report-2-report-1.json | 2 +- .../reports/test-sbom-report-3-report-2.json | 2 +- .../reports/test-sbom-report-4-report-1.json | 3 +- .../reports/test-sbom-report-4-report-2.json | 3 +- .../reports/test-sbom-report-9-report-1.json | 2 +- 51 files changed, 3763 insertions(+), 112 deletions(-) create mode 100644 docs/images/cve_click.png create mode 100644 docs/images/cve_details_page.png create mode 100644 openspec/specs/cve-details-page/spec.md create mode 100644 openspec/specs/sbom-reports-api/spec.md create mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/model/ReportsSummary.java create mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/model/SbomReport.java create mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/rest/RestApplication.java create mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportEndpoint.java create mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/service/CveIdValidationException.java create mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/service/FileValidationException.java create mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/service/ParsedCycloneDx.java create mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportsService.java create mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/service/ValidationException.java create mode 100755 src/main/webui/scripts/remove-servers.js create mode 100644 src/main/webui/src/components/CveDescriptionCard.tsx create mode 100644 src/main/webui/src/components/CveDetailsPageSkeleton.tsx create mode 100644 src/main/webui/src/components/CveMetadataCard.tsx create mode 100644 src/main/webui/src/components/CveReferencesCard.tsx create mode 100644 src/main/webui/src/components/CveVulnerablePackagesCard.tsx create mode 100644 src/main/webui/src/generated-client/models/ReportsSummary.ts create mode 100644 src/main/webui/src/generated-client/models/SbomReport.ts create mode 100644 src/main/webui/src/generated-client/models/Vulnerability.ts create mode 100644 src/main/webui/src/generated-client/services/SbomReportEndpointService.ts create mode 100644 src/main/webui/src/generated-client/services/VulnerabilityEndpointService.ts create mode 100644 src/main/webui/src/hooks/useCveDetails.ts create mode 100644 src/main/webui/src/hooks/usePostApi.ts create mode 100644 src/main/webui/src/pages/CveDetailsPage.tsx create mode 100644 src/test/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportsEndpointTest.java diff --git a/README.md b/README.md index 571a005e..a3ceb61d 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ for sending requests to evaluate vulnerabilities on specific SBOMs. Check this other documents for: -* [Configuration](./docs/configuration.md) -* [Development](./docs/development.md) +- [Configuration](./docs/configuration.md) +- [Development](./docs/development.md) ## Using the Application @@ -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 0000000000000000000000000000000000000000..04b49ecad5686540597ae61f15cf7d4541eacadb GIT binary patch literal 26984 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 b9iP@398FH(qi=$*Afaw6YbX^em_Ppyd8WVD literal 0 HcmV?d00001 diff --git a/docs/images/cve_details_page.png b/docs/images/cve_details_page.png new file mode 100644 index 0000000000000000000000000000000000000000..0f204dd1caadaa3e1764163af9eeb948da683dd7 GIT binary patch literal 142989 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

%90NXC3VV@TA1_vu=;lPwM~hw|Xw0*3km>n}1mW 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 U&-U0dk+(~UpF5R&;?j-(1}*acqW}N^ literal 0 HcmV?d00001 diff --git a/openspec/specs/cve-details-page/spec.md b/openspec/specs/cve-details-page/spec.md new file mode 100644 index 00000000..aec73f42 --- /dev/null +++ b/openspec/specs/cve-details-page/spec.md @@ -0,0 +1,67 @@ +# 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 a route to display CVE details for a specific CVE ID. + +#### Scenario: Navigate to CVE Details page +- **WHEN** a user navigates to `/reports/cve/:cveId` where `:cveId` is a CVE identifier (e.g., "cve-2024-0987") +- **THEN** the CVE Details page displays with the CVE ID from the route parameter + +#### Scenario: Breadcrumb navigation displays full path +- **WHEN** a user navigates to the CVE Details page from a repository report page +- **THEN** a breadcrumb navigation is displayed showing: Reports > SbomName/CVE > Cve xxxx-xxxx > CVE Details +- **AND** breadcrumb items are clickable links that navigate to their respective pages, except the last "CVE Details" item which is non-clickable + +### 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 in the format "Severity (Score)" (e.g., "High (8.2)") where severity is determined from the CVSS score using the shared `getCvssSeverityIconAndColor` utility function from `CvssBanner` component (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 \ No newline at end of file diff --git a/openspec/specs/request-analysis-modal/spec.md b/openspec/specs/request-analysis-modal/spec.md index 02b8dbfa..81635106 100644 --- a/openspec/specs/request-analysis-modal/spec.md +++ b/openspec/specs/request-analysis-modal/spec.md @@ -12,10 +12,8 @@ The request analysis modal SHALL submit the CVE ID and CycloneDX file to the `/a - **THEN** the modal validates the CVE ID format matches the official CVE regex pattern `^CVE-[0-9]{4}-[0-9]{4,19}$` - **AND** creates a multipart form with the CVE ID and file - **AND** calls the `/api/v1/products/upload-cyclonedx` API endpoint using the generated OpenAPI client service -- **AND** displays a loading state (disables submit button) during the API call -- **AND** upon successful response (HTTP 202), extracts the `reportRequestId.id` field from the `ReportData` response -- **AND** navigates to the repository report page at `/reports/component/:cveId/:reportId` where `:cveId` is the CVE ID from the form and `:reportId` is the `reportRequestId.id` from the API response -- **AND** closes the modal +- **THEN** the modal creates a multipart form with the CVE ID and file +- **AND** calls the `/api/v1/sbom-reports/upload-cyclonedx` API endpoint using the generated OpenAPI client service #### Scenario: API field-specific error display - **WHEN** a user submits the request analysis modal diff --git a/openspec/specs/sbom-reports-api/spec.md b/openspec/specs/sbom-reports-api/spec.md new file mode 100644 index 00000000..5003ae41 --- /dev/null +++ b/openspec/specs/sbom-reports-api/spec.md @@ -0,0 +1,102 @@ +# sbom-reports-api Specification + +## Purpose +The SBOM reports API provides a REST endpoint to retrieve reports grouped by sbom_report_id. It filters reports to only include those with `metadata.sbom_report_id`, sorts them by a configurable sort field, groups them by `metadata.sbom_report_id`, and returns paginated results. +## Requirements +### Requirement: SBOM Reports Endpoint +The system SHALL provide a REST API endpoint `/api/v1/sbom-reports` that filters reports to only include those with `metadata.sbom_report_id`, sorts them by a configurable sort field (valid fields: `submittedAt`, `sbomReportId`, `sbomName`), groups them by `metadata.sbom_report_id`, and returns paginated results. The service logic SHALL be implemented in `SbomReportsService.java` separate from the existing ReportService. + +#### Scenario: Filter reports by sbom_report_id +- **WHEN** a client requests `/api/v1/sbom-reports` +- **THEN** the API filters reports to only include those that have `metadata.sbom_report_id` set +- **AND** reports without `metadata.sbom_report_id` are excluded from the results + +#### Scenario: Sort reports by submitted_at +- **WHEN** a client requests `/api/v1/sbom-reports` with sort field `submittedAt` +- **THEN** reports are grouped by `metadata.sbom_report_id` first +- **AND** during grouping, the first report's `metadata.submitted_at` value is captured as `sortValue` for each group +- **AND** after grouping, groups are sorted by the captured `sortValue` in the specified direction (ASC or DESC) + +#### Scenario: Sort reports by sbom_name +- **WHEN** a client requests `/api/v1/sbom-reports` with sort field `sbomName` +- **THEN** reports are grouped by `metadata.sbom_report_id` first +- **AND** during grouping, the first report's `metadata.sbom_name` value is captured as `sortValue` for each group +- **AND** after grouping, groups are sorted by the captured `sortValue` in the specified direction (ASC or DESC) + +#### Scenario: Group reports by sbom_report_id +- **WHEN** a client requests `/api/v1/sbom-reports` AND there are reports with sbom_report_id values +- **THEN** reports are grouped by their `metadata.sbom_report_id` value +- **AND** each group contains all reports sharing the same sbom_report_id value +- **AND** during grouping, the sort field value from the first report in each group is captured as `sortValue` +- **AND** after grouping, groups are sorted by the captured `sortValue` according to the sort field and direction + +#### Scenario: Paginated response +- **WHEN** a client requests `/api/v1/sbom-reports` with pagination parameters (page and pageSize) +- **THEN** the API returns a paginated result containing only the requested page of SBOM reports +- **AND** response headers include `X-Total-Pages` and `X-Total-Elements` following existing pagination patterns +- **AND** pagination is applied after filtering, grouping, and sorting + +#### Scenario: Service implementation +- **WHEN** the SBOM reports functionality is implemented +- **THEN** the service logic is implemented in `SbomReportsService.java` (not in the existing ReportService) +- **AND** the service file follows existing service patterns and conventions + +#### Scenario: MongoDB aggregation implementation +- **WHEN** reports are fetched and grouped +- **THEN** the implementation uses `getCollection().aggregate()` pattern +- **AND** filtering is applied first to include only reports with `metadata.sbom_report_id` using `$match` stage +- **AND** the aggregation pipeline uses `$group` stage to group reports by `metadata.sbom_report_id` +- **AND** during the `$group` stage, all reports for each sbom_report_id are pushed into an array using `$push` +- **AND** during the `$group` stage, the sort field value from the first report in each group is captured as `sortValue` using `$first` accumulator +- **AND** after the `$group` stage, groups are sorted by the captured `sortValue` using `$sort` stage in the specified direction (ASC or DESC) +- **AND** the aggregation pipeline includes `$skip` and `$limit` stages after `$sort` to paginate the sorted groups +- **AND** a separate count aggregation is used to determine the total number of groups for pagination headers +- **AND** post-processing in Java extracts and computes the response fields (sbomName, sbomReportId, cveId, cveStatusCounts, statusCounts, completedAt, submittedAt, numReports, firstReportId) from the grouped results + +#### Scenario: API error handling +- **WHEN** a client requests `/api/v1/sbom-reports` AND an error occurs +- **THEN** the API returns an appropriate HTTP error status code (500 for internal server errors) +- **AND** the error response follows standard REST error response format + +### Requirement: Get SBOM Report by ID Endpoint +The system SHALL provide a REST API endpoint `/api/v1/sbom-reports/{sbomReportId}` that retrieves SBOM report data for a specific SBOM report ID. + +#### Scenario: Get SBOM report by ID +- **WHEN** a client requests `/api/v1/sbom-reports/{sbomReportId}` with a valid SBOM report ID +- **THEN** the API returns a single `SbomReport` object for that sbom_report_id +- **AND** the response structure matches the SBOM Reports API Response Structure +- **AND** the API returns HTTP status 200 + +#### Scenario: SBOM report not found +- **WHEN** a client requests `/api/v1/sbom-reports/{sbomReportId}` with a SBOM report ID that does not exist +- **THEN** the API returns HTTP status 404 (Not Found) + +#### Scenario: Get SBOM report by ID error handling +- **WHEN** a client requests `/api/v1/sbom-reports/{sbomReportId}` AND an error occurs +- **THEN** the API returns an appropriate HTTP error status code (500 for internal server errors) +- **AND** the error response follows standard REST error response format + +### Requirement: SBOM Reports API Response Structure +The `/api/v1/sbom-reports` API endpoint SHALL return a list of `SbomReport` objects with the following structure: +- `sbomName`: String containing the SBOM name from the first report's `metadata.sbom_name` +- `sbomReportId`: String containing the sbom_report_id from `metadata.sbom_report_id` (required) +- `cveId`: String containing the CVE ID from the first report's `input.scan.vulns[0].vuln_id` +- `cveStatusCounts`: Map containing ExploitIQ status counts (direct mapping from status to count), aggregated from all component reports for the SBOM report and CVE (required) +- `statusCounts`: Map containing report status counts (direct mapping from status to count), aggregated from all component reports for the SBOM report (required) +- `completedAt`: String timestamp of when reports were completed - empty if any report's completed_at is empty, otherwise the latest value +- `submittedAt`: String timestamp of when the first report was created (submitted) - taken from the first report's `metadata.submitted_at` +- `numReports`: Integer containing the number of reports in this SBOM report group (required) +- `firstReportId`: String containing the MongoDB document _id (as hex string) of the first report in the group, always populated for navigation purposes + +#### Scenario: SBOM report response structure +- **WHEN** the API returns a SBOM report row +- **THEN** the `sbomReportId` field contains the sbom_report_id value from `metadata.sbom_report_id` +- **AND** the `sbomName` field contains the SBOM name from the first report's `metadata.sbom_name` +- **AND** the `cveId` field contains the CVE ID from the first report's `input.scan.vulns[0].vuln_id` +- **AND** the `cveStatusCounts` field contains aggregated status counts from all component reports for that SBOM report and CVE (direct map from status to count) +- **AND** the `statusCounts` field contains aggregated report status counts from all component reports for that SBOM report (direct map from status to count) +- **AND** the `completedAt` field contains the latest completion timestamp from component reports, or empty string if any report's completed_at is empty +- **AND** the `submittedAt` field contains the submitted timestamp from the first report's `metadata.submitted_at` +- **AND** the `numReports` field contains the count of reports in the SBOM report group +- **AND** the `firstReportId` field contains the MongoDB document _id (as hex string) of the first report in the group for navigation purposes + diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/ReportsSummary.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/ReportsSummary.java new file mode 100644 index 00000000..a3a6cf2b --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/ReportsSummary.java @@ -0,0 +1,18 @@ +package com.redhat.ecosystemappeng.morpheus.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "ReportsSummary", description = "Summary of reports statistics") +@RegisterForReflection +public record ReportsSummary( + @Schema(required = true, description = "Count of reports containing vulnerable CVEs") + long vulnerableReportsCount, + @Schema(required = true, description = "Count of reports containing only non-vulnerable CVEs") + long nonVulnerableReportsCount, + @Schema(required = true, description = "Count of pending analysis requests") + long pendingRequestsCount, + @Schema(required = true, description = "Count of new reports submitted today") + long newReportsTodayCount) { +} + diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/SbomReport.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/SbomReport.java new file mode 100644 index 00000000..8f512577 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/SbomReport.java @@ -0,0 +1,30 @@ +package com.redhat.ecosystemappeng.morpheus.model; + +import java.util.Map; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "SbomReport", description = "SBOM report data grouped by sbom_report_id") +@RegisterForReflection +public record SbomReport( + @Schema(description = "SBOM name from first report's metadata.sbom_name") + String sbomName, + @Schema(required = true, description = "SBOM report ID from first report's metadata.sbom_report_id") + String sbomReportId, + @Schema(description = "CVE ID from first report's input.scan.vulns[0].vuln_id") + String cveId, + @Schema(required = true, description = "Map of CVE status to count of reports with that status") + Map cveStatusCounts, + @Schema(required = true, description = "Map of report status to count of reports with that status") + Map statusCounts, + @Schema(description = "Completed at timestamp - empty if any report's completed_at is empty, otherwise latest value") + String completedAt, + @Schema(description = "Submitted at timestamp from first report's metadata.submitted_at") + String submittedAt, + @Schema(required = true, description = "Number of reports in this SBOM report group") + Integer numReports, + @Schema(description = "MongoDB document _id (as hex string) of the first report in the group, always populated for navigation purposes") + String firstReportId) { +} + diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java index 07b7b72d..2414f7c8 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java @@ -648,4 +648,3 @@ private Bson buildQueryFilter(Map queryFilter) { } - diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/RestApplication.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/RestApplication.java new file mode 100644 index 00000000..ce6e49d3 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/RestApplication.java @@ -0,0 +1,13 @@ +package com.redhat.ecosystemappeng.morpheus.rest; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +/** + * JAX-RS Application class to configure the base path for all REST endpoints. + * All REST endpoints will be prefixed with /api/v1 + */ +@ApplicationPath("/api/v1") +public class RestApplication extends Application { +} + diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportEndpoint.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportEndpoint.java new file mode 100644 index 00000000..58673a94 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportEndpoint.java @@ -0,0 +1,238 @@ +package com.redhat.ecosystemappeng.morpheus.rest; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.ClientWebApplicationException; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.redhat.ecosystemappeng.morpheus.model.SbomReport; +import com.redhat.ecosystemappeng.morpheus.model.Pagination; +import com.redhat.ecosystemappeng.morpheus.model.ReportData; +import com.redhat.ecosystemappeng.morpheus.model.ReportRequest; +import com.redhat.ecosystemappeng.morpheus.model.SortType; +import com.redhat.ecosystemappeng.morpheus.model.ValidationErrorResponse; +import com.redhat.ecosystemappeng.morpheus.service.SbomReportsService; +import com.redhat.ecosystemappeng.morpheus.service.ReportService; +import com.redhat.ecosystemappeng.morpheus.service.RequestQueueExceededException; +import com.redhat.ecosystemappeng.morpheus.service.CycloneDxUploadService; +import com.redhat.ecosystemappeng.morpheus.service.ValidationException; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import java.io.InputStream; + +@SecurityScheme(securitySchemeName = "jwt", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "jwt", description = "Please enter your JWT Token without Bearer") +@SecurityRequirement(name = "jwt") +@Path("/sbom-reports") +@Produces(MediaType.APPLICATION_JSON) +public class SbomReportEndpoint { + + private static final Logger LOGGER = Logger.getLogger(SbomReportEndpoint.class); + + private static final String PAGE = "page"; + private static final String PAGE_SIZE = "pageSize"; + + @Inject + SbomReportsService sbomReportsService; + + @Inject + ObjectMapper objectMapper; + + @Inject + CycloneDxUploadService cycloneDxUploadService; + + @Inject + ReportService reportService; + + @GET + @Operation( + summary = "List SBOM reports", + description = "Retrieves a paginated list of reports grouped by sbom_report_id, filtered to only include reports with metadata.sbom_report_id, sorted by submittedAt, sbomName, or sbomReportId") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "SBOM reports retrieved successfully", + content = @Content( + schema = @Schema(type = SchemaType.ARRAY, implementation = SbomReport.class) + ) + ), + @APIResponse( + responseCode = "500", + description = "Internal server error" + ) + }) + public Response listSbomReports( + @Parameter( + description = "Sort field: 'submittedAt', 'sbomName', or 'sbomReportId'" + ) + @QueryParam("sortField") @DefaultValue("submittedAt") String sortField, + @Parameter( + description = "Sort direction: 'ASC' or 'DESC'" + ) + @QueryParam("sortDirection") @DefaultValue("DESC") String sortDirection, + @Parameter( + description = "Page number (0-based)" + ) + @QueryParam(PAGE) @DefaultValue("0") Integer page, + @Parameter( + description = "Number of items per page" + ) + @QueryParam(PAGE_SIZE) @DefaultValue("100") Integer pageSize, + @Parameter( + description = "Filter by SBOM name (case-insensitive partial match)" + ) + @QueryParam("sbomName") String sbomName, + @Parameter( + description = "Filter by CVE ID (case-insensitive partial match)" + ) + @QueryParam("cveId") String cveId) { + try { + SortType sortType = SortType.valueOf(sortDirection.toUpperCase()); + var result = sbomReportsService.getSbomReports(sortField, sortType, new Pagination(page, pageSize), + sbomName, cveId); + return Response.ok(result.results) + .header("X-Total-Pages", result.totalPages) + .header("X-Total-Elements", result.totalElements) + .build(); + } catch (Exception e) { + LOGGER.error("Unable to retrieve SBOM reports", e); + return Response.serverError() + .entity(objectMapper.createObjectNode() + .put("error", e.getMessage())) + .build(); + } + } + + @GET + @Path("/{sbomReportId}") + @Operation( + summary = "Get SBOM report by ID", + description = "Retrieves SBOM report data for a specific SBOM report ID") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "SBOM report retrieved successfully", + content = @Content( + schema = @Schema(type = SchemaType.OBJECT, implementation = SbomReport.class) + ) + ), + @APIResponse( + responseCode = "404", + description = "SBOM report not found" + ), + @APIResponse( + responseCode = "500", + description = "Internal server error" + ) + }) + public Response getSbomReport( + @Parameter( + description = "SBOM report ID", + required = true + ) + @PathParam("sbomReportId") String sbomReportId) { + try { + SbomReport sbomReport = sbomReportsService.getSbomReportById(sbomReportId); + if (sbomReport == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + return Response.ok(sbomReport).build(); + } catch (Exception e) { + LOGGER.error("Unable to retrieve SBOM report", e); + return Response.serverError() + .entity(objectMapper.createObjectNode() + .put("error", e.getMessage())) + .build(); + } + } + + @POST + @Path("/upload-cyclonedx") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation( + summary = "Upload CycloneDX file for analysis", + description = "Accepts a multipart form with CVE ID and CycloneDX file, validates the file structure, creates a report with SBOM report ID, and queues it for analysis") + @APIResponses({ + @APIResponse( + responseCode = "202", + description = "File uploaded and analysis request queued", + content = @Content( + schema = @Schema(implementation = ReportData.class) + ) + ), + @APIResponse( + responseCode = "400", + description = "Invalid request data (invalid CVE format, invalid JSON, missing required fields)", + content = @Content( + schema = @Schema(implementation = ValidationErrorResponse.class) + ) + ), + @APIResponse( + responseCode = "429", + description = "Request queue exceeded" + ), + @APIResponse( + responseCode = "500", + description = "Internal server error" + ) + }) + public Response uploadCycloneDx( + @Parameter( + description = "CVE ID to analyze (must match the official CVE pattern CVE-YYYY-NNNN+)", + required = true + ) + @FormParam("cveId") String cveId, + @Parameter( + description = "CycloneDX JSON file", + required = true + ) + @FormParam("file") InputStream fileInputStream) { + try { + ReportRequest request = cycloneDxUploadService.processUpload(cveId, fileInputStream); + ReportData res = reportService.process(request); + reportService.submit(res.reportRequestId().id(), res.report()); + return Response.accepted(res).build(); + } catch (ValidationException e) { + ValidationErrorResponse errorResponse = new ValidationErrorResponse(e.getErrors()); + return Response.status(Status.BAD_REQUEST) + .entity(errorResponse) + .build(); + } catch (ClientWebApplicationException e) { + return Response.status(e.getResponse().getStatus()) + .entity(e.getResponse().getEntity()) + .build(); + } catch (RequestQueueExceededException e) { + return Response.status(Status.TOO_MANY_REQUESTS) + .entity(objectMapper.createObjectNode() + .put("error", e.getMessage())) + .build(); + } catch (Exception e) { + LOGGER.error("Unable to process CycloneDX file upload request", e); + return Response.serverError() + .entity(objectMapper.createObjectNode() + .put("error", e.getMessage())) + .build(); + } + } +} + diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/CveIdValidationException.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/CveIdValidationException.java new file mode 100644 index 00000000..178eb6b0 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/CveIdValidationException.java @@ -0,0 +1,15 @@ +package com.redhat.ecosystemappeng.morpheus.service; + +/** + * Exception thrown when CVE ID validation fails + */ +public class CveIdValidationException extends IllegalArgumentException { + public CveIdValidationException(String message) { + super(message); + } + + public CveIdValidationException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/FileValidationException.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/FileValidationException.java new file mode 100644 index 00000000..a472f5bf --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/FileValidationException.java @@ -0,0 +1,15 @@ +package com.redhat.ecosystemappeng.morpheus.service; + +/** + * Exception thrown when file validation fails + */ +public class FileValidationException extends IllegalArgumentException { + public FileValidationException(String message) { + super(message); + } + + public FileValidationException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ParsedCycloneDx.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ParsedCycloneDx.java new file mode 100644 index 00000000..bbb05a55 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ParsedCycloneDx.java @@ -0,0 +1,23 @@ +package com.redhat.ecosystemappeng.morpheus.service; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Result of parsing a CycloneDX file, containing both the parsed JSON and the extracted SBOM name and version. + */ +public record ParsedCycloneDx( + /** + * The parsed CycloneDX JSON structure + */ + JsonNode sbomJson, + /** + * The SBOM name extracted from metadata.component.name + */ + String sbomName, + /** + * The SBOM version extracted from metadata.component.version (may be null if not present) + */ + String sbomVersion +) { +} + diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java index 835c21f5..7e7aa8ee 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java @@ -1,17 +1,17 @@ package com.redhat.ecosystemappeng.morpheus.service; -import java.io.IOException; -import java.io.InputStream; -import java.time.Duration; -import java.time.LocalDateTime; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; +import java.util.Objects; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.rest.client.inject.RestClient; @@ -26,6 +26,7 @@ import com.redhat.ecosystemappeng.morpheus.repository.ReportRepositoryService; import io.quarkus.scheduler.Scheduled; + import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; @@ -39,7 +40,7 @@ public class PreProcessingService { @ConfigProperty(name = "morpheus.syncer.timeout", defaultValue = "1h") Duration timeout; - + @Inject ObjectMapper objectMapper; @@ -54,7 +55,7 @@ public JsonNode parse(List payloads) throws IOException { try (InputStream is = getClass().getClassLoader().getResourceAsStream("preProcessingTemplate.json")) { if (Objects.isNull(is)) { - throw new IllegalArgumentException("Template file not found in resources."); + throw new IllegalArgumentException("Template file not found in resources."); } ObjectNode templateJson = (ObjectNode) objectMapper.readTree(is); @@ -63,14 +64,14 @@ public JsonNode parse(List payloads) throws IOException { ArrayNode dataArray = objectMapper.createArrayNode(); for (ReportData payload : payloads) { - String reportId = payload.reportRequestId().id(); + String scanId = payload.reportRequestId().id(); JsonNode sourceInfo = payload.report().at("/input/image/source_info"); - if (Objects.nonNull(reportId) && !reportId.isEmpty() && !sourceInfo.isMissingNode()) { - ObjectNode dataEntry = objectMapper.createObjectNode(); - dataEntry.put("report_id", reportId); - dataEntry.set("source_info", sourceInfo); - dataArray.add(dataEntry); + if (Objects.nonNull(scanId) && !scanId.isEmpty() && !sourceInfo.isMissingNode()) { + ObjectNode dataEntry = objectMapper.createObjectNode(); + dataEntry.put("scan_id", scanId); + dataEntry.set("source_info", sourceInfo); + dataArray.add(dataEntry); } } @@ -98,19 +99,19 @@ public Response submit(JsonNode request, List ids) throws IOException, I LOGGER.debug("Component Syncer response status: " + status); if (status >= Response.Status.OK.getStatusCode() && status < Response.Status.MULTIPLE_CHOICES.getStatusCode()) { - LOGGER.info("Successfully sent payloads to Component Syncer"); + LOGGER.info("Successfully sent payloads to Component Syncer"); + + LocalDateTime now = LocalDateTime.now(); + ids.forEach(id -> submitted.put(id, now)); - LocalDateTime now = LocalDateTime.now(); - ids.forEach(id -> submitted.put(id, now)); - - return response; + return response; } else if (status >= Response.Status.INTERNAL_SERVER_ERROR.getStatusCode() && attempt < maxRetries) { - LOGGER.warnf("Component Syncer failed with status code: %s, will retry in %dms", status, delay); - Thread.sleep(delay); - delay = delay * BACKOFF_MULTIPLIER; + LOGGER.warnf("Component Syncer failed with status code: %s, will retry in %dms", status, delay); + Thread.sleep(delay); + delay = delay * BACKOFF_MULTIPLIER; } else { - LOGGER.errorf("Component Syncer failed with status code: %s, all retries exhausted", status); - return response; + LOGGER.errorf("Component Syncer failed with status code: %s, all retries exhausted", status); + return response; } } } @@ -130,12 +131,12 @@ public void checkSubmitted() { Set expired = new HashSet<>(); submitted.forEach((id, startTime) -> { - if (now.isAfter(startTime.plus(timeout))) { - expired.add(id); - LOGGER.warnf("Component Syncer timeout for component Id: %s", id); - handleError(id,"component-syncer-timeout-error",String.format("No response from Component Syncer after %s seconds", timeout.toSeconds()) + if (now.isAfter(startTime.plus(timeout))) { + expired.add(id); + LOGGER.warnf("Component Syncer timeout for component Id: %s", id); + handleError(id,"component-syncer-timeout-error",String.format("No response from Component Syncer after %s seconds", timeout.toSeconds()) ); - } + } }); expired.forEach(submitted::remove); diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportsService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportsService.java new file mode 100644 index 00000000..0a58c2e6 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportsService.java @@ -0,0 +1,338 @@ +package com.redhat.ecosystemappeng.morpheus.service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import org.bson.Document; +import org.bson.conversions.Bson; +import org.bson.types.ObjectId; +import org.jboss.logging.Logger; + +import com.mongodb.client.model.Accumulators; +import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.BsonField; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.Sorts; +import com.redhat.ecosystemappeng.morpheus.model.SbomReport; +import com.redhat.ecosystemappeng.morpheus.model.PaginatedResult; +import com.redhat.ecosystemappeng.morpheus.model.Pagination; +import com.redhat.ecosystemappeng.morpheus.model.SortType; +import com.redhat.ecosystemappeng.morpheus.repository.ReportRepositoryService; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class SbomReportsService { + + private static final Logger LOGGER = Logger.getLogger(SbomReportsService.class); + private static final String SBOM_REPORT_ID = "sbom_report_id"; + private static final String SBOM_NAME = "sbom_name"; + private static final String SENT_AT = "sent_at"; + private static final String SUBMITTED_AT = "submitted_at"; + + @Inject + ReportRepositoryService reportRepositoryService; + + public PaginatedResult getSbomReports(String sortField, SortType sortType, Pagination pagination, + String sbomName, String cveId) { + List sbomReports = new ArrayList<>(); + + List filterConditions = new ArrayList<>(); + filterConditions.add(Filters.exists("metadata." + SBOM_REPORT_ID, true)); + + List filterOptions = new ArrayList<>(); + + if (sbomName != null && !sbomName.trim().isEmpty()) { + // This allows users to search for literal text containing special characters while still + // supporting partial matching (e.g., searching "test" matches "test-product" and "my-test-sbom") + String escaped = sbomName.trim().replaceAll("([\\\\+*?\\[\\](){}^$|.])", "\\\\$1"); + filterOptions.add(Filters.regex("metadata." + SBOM_NAME, + Pattern.compile(escaped, Pattern.CASE_INSENSITIVE))); + } + + if (cveId != null && !cveId.trim().isEmpty()) { + String escaped = cveId.trim().replaceAll("([\\\\+*?\\[\\](){}^$|.])", "\\\\$1"); + filterOptions.add(Filters.elemMatch("input.scan.vulns", + Filters.regex("vuln_id", Pattern.compile(escaped, Pattern.CASE_INSENSITIVE)))); + } + + // Combine all filters: all filter options with AND logic, Multiple values within the same filter type use OR logic + if (!filterOptions.isEmpty()) { + filterConditions.add(filterOptions.size() == 1 + ? filterOptions.get(0) + : Filters.and(filterOptions)); + } + + Bson filter = filterConditions.size() == 1 + ? filterConditions.get(0) + : Filters.and(filterConditions); + + // Build sort + String sortFieldPath = getSortFieldPath(sortField); + Bson sort = sortType == SortType.ASC + ? Sorts.ascending(sortFieldPath) + : Sorts.descending(sortFieldPath); + + // Build aggregation pipeline + List pipeline = new ArrayList<>(); + pipeline.add(Aggregates.match(filter)); + pipeline.add(Aggregates.sort(sort)); + + // Group by sbom_report_id, capturing first sort value for post-group sorting + List groupAccumulators = new ArrayList<>(); + groupAccumulators.add(Accumulators.push("reports", "$$ROOT")); + groupAccumulators.add(Accumulators.first("sortValue", "$" + sortFieldPath)); + pipeline.add(Aggregates.group( + "$metadata." + SBOM_REPORT_ID, + groupAccumulators.toArray(new BsonField[0]) + )); + + // Sort groups by the captured sort value + Bson groupSort = sortType == SortType.ASC + ? Sorts.ascending("sortValue") + : Sorts.descending("sortValue"); + pipeline.add(Aggregates.sort(groupSort)); + + pipeline.add(Aggregates.skip(pagination.page() * pagination.size())); + pipeline.add(Aggregates.limit(pagination.size())); + + // Execute aggregation + reportRepositoryService.getCollection() + .aggregate(pipeline) + .forEach(groupDoc -> { + SbomReport sbomReport = processGroup(groupDoc); + if (sbomReport != null) { + sbomReports.add(sbomReport); + } + }); + + // Get total count of groups for pagination + long totalGroups = getTotalGroupCount(filter); + int totalPages = (int) Math.ceil((double) totalGroups / pagination.size()); + + return new PaginatedResult<>(totalGroups, totalPages, sbomReports.stream()); + } + + public SbomReport getSbomReportById(String sbomReportId) { + // Build filter for reports with specific sbom_report_id + LOGGER.infof("Getting SBOM report by ID: %s", sbomReportId); + Bson filter = Filters.eq("metadata." + SBOM_REPORT_ID, sbomReportId); + + // Collect all reports for this sbom_report_id + List reports = new ArrayList<>(); + reportRepositoryService.getCollection() + .find(filter) + .forEach(reports::add); + + // If no reports found, return null + if (reports.isEmpty()) { + return null; + } + + // Process reports directly (no need for aggregation since all have same sbom_report_id) + return processReports(sbomReportId, reports); + } + + private String getSortFieldPath(String sortField) { + return switch (sortField) { + case "submittedAt" -> "metadata.submitted_at"; + case "sbomName" -> "metadata." + SBOM_NAME; + case "sbomReportId" -> "metadata." + SBOM_REPORT_ID; + default -> "metadata.submitted_at"; + }; + } + + private long getTotalGroupCount(Bson filter) { + List countPipeline = new ArrayList<>(); + countPipeline.add(Aggregates.match(filter)); + countPipeline.add(Aggregates.group("$metadata." + SBOM_REPORT_ID)); + + long count = 0; + for (@SuppressWarnings("unused") Document doc : reportRepositoryService.getCollection().aggregate(countPipeline)) { + count++; + } + return count; + } + + private SbomReport processGroup(Document groupDoc) { + try { + String sbomReportId = groupDoc.getString("_id"); + if (sbomReportId == null || sbomReportId.isEmpty()) { + return null; + } + + List reports = groupDoc.getList("reports", Document.class); + if (reports == null || reports.isEmpty()) { + return null; + } + + return processReports(sbomReportId, reports); + } catch (Exception e) { + LOGGER.errorf("Error processing group: %s", e.getMessage(), e); + return null; + } + } + + private SbomReport processReports(String sbomReportId, List reports) { + try { + if (reports == null || reports.isEmpty()) { + return null; + } + + Document firstReport = reports.get(0); + + // Extract firstReportId from first report + String firstReportId = extractReportId(firstReport); + + // Extract sbomName from first report + String sbomName = extractSbomName(firstReport); + + // Extract cveId from first report + String cveId = extractCveId(firstReport); + + // Build cveStatusCounts + Map cveStatusCounts = buildCveStatusCounts(reports); + + // Build statusCounts + Map statusCounts = buildStatusCounts(reports); + + // Calculate completedAt + String completedAt = calculateCompletedAt(reports); + + // Extract submittedAt from first report + String submittedAt = extractSubmittedAt(firstReport); + + // Get numReports + int numReports = reports.size(); + + return new SbomReport( + sbomName, + sbomReportId, + cveId, + cveStatusCounts, + statusCounts, + completedAt, + submittedAt, + numReports, + firstReportId + ); + } catch (Exception e) { + LOGGER.errorf("Error processing reports: %s", e.getMessage(), e); + return null; + } + } + + private String extractReportId(Document report) { + // Extract MongoDB document _id instead of scan ID + ObjectId id = report.get(RepositoryConstants.ID_KEY, ObjectId.class); + if (id != null) { + return id.toHexString(); + } + return null; + } + + private String extractSbomName(Document report) { + Document metadata = report.get("metadata", Document.class); + if (metadata != null) { + return metadata.getString(SBOM_NAME); + } + return null; + } + + private String extractCveId(Document report) { + Document input = report.get("input", Document.class); + if (input != null) { + Document scan = input.get("scan", Document.class); + if (scan != null) { + List vulns = scan.getList("vulns", Document.class); + if (vulns != null && !vulns.isEmpty()) { + Document firstVuln = vulns.get(0); + return firstVuln.getString("vuln_id"); + } + } + } + return null; + } + + private String extractSubmittedAt(Document report) { + Document metadata = report.get("metadata", Document.class); + if (metadata != null) { + Object submittedAtObj = metadata.get("submitted_at"); + if (submittedAtObj != null) { + // Handle both String and Date/Instant types (MongoDB stores dates as Date objects) + if (submittedAtObj instanceof String) { + return (String) submittedAtObj; + } else if (submittedAtObj instanceof java.util.Date) { + return ((java.util.Date) submittedAtObj).toInstant().toString(); + } else { + return submittedAtObj.toString(); + } + } + } + return null; + } + + private Map buildCveStatusCounts(List reports) { + Map counts = new HashMap<>(); + + for (Document report : reports) { + Document output = report.get("output", Document.class); + if (output != null) { + List analysis = output.getList("analysis", Document.class); + if (analysis != null && !analysis.isEmpty()) { + Document firstAnalysis = analysis.get(0); + Document justification = firstAnalysis.get("justification", Document.class); + if (justification != null) { + String status = justification.getString("status"); + if (status != null && !status.isEmpty()) { + counts.merge(status, 1, Integer::sum); + } + } + } + } + } + + return counts; + } + + private Map buildStatusCounts(List reports) { + Map counts = new HashMap<>(); + + for (Document report : reports) { + Map metadata = reportRepositoryService.extractMetadata(report); + String status = reportRepositoryService.getStatus(report, metadata); + counts.merge(status, 1, Integer::sum); + } + + return counts; + } + + private String calculateCompletedAt(List reports) { + String latestCompletedAt = null; + boolean hasEmpty = false; + + for (Document report : reports) { + Document input = report.get("input", Document.class); + if (input != null) { + Document scan = input.get("scan", Document.class); + if (scan != null) { + String completedAt = scan.getString("completed_at"); + if (completedAt == null || completedAt.isEmpty()) { + hasEmpty = true; + } else { + if (latestCompletedAt == null || completedAt.compareTo(latestCompletedAt) > 0) { + latestCompletedAt = completedAt; + } + } + } + } + } + + return hasEmpty ? "" : (latestCompletedAt != null ? latestCompletedAt : ""); + } +} + diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ValidationException.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ValidationException.java new file mode 100644 index 00000000..c649659c --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ValidationException.java @@ -0,0 +1,20 @@ +package com.redhat.ecosystemappeng.morpheus.service; + +import java.util.Map; + +/** + * Exception thrown when validation fails, containing field-specific error messages + */ +public class ValidationException extends IllegalArgumentException { + private final Map errors; + + public ValidationException(Map errors) { + super("Validation failed"); + this.errors = errors; + } + + public Map getErrors() { + return errors; + } +} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 94f69dfc..eb18c82a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -15,7 +15,6 @@ quarkus.rest-client.morpheus.url=https://agent-morpheus:8080/generate quarkus.rest-client.component-syncer.url=http://job-sink.knative-eventing.svc.cluster.local/${NAMESPACE}/component-syncer %dev.quarkus.rest-client.component-syncer.url=http://localhost:8088/${NAMESPACE:exploit-iq}/component-syncer quarkus.smallrye-openapi.store-schema-directory=target/generated/openapi - quarkus.swagger-ui.always-include=${INCLUDE_SWAGGER_UI:true} quarkus.http.filter.others.header.Cache-Control=no-cache quarkus.http.filter.others.matches=/.* diff --git a/src/main/webui/package-lock.json b/src/main/webui/package-lock.json index 9b0f175c..8f8f8cc6 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" @@ -1161,13 +1162,36 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1182,6 +1206,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, "node_modules/@types/node": { "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", @@ -1196,14 +1233,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1227,6 +1262,11 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -1453,7 +1493,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { @@ -1589,6 +1628,15 @@ "node": ">=4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1707,6 +1755,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1744,6 +1801,42 @@ "dev": true, "license": "MIT" }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -1814,6 +1907,15 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -2022,7 +2124,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2036,6 +2137,18 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2058,6 +2171,26 @@ "delaunator": "^4.0.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2323,6 +2456,15 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2333,6 +2475,11 @@ "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2666,6 +2813,44 @@ "node": ">=8" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/headers-polyfill": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", @@ -2682,6 +2867,15 @@ "react-is": "^16.7.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -2769,6 +2963,11 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2778,6 +2977,37 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2811,6 +3041,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-in-browser": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", @@ -2843,6 +3082,17 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3149,6 +3399,15 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3171,6 +3430,151 @@ "yallist": "^3.0.2" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3181,6 +3585,427 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3233,7 +4058,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/msw": { @@ -3474,6 +4298,29 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, "node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -3632,6 +4479,15 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3743,6 +4599,32 @@ "react": ">=16.8.6" } }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3791,6 +4673,37 @@ "react-dom": ">=18" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4023,6 +4936,15 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4055,6 +4977,19 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4081,6 +5016,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -4196,6 +5147,24 @@ "node": ">=16" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -4276,6 +5245,87 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -4337,6 +5387,32 @@ "punycode": "^2.1.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory/-/victory-37.3.6.tgz", @@ -5056,6 +6132,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } 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/scripts/remove-servers.js b/src/main/webui/scripts/remove-servers.js new file mode 100755 index 00000000..be257f3c --- /dev/null +++ b/src/main/webui/scripts/remove-servers.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +/** + * Removes the 'servers' field from OpenAPI spec to allow frontend to use relative URLs + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const inputPath = process.argv[2] || resolve(__dirname, '../../../target/generated/openapi/openapi.json'); +const outputPath = process.argv[3] || resolve(__dirname, '../openapi.json'); + +try { + const spec = JSON.parse(readFileSync(inputPath, 'utf8')); + + // Set servers to empty array instead of removing it + // This ensures the generated client uses an empty BASE URL + spec.servers = []; + + writeFileSync(outputPath, JSON.stringify(spec, null, 2) + '\n', 'utf8'); + console.log(`✓ Set servers to empty array in OpenAPI spec`); + console.log(` Input: ${inputPath}`); + console.log(` Output: ${outputPath}`); +} catch (error) { + console.error(`Error processing OpenAPI spec: ${error.message}`); + process.exit(1); +} + diff --git a/src/main/webui/src/App.tsx b/src/main/webui/src/App.tsx index 4dba58a0..5572b955 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,15 @@ 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..1b5e7836 --- /dev/null +++ b/src/main/webui/src/components/CveDescriptionCard.tsx @@ -0,0 +1,41 @@ +import ReactMarkdown from "react-markdown"; +import { Content } from "@patternfly/react-core"; +import type { CveMetadata } from "../hooks/useCveDetails"; +import NotAvailable from "./NotAvailable"; + +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 ; + } + + // 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/CveDetailsPageSkeleton.tsx b/src/main/webui/src/components/CveDetailsPageSkeleton.tsx new file mode 100644 index 00000000..0d2b2385 --- /dev/null +++ b/src/main/webui/src/components/CveDetailsPageSkeleton.tsx @@ -0,0 +1,53 @@ +import { PageSection, Grid, GridItem, Skeleton } from "@patternfly/react-core"; +import SkeletonCard from "./SkeletonCard"; + +/** + * Skeleton loading state for the CVE Details page + * Matches the structure of CveDetailsPage content + */ +const CveDetailsPageSkeleton: React.FC = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CveDetailsPageSkeleton; diff --git a/src/main/webui/src/components/CveMetadataCard.tsx b/src/main/webui/src/components/CveMetadataCard.tsx new file mode 100644 index 00000000..8036fa4f --- /dev/null +++ b/src/main/webui/src/components/CveMetadataCard.tsx @@ -0,0 +1,109 @@ +import { + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + Flex, + FlexItem, +} from "@patternfly/react-core"; +import type { CveMetadata } from "../hooks/useCveDetails"; +import { getCvssSeverityIconAndColor } 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 }) => { + // Format CVSS score with icon using shared utility from CvssBanner + // Uses PatternFly CSS variables for icon colors + const cvssDisplay = + metadata?.cvssScore !== undefined + ? (() => { + const { severity, Icon, color } = getCvssSeverityIconAndColor( + metadata.cvssScore + ); + return ( + + {Icon && ( + + + + )} + + + {severity} ({metadata.cvssScore}) + + + + ); + })() + : 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..9f26e23c --- /dev/null +++ b/src/main/webui/src/components/CveReferencesCard.tsx @@ -0,0 +1,32 @@ +import { List, ListItem } from "@patternfly/react-core"; +import type { CveMetadata } from "../hooks/useCveDetails"; +import NotAvailable from "./NotAvailable"; + +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 ; + } + + 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..a1173e1b 100644 --- a/src/main/webui/src/components/CveStatus.tsx +++ b/src/main/webui/src/components/CveStatus.tsx @@ -1,13 +1,21 @@ import { Label } from "@patternfly/react-core"; +import type { ReportOutput } from "../types/FullReport"; +import NotAvailable from "./NotAvailable"; interface CveStatusProps { - status: string; + vuln: ReportOutput; } /** * Component to display CVE status based on justification */ -const CveStatus: React.FC = ({ status }) => { +const CveStatus: React.FC = ({ vuln }) => { + const status = vuln?.justification?.status; + const label = vuln?.justification?.label; + + if (!status || !label) { + return ; + } 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..0c92c39f 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"; @@ -38,7 +39,30 @@ 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 productName = report?.metadata?.product_id; + const reportIdDisplay = vuln?.vuln_id + ? `${vuln.vuln_id} | ${image?.name || ""} | ${image?.tag || ""}` + : ""; + + const getBreadcrumbState = () => { + return { + sbomReportId: sbomReportId || productId, + sbomName: productName, + reportId, + reportIdDisplay, + isComponentRoute, + }; + }; return ( @@ -61,14 +85,15 @@ const DetailsCard: React.FC = ({ - {vuln.vuln_id} + + {vuln.vuln_id} + - {outputVuln?.justification?.status ? ( - - ) : ( - - )} + {outputVuln ? : } @@ -141,4 +166,3 @@ const DetailsCard: React.FC = ({ }; export default DetailsCard; - diff --git a/src/main/webui/src/components/Navigation.tsx b/src/main/webui/src/components/Navigation.tsx index 0d9e1278..ade0f64c 100644 --- a/src/main/webui/src/components/Navigation.tsx +++ b/src/main/webui/src/components/Navigation.tsx @@ -17,6 +17,7 @@ const Navigation: React.FC = () => { itemId="home" isActive={location.pathname === "/"} onClick={() => navigate("/")} + style={{ cursor: "pointer" }} > Home @@ -24,6 +25,7 @@ const Navigation: React.FC = () => { itemId="reports" isActive={location.pathname.startsWith("/reports")} onClick={() => navigate("/reports")} + style={{ cursor: "pointer" }} > Reports diff --git a/src/main/webui/src/components/ReportDetails.tsx b/src/main/webui/src/components/ReportDetails.tsx index 164f3ddf..738513b7 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,19 @@ 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; + + const getBreadcrumbState = () => { + return { + sbomReportId: productId, + sbomName: name, + }; + }; return ( @@ -37,7 +45,14 @@ const ReportDetails: React.FC = ({ CVE Analyzed - {cveId} + + + {cveId} + + Report name @@ -64,4 +79,3 @@ const ReportDetails: React.FC = ({ }; export default ReportDetails; - diff --git a/src/main/webui/src/generated-client/models/ReportsSummary.ts b/src/main/webui/src/generated-client/models/ReportsSummary.ts new file mode 100644 index 00000000..561b462d --- /dev/null +++ b/src/main/webui/src/generated-client/models/ReportsSummary.ts @@ -0,0 +1,26 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Summary of reports statistics + */ +export type ReportsSummary = { + /** + * Count of reports containing vulnerable CVEs + */ + vulnerableReportsCount: number; + /** + * Count of reports containing only non-vulnerable CVEs + */ + nonVulnerableReportsCount: number; + /** + * Count of pending analysis requests + */ + pendingRequestsCount: number; + /** + * Count of new reports submitted today + */ + newReportsTodayCount: number; +}; + diff --git a/src/main/webui/src/generated-client/models/SbomReport.ts b/src/main/webui/src/generated-client/models/SbomReport.ts new file mode 100644 index 00000000..88b7adef --- /dev/null +++ b/src/main/webui/src/generated-client/models/SbomReport.ts @@ -0,0 +1,46 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * SBOM report data grouped by sbom_report_id + */ +export type SbomReport = { + /** + * SBOM name from first report's metadata.sbom_name + */ + sbomName?: string; + /** + * SBOM report ID from first report's metadata.sbom_report_id + */ + sbomReportId: string; + /** + * CVE ID from first report's input.scan.vulns[0].vuln_id + */ + cveId?: string; + /** + * Map of CVE status to count of reports with that status + */ + cveStatusCounts: Record; + /** + * Map of report status to count of reports with that status + */ + statusCounts: Record; + /** + * Completed at timestamp - empty if any report's completed_at is empty, otherwise latest value + */ + completedAt?: string; + /** + * Submitted at timestamp from first report's metadata.submitted_at + */ + submittedAt?: string; + /** + * Number of reports in this SBOM report group + */ + numReports: number; + /** + * MongoDB document _id (as hex string) of the first report in the group, always populated for navigation purposes + */ + firstReportId?: string; +}; + diff --git a/src/main/webui/src/generated-client/models/Vulnerability.ts b/src/main/webui/src/generated-client/models/Vulnerability.ts new file mode 100644 index 00000000..401d0932 --- /dev/null +++ b/src/main/webui/src/generated-client/models/Vulnerability.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * User provided vulnerability information + */ +export type Vulnerability = { + /** + * Vulnerability ID (CVE ID) + */ + id: string; + /** + * User provided comments on the vulnerability + */ + comments: string; +}; + diff --git a/src/main/webui/src/generated-client/services/SbomReportEndpointService.ts b/src/main/webui/src/generated-client/services/SbomReportEndpointService.ts new file mode 100644 index 00000000..3989de51 --- /dev/null +++ b/src/main/webui/src/generated-client/services/SbomReportEndpointService.ts @@ -0,0 +1,155 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ReportData } from '../models/ReportData'; +import type { SbomReport } from '../models/SbomReport'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class SbomReportEndpointService { + /** + * List SBOM reports + * Retrieves a paginated list of reports grouped by sbom_report_id, filtered to only include reports with metadata.sbom_report_id, sorted by submittedAt, sbomName, or sbomReportId + * @returns SbomReport SBOM reports retrieved successfully + * @throws ApiError + */ + public static getApiV1SbomReports({ + cveId, + page = 0, + pageSize = 100, + sbomName, + sortDirection = 'DESC', + sortField = 'submittedAt', + }: { + /** + * Filter by CVE ID (case-insensitive partial match) + */ + cveId?: string, + /** + * Page number (0-based) + */ + page?: number, + /** + * Number of items per page + */ + pageSize?: number, + /** + * Filter by SBOM name (case-insensitive partial match) + */ + sbomName?: string, + /** + * Sort direction: 'ASC' or 'DESC' + */ + sortDirection?: string, + /** + * Sort field: 'submittedAt', 'sbomName', or 'sbomReportId' + */ + sortField?: string, + }): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/sbom-reports', + query: { + 'cveId': cveId, + 'page': page, + 'pageSize': pageSize, + 'sbomName': sbomName, + 'sortDirection': sortDirection, + 'sortField': sortField, + }, + errors: { + 500: `Internal server error`, + }, + }); + } + /** + * Upload CycloneDX file for analysis + * Accepts a multipart form with CVE ID and CycloneDX file, validates the file structure, creates a report with SBOM report ID, and queues it for analysis + * @returns ReportData File uploaded and analysis request queued + * @throws ApiError + */ + public static postApiV1SbomReportsUploadCyclonedx({ + formData, + }: { + formData: { + cveId?: string; + file?: Blob; + }, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/sbom-reports/upload-cyclonedx', + formData: formData, + mediaType: 'multipart/form-data', + errors: { + 400: `Invalid request data (invalid CVE format, invalid JSON, missing required fields)`, + 429: `Request queue exceeded`, + 500: `Internal server error`, + }, + }); + } + /** + * Get SBOM report by ID + * Retrieves SBOM report data for a specific SBOM report ID + * @returns any SBOM report retrieved successfully + * @throws ApiError + */ + public static getApiV1SbomReports1({ + sbomReportId, + }: { + /** + * SBOM report ID + */ + sbomReportId: string, + }): CancelablePromise<{ + /** + * SBOM name from first report's metadata.sbom_name + */ + sbomName?: string; + /** + * SBOM report ID from first report's metadata.sbom_report_id + */ + sbomReportId: string; + /** + * CVE ID from first report's input.scan.vulns[0].vuln_id + */ + cveId?: string; + /** + * Map of CVE status to count of reports with that status + */ + cveStatusCounts: Record; + /** + * Map of report status to count of reports with that status + */ + statusCounts: Record; + /** + * Completed at timestamp - empty if any report's completed_at is empty, otherwise latest value + */ + completedAt?: string; + /** + * Submitted at timestamp from first report's metadata.submitted_at + */ + submittedAt?: string; + /** + * Number of reports in this SBOM report group + */ + numReports: number; + /** + * MongoDB document _id (as hex string) of the first report in the group, always populated for navigation purposes + */ + firstReportId?: string; + }> { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/sbom-reports/{sbomReportId}', + path: { + 'sbomReportId': sbomReportId, + }, + errors: { + 404: `SBOM report not found`, + 500: `Internal server error`, + }, + }); + } +} diff --git a/src/main/webui/src/generated-client/services/VulnerabilityEndpointService.ts b/src/main/webui/src/generated-client/services/VulnerabilityEndpointService.ts new file mode 100644 index 00000000..44f15a64 --- /dev/null +++ b/src/main/webui/src/generated-client/services/VulnerabilityEndpointService.ts @@ -0,0 +1,173 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { UserComments } from '../models/UserComments'; +import type { Vulnerability } from '../models/Vulnerability'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class VulnerabilityEndpointService { + /** + * List vulnerabilities + * Retrieves a paginated list of vulnerabilities with optional sorting + * @returns Vulnerability Vulnerabilities retrieved successfully + * @throws ApiError + */ + public static getApiV1Vulnerabilities({ + page = 0, + pageSize = 1000, + sortBy, + }: { + /** + * Page number (0-based) + */ + page?: number, + /** + * Number of items per page + */ + pageSize?: number, + /** + * Sort criteria in format 'field:direction' (e.g., '_id:ASC') + */ + sortBy?: Array, + }): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/vulnerabilities', + query: { + 'page': page, + 'pageSize': pageSize, + 'sortBy': sortBy, + }, + errors: { + 500: `Internal server error`, + }, + }); + } + /** + * Generate user comments template + * Generates a template structure for user comments on vulnerabilities + * @returns UserComments Comments template generated successfully + * @throws ApiError + */ + public static getApiV1VulnerabilitiesGenerateCommentsTemplate(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/vulnerabilities/generate-comments-template', + errors: { + 500: `Internal server error`, + }, + }); + } + /** + * Update vulnerability + * Updates vulnerability information by ID + * @returns any Vulnerability updated successfully + * @throws ApiError + */ + public static putApiV1Vulnerabilities({ + vulnId, + requestBody, + }: { + /** + * Vulnerability ID to update + */ + vulnId: string, + /** + * Vulnerability data to update + */ + requestBody: Vulnerability, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/vulnerabilities/{vuln_id}', + path: { + 'vuln_id': vulnId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid vulnerability data`, + 500: `Internal server error`, + }, + }); + } + /** + * Get vulnerability + * Retrieves detailed information for a specific vulnerability by ID + * @returns Vulnerability Vulnerability retrieved successfully + * @throws ApiError + */ + public static getApiV1Vulnerabilities1({ + vulnId, + }: { + /** + * Vulnerability ID to get + */ + vulnId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/vulnerabilities/{vuln_id}', + path: { + 'vuln_id': vulnId, + }, + errors: { + 404: `Vulnerability not found`, + 500: `Internal server error`, + }, + }); + } + /** + * Delete vulnerability + * Deletes a specific vulnerability by ID + * @returns any Vulnerability deleted successfully + * @throws ApiError + */ + public static deleteApiV1Vulnerabilities({ + vulnId, + }: { + /** + * Vulnerability ID to delete + */ + vulnId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/vulnerabilities/{vuln_id}', + path: { + 'vuln_id': vulnId, + }, + errors: { + 500: `Internal server error`, + }, + }); + } + /** + * Get vulnerability comments + * Retrieves user comments for a specific vulnerability by ID + * @returns string Comments retrieved successfully + * @throws ApiError + */ + public static getApiV1VulnerabilitiesComments({ + vulnId, + }: { + /** + * Vulnerability ID to get comments + */ + vulnId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/vulnerabilities/{vuln_id}/comments', + path: { + 'vuln_id': vulnId, + }, + errors: { + 404: `Vulnerability not found`, + 500: `Internal server error`, + }, + }); + } +} diff --git a/src/main/webui/src/hooks/useCveDetails.ts b/src/main/webui/src/hooks/useCveDetails.ts new file mode 100644 index 00000000..54d6b5ea --- /dev/null +++ b/src/main/webui/src/hooks/useCveDetails.ts @@ -0,0 +1,255 @@ +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 reports filtered by CVE ID + * Fetches reports filtered by vulnId, then gets the full report data from the first result + * + * @param cveId - The CVE ID to fetch metadata for + * @returns Object with metadata, loading, and error states + */ +export function useCveDetails(cveId: string): UseCveDetailsResult { + // Step 1: Fetch reports filtered by CVE ID to get report IDs + const { + data: reports, + loading: reportsLoading, + error: reportsError, + } = usePaginatedApi>( + () => ({ + method: "GET" as const, + url: "/api/v1/reports", + query: { + page: 0, + pageSize: 1, // We only need the first report + vulnId: cveId, + }, + }), + { + deps: [cveId], + } + ); + + // Get the first report ID + const firstReportId = useMemo(() => { + if (!reports || reports.length === 0) { + return null; + } + return reports[0]?.id || null; + }, [reports]); + + // Step 2: Fetch the full report data using the first report ID + // Only fetch if we have a report ID + const { + data: reportWithStatus, + loading: fullReportLoading, + error: fullReportError, + } = useApi( + () => { + if (!firstReportId) { + // Return a resolved promise with null if no report ID + // This is a valid state (CVE not found in any reports) + return Promise.resolve(null); + } + return getRepositoryReport(firstReportId); + }, + { + deps: [firstReportId], + } + ); + + const fullReport = useMemo(() => { + return reportWithStatus?.report || null; + }, [reportWithStatus]); + + // Extract metadata from the full report + const metadata = useMemo(() => { + return extractCveMetadata(fullReport, cveId); + }, [fullReport, cveId]); + + return { + metadata, + loading: reportsLoading || fullReportLoading, + error: reportsError || fullReportError, + }; +} diff --git a/src/main/webui/src/hooks/usePostApi.ts b/src/main/webui/src/hooks/usePostApi.ts new file mode 100644 index 00000000..b3037f5b --- /dev/null +++ b/src/main/webui/src/hooks/usePostApi.ts @@ -0,0 +1,100 @@ +/** + * Hook for manual/triggered API calls (typically POST requests) + * Does not fetch immediately - requires manual trigger via execute() or refetch() + * Does not support polling (use useApi for polling scenarios) + */ + +import { useState, useRef, useCallback, useEffect } from 'react'; +import type { CancelablePromise } from '../generated-client'; + +export interface UsePostApiResult { + data: T | null; + loading: boolean; + error: Error | null; + execute: () => void; +} + +/** + * Hook for manual API calls that require explicit triggering + * Typically used for POST, PUT, DELETE operations + * + * @param apiCall - Function that returns a promise (or CancelablePromise) + * @returns Object with data, loading, error states and execute function + * + * @example + * ```tsx + * const { data, loading, error, execute } = usePostApi(() => + * Reports.postApiReportsNew({ requestBody: report }) + * ); + * + * // Trigger manually + * + * ``` + */ +export function usePostApi( + apiCall: () => Promise | CancelablePromise +): UsePostApiResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Keep track of the current promise to cancel it if needed + const promiseRef = useRef | Promise | null>(null); + const cancelledRef = useRef(false); + + const execute = useCallback(() => { + // Cancel previous request if it's a CancelablePromise + if (promiseRef.current && 'cancel' in promiseRef.current) { + (promiseRef.current as CancelablePromise).cancel(); + } + + cancelledRef.current = false; + setLoading(true); + setError(null); + + try { + const promise = apiCall(); + promiseRef.current = promise; + + promise + .then((result) => { + if (!cancelledRef.current) { + setData(result); + setLoading(false); + promiseRef.current = null; + } + }) + .catch((err) => { + // Ignore cancellation errors + if (err?.isCancelled || err?.name === 'CancelError') { + return; + } + + if (!cancelledRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + setLoading(false); + promiseRef.current = null; + } + }); + } catch (err) { + if (!cancelledRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + setLoading(false); + } + } + }, [apiCall]); + + // Cleanup on unmount + useEffect(() => { + return () => { + cancelledRef.current = true; + // Cancel the promise if it's a CancelablePromise + if (promiseRef.current && 'cancel' in promiseRef.current) { + (promiseRef.current as CancelablePromise).cancel(); + } + }; + }, []); + + return { data, loading, error, execute }; +} + 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..a33551f4 100644 --- a/src/main/webui/src/mocks/handlers.ts +++ b/src/main/webui/src/mocks/handlers.ts @@ -120,10 +120,7 @@ const generateMockReport = ( imageName?: string; imageTag?: string; cveId?: string; - } ): Report => { - const now = new Date().toISOString(); - const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const twoDaysAgo = new Date( Date.now() - 2 * 24 * 60 * 60 * 1000 ).toISOString(); diff --git a/src/main/webui/src/pages/CveDetailsPage.tsx b/src/main/webui/src/pages/CveDetailsPage.tsx new file mode 100644 index 00000000..435254b7 --- /dev/null +++ b/src/main/webui/src/pages/CveDetailsPage.tsx @@ -0,0 +1,256 @@ +import { useParams, Link, useLocation } 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 BreadcrumbContext { + sbomReportId?: string; + sbomName?: string; + reportId?: string; + reportIdDisplay?: string; + isComponentRoute?: boolean; +} + +interface CveDetailsPageErrorProps { + title: string; + message: string | React.ReactNode; +} + +const CveDetailsPageError: React.FC = ({ + title, + message, +}) => { + return ( + + + {message} + + + ); +}; + +const CveDetailsPage: React.FC = () => { + const { cveId } = useParams<{ cveId: string }>(); + const location = useLocation(); + const breadcrumbContext = (location.state as BreadcrumbContext) || {}; + + if (!cveId) { + return ( + + ); + } + + const cveIdDisplay = cveId.toUpperCase(); + const { + sbomReportId, + sbomName, + reportId, + reportIdDisplay, + isComponentRoute, + } = breadcrumbContext; + + const buildBreadcrumb = () => { + const hasBreadcrumbContext = + (sbomReportId || reportId) && (sbomName || reportIdDisplay); + + return ( + + + Reports + + {hasBreadcrumbContext && ( + <> + {sbomReportId && sbomName && !isComponentRoute && ( + + + {`${sbomName}/${cveId}`} + + + )} + {reportId && reportIdDisplay && ( + + + {reportIdDisplay} + + + )} + + )} + CVE Details + + ); + }; + + const { metadata, loading, error } = useCveDetails(cveId); + + 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..d2f8d831 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,13 +129,13 @@ 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 ( @@ -132,30 +143,45 @@ const RepositoryReportPage: React.FC = () => { - + CVE Repository Report:{" "} <span style={{ fontSize: "var(--pf-t--global--font--size--heading--h6)", + wordBreak: "break-word", + overflowWrap: "break-word", }} > - {reportIdDisplay} + <Link + to={`/reports/cve/${cveId}`} + state={{ + sbomReportId: productId, + sbomName: productName, + reportId, + reportIdDisplay, + isComponentRoute: !productId, + }} + > + {cveId} + </Link> + {" | "} + {image?.name || ""} | {image?.tag || ""} </span> - + - + @@ -177,7 +203,37 @@ 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-1-report-2.json b/src/test/resources/devservices/reports/test-sbom-report-1-report-2.json index 454a2123..8653a839 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-1-report-2.json +++ b/src/test/resources/devservices/reports/test-sbom-report-1-report-2.json @@ -57,4 +57,4 @@ "user": "test@example.com" }, "info": {} -} \ No newline at end of file +} diff --git a/src/test/resources/devservices/reports/test-sbom-report-2-report-1.json b/src/test/resources/devservices/reports/test-sbom-report-2-report-1.json index bd89ecdf..916a44b5 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-2-report-1.json +++ b/src/test/resources/devservices/reports/test-sbom-report-2-report-1.json @@ -57,4 +57,4 @@ "user": "test@example.com" }, "info": {} -} \ No newline at end of file +} diff --git a/src/test/resources/devservices/reports/test-sbom-report-3-report-2.json b/src/test/resources/devservices/reports/test-sbom-report-3-report-2.json index be2b51ab..b6341a0a 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-3-report-2.json +++ b/src/test/resources/devservices/reports/test-sbom-report-3-report-2.json @@ -10908,4 +10908,4 @@ ], "vex": null } -} +} \ No newline at end of file diff --git a/src/test/resources/devservices/reports/test-sbom-report-4-report-1.json b/src/test/resources/devservices/reports/test-sbom-report-4-report-1.json index c39c7864..788877e4 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-4-report-1.json +++ b/src/test/resources/devservices/reports/test-sbom-report-4-report-1.json @@ -2466,7 +2466,8 @@ "user": "zgrinber@redhat.com", "requested_at": "2025-02-20T14:46:58.335Z", "submitted_at": "2025-02-20T15:46:58.335583Z", - "sent_at": "2025-02-20T15:47:00.245249Z" + "sent_at": "2025-02-20T15:47:00.245249Z", + "sbom_name": "Product_4" }, "info": { "vdb": { diff --git a/src/test/resources/devservices/reports/test-sbom-report-4-report-2.json b/src/test/resources/devservices/reports/test-sbom-report-4-report-2.json index d181a3d3..5e66fc71 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-4-report-2.json +++ b/src/test/resources/devservices/reports/test-sbom-report-4-report-2.json @@ -2722,7 +2722,8 @@ "requested_at": "2025-02-24T06:11:41.123Z", "submitted_at": "2025-02-24T07:11:41.123398Z", "user": "zgrinber@redhat.com", - "sent_at": "2025-02-24T07:11:43.437450Z" + "sent_at": "2025-02-24T07:11:43.437450Z", + "sbom_name": "Product_4" }, "info": { "vdb": { 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", From 3bce346a8fba454bde8c0227ca261b7085749730 Mon Sep 17 00:00:00 2001 From: rhartuv Date: Mon, 23 Feb 2026 15:47:41 +0200 Subject: [PATCH 2/7] feat: add cve details page and integrate with new ui2 --- README.md | 4 +- openspec/specs/request-analysis-modal/spec.md | 6 +- openspec/specs/sbom-reports-api/spec.md | 102 -- .../morpheus/model/ReportsSummary.java | 18 - .../morpheus/model/SbomReport.java | 30 - .../repository/ReportRepositoryService.java | 2 +- .../morpheus/rest/RestApplication.java | 13 - .../morpheus/rest/SbomReportEndpoint.java | 238 ---- .../service/FileValidationException.java | 15 - .../morpheus/service/ParsedCycloneDx.java | 23 - .../service/PreProcessingService.java | 59 +- .../morpheus/service/SbomReportsService.java | 338 ----- .../morpheus/service/ValidationException.java | 20 - src/main/webui/package-lock.json | 1098 +---------------- src/main/webui/scripts/remove-servers.js | 32 - .../generated-client/models/ReportsSummary.ts | 26 - .../src/generated-client/models/SbomReport.ts | 46 - .../generated-client/models/Vulnerability.ts | 18 - .../services/SbomReportEndpointService.ts | 155 --- .../services/VulnerabilityEndpointService.ts | 173 --- src/main/webui/src/hooks/usePostApi.ts | 100 -- .../webui/src/pages/RepositoryReportPage.tsx | 21 +- .../reports/test-sbom-report-1-report-2.json | 2 +- .../reports/test-sbom-report-2-report-1.json | 2 +- .../reports/test-sbom-report-3-report-2.json | 2 +- .../reports/test-sbom-report-4-report-1.json | 3 +- .../reports/test-sbom-report-4-report-2.json | 3 +- 27 files changed, 65 insertions(+), 2484 deletions(-) delete mode 100644 openspec/specs/sbom-reports-api/spec.md delete mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/model/ReportsSummary.java delete mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/model/SbomReport.java delete mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/rest/RestApplication.java delete mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportEndpoint.java delete mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/service/FileValidationException.java delete mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/service/ParsedCycloneDx.java delete mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportsService.java delete mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/service/ValidationException.java delete mode 100755 src/main/webui/scripts/remove-servers.js delete mode 100644 src/main/webui/src/generated-client/models/ReportsSummary.ts delete mode 100644 src/main/webui/src/generated-client/models/SbomReport.ts delete mode 100644 src/main/webui/src/generated-client/models/Vulnerability.ts delete mode 100644 src/main/webui/src/generated-client/services/SbomReportEndpointService.ts delete mode 100644 src/main/webui/src/generated-client/services/VulnerabilityEndpointService.ts delete mode 100644 src/main/webui/src/hooks/usePostApi.ts diff --git a/README.md b/README.md index a3ceb61d..5c9bc2ad 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ for sending requests to evaluate vulnerabilities on specific SBOMs. Check this other documents for: -- [Configuration](./docs/configuration.md) -- [Development](./docs/development.md) +* [Configuration](./docs/configuration.md) +* [Development](./docs/development.md) ## Using the Application diff --git a/openspec/specs/request-analysis-modal/spec.md b/openspec/specs/request-analysis-modal/spec.md index 81635106..02b8dbfa 100644 --- a/openspec/specs/request-analysis-modal/spec.md +++ b/openspec/specs/request-analysis-modal/spec.md @@ -12,8 +12,10 @@ The request analysis modal SHALL submit the CVE ID and CycloneDX file to the `/a - **THEN** the modal validates the CVE ID format matches the official CVE regex pattern `^CVE-[0-9]{4}-[0-9]{4,19}$` - **AND** creates a multipart form with the CVE ID and file - **AND** calls the `/api/v1/products/upload-cyclonedx` API endpoint using the generated OpenAPI client service -- **THEN** the modal creates a multipart form with the CVE ID and file -- **AND** calls the `/api/v1/sbom-reports/upload-cyclonedx` API endpoint using the generated OpenAPI client service +- **AND** displays a loading state (disables submit button) during the API call +- **AND** upon successful response (HTTP 202), extracts the `reportRequestId.id` field from the `ReportData` response +- **AND** navigates to the repository report page at `/reports/component/:cveId/:reportId` where `:cveId` is the CVE ID from the form and `:reportId` is the `reportRequestId.id` from the API response +- **AND** closes the modal #### Scenario: API field-specific error display - **WHEN** a user submits the request analysis modal diff --git a/openspec/specs/sbom-reports-api/spec.md b/openspec/specs/sbom-reports-api/spec.md deleted file mode 100644 index 5003ae41..00000000 --- a/openspec/specs/sbom-reports-api/spec.md +++ /dev/null @@ -1,102 +0,0 @@ -# sbom-reports-api Specification - -## Purpose -The SBOM reports API provides a REST endpoint to retrieve reports grouped by sbom_report_id. It filters reports to only include those with `metadata.sbom_report_id`, sorts them by a configurable sort field, groups them by `metadata.sbom_report_id`, and returns paginated results. -## Requirements -### Requirement: SBOM Reports Endpoint -The system SHALL provide a REST API endpoint `/api/v1/sbom-reports` that filters reports to only include those with `metadata.sbom_report_id`, sorts them by a configurable sort field (valid fields: `submittedAt`, `sbomReportId`, `sbomName`), groups them by `metadata.sbom_report_id`, and returns paginated results. The service logic SHALL be implemented in `SbomReportsService.java` separate from the existing ReportService. - -#### Scenario: Filter reports by sbom_report_id -- **WHEN** a client requests `/api/v1/sbom-reports` -- **THEN** the API filters reports to only include those that have `metadata.sbom_report_id` set -- **AND** reports without `metadata.sbom_report_id` are excluded from the results - -#### Scenario: Sort reports by submitted_at -- **WHEN** a client requests `/api/v1/sbom-reports` with sort field `submittedAt` -- **THEN** reports are grouped by `metadata.sbom_report_id` first -- **AND** during grouping, the first report's `metadata.submitted_at` value is captured as `sortValue` for each group -- **AND** after grouping, groups are sorted by the captured `sortValue` in the specified direction (ASC or DESC) - -#### Scenario: Sort reports by sbom_name -- **WHEN** a client requests `/api/v1/sbom-reports` with sort field `sbomName` -- **THEN** reports are grouped by `metadata.sbom_report_id` first -- **AND** during grouping, the first report's `metadata.sbom_name` value is captured as `sortValue` for each group -- **AND** after grouping, groups are sorted by the captured `sortValue` in the specified direction (ASC or DESC) - -#### Scenario: Group reports by sbom_report_id -- **WHEN** a client requests `/api/v1/sbom-reports` AND there are reports with sbom_report_id values -- **THEN** reports are grouped by their `metadata.sbom_report_id` value -- **AND** each group contains all reports sharing the same sbom_report_id value -- **AND** during grouping, the sort field value from the first report in each group is captured as `sortValue` -- **AND** after grouping, groups are sorted by the captured `sortValue` according to the sort field and direction - -#### Scenario: Paginated response -- **WHEN** a client requests `/api/v1/sbom-reports` with pagination parameters (page and pageSize) -- **THEN** the API returns a paginated result containing only the requested page of SBOM reports -- **AND** response headers include `X-Total-Pages` and `X-Total-Elements` following existing pagination patterns -- **AND** pagination is applied after filtering, grouping, and sorting - -#### Scenario: Service implementation -- **WHEN** the SBOM reports functionality is implemented -- **THEN** the service logic is implemented in `SbomReportsService.java` (not in the existing ReportService) -- **AND** the service file follows existing service patterns and conventions - -#### Scenario: MongoDB aggregation implementation -- **WHEN** reports are fetched and grouped -- **THEN** the implementation uses `getCollection().aggregate()` pattern -- **AND** filtering is applied first to include only reports with `metadata.sbom_report_id` using `$match` stage -- **AND** the aggregation pipeline uses `$group` stage to group reports by `metadata.sbom_report_id` -- **AND** during the `$group` stage, all reports for each sbom_report_id are pushed into an array using `$push` -- **AND** during the `$group` stage, the sort field value from the first report in each group is captured as `sortValue` using `$first` accumulator -- **AND** after the `$group` stage, groups are sorted by the captured `sortValue` using `$sort` stage in the specified direction (ASC or DESC) -- **AND** the aggregation pipeline includes `$skip` and `$limit` stages after `$sort` to paginate the sorted groups -- **AND** a separate count aggregation is used to determine the total number of groups for pagination headers -- **AND** post-processing in Java extracts and computes the response fields (sbomName, sbomReportId, cveId, cveStatusCounts, statusCounts, completedAt, submittedAt, numReports, firstReportId) from the grouped results - -#### Scenario: API error handling -- **WHEN** a client requests `/api/v1/sbom-reports` AND an error occurs -- **THEN** the API returns an appropriate HTTP error status code (500 for internal server errors) -- **AND** the error response follows standard REST error response format - -### Requirement: Get SBOM Report by ID Endpoint -The system SHALL provide a REST API endpoint `/api/v1/sbom-reports/{sbomReportId}` that retrieves SBOM report data for a specific SBOM report ID. - -#### Scenario: Get SBOM report by ID -- **WHEN** a client requests `/api/v1/sbom-reports/{sbomReportId}` with a valid SBOM report ID -- **THEN** the API returns a single `SbomReport` object for that sbom_report_id -- **AND** the response structure matches the SBOM Reports API Response Structure -- **AND** the API returns HTTP status 200 - -#### Scenario: SBOM report not found -- **WHEN** a client requests `/api/v1/sbom-reports/{sbomReportId}` with a SBOM report ID that does not exist -- **THEN** the API returns HTTP status 404 (Not Found) - -#### Scenario: Get SBOM report by ID error handling -- **WHEN** a client requests `/api/v1/sbom-reports/{sbomReportId}` AND an error occurs -- **THEN** the API returns an appropriate HTTP error status code (500 for internal server errors) -- **AND** the error response follows standard REST error response format - -### Requirement: SBOM Reports API Response Structure -The `/api/v1/sbom-reports` API endpoint SHALL return a list of `SbomReport` objects with the following structure: -- `sbomName`: String containing the SBOM name from the first report's `metadata.sbom_name` -- `sbomReportId`: String containing the sbom_report_id from `metadata.sbom_report_id` (required) -- `cveId`: String containing the CVE ID from the first report's `input.scan.vulns[0].vuln_id` -- `cveStatusCounts`: Map containing ExploitIQ status counts (direct mapping from status to count), aggregated from all component reports for the SBOM report and CVE (required) -- `statusCounts`: Map containing report status counts (direct mapping from status to count), aggregated from all component reports for the SBOM report (required) -- `completedAt`: String timestamp of when reports were completed - empty if any report's completed_at is empty, otherwise the latest value -- `submittedAt`: String timestamp of when the first report was created (submitted) - taken from the first report's `metadata.submitted_at` -- `numReports`: Integer containing the number of reports in this SBOM report group (required) -- `firstReportId`: String containing the MongoDB document _id (as hex string) of the first report in the group, always populated for navigation purposes - -#### Scenario: SBOM report response structure -- **WHEN** the API returns a SBOM report row -- **THEN** the `sbomReportId` field contains the sbom_report_id value from `metadata.sbom_report_id` -- **AND** the `sbomName` field contains the SBOM name from the first report's `metadata.sbom_name` -- **AND** the `cveId` field contains the CVE ID from the first report's `input.scan.vulns[0].vuln_id` -- **AND** the `cveStatusCounts` field contains aggregated status counts from all component reports for that SBOM report and CVE (direct map from status to count) -- **AND** the `statusCounts` field contains aggregated report status counts from all component reports for that SBOM report (direct map from status to count) -- **AND** the `completedAt` field contains the latest completion timestamp from component reports, or empty string if any report's completed_at is empty -- **AND** the `submittedAt` field contains the submitted timestamp from the first report's `metadata.submitted_at` -- **AND** the `numReports` field contains the count of reports in the SBOM report group -- **AND** the `firstReportId` field contains the MongoDB document _id (as hex string) of the first report in the group for navigation purposes - diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/ReportsSummary.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/ReportsSummary.java deleted file mode 100644 index a3a6cf2b..00000000 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/ReportsSummary.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.redhat.ecosystemappeng.morpheus.model; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import org.eclipse.microprofile.openapi.annotations.media.Schema; - -@Schema(name = "ReportsSummary", description = "Summary of reports statistics") -@RegisterForReflection -public record ReportsSummary( - @Schema(required = true, description = "Count of reports containing vulnerable CVEs") - long vulnerableReportsCount, - @Schema(required = true, description = "Count of reports containing only non-vulnerable CVEs") - long nonVulnerableReportsCount, - @Schema(required = true, description = "Count of pending analysis requests") - long pendingRequestsCount, - @Schema(required = true, description = "Count of new reports submitted today") - long newReportsTodayCount) { -} - diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/SbomReport.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/SbomReport.java deleted file mode 100644 index 8f512577..00000000 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/SbomReport.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.redhat.ecosystemappeng.morpheus.model; - -import java.util.Map; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import org.eclipse.microprofile.openapi.annotations.media.Schema; - -@Schema(name = "SbomReport", description = "SBOM report data grouped by sbom_report_id") -@RegisterForReflection -public record SbomReport( - @Schema(description = "SBOM name from first report's metadata.sbom_name") - String sbomName, - @Schema(required = true, description = "SBOM report ID from first report's metadata.sbom_report_id") - String sbomReportId, - @Schema(description = "CVE ID from first report's input.scan.vulns[0].vuln_id") - String cveId, - @Schema(required = true, description = "Map of CVE status to count of reports with that status") - Map cveStatusCounts, - @Schema(required = true, description = "Map of report status to count of reports with that status") - Map statusCounts, - @Schema(description = "Completed at timestamp - empty if any report's completed_at is empty, otherwise latest value") - String completedAt, - @Schema(description = "Submitted at timestamp from first report's metadata.submitted_at") - String submittedAt, - @Schema(required = true, description = "Number of reports in this SBOM report group") - Integer numReports, - @Schema(description = "MongoDB document _id (as hex string) of the first report in the group, always populated for navigation purposes") - String firstReportId) { -} - diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java index 2414f7c8..f9a86805 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java @@ -647,4 +647,4 @@ private Bson buildQueryFilter(Map queryFilter) { } -} +} \ No newline at end of file diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/RestApplication.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/RestApplication.java deleted file mode 100644 index ce6e49d3..00000000 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/RestApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.redhat.ecosystemappeng.morpheus.rest; - -import jakarta.ws.rs.ApplicationPath; -import jakarta.ws.rs.core.Application; - -/** - * JAX-RS Application class to configure the base path for all REST endpoints. - * All REST endpoints will be prefixed with /api/v1 - */ -@ApplicationPath("/api/v1") -public class RestApplication extends Application { -} - diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportEndpoint.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportEndpoint.java deleted file mode 100644 index 58673a94..00000000 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/SbomReportEndpoint.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.redhat.ecosystemappeng.morpheus.rest; - -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; -import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; -import org.eclipse.microprofile.openapi.annotations.media.Content; -import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; -import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; -import org.jboss.logging.Logger; -import org.jboss.resteasy.reactive.ClientWebApplicationException; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.redhat.ecosystemappeng.morpheus.model.SbomReport; -import com.redhat.ecosystemappeng.morpheus.model.Pagination; -import com.redhat.ecosystemappeng.morpheus.model.ReportData; -import com.redhat.ecosystemappeng.morpheus.model.ReportRequest; -import com.redhat.ecosystemappeng.morpheus.model.SortType; -import com.redhat.ecosystemappeng.morpheus.model.ValidationErrorResponse; -import com.redhat.ecosystemappeng.morpheus.service.SbomReportsService; -import com.redhat.ecosystemappeng.morpheus.service.ReportService; -import com.redhat.ecosystemappeng.morpheus.service.RequestQueueExceededException; -import com.redhat.ecosystemappeng.morpheus.service.CycloneDxUploadService; -import com.redhat.ecosystemappeng.morpheus.service.ValidationException; - -import jakarta.inject.Inject; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.DefaultValue; -import jakarta.ws.rs.FormParam; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.Status; -import java.io.InputStream; - -@SecurityScheme(securitySchemeName = "jwt", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "jwt", description = "Please enter your JWT Token without Bearer") -@SecurityRequirement(name = "jwt") -@Path("/sbom-reports") -@Produces(MediaType.APPLICATION_JSON) -public class SbomReportEndpoint { - - private static final Logger LOGGER = Logger.getLogger(SbomReportEndpoint.class); - - private static final String PAGE = "page"; - private static final String PAGE_SIZE = "pageSize"; - - @Inject - SbomReportsService sbomReportsService; - - @Inject - ObjectMapper objectMapper; - - @Inject - CycloneDxUploadService cycloneDxUploadService; - - @Inject - ReportService reportService; - - @GET - @Operation( - summary = "List SBOM reports", - description = "Retrieves a paginated list of reports grouped by sbom_report_id, filtered to only include reports with metadata.sbom_report_id, sorted by submittedAt, sbomName, or sbomReportId") - @APIResponses({ - @APIResponse( - responseCode = "200", - description = "SBOM reports retrieved successfully", - content = @Content( - schema = @Schema(type = SchemaType.ARRAY, implementation = SbomReport.class) - ) - ), - @APIResponse( - responseCode = "500", - description = "Internal server error" - ) - }) - public Response listSbomReports( - @Parameter( - description = "Sort field: 'submittedAt', 'sbomName', or 'sbomReportId'" - ) - @QueryParam("sortField") @DefaultValue("submittedAt") String sortField, - @Parameter( - description = "Sort direction: 'ASC' or 'DESC'" - ) - @QueryParam("sortDirection") @DefaultValue("DESC") String sortDirection, - @Parameter( - description = "Page number (0-based)" - ) - @QueryParam(PAGE) @DefaultValue("0") Integer page, - @Parameter( - description = "Number of items per page" - ) - @QueryParam(PAGE_SIZE) @DefaultValue("100") Integer pageSize, - @Parameter( - description = "Filter by SBOM name (case-insensitive partial match)" - ) - @QueryParam("sbomName") String sbomName, - @Parameter( - description = "Filter by CVE ID (case-insensitive partial match)" - ) - @QueryParam("cveId") String cveId) { - try { - SortType sortType = SortType.valueOf(sortDirection.toUpperCase()); - var result = sbomReportsService.getSbomReports(sortField, sortType, new Pagination(page, pageSize), - sbomName, cveId); - return Response.ok(result.results) - .header("X-Total-Pages", result.totalPages) - .header("X-Total-Elements", result.totalElements) - .build(); - } catch (Exception e) { - LOGGER.error("Unable to retrieve SBOM reports", e); - return Response.serverError() - .entity(objectMapper.createObjectNode() - .put("error", e.getMessage())) - .build(); - } - } - - @GET - @Path("/{sbomReportId}") - @Operation( - summary = "Get SBOM report by ID", - description = "Retrieves SBOM report data for a specific SBOM report ID") - @APIResponses({ - @APIResponse( - responseCode = "200", - description = "SBOM report retrieved successfully", - content = @Content( - schema = @Schema(type = SchemaType.OBJECT, implementation = SbomReport.class) - ) - ), - @APIResponse( - responseCode = "404", - description = "SBOM report not found" - ), - @APIResponse( - responseCode = "500", - description = "Internal server error" - ) - }) - public Response getSbomReport( - @Parameter( - description = "SBOM report ID", - required = true - ) - @PathParam("sbomReportId") String sbomReportId) { - try { - SbomReport sbomReport = sbomReportsService.getSbomReportById(sbomReportId); - if (sbomReport == null) { - return Response.status(Response.Status.NOT_FOUND).build(); - } - return Response.ok(sbomReport).build(); - } catch (Exception e) { - LOGGER.error("Unable to retrieve SBOM report", e); - return Response.serverError() - .entity(objectMapper.createObjectNode() - .put("error", e.getMessage())) - .build(); - } - } - - @POST - @Path("/upload-cyclonedx") - @Consumes(MediaType.MULTIPART_FORM_DATA) - @Operation( - summary = "Upload CycloneDX file for analysis", - description = "Accepts a multipart form with CVE ID and CycloneDX file, validates the file structure, creates a report with SBOM report ID, and queues it for analysis") - @APIResponses({ - @APIResponse( - responseCode = "202", - description = "File uploaded and analysis request queued", - content = @Content( - schema = @Schema(implementation = ReportData.class) - ) - ), - @APIResponse( - responseCode = "400", - description = "Invalid request data (invalid CVE format, invalid JSON, missing required fields)", - content = @Content( - schema = @Schema(implementation = ValidationErrorResponse.class) - ) - ), - @APIResponse( - responseCode = "429", - description = "Request queue exceeded" - ), - @APIResponse( - responseCode = "500", - description = "Internal server error" - ) - }) - public Response uploadCycloneDx( - @Parameter( - description = "CVE ID to analyze (must match the official CVE pattern CVE-YYYY-NNNN+)", - required = true - ) - @FormParam("cveId") String cveId, - @Parameter( - description = "CycloneDX JSON file", - required = true - ) - @FormParam("file") InputStream fileInputStream) { - try { - ReportRequest request = cycloneDxUploadService.processUpload(cveId, fileInputStream); - ReportData res = reportService.process(request); - reportService.submit(res.reportRequestId().id(), res.report()); - return Response.accepted(res).build(); - } catch (ValidationException e) { - ValidationErrorResponse errorResponse = new ValidationErrorResponse(e.getErrors()); - return Response.status(Status.BAD_REQUEST) - .entity(errorResponse) - .build(); - } catch (ClientWebApplicationException e) { - return Response.status(e.getResponse().getStatus()) - .entity(e.getResponse().getEntity()) - .build(); - } catch (RequestQueueExceededException e) { - return Response.status(Status.TOO_MANY_REQUESTS) - .entity(objectMapper.createObjectNode() - .put("error", e.getMessage())) - .build(); - } catch (Exception e) { - LOGGER.error("Unable to process CycloneDX file upload request", e); - return Response.serverError() - .entity(objectMapper.createObjectNode() - .put("error", e.getMessage())) - .build(); - } - } -} - diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/FileValidationException.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/FileValidationException.java deleted file mode 100644 index a472f5bf..00000000 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/FileValidationException.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.redhat.ecosystemappeng.morpheus.service; - -/** - * Exception thrown when file validation fails - */ -public class FileValidationException extends IllegalArgumentException { - public FileValidationException(String message) { - super(message); - } - - public FileValidationException(String message, Throwable cause) { - super(message, cause); - } -} - diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ParsedCycloneDx.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ParsedCycloneDx.java deleted file mode 100644 index bbb05a55..00000000 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ParsedCycloneDx.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.redhat.ecosystemappeng.morpheus.service; - -import com.fasterxml.jackson.databind.JsonNode; - -/** - * Result of parsing a CycloneDX file, containing both the parsed JSON and the extracted SBOM name and version. - */ -public record ParsedCycloneDx( - /** - * The parsed CycloneDX JSON structure - */ - JsonNode sbomJson, - /** - * The SBOM name extracted from metadata.component.name - */ - String sbomName, - /** - * The SBOM version extracted from metadata.component.version (may be null if not present) - */ - String sbomVersion -) { -} - diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java index 7e7aa8ee..8ca17657 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java @@ -1,17 +1,17 @@ package com.redhat.ecosystemappeng.morpheus.service; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; import java.io.IOException; import java.io.InputStream; import java.time.Duration; import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; -import java.util.Objects; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.rest.client.inject.RestClient; @@ -26,7 +26,6 @@ import com.redhat.ecosystemappeng.morpheus.repository.ReportRepositoryService; import io.quarkus.scheduler.Scheduled; - import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; @@ -40,7 +39,7 @@ public class PreProcessingService { @ConfigProperty(name = "morpheus.syncer.timeout", defaultValue = "1h") Duration timeout; - + @Inject ObjectMapper objectMapper; @@ -55,7 +54,7 @@ public JsonNode parse(List payloads) throws IOException { try (InputStream is = getClass().getClassLoader().getResourceAsStream("preProcessingTemplate.json")) { if (Objects.isNull(is)) { - throw new IllegalArgumentException("Template file not found in resources."); + throw new IllegalArgumentException("Template file not found in resources."); } ObjectNode templateJson = (ObjectNode) objectMapper.readTree(is); @@ -64,14 +63,14 @@ public JsonNode parse(List payloads) throws IOException { ArrayNode dataArray = objectMapper.createArrayNode(); for (ReportData payload : payloads) { - String scanId = payload.reportRequestId().id(); + String reportId = payload.reportRequestId().id(); JsonNode sourceInfo = payload.report().at("/input/image/source_info"); - if (Objects.nonNull(scanId) && !scanId.isEmpty() && !sourceInfo.isMissingNode()) { - ObjectNode dataEntry = objectMapper.createObjectNode(); - dataEntry.put("scan_id", scanId); - dataEntry.set("source_info", sourceInfo); - dataArray.add(dataEntry); + if (Objects.nonNull(reportId) && !reportId.isEmpty() && !sourceInfo.isMissingNode()) { + ObjectNode dataEntry = objectMapper.createObjectNode(); + dataEntry.put("report_id", reportId); + dataEntry.set("source_info", sourceInfo); + dataArray.add(dataEntry); } } @@ -99,19 +98,19 @@ public Response submit(JsonNode request, List ids) throws IOException, I LOGGER.debug("Component Syncer response status: " + status); if (status >= Response.Status.OK.getStatusCode() && status < Response.Status.MULTIPLE_CHOICES.getStatusCode()) { - LOGGER.info("Successfully sent payloads to Component Syncer"); - - LocalDateTime now = LocalDateTime.now(); - ids.forEach(id -> submitted.put(id, now)); + LOGGER.info("Successfully sent payloads to Component Syncer"); - return response; + LocalDateTime now = LocalDateTime.now(); + ids.forEach(id -> submitted.put(id, now)); + + return response; } else if (status >= Response.Status.INTERNAL_SERVER_ERROR.getStatusCode() && attempt < maxRetries) { - LOGGER.warnf("Component Syncer failed with status code: %s, will retry in %dms", status, delay); - Thread.sleep(delay); - delay = delay * BACKOFF_MULTIPLIER; + LOGGER.warnf("Component Syncer failed with status code: %s, will retry in %dms", status, delay); + Thread.sleep(delay); + delay = delay * BACKOFF_MULTIPLIER; } else { - LOGGER.errorf("Component Syncer failed with status code: %s, all retries exhausted", status); - return response; + LOGGER.errorf("Component Syncer failed with status code: %s, all retries exhausted", status); + return response; } } } @@ -131,12 +130,12 @@ public void checkSubmitted() { Set expired = new HashSet<>(); submitted.forEach((id, startTime) -> { - if (now.isAfter(startTime.plus(timeout))) { - expired.add(id); - LOGGER.warnf("Component Syncer timeout for component Id: %s", id); - handleError(id,"component-syncer-timeout-error",String.format("No response from Component Syncer after %s seconds", timeout.toSeconds()) + if (now.isAfter(startTime.plus(timeout))) { + expired.add(id); + LOGGER.warnf("Component Syncer timeout for component Id: %s", id); + handleError(id,"component-syncer-timeout-error",String.format("No response from Component Syncer after %s seconds", timeout.toSeconds()) ); - } + } }); expired.forEach(submitted::remove); @@ -145,4 +144,4 @@ public void checkSubmitted() { public void confirmResponse(String id) { submitted.remove(id); } -} +} \ No newline at end of file diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportsService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportsService.java deleted file mode 100644 index 0a58c2e6..00000000 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportsService.java +++ /dev/null @@ -1,338 +0,0 @@ -package com.redhat.ecosystemappeng.morpheus.service; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.regex.Pattern; - -import org.bson.Document; -import org.bson.conversions.Bson; -import org.bson.types.ObjectId; -import org.jboss.logging.Logger; - -import com.mongodb.client.model.Accumulators; -import com.mongodb.client.model.Aggregates; -import com.mongodb.client.model.BsonField; -import com.mongodb.client.model.Filters; -import com.mongodb.client.model.Sorts; -import com.redhat.ecosystemappeng.morpheus.model.SbomReport; -import com.redhat.ecosystemappeng.morpheus.model.PaginatedResult; -import com.redhat.ecosystemappeng.morpheus.model.Pagination; -import com.redhat.ecosystemappeng.morpheus.model.SortType; -import com.redhat.ecosystemappeng.morpheus.repository.ReportRepositoryService; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; - -@ApplicationScoped -public class SbomReportsService { - - private static final Logger LOGGER = Logger.getLogger(SbomReportsService.class); - private static final String SBOM_REPORT_ID = "sbom_report_id"; - private static final String SBOM_NAME = "sbom_name"; - private static final String SENT_AT = "sent_at"; - private static final String SUBMITTED_AT = "submitted_at"; - - @Inject - ReportRepositoryService reportRepositoryService; - - public PaginatedResult getSbomReports(String sortField, SortType sortType, Pagination pagination, - String sbomName, String cveId) { - List sbomReports = new ArrayList<>(); - - List filterConditions = new ArrayList<>(); - filterConditions.add(Filters.exists("metadata." + SBOM_REPORT_ID, true)); - - List filterOptions = new ArrayList<>(); - - if (sbomName != null && !sbomName.trim().isEmpty()) { - // This allows users to search for literal text containing special characters while still - // supporting partial matching (e.g., searching "test" matches "test-product" and "my-test-sbom") - String escaped = sbomName.trim().replaceAll("([\\\\+*?\\[\\](){}^$|.])", "\\\\$1"); - filterOptions.add(Filters.regex("metadata." + SBOM_NAME, - Pattern.compile(escaped, Pattern.CASE_INSENSITIVE))); - } - - if (cveId != null && !cveId.trim().isEmpty()) { - String escaped = cveId.trim().replaceAll("([\\\\+*?\\[\\](){}^$|.])", "\\\\$1"); - filterOptions.add(Filters.elemMatch("input.scan.vulns", - Filters.regex("vuln_id", Pattern.compile(escaped, Pattern.CASE_INSENSITIVE)))); - } - - // Combine all filters: all filter options with AND logic, Multiple values within the same filter type use OR logic - if (!filterOptions.isEmpty()) { - filterConditions.add(filterOptions.size() == 1 - ? filterOptions.get(0) - : Filters.and(filterOptions)); - } - - Bson filter = filterConditions.size() == 1 - ? filterConditions.get(0) - : Filters.and(filterConditions); - - // Build sort - String sortFieldPath = getSortFieldPath(sortField); - Bson sort = sortType == SortType.ASC - ? Sorts.ascending(sortFieldPath) - : Sorts.descending(sortFieldPath); - - // Build aggregation pipeline - List pipeline = new ArrayList<>(); - pipeline.add(Aggregates.match(filter)); - pipeline.add(Aggregates.sort(sort)); - - // Group by sbom_report_id, capturing first sort value for post-group sorting - List groupAccumulators = new ArrayList<>(); - groupAccumulators.add(Accumulators.push("reports", "$$ROOT")); - groupAccumulators.add(Accumulators.first("sortValue", "$" + sortFieldPath)); - pipeline.add(Aggregates.group( - "$metadata." + SBOM_REPORT_ID, - groupAccumulators.toArray(new BsonField[0]) - )); - - // Sort groups by the captured sort value - Bson groupSort = sortType == SortType.ASC - ? Sorts.ascending("sortValue") - : Sorts.descending("sortValue"); - pipeline.add(Aggregates.sort(groupSort)); - - pipeline.add(Aggregates.skip(pagination.page() * pagination.size())); - pipeline.add(Aggregates.limit(pagination.size())); - - // Execute aggregation - reportRepositoryService.getCollection() - .aggregate(pipeline) - .forEach(groupDoc -> { - SbomReport sbomReport = processGroup(groupDoc); - if (sbomReport != null) { - sbomReports.add(sbomReport); - } - }); - - // Get total count of groups for pagination - long totalGroups = getTotalGroupCount(filter); - int totalPages = (int) Math.ceil((double) totalGroups / pagination.size()); - - return new PaginatedResult<>(totalGroups, totalPages, sbomReports.stream()); - } - - public SbomReport getSbomReportById(String sbomReportId) { - // Build filter for reports with specific sbom_report_id - LOGGER.infof("Getting SBOM report by ID: %s", sbomReportId); - Bson filter = Filters.eq("metadata." + SBOM_REPORT_ID, sbomReportId); - - // Collect all reports for this sbom_report_id - List reports = new ArrayList<>(); - reportRepositoryService.getCollection() - .find(filter) - .forEach(reports::add); - - // If no reports found, return null - if (reports.isEmpty()) { - return null; - } - - // Process reports directly (no need for aggregation since all have same sbom_report_id) - return processReports(sbomReportId, reports); - } - - private String getSortFieldPath(String sortField) { - return switch (sortField) { - case "submittedAt" -> "metadata.submitted_at"; - case "sbomName" -> "metadata." + SBOM_NAME; - case "sbomReportId" -> "metadata." + SBOM_REPORT_ID; - default -> "metadata.submitted_at"; - }; - } - - private long getTotalGroupCount(Bson filter) { - List countPipeline = new ArrayList<>(); - countPipeline.add(Aggregates.match(filter)); - countPipeline.add(Aggregates.group("$metadata." + SBOM_REPORT_ID)); - - long count = 0; - for (@SuppressWarnings("unused") Document doc : reportRepositoryService.getCollection().aggregate(countPipeline)) { - count++; - } - return count; - } - - private SbomReport processGroup(Document groupDoc) { - try { - String sbomReportId = groupDoc.getString("_id"); - if (sbomReportId == null || sbomReportId.isEmpty()) { - return null; - } - - List reports = groupDoc.getList("reports", Document.class); - if (reports == null || reports.isEmpty()) { - return null; - } - - return processReports(sbomReportId, reports); - } catch (Exception e) { - LOGGER.errorf("Error processing group: %s", e.getMessage(), e); - return null; - } - } - - private SbomReport processReports(String sbomReportId, List reports) { - try { - if (reports == null || reports.isEmpty()) { - return null; - } - - Document firstReport = reports.get(0); - - // Extract firstReportId from first report - String firstReportId = extractReportId(firstReport); - - // Extract sbomName from first report - String sbomName = extractSbomName(firstReport); - - // Extract cveId from first report - String cveId = extractCveId(firstReport); - - // Build cveStatusCounts - Map cveStatusCounts = buildCveStatusCounts(reports); - - // Build statusCounts - Map statusCounts = buildStatusCounts(reports); - - // Calculate completedAt - String completedAt = calculateCompletedAt(reports); - - // Extract submittedAt from first report - String submittedAt = extractSubmittedAt(firstReport); - - // Get numReports - int numReports = reports.size(); - - return new SbomReport( - sbomName, - sbomReportId, - cveId, - cveStatusCounts, - statusCounts, - completedAt, - submittedAt, - numReports, - firstReportId - ); - } catch (Exception e) { - LOGGER.errorf("Error processing reports: %s", e.getMessage(), e); - return null; - } - } - - private String extractReportId(Document report) { - // Extract MongoDB document _id instead of scan ID - ObjectId id = report.get(RepositoryConstants.ID_KEY, ObjectId.class); - if (id != null) { - return id.toHexString(); - } - return null; - } - - private String extractSbomName(Document report) { - Document metadata = report.get("metadata", Document.class); - if (metadata != null) { - return metadata.getString(SBOM_NAME); - } - return null; - } - - private String extractCveId(Document report) { - Document input = report.get("input", Document.class); - if (input != null) { - Document scan = input.get("scan", Document.class); - if (scan != null) { - List vulns = scan.getList("vulns", Document.class); - if (vulns != null && !vulns.isEmpty()) { - Document firstVuln = vulns.get(0); - return firstVuln.getString("vuln_id"); - } - } - } - return null; - } - - private String extractSubmittedAt(Document report) { - Document metadata = report.get("metadata", Document.class); - if (metadata != null) { - Object submittedAtObj = metadata.get("submitted_at"); - if (submittedAtObj != null) { - // Handle both String and Date/Instant types (MongoDB stores dates as Date objects) - if (submittedAtObj instanceof String) { - return (String) submittedAtObj; - } else if (submittedAtObj instanceof java.util.Date) { - return ((java.util.Date) submittedAtObj).toInstant().toString(); - } else { - return submittedAtObj.toString(); - } - } - } - return null; - } - - private Map buildCveStatusCounts(List reports) { - Map counts = new HashMap<>(); - - for (Document report : reports) { - Document output = report.get("output", Document.class); - if (output != null) { - List analysis = output.getList("analysis", Document.class); - if (analysis != null && !analysis.isEmpty()) { - Document firstAnalysis = analysis.get(0); - Document justification = firstAnalysis.get("justification", Document.class); - if (justification != null) { - String status = justification.getString("status"); - if (status != null && !status.isEmpty()) { - counts.merge(status, 1, Integer::sum); - } - } - } - } - } - - return counts; - } - - private Map buildStatusCounts(List reports) { - Map counts = new HashMap<>(); - - for (Document report : reports) { - Map metadata = reportRepositoryService.extractMetadata(report); - String status = reportRepositoryService.getStatus(report, metadata); - counts.merge(status, 1, Integer::sum); - } - - return counts; - } - - private String calculateCompletedAt(List reports) { - String latestCompletedAt = null; - boolean hasEmpty = false; - - for (Document report : reports) { - Document input = report.get("input", Document.class); - if (input != null) { - Document scan = input.get("scan", Document.class); - if (scan != null) { - String completedAt = scan.getString("completed_at"); - if (completedAt == null || completedAt.isEmpty()) { - hasEmpty = true; - } else { - if (latestCompletedAt == null || completedAt.compareTo(latestCompletedAt) > 0) { - latestCompletedAt = completedAt; - } - } - } - } - } - - return hasEmpty ? "" : (latestCompletedAt != null ? latestCompletedAt : ""); - } -} - diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ValidationException.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ValidationException.java deleted file mode 100644 index c649659c..00000000 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ValidationException.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.redhat.ecosystemappeng.morpheus.service; - -import java.util.Map; - -/** - * Exception thrown when validation fails, containing field-specific error messages - */ -public class ValidationException extends IllegalArgumentException { - private final Map errors; - - public ValidationException(Map errors) { - super("Validation failed"); - this.errors = errors; - } - - public Map getErrors() { - return errors; - } -} - diff --git a/src/main/webui/package-lock.json b/src/main/webui/package-lock.json index 8f8f8cc6..e47436af 100644 --- a/src/main/webui/package-lock.json +++ b/src/main/webui/package-lock.json @@ -1162,36 +1162,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dependencies": { - "@types/ms": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dependencies": { - "@types/unist": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1206,19 +1183,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" - }, "node_modules/@types/node": { "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", @@ -1233,12 +1197,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1262,11 +1228,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -1493,6 +1454,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { @@ -1628,15 +1590,6 @@ "node": ">=4" } }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1755,15 +1708,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1801,42 +1745,6 @@ "dev": true, "license": "MIT" }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -1907,15 +1815,6 @@ "dev": true, "license": "MIT" }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -2124,6 +2023,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2137,18 +2037,6 @@ } } }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2171,26 +2059,6 @@ "delaunator": "^4.0.0" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2456,15 +2324,6 @@ "node": ">=4.0" } }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2475,11 +2334,6 @@ "node": ">=0.10.0" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2813,44 +2667,6 @@ "node": ">=8" } }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/headers-polyfill": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", @@ -2867,15 +2683,6 @@ "react-is": "^16.7.0" } }, - "node_modules/html-url-attributes": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", - "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -2963,11 +2770,6 @@ "dev": true, "license": "ISC" }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==" - }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2977,37 +2779,6 @@ "node": ">=12" } }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3041,15 +2812,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-in-browser": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", @@ -3082,17 +2844,6 @@ "node": ">=8" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3399,15 +3150,6 @@ "dev": true, "license": "MIT" }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3430,151 +3172,6 @@ "yallist": "^3.0.2" } }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3585,427 +3182,6 @@ "node": ">= 8" } }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4058,6 +3234,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/msw": { @@ -4298,29 +3475,6 @@ "node": ">=6" } }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" - }, "node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -4479,15 +3633,6 @@ "react-is": "^16.13.1" } }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4599,32 +3744,6 @@ "react": ">=16.8.6" } }, - "node_modules/react-markdown": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", - "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "html-url-attributes": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=18", - "react": ">=18" - } - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4673,37 +3792,6 @@ "react-dom": ">=18" } }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4936,15 +4024,6 @@ "node": ">=0.10.0" } }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4977,19 +4056,6 @@ "node": ">=8" } }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -5016,22 +4082,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "dependencies": { - "style-to-object": "1.0.14" - } - }, - "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "dependencies": { - "inline-style-parser": "0.2.7" - } - }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -5147,24 +4197,6 @@ "node": ">=16" } }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -5245,87 +4277,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -5387,32 +4338,6 @@ "punycode": "^2.1.0" } }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/victory": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory/-/victory-37.3.6.tgz", @@ -6132,15 +5057,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } } } -} +} \ No newline at end of file diff --git a/src/main/webui/scripts/remove-servers.js b/src/main/webui/scripts/remove-servers.js deleted file mode 100755 index be257f3c..00000000 --- a/src/main/webui/scripts/remove-servers.js +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node - -/** - * Removes the 'servers' field from OpenAPI spec to allow frontend to use relative URLs - */ - -import { readFileSync, writeFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, resolve } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const inputPath = process.argv[2] || resolve(__dirname, '../../../target/generated/openapi/openapi.json'); -const outputPath = process.argv[3] || resolve(__dirname, '../openapi.json'); - -try { - const spec = JSON.parse(readFileSync(inputPath, 'utf8')); - - // Set servers to empty array instead of removing it - // This ensures the generated client uses an empty BASE URL - spec.servers = []; - - writeFileSync(outputPath, JSON.stringify(spec, null, 2) + '\n', 'utf8'); - console.log(`✓ Set servers to empty array in OpenAPI spec`); - console.log(` Input: ${inputPath}`); - console.log(` Output: ${outputPath}`); -} catch (error) { - console.error(`Error processing OpenAPI spec: ${error.message}`); - process.exit(1); -} - diff --git a/src/main/webui/src/generated-client/models/ReportsSummary.ts b/src/main/webui/src/generated-client/models/ReportsSummary.ts deleted file mode 100644 index 561b462d..00000000 --- a/src/main/webui/src/generated-client/models/ReportsSummary.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Summary of reports statistics - */ -export type ReportsSummary = { - /** - * Count of reports containing vulnerable CVEs - */ - vulnerableReportsCount: number; - /** - * Count of reports containing only non-vulnerable CVEs - */ - nonVulnerableReportsCount: number; - /** - * Count of pending analysis requests - */ - pendingRequestsCount: number; - /** - * Count of new reports submitted today - */ - newReportsTodayCount: number; -}; - diff --git a/src/main/webui/src/generated-client/models/SbomReport.ts b/src/main/webui/src/generated-client/models/SbomReport.ts deleted file mode 100644 index 88b7adef..00000000 --- a/src/main/webui/src/generated-client/models/SbomReport.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * SBOM report data grouped by sbom_report_id - */ -export type SbomReport = { - /** - * SBOM name from first report's metadata.sbom_name - */ - sbomName?: string; - /** - * SBOM report ID from first report's metadata.sbom_report_id - */ - sbomReportId: string; - /** - * CVE ID from first report's input.scan.vulns[0].vuln_id - */ - cveId?: string; - /** - * Map of CVE status to count of reports with that status - */ - cveStatusCounts: Record; - /** - * Map of report status to count of reports with that status - */ - statusCounts: Record; - /** - * Completed at timestamp - empty if any report's completed_at is empty, otherwise latest value - */ - completedAt?: string; - /** - * Submitted at timestamp from first report's metadata.submitted_at - */ - submittedAt?: string; - /** - * Number of reports in this SBOM report group - */ - numReports: number; - /** - * MongoDB document _id (as hex string) of the first report in the group, always populated for navigation purposes - */ - firstReportId?: string; -}; - diff --git a/src/main/webui/src/generated-client/models/Vulnerability.ts b/src/main/webui/src/generated-client/models/Vulnerability.ts deleted file mode 100644 index 401d0932..00000000 --- a/src/main/webui/src/generated-client/models/Vulnerability.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * User provided vulnerability information - */ -export type Vulnerability = { - /** - * Vulnerability ID (CVE ID) - */ - id: string; - /** - * User provided comments on the vulnerability - */ - comments: string; -}; - diff --git a/src/main/webui/src/generated-client/services/SbomReportEndpointService.ts b/src/main/webui/src/generated-client/services/SbomReportEndpointService.ts deleted file mode 100644 index 3989de51..00000000 --- a/src/main/webui/src/generated-client/services/SbomReportEndpointService.ts +++ /dev/null @@ -1,155 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ReportData } from '../models/ReportData'; -import type { SbomReport } from '../models/SbomReport'; -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class SbomReportEndpointService { - /** - * List SBOM reports - * Retrieves a paginated list of reports grouped by sbom_report_id, filtered to only include reports with metadata.sbom_report_id, sorted by submittedAt, sbomName, or sbomReportId - * @returns SbomReport SBOM reports retrieved successfully - * @throws ApiError - */ - public static getApiV1SbomReports({ - cveId, - page = 0, - pageSize = 100, - sbomName, - sortDirection = 'DESC', - sortField = 'submittedAt', - }: { - /** - * Filter by CVE ID (case-insensitive partial match) - */ - cveId?: string, - /** - * Page number (0-based) - */ - page?: number, - /** - * Number of items per page - */ - pageSize?: number, - /** - * Filter by SBOM name (case-insensitive partial match) - */ - sbomName?: string, - /** - * Sort direction: 'ASC' or 'DESC' - */ - sortDirection?: string, - /** - * Sort field: 'submittedAt', 'sbomName', or 'sbomReportId' - */ - sortField?: string, - }): CancelablePromise> { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/sbom-reports', - query: { - 'cveId': cveId, - 'page': page, - 'pageSize': pageSize, - 'sbomName': sbomName, - 'sortDirection': sortDirection, - 'sortField': sortField, - }, - errors: { - 500: `Internal server error`, - }, - }); - } - /** - * Upload CycloneDX file for analysis - * Accepts a multipart form with CVE ID and CycloneDX file, validates the file structure, creates a report with SBOM report ID, and queues it for analysis - * @returns ReportData File uploaded and analysis request queued - * @throws ApiError - */ - public static postApiV1SbomReportsUploadCyclonedx({ - formData, - }: { - formData: { - cveId?: string; - file?: Blob; - }, - }): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/v1/sbom-reports/upload-cyclonedx', - formData: formData, - mediaType: 'multipart/form-data', - errors: { - 400: `Invalid request data (invalid CVE format, invalid JSON, missing required fields)`, - 429: `Request queue exceeded`, - 500: `Internal server error`, - }, - }); - } - /** - * Get SBOM report by ID - * Retrieves SBOM report data for a specific SBOM report ID - * @returns any SBOM report retrieved successfully - * @throws ApiError - */ - public static getApiV1SbomReports1({ - sbomReportId, - }: { - /** - * SBOM report ID - */ - sbomReportId: string, - }): CancelablePromise<{ - /** - * SBOM name from first report's metadata.sbom_name - */ - sbomName?: string; - /** - * SBOM report ID from first report's metadata.sbom_report_id - */ - sbomReportId: string; - /** - * CVE ID from first report's input.scan.vulns[0].vuln_id - */ - cveId?: string; - /** - * Map of CVE status to count of reports with that status - */ - cveStatusCounts: Record; - /** - * Map of report status to count of reports with that status - */ - statusCounts: Record; - /** - * Completed at timestamp - empty if any report's completed_at is empty, otherwise latest value - */ - completedAt?: string; - /** - * Submitted at timestamp from first report's metadata.submitted_at - */ - submittedAt?: string; - /** - * Number of reports in this SBOM report group - */ - numReports: number; - /** - * MongoDB document _id (as hex string) of the first report in the group, always populated for navigation purposes - */ - firstReportId?: string; - }> { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/sbom-reports/{sbomReportId}', - path: { - 'sbomReportId': sbomReportId, - }, - errors: { - 404: `SBOM report not found`, - 500: `Internal server error`, - }, - }); - } -} diff --git a/src/main/webui/src/generated-client/services/VulnerabilityEndpointService.ts b/src/main/webui/src/generated-client/services/VulnerabilityEndpointService.ts deleted file mode 100644 index 44f15a64..00000000 --- a/src/main/webui/src/generated-client/services/VulnerabilityEndpointService.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { UserComments } from '../models/UserComments'; -import type { Vulnerability } from '../models/Vulnerability'; -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class VulnerabilityEndpointService { - /** - * List vulnerabilities - * Retrieves a paginated list of vulnerabilities with optional sorting - * @returns Vulnerability Vulnerabilities retrieved successfully - * @throws ApiError - */ - public static getApiV1Vulnerabilities({ - page = 0, - pageSize = 1000, - sortBy, - }: { - /** - * Page number (0-based) - */ - page?: number, - /** - * Number of items per page - */ - pageSize?: number, - /** - * Sort criteria in format 'field:direction' (e.g., '_id:ASC') - */ - sortBy?: Array, - }): CancelablePromise> { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/vulnerabilities', - query: { - 'page': page, - 'pageSize': pageSize, - 'sortBy': sortBy, - }, - errors: { - 500: `Internal server error`, - }, - }); - } - /** - * Generate user comments template - * Generates a template structure for user comments on vulnerabilities - * @returns UserComments Comments template generated successfully - * @throws ApiError - */ - public static getApiV1VulnerabilitiesGenerateCommentsTemplate(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/vulnerabilities/generate-comments-template', - errors: { - 500: `Internal server error`, - }, - }); - } - /** - * Update vulnerability - * Updates vulnerability information by ID - * @returns any Vulnerability updated successfully - * @throws ApiError - */ - public static putApiV1Vulnerabilities({ - vulnId, - requestBody, - }: { - /** - * Vulnerability ID to update - */ - vulnId: string, - /** - * Vulnerability data to update - */ - requestBody: Vulnerability, - }): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/api/v1/vulnerabilities/{vuln_id}', - path: { - 'vuln_id': vulnId, - }, - body: requestBody, - mediaType: 'application/json', - errors: { - 400: `Invalid vulnerability data`, - 500: `Internal server error`, - }, - }); - } - /** - * Get vulnerability - * Retrieves detailed information for a specific vulnerability by ID - * @returns Vulnerability Vulnerability retrieved successfully - * @throws ApiError - */ - public static getApiV1Vulnerabilities1({ - vulnId, - }: { - /** - * Vulnerability ID to get - */ - vulnId: string, - }): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/vulnerabilities/{vuln_id}', - path: { - 'vuln_id': vulnId, - }, - errors: { - 404: `Vulnerability not found`, - 500: `Internal server error`, - }, - }); - } - /** - * Delete vulnerability - * Deletes a specific vulnerability by ID - * @returns any Vulnerability deleted successfully - * @throws ApiError - */ - public static deleteApiV1Vulnerabilities({ - vulnId, - }: { - /** - * Vulnerability ID to delete - */ - vulnId: string, - }): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/api/v1/vulnerabilities/{vuln_id}', - path: { - 'vuln_id': vulnId, - }, - errors: { - 500: `Internal server error`, - }, - }); - } - /** - * Get vulnerability comments - * Retrieves user comments for a specific vulnerability by ID - * @returns string Comments retrieved successfully - * @throws ApiError - */ - public static getApiV1VulnerabilitiesComments({ - vulnId, - }: { - /** - * Vulnerability ID to get comments - */ - vulnId: string, - }): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/vulnerabilities/{vuln_id}/comments', - path: { - 'vuln_id': vulnId, - }, - errors: { - 404: `Vulnerability not found`, - 500: `Internal server error`, - }, - }); - } -} diff --git a/src/main/webui/src/hooks/usePostApi.ts b/src/main/webui/src/hooks/usePostApi.ts deleted file mode 100644 index b3037f5b..00000000 --- a/src/main/webui/src/hooks/usePostApi.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Hook for manual/triggered API calls (typically POST requests) - * Does not fetch immediately - requires manual trigger via execute() or refetch() - * Does not support polling (use useApi for polling scenarios) - */ - -import { useState, useRef, useCallback, useEffect } from 'react'; -import type { CancelablePromise } from '../generated-client'; - -export interface UsePostApiResult { - data: T | null; - loading: boolean; - error: Error | null; - execute: () => void; -} - -/** - * Hook for manual API calls that require explicit triggering - * Typically used for POST, PUT, DELETE operations - * - * @param apiCall - Function that returns a promise (or CancelablePromise) - * @returns Object with data, loading, error states and execute function - * - * @example - * ```tsx - * const { data, loading, error, execute } = usePostApi(() => - * Reports.postApiReportsNew({ requestBody: report }) - * ); - * - * // Trigger manually - * - * ``` - */ -export function usePostApi( - apiCall: () => Promise | CancelablePromise -): UsePostApiResult { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // Keep track of the current promise to cancel it if needed - const promiseRef = useRef | Promise | null>(null); - const cancelledRef = useRef(false); - - const execute = useCallback(() => { - // Cancel previous request if it's a CancelablePromise - if (promiseRef.current && 'cancel' in promiseRef.current) { - (promiseRef.current as CancelablePromise).cancel(); - } - - cancelledRef.current = false; - setLoading(true); - setError(null); - - try { - const promise = apiCall(); - promiseRef.current = promise; - - promise - .then((result) => { - if (!cancelledRef.current) { - setData(result); - setLoading(false); - promiseRef.current = null; - } - }) - .catch((err) => { - // Ignore cancellation errors - if (err?.isCancelled || err?.name === 'CancelError') { - return; - } - - if (!cancelledRef.current) { - setError(err instanceof Error ? err : new Error(String(err))); - setLoading(false); - promiseRef.current = null; - } - }); - } catch (err) { - if (!cancelledRef.current) { - setError(err instanceof Error ? err : new Error(String(err))); - setLoading(false); - } - } - }, [apiCall]); - - // Cleanup on unmount - useEffect(() => { - return () => { - cancelledRef.current = true; - // Cancel the promise if it's a CancelablePromise - if (promiseRef.current && 'cancel' in promiseRef.current) { - (promiseRef.current as CancelablePromise).cancel(); - } - }; - }, []); - - return { data, loading, error, execute }; -} - diff --git a/src/main/webui/src/pages/RepositoryReportPage.tsx b/src/main/webui/src/pages/RepositoryReportPage.tsx index d2f8d831..4850b986 100644 --- a/src/main/webui/src/pages/RepositoryReportPage.tsx +++ b/src/main/webui/src/pages/RepositoryReportPage.tsx @@ -142,12 +142,17 @@ const RepositoryReportPage: React.FC = () => { CVE Repository Report:{" "} @@ -156,6 +161,8 @@ const RepositoryReportPage: React.FC = () => { fontSize: "var(--pf-t--global--font--size--heading--h6)", wordBreak: "break-word", overflowWrap: "break-word", + display: "inline-block", + maxWidth: "100%", }} > <Link @@ -175,7 +182,13 @@ const RepositoryReportPage: React.FC = () => { </span> - + diff --git a/src/test/resources/devservices/reports/test-sbom-report-1-report-2.json b/src/test/resources/devservices/reports/test-sbom-report-1-report-2.json index 8653a839..454a2123 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-1-report-2.json +++ b/src/test/resources/devservices/reports/test-sbom-report-1-report-2.json @@ -57,4 +57,4 @@ "user": "test@example.com" }, "info": {} -} +} \ No newline at end of file diff --git a/src/test/resources/devservices/reports/test-sbom-report-2-report-1.json b/src/test/resources/devservices/reports/test-sbom-report-2-report-1.json index 916a44b5..bd89ecdf 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-2-report-1.json +++ b/src/test/resources/devservices/reports/test-sbom-report-2-report-1.json @@ -57,4 +57,4 @@ "user": "test@example.com" }, "info": {} -} +} \ No newline at end of file diff --git a/src/test/resources/devservices/reports/test-sbom-report-3-report-2.json b/src/test/resources/devservices/reports/test-sbom-report-3-report-2.json index b6341a0a..be2b51ab 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-3-report-2.json +++ b/src/test/resources/devservices/reports/test-sbom-report-3-report-2.json @@ -10908,4 +10908,4 @@ ], "vex": null } -} \ No newline at end of file +} diff --git a/src/test/resources/devservices/reports/test-sbom-report-4-report-1.json b/src/test/resources/devservices/reports/test-sbom-report-4-report-1.json index 788877e4..c39c7864 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-4-report-1.json +++ b/src/test/resources/devservices/reports/test-sbom-report-4-report-1.json @@ -2466,8 +2466,7 @@ "user": "zgrinber@redhat.com", "requested_at": "2025-02-20T14:46:58.335Z", "submitted_at": "2025-02-20T15:46:58.335583Z", - "sent_at": "2025-02-20T15:47:00.245249Z", - "sbom_name": "Product_4" + "sent_at": "2025-02-20T15:47:00.245249Z" }, "info": { "vdb": { diff --git a/src/test/resources/devservices/reports/test-sbom-report-4-report-2.json b/src/test/resources/devservices/reports/test-sbom-report-4-report-2.json index 5e66fc71..d181a3d3 100644 --- a/src/test/resources/devservices/reports/test-sbom-report-4-report-2.json +++ b/src/test/resources/devservices/reports/test-sbom-report-4-report-2.json @@ -2722,8 +2722,7 @@ "requested_at": "2025-02-24T06:11:41.123Z", "submitted_at": "2025-02-24T07:11:41.123398Z", "user": "zgrinber@redhat.com", - "sent_at": "2025-02-24T07:11:43.437450Z", - "sbom_name": "Product_4" + "sent_at": "2025-02-24T07:11:43.437450Z" }, "info": { "vdb": { From 3e8ec2b44d5304a576f7faf179f949ddd151085e Mon Sep 17 00:00:00 2001 From: rhartuv Date: Mon, 23 Feb 2026 15:54:37 +0200 Subject: [PATCH 3/7] feat: add cve details page and integrate with new ui3 --- .../repository/ReportRepositoryService.java | 2 +- .../service/CveIdValidationException.java | 15 --------------- .../morpheus/service/PreProcessingService.java | 2 +- src/main/resources/application.properties | 1 + src/main/webui/src/App.tsx | 5 +---- 5 files changed, 4 insertions(+), 21 deletions(-) delete mode 100644 src/main/java/com/redhat/ecosystemappeng/morpheus/service/CveIdValidationException.java diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java index f9a86805..2414f7c8 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java @@ -647,4 +647,4 @@ private Bson buildQueryFilter(Map queryFilter) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/CveIdValidationException.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/CveIdValidationException.java deleted file mode 100644 index 178eb6b0..00000000 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/CveIdValidationException.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.redhat.ecosystemappeng.morpheus.service; - -/** - * Exception thrown when CVE ID validation fails - */ -public class CveIdValidationException extends IllegalArgumentException { - public CveIdValidationException(String message) { - super(message); - } - - public CveIdValidationException(String message, Throwable cause) { - super(message, cause); - } -} - diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java index 8ca17657..835c21f5 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/PreProcessingService.java @@ -144,4 +144,4 @@ public void checkSubmitted() { public void confirmResponse(String id) { submitted.remove(id); } -} \ No newline at end of file +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index eb18c82a..94f69dfc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -15,6 +15,7 @@ quarkus.rest-client.morpheus.url=https://agent-morpheus:8080/generate quarkus.rest-client.component-syncer.url=http://job-sink.knative-eventing.svc.cluster.local/${NAMESPACE}/component-syncer %dev.quarkus.rest-client.component-syncer.url=http://localhost:8088/${NAMESPACE:exploit-iq}/component-syncer quarkus.smallrye-openapi.store-schema-directory=target/generated/openapi + quarkus.swagger-ui.always-include=${INCLUDE_SWAGGER_UI:true} quarkus.http.filter.others.header.Cache-Control=no-cache quarkus.http.filter.others.matches=/.* diff --git a/src/main/webui/src/App.tsx b/src/main/webui/src/App.tsx index 5572b955..f40fd6e6 100644 --- a/src/main/webui/src/App.tsx +++ b/src/main/webui/src/App.tsx @@ -21,10 +21,7 @@ const App: React.FC = () => { path="/reports/product/:productId/:cveId/:reportId" element={} /> - } - /> + } /> } From 218305aa8bfc213983c772416123545bd0cdbfd2 Mon Sep 17 00:00:00 2001 From: rhartuv Date: Tue, 24 Feb 2026 10:44:32 +0200 Subject: [PATCH 4/7] fix: fix bugs --- src/main/webui/src/components/Navigation.tsx | 2 -- .../src/components/RepositoryReportsTable.tsx | 27 +++++++------------ src/main/webui/src/mocks/handlers.ts | 5 +++- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/main/webui/src/components/Navigation.tsx b/src/main/webui/src/components/Navigation.tsx index ade0f64c..0d9e1278 100644 --- a/src/main/webui/src/components/Navigation.tsx +++ b/src/main/webui/src/components/Navigation.tsx @@ -17,7 +17,6 @@ const Navigation: React.FC = () => { itemId="home" isActive={location.pathname === "/"} onClick={() => navigate("/")} - style={{ cursor: "pointer" }} > Home @@ -25,7 +24,6 @@ const Navigation: React.FC = () => { itemId="reports" isActive={location.pathname.startsWith("/reports")} onClick={() => navigate("/reports")} - style={{ cursor: "pointer" }} > Reports diff --git a/src/main/webui/src/components/RepositoryReportsTable.tsx b/src/main/webui/src/components/RepositoryReportsTable.tsx index a88c3712..8e1d4299 100644 --- a/src/main/webui/src/components/RepositoryReportsTable.tsx +++ b/src/main/webui/src/components/RepositoryReportsTable.tsx @@ -1,10 +1,6 @@ import { useState, useMemo } from "react"; import { useNavigate } from "react-router"; -import { - Button, - Alert, - AlertVariant, -} from "@patternfly/react-core"; +import { Button, Alert, AlertVariant } from "@patternfly/react-core"; import { Table, TableText, @@ -67,7 +63,7 @@ const RepositoryReportsTable: React.FC = ({ if (!report.vulns || !cveId) return null; const vuln = report.vulns.find((v) => v.vulnId === cveId); if (!vuln?.justification?.status) return null; - + return ; }; @@ -241,9 +237,7 @@ const RepositoryReportsTable: React.FC = ({ > Completed - - Analysis state - + Analysis state CVE Repository Report @@ -253,11 +247,7 @@ const RepositoryReportsTable: React.FC = ({ {report.gitRepo ? ( - + {report.gitRepo} ) : ( @@ -306,10 +296,11 @@ const RepositoryReportsTable: React.FC = ({