diff --git a/appinfo/routes.php b/appinfo/routes.php index ab7ee994..e2e20d98 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -78,6 +78,12 @@ // Configuration cache management ['name' => 'settings#clearCache', 'url' => '/api/settings/clear-cache', 'verb' => 'POST'], + // SBOM (Software Bill of Materials) import routes — CycloneDX/SPDX + // upload scoped to a single moduleVersie, and its status. + // @spec openspec/specs/sbom-import/spec.md + ['name' => 'sbom#importSbom', 'url' => '/api/moduleversies/{moduleVersieUuid}/sbom', 'verb' => 'POST'], + ['name' => 'sbom#getSbomImportStatus', 'url' => '/api/moduleversies/{moduleVersieUuid}/sbom', 'verb' => 'GET'], + // ArchiMate import/export routes ['name' => 'settings#importArchiMate', 'url' => '/api/archimate/import', 'verb' => 'POST'], ['name' => 'settings#exportArchiMate', 'url' => '/api/archimate/export', 'verb' => 'POST'], diff --git a/docs/features/sbom-import.md b/docs/features/sbom-import.md new file mode 100644 index 00000000..017c79d6 --- /dev/null +++ b/docs/features/sbom-import.md @@ -0,0 +1,133 @@ + + +# SBOM import + +Imports a Software Bill of Materials (SBOM) — CycloneDX 1.5/1.6 JSON, with +SPDX 2.3 JSON as an optional second format — for a specific `moduleVersie` +(a released version of an application), parsing its components into +`sbomComponent` OpenRegister objects and surfacing them on a **Components** +tab with licenses, summary counts, and a render-time cross-reference against +the existing `kwetsbaarheid` (vulnerability) register. + +Specification: [`openspec/specs/sbom-import/spec.md`](../../openspec/specs/sbom-import/spec.md). + +## Uploading an SBOM + +On a module version's detail page, open the **Components** sidebar tab. +Choose a format (CycloneDX or SPDX, both JSON) and a file, then **Import +SBOM**: + +``` +POST /apps/softwarecatalog/api/moduleversies/{moduleVersieUuid}/sbom +multipart/form-data: sbomFile=, format=cyclonedx-json|spdx-json +``` + +The upload is rejected — before the parser ever runs — when it exceeds the +configured maximum size (10 MB by default) or is not valid JSON. Importing +requires admin group membership, or membership of a manage-tier group +**and** manage-ACL (RBAC read) on the version's parent application; anyone +else gets a 403 and no objects change. + +```json +{ + "success": true, + "operationId": null, + "moduleVersieUuid": "b2c3d4e5-...", + "componentCount": 3, + "previousComponentCount": 0, + "distinctLicenseCount": 2, + "vulnerabilityPairCount": 0, + "sbomFormat": "cyclonedx-json", + "sbomFileName": "sbom.json" +} +``` + +## Re-import replaces, never accumulates + +Importing a second SBOM for the same version **replaces** the previous +component set: the previous live `sbomComponent` objects are soft-deleted +and the newly parsed set is created. Already-trashed rows from an earlier +replace are never re-queried or re-deleted (OpenRegister's default search +already excludes `_deleted` rows). If the create step fails partway through, +the version is left with no live component set rather than a mixed +old/new one — a re-run of the import starts clean either way. This mirrors +the same replace-not-accumulate model used elsewhere in this app rather than +introducing an import-history/audit-log concept. + +Both the soft-delete and the create step run in bounded batches (~100 +objects per OpenRegister call). Imports whose parsed component count +exceeds 50 start a `progress-tracking` operation, update it per batch, and +complete it — the operation id is returned in the response so the frontend +can poll `GET .../sbom?operationId=...` for `{ phase, percentage, +processed_items }`. Smaller imports complete synchronously and the response +already carries the final counts. + +## What gets stored + +Each parsed component persists as one `sbomComponent` OpenRegister object, +related to its `moduleVersie`: + +| Field | Source | +|---|---| +| `name`, `version` | CycloneDX/SPDX component name + version | +| `purl` | Package URL (`pkg:...`) | +| `licenses` | SPDX license id(s)/expression(s), or free text | +| `type` | CycloneDX component type (`library`, `application`, …) | +| `hashes` | Informational file hashes — never used for matching | +| `bomRef` | CycloneDX `bom-ref` — within-import traceability only | +| `vexCveIds` | CVE ids the SBOM's own VEX block associates with this component's `bom-ref` — a raw fact from the source document, not a stored vulnerability match | + +Three optional provenance fields are set on the `moduleVersie` itself on +every successful import: `sbomLastImportedAt`, `sbomFormat`, `sbomFileName` +— shown as a "last imported ⟨date⟩ from ⟨file⟩" line on the Components tab. + +## Vulnerability matching — computed, never stored + +The Components tab cross-references each imported component against the +existing `kwetsbaarheid` register using two bounded, local strategies — +never an outbound HTTP call to an external advisory feed (OSV.dev, NVD, …): + +1. **Confirmed match** — a component's VEX-extracted `vexCveIds` compared, + case-insensitively, against `kwetsbaarheid.cveCode`. +2. **Possible match** — the component's `name` (or the package segment of + its `purl`) compared, case-insensitively (substring), against + `kwetsbaarheid.naam`, scoped to `kwetsbaarheid` records whose `modules` + already reference the version's parent `module`. A same-name + vulnerability recorded against a *different* application never surfaces + here. + +Both matches are computed at render time by +[`src/utils/sbomVulnerabilityMatch.js`](../../src/utils/sbomVulnerabilityMatch.js) +— nothing is written back to either `sbomComponent` or `kwetsbaarheid`. +Editing a `kwetsbaarheid`'s `cveCode`/`naam` after an import changes the +match set on next render, with no re-import required. This feeds +`module-vulnerability-tracking` rather than forking a parallel vulnerability +model. + +## Components tab + +The **Components** tab on a module version's detail page (`SbomComponentsPanel`) +shows: + +- Summary counts — total components, distinct licenses, matched + vulnerabilities. +- The "last imported" provenance line, when an import has happened. +- The upload control (format select + file input + Import button). +- The component table (name, version, package URL, licenses) with a + **Confirmed match** / **Possible match** badge per matched component. +- An empty state with the upload control when no SBOM has been imported yet. + +## Out of scope + +- Outbound calls to an external vulnerability/advisory service — that + integration, if built, belongs in `openconnector` (per + `feedback_integrations-not-leaves`). +- SBOM generation/export — this feature only imports. +- License-policy evaluation (allow/deny lists, obligations) — only the raw + license identifiers are captured. +- Transitive dependency graphs — the component **list** only; `bomRef` is + captured for future use but no dependency-edge graph is parsed or + rendered. diff --git a/l10n/en_US.js b/l10n/en_US.js index 59eb203b..8a1ead92 100644 --- a/l10n/en_US.js +++ b/l10n/en_US.js @@ -323,6 +323,27 @@ OC.L10N.register( "Unclassified" : "Unclassified", "An error occurred while changing the status" : "An error occurred while changing the status", "Approval" : "Approval", + "Loading components" : "Loading components", + "Components" : "Components", + "Distinct licenses" : "Distinct licenses", + "Matched vulnerabilities" : "Matched vulnerabilities", + "SBOM format" : "SBOM format", + "Choose an SBOM JSON file" : "Choose an SBOM JSON file", + "Import SBOM" : "Import SBOM", + "No components imported yet" : "No components imported yet", + "Import a CycloneDX or SPDX SBOM to see this version's components, licenses and any matching known vulnerabilities." : "Import a CycloneDX or SPDX SBOM to see this version's components, licenses and any matching known vulnerabilities.", + "Confirmed match" : "Confirmed match", + "Possible match" : "Possible match", + "Name" : "Name", + "Version" : "Version", + "Package URL" : "Package URL", + "Licenses" : "Licenses", + "Vulnerability match" : "Vulnerability match", + "CycloneDX (JSON)" : "CycloneDX (JSON)", + "SPDX (JSON)" : "SPDX (JSON)", + "SBOM import failed" : "SBOM import failed", + "Imported {count} components." : "Imported {count} components.", + "Last imported {date} from {file} ({format})" : "Last imported {date} from {file} ({format})" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/en_US.json b/l10n/en_US.json index b628f3eb..4639b77a 100644 --- a/l10n/en_US.json +++ b/l10n/en_US.json @@ -364,6 +364,27 @@ "Tolerate": "Tolerate", "Unclassified": "Unclassified", "An error occurred while changing the status": "An error occurred while changing the status", - "Approval": "Approval" + "Approval": "Approval", + "Loading components": "Loading components", + "Components": "Components", + "Distinct licenses": "Distinct licenses", + "Matched vulnerabilities": "Matched vulnerabilities", + "SBOM format": "SBOM format", + "Choose an SBOM JSON file": "Choose an SBOM JSON file", + "Import SBOM": "Import SBOM", + "No components imported yet": "No components imported yet", + "Import a CycloneDX or SPDX SBOM to see this version's components, licenses and any matching known vulnerabilities.": "Import a CycloneDX or SPDX SBOM to see this version's components, licenses and any matching known vulnerabilities.", + "Confirmed match": "Confirmed match", + "Possible match": "Possible match", + "Name": "Name", + "Version": "Version", + "Package URL": "Package URL", + "Licenses": "Licenses", + "Vulnerability match": "Vulnerability match", + "CycloneDX (JSON)": "CycloneDX (JSON)", + "SPDX (JSON)": "SPDX (JSON)", + "SBOM import failed": "SBOM import failed", + "Imported {count} components.": "Imported {count} components.", + "Last imported {date} from {file} ({format})": "Last imported {date} from {file} ({format})" } } diff --git a/l10n/nl.js b/l10n/nl.js index 32c9a2e8..6b6bc25f 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -360,6 +360,27 @@ OC.L10N.register( "Unclassified" : "Ongeclassificeerd", "An error occurred while changing the status" : "Er is een fout opgetreden bij het wijzigen van de status", "Approval" : "Goedkeuring", + "Loading components" : "Componenten laden", + "Components" : "Componenten", + "Distinct licenses" : "Unieke licenties", + "Matched vulnerabilities" : "Overeenkomende kwetsbaarheden", + "SBOM format" : "SBOM-formaat", + "Choose an SBOM JSON file" : "Kies een SBOM JSON-bestand", + "Import SBOM" : "SBOM importeren", + "No components imported yet" : "Nog geen componenten geïmporteerd", + "Import a CycloneDX or SPDX SBOM to see this version's components, licenses and any matching known vulnerabilities." : "Importeer een CycloneDX- of SPDX-SBOM om de componenten, licenties en eventuele overeenkomende bekende kwetsbaarheden van deze versie te zien.", + "Confirmed match" : "Bevestigde overeenkomst", + "Possible match" : "Mogelijke overeenkomst", + "Name" : "Naam", + "Version" : "Versie", + "Package URL" : "Package-URL", + "Licenses" : "Licenties", + "Vulnerability match" : "Kwetsbaarheid-overeenkomst", + "CycloneDX (JSON)" : "CycloneDX (JSON)", + "SPDX (JSON)" : "SPDX (JSON)", + "SBOM import failed" : "SBOM-import mislukt", + "Imported {count} components." : "{count} componenten geïmporteerd.", + "Last imported {date} from {file} ({format})" : "Laatst geïmporteerd op {date} vanuit {file} ({format})" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/nl.json b/l10n/nl.json index dac9ff43..4d10a218 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -507,6 +507,27 @@ "TIME quadrant counts": "TIME-kwadrantaantallen", "Tolerate": "Tolereren", "Unclassified": "Ongeclassificeerd", - "Approval": "Goedkeuring" + "Approval": "Goedkeuring", + "Loading components": "Componenten laden", + "Components": "Componenten", + "Distinct licenses": "Unieke licenties", + "Matched vulnerabilities": "Overeenkomende kwetsbaarheden", + "SBOM format": "SBOM-formaat", + "Choose an SBOM JSON file": "Kies een SBOM JSON-bestand", + "Import SBOM": "SBOM importeren", + "No components imported yet": "Nog geen componenten geïmporteerd", + "Import a CycloneDX or SPDX SBOM to see this version's components, licenses and any matching known vulnerabilities.": "Importeer een CycloneDX- of SPDX-SBOM om de componenten, licenties en eventuele overeenkomende bekende kwetsbaarheden van deze versie te zien.", + "Confirmed match": "Bevestigde overeenkomst", + "Possible match": "Mogelijke overeenkomst", + "Name": "Naam", + "Version": "Versie", + "Package URL": "Package-URL", + "Licenses": "Licenties", + "Vulnerability match": "Kwetsbaarheid-overeenkomst", + "CycloneDX (JSON)": "CycloneDX (JSON)", + "SPDX (JSON)": "SPDX (JSON)", + "SBOM import failed": "SBOM-import mislukt", + "Imported {count} components.": "{count} componenten geïmporteerd.", + "Last imported {date} from {file} ({format})": "Laatst geïmporteerd op {date} vanuit {file} ({format})" } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 904d0944..6f573e7c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -51,6 +51,8 @@ use OCA\SoftwareCatalog\Service\OrganisatieService; use OCA\SoftwareCatalog\Service\OrganizationSyncService; use OCA\SoftwareCatalog\Service\ProgressTracker; +use OCA\SoftwareCatalog\Service\SbomImportService; +use OCA\SoftwareCatalog\Service\SbomParserService; use OCA\SoftwareCatalog\Service\SettingsService; use OCA\SoftwareCatalog\Service\SoftwareCatalogContactSyncService; use OCA\SoftwareCatalog\Service\SoftwareCatalogue\ContactPersonHandler; @@ -434,6 +436,29 @@ function ($container) { } ); + // Register the pure SBOM parser (no OR/HTTP dependency — ADR-008). + $context->registerService( + SbomParserService::class, + function ($container) { + return new SbomParserService(); + } + ); + + // Register the SBOM import orchestrator (parse → replace previous + // component set → bulk-save new set → record provenance). + $context->registerService( + SbomImportService::class, + function ($container) { + return new SbomImportService( + container: $container, + settingsService: $container->get(SettingsService::class), + parser: $container->get(SbomParserService::class), + progressTracker: $container->get(ProgressTracker::class), + logger: $container->get('Psr\Log\LoggerInterface') + ); + } + ); + // Register ArchiMate import service. $context->registerService( ArchiMateImportService::class, diff --git a/lib/Controller/SbomController.php b/lib/Controller/SbomController.php new file mode 100644 index 00000000..83686f33 --- /dev/null +++ b/lib/Controller/SbomController.php @@ -0,0 +1,360 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/sbom-import/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Controller; + +use OCA\SoftwareCatalog\AppInfo\Application; +use OCA\SoftwareCatalog\Exception\UnsupportedSbomFormatException; +use OCA\SoftwareCatalog\Service\SbomImportService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * SBOM upload + status endpoints, scoped to a single `moduleVersie`. + * + * @spec openspec/specs/sbom-import/spec.md + */ +class SbomController extends Controller +{ + /** + * Maximum accepted upload size in bytes (design: default 10 MB). + */ + private const MAX_UPLOAD_BYTES = 10485760; + + /** + * Groups (beyond admin) allowed to import an SBOM, subject to the + * per-object manage-ACL check on the target module (design: "admin + * group membership OR manage-ACL on the target moduleVersie's parent + * module"). + * + * @var array + */ + private const MANAGE_GROUPS = ['software-catalog-admins', 'aanbod-beheerder', 'functioneel-beheerder']; + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param IUserSession $userSession The user session (auth guard). + * @param IGroupManager $groupManager Group membership (role/admin guard). + * @param SbomImportService $importService The SBOM import service. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + IRequest $request, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly SbomImportService $importService, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Import an SBOM for a `moduleVersie`. Multipart upload only. + * + * @param string $moduleVersieUuid The target moduleVersie's uuid. + * + * @return JSONResponse The import result summary, or a 400/401/403/404/422/500. + * + * @NoAdminRequired + * @NoCSRFRequired + * @spec openspec/specs/sbom-import/spec.md#requirement-uploaded-sbom-files-are-bounded-in-size-and-json-only + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function importSbom(string $moduleVersieUuid): JSONResponse + { + $guard = $this->authorizeManage(moduleVersieUuid: $moduleVersieUuid); + if ($guard instanceof JSONResponse) { + return $guard; + } + + $validated = $this->validateUpload(moduleVersieUuid: $moduleVersieUuid); + if ($validated instanceof JSONResponse) { + return $validated; + } + + try { + $result = $this->importService->importForModuleVersie( + moduleVersieUuid: $moduleVersieUuid, + rawJson: $validated['contents'], + format: $validated['format'], + fileName: $validated['fileName'] + ); + } catch (UnsupportedSbomFormatException $e) { + return new JSONResponse( + data: ['message' => $e->getMessage(), 'error' => 'UNSUPPORTED_SBOM_FORMAT'], + statusCode: Http::STATUS_UNPROCESSABLE_ENTITY + ); + } catch (\RuntimeException $e) { + return new JSONResponse( + data: ['message' => $e->getMessage(), 'error' => 'MODULE_VERSION_NOT_FOUND'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (\Exception $e) { + $this->logger->error( + 'SbomController: import failed', + ['moduleVersieUuid' => $moduleVersieUuid, 'error' => $e->getMessage()] + ); + return new JSONResponse( + data: ['message' => 'Import failed: '.$e->getMessage(), 'error' => 'IMPORT_FAILED'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + + return new JSONResponse(data: $result); + }//end importSbom() + + /** + * Validate the multipart upload (present, bounded size, readable, valid + * JSON, known `format` param) BEFORE the parser is ever invoked — an + * oversized or non-JSON upload never reaches parsing and never changes + * the previous component set. + * + * @param string $moduleVersieUuid The target moduleVersie's uuid (for the size-rejection log line). + * + * @return array{contents:string,format:string,fileName:string}|JSONResponse + * The validated upload, or a 400 JSONResponse on the first failed check. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-uploaded-sbom-files-are-bounded-in-size-and-json-only + */ + private function validateUpload(string $moduleVersieUuid): array|JSONResponse + { + $upload = $this->parseUploadedFile(); + if ($upload === null) { + return new JSONResponse( + data: ['message' => 'No SBOM file uploaded', 'error' => 'NO_FILE_UPLOADED'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + if ($upload['fileSize'] > self::MAX_UPLOAD_BYTES) { + $this->logger->warning( + 'SbomController: upload rejected (too large)', + ['moduleVersieUuid' => $moduleVersieUuid, 'fileSize' => $upload['fileSize']] + ); + return new JSONResponse( + data: [ + 'message' => sprintf('File exceeds the maximum allowed size of %d bytes', self::MAX_UPLOAD_BYTES), + 'error' => 'FILE_TOO_LARGE', + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $contents = false; + if (is_readable($upload['tmpName']) === true) { + $contents = file_get_contents($upload['tmpName']); + } + + if ($contents === false) { + return new JSONResponse( + data: ['message' => 'Uploaded file could not be read', 'error' => 'FILE_UNREADABLE'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + // JSON-only gate BEFORE the parser is invoked (semantic bomFormat/ + // specVersion validation happens inside SbomParserService). + json_decode($contents); + if (json_last_error() !== JSON_ERROR_NONE) { + return new JSONResponse( + data: [ + 'message' => 'Uploaded file is not valid JSON: '.json_last_error_msg(), + 'error' => 'INVALID_JSON', + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $format = (string) $this->request->getParam('format', SbomImportService::SUPPORTED_FORMATS[0]); + if (in_array($format, SbomImportService::SUPPORTED_FORMATS, true) === false) { + return new JSONResponse( + data: ['message' => 'Unknown SBOM format: '.$format, 'error' => 'UNKNOWN_FORMAT'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return [ + 'contents' => $contents, + 'format' => $format, + 'fileName' => $upload['fileName'], + ]; + }//end validateUpload() + + /** + * Read SBOM import status/provenance for a `moduleVersie`, optionally + * including a `progress-tracking` snapshot when `operationId` is given. + * + * @param string $moduleVersieUuid The target moduleVersie's uuid. + * + * @return JSONResponse `{sbomLastImportedAt, sbomFormat, sbomFileName, progress}`. + * + * @NoAdminRequired + * @spec openspec/specs/sbom-import/spec.md#requirement-large-imports-run-in-bounded-batches-with-progress-reporting + */ + #[NoAdminRequired] + public function getSbomImportStatus(string $moduleVersieUuid): JSONResponse + { + if ($this->userSession->getUser() === null) { + return new JSONResponse(data: ['message' => 'Not logged in'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + $operationId = $this->request->getParam('operationId'); + if (is_string($operationId) === false) { + $operationId = null; + } + + return new JSONResponse( + data: $this->importService->getStatus( + moduleVersieUuid: $moduleVersieUuid, + operationId: $operationId + ) + ); + }//end getSbomImportStatus() + + /** + * Manage-ACL authorization guard (IDOR guard). Returns a JSONResponse to + * short-circuit on failure, or null when the caller may import. + * + * Authorized when the caller is an admin, OR is a member of one of + * `self::MANAGE_GROUPS` AND can resolve the target moduleVersie's parent + * `module` under normal RBAC (manage-ACL proxy: readable-under-RBAC AND + * an editor-tier group, per the module/kwetsbaarheid schema's own role + * vocabulary). + * + * @param string $moduleVersieUuid The target moduleVersie's uuid. + * + * @return JSONResponse|null Error response, or null when authorized. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-uploaded-sbom-files-are-bounded-in-size-and-json-only + */ + private function authorizeManage(string $moduleVersieUuid): ?JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Not logged in'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + if ($this->groupManager->isAdmin($user->getUID()) === true) { + return null; + } + + $inManageGroup = false; + foreach (self::MANAGE_GROUPS as $group) { + if ($this->groupManager->isInGroup($user->getUID(), $group) === true) { + $inManageGroup = true; + break; + } + } + + if ($inManageGroup === false) { + $this->logger->warning( + 'SbomController: import refused (not admin, no manage group)', + ['moduleVersieUuid' => $moduleVersieUuid, 'uid' => $user->getUID()] + ); + return new JSONResponse( + data: ['message' => 'Admin privileges or a manage role are required to import an SBOM'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + $moduleUuid = $this->importService->resolveParentModuleUuid($moduleVersieUuid); + if ($moduleUuid === null || $this->importService->userCanReadModule($moduleUuid) === false) { + $this->logger->warning( + 'SbomController: import refused (no manage-ACL on target module)', + ['moduleVersieUuid' => $moduleVersieUuid, 'uid' => $user->getUID()] + ); + return new JSONResponse( + data: ['message' => 'You do not have manage access to this application'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return null; + }//end authorizeManage() + + /** + * Parse the uploaded SBOM file from the multipart request. + * + * Inspects both the NC request wrapper and the `$_FILES` superglobal as + * a fallback, mirroring `SettingsController::parseArchiMateFileUpload`. + * + * @return array{tmpName:string,fileName:string,fileSize:int}|null Upload + * info, or null when no file was uploaded. + * + * @SuppressWarnings(PHPMD.Superglobals) + */ + private function parseUploadedFile(): ?array + { + $uploadedFiles = $this->request->getUploadedFile('sbomFile'); + $filesArray = $_FILES['sbomFile'] ?? null; + + if (empty($uploadedFiles) === true && empty($filesArray) === true) { + return null; + } + + $fileData = $filesArray; + if ($uploadedFiles !== null) { + $fileData = $uploadedFiles; + } + + $tmpName = $fileData['tmp_name'] ?? ''; + + $fileSize = $fileData['size'] ?? null; + if ($fileSize === null) { + $fileSize = 0; + if (is_string($tmpName) === true && $tmpName !== '') { + $fileSize = (int) filesize($tmpName); + } + } + + return [ + 'tmpName' => $tmpName, + 'fileName' => $fileData['name'] ?? 'sbom.json', + 'fileSize' => (int) $fileSize, + ]; + }//end parseUploadedFile() +}//end class diff --git a/lib/Exception/UnsupportedSbomFormatException.php b/lib/Exception/UnsupportedSbomFormatException.php new file mode 100644 index 00000000..a7e213fa --- /dev/null +++ b/lib/Exception/UnsupportedSbomFormatException.php @@ -0,0 +1,36 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/sbom-import/spec.md#requirement-cyclonedx-sbom-files-are-parsed-into-a-normalized-component-list + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Exception; + +/** + * Thrown when an uploaded SBOM document's format/spec-version is not supported. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-cyclonedx-sbom-files-are-parsed-into-a-normalized-component-list + */ +class UnsupportedSbomFormatException extends \RuntimeException +{ +}//end class diff --git a/lib/Service/SbomImportService.php b/lib/Service/SbomImportService.php new file mode 100644 index 00000000..20ffd4fb --- /dev/null +++ b/lib/Service/SbomImportService.php @@ -0,0 +1,635 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/sbom-import/spec.md#requirement-re-import-replaces-the-previous-component-set-and-is-soft-delete-aware + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Service; + +use DateTime; +use OCA\OpenRegister\Service\ObjectService; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Imports a parsed SBOM's components as `sbomComponent` objects scoped to a + * `moduleVersie`, replacing any previous set. + * + * @spec openspec/specs/sbom-import/spec.md + */ +class SbomImportService +{ + /** + * Maximum objects per OR bulk create/delete call — bounds a single + * unbounded bulk-save call for large SBOMs (design Decision 4 / + * non-functional performance requirement: batches of ~100). + */ + private const BATCH_SIZE = 100; + + /** + * Component count above which a `progress-tracking` operation is + * started (design Decision 4 / spec "Large imports run in bounded + * batches with progress reporting"). + */ + private const PROGRESS_THRESHOLD = 50; + + /** + * Supported SBOM upload formats. + * + * @var array + */ + public const SUPPORTED_FORMATS = ['cyclonedx-json', 'spdx-json']; + + /** + * Constructor. + * + * @param ContainerInterface $container The DI container (lazy OR lookup). + * @param SettingsService $settingsService Resolves register/schema ids. + * @param SbomParserService $parser The pure SBOM parser. + * @param ProgressTracker $progressTracker Progress reporting for large imports. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly SettingsService $settingsService, + private readonly SbomParserService $parser, + private readonly ProgressTracker $progressTracker, + private readonly LoggerInterface $logger + ) { + }//end __construct() + + /** + * Import an uploaded SBOM for a `moduleVersie`: parse, replace the + * previous component set, and record provenance. + * + * @param string $moduleVersieUuid The target moduleVersie's uuid. + * @param string $rawJson The raw uploaded file contents. + * @param string $format `cyclonedx-json` or `spdx-json`. + * @param string $fileName The uploaded file's original name. + * + * @return array Import result summary. + * + * @throws \OCA\SoftwareCatalog\Exception\UnsupportedSbomFormatException When the + * document's format/version is not supported. + * No component is written in that case. + * @throws RuntimeException When the target `moduleVersie` cannot be + * resolved, or required configuration is missing. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-imported-components-persist-as-openregister-objects-scoped-to-a-moduleversie + */ + public function importForModuleVersie( + string $moduleVersieUuid, + string $rawJson, + string $format, + string $fileName + ): array { + // 1. Parse — pure, no OR/HTTP call. Throws on unsupported format; + // nothing has been written yet at this point. + $parsed = $this->parseUpload(rawJson: $rawJson, format: $format); + $components = $parsed['components']; + $vexPairs = $parsed['vulnerabilities']; + + $coordinates = $this->resolveCoordinates(); + $objectService = $coordinates['objectService']; + + $moduleVersie = $objectService->find( + id: $moduleVersieUuid, + register: $coordinates['registerId'], + schema: $coordinates['moduleVersieSchemaId'], + _rbac: false, + _multitenancy: false + ); + + if ($moduleVersie === null) { + throw new RuntimeException('moduleVersie not found: '.$moduleVersieUuid); + } + + $componentCount = count($components); + $trackProgress = $componentCount > self::PROGRESS_THRESHOLD; + $operationId = null; + + if ($trackProgress === true) { + $operationId = $this->progressTracker->startOperation( + 'sbom-import', + ['total_items' => $componentCount] + ); + $this->progressTracker->setPhase('processing_elements'); + } + + $previousUuids = $this->replacePreviousComponentSet( + objectService: $objectService, + registerId: $coordinates['registerId'], + componentSchemaId: $coordinates['sbomComponentSchemaId'], + moduleVersieUuid: $moduleVersieUuid + ); + + $createdCount = $this->createComponentSet( + objectService: $objectService, + registerId: $coordinates['registerId'], + componentSchemaId: $coordinates['sbomComponentSchemaId'], + moduleVersieUuid: $moduleVersieUuid, + components: $components, + vexPairs: $vexPairs, + trackProgress: $trackProgress + ); + + $this->recordProvenance( + objectService: $objectService, + registerId: $coordinates['registerId'], + moduleVersieSchemaId: $coordinates['moduleVersieSchemaId'], + moduleVersie: $moduleVersie, + format: $format, + fileName: $fileName + ); + + if ($trackProgress === true) { + $this->progressTracker->completeOperation( + [ + 'componentsCreated' => $createdCount, + 'previousCount' => count($previousUuids), + ] + ); + } + + $this->logger->info( + 'SbomImportService: import completed', + [ + 'moduleVersieUuid' => $moduleVersieUuid, + 'format' => $format, + 'componentCount' => $createdCount, + 'previousCount' => count($previousUuids), + ] + ); + + return [ + 'success' => true, + 'operationId' => $operationId, + 'moduleVersieUuid' => $moduleVersieUuid, + 'componentCount' => $createdCount, + 'previousComponentCount' => count($previousUuids), + 'distinctLicenseCount' => $this->countDistinctLicenses(components: $components), + 'vulnerabilityPairCount' => count($vexPairs), + 'sbomFormat' => $format, + 'sbomFileName' => $fileName, + ]; + }//end importForModuleVersie() + + /** + * Select and invoke the parser matching the uploaded `format` (design + * Decision 1 — explicit format selection, never content-sniffed). + * + * @param string $rawJson The raw uploaded file contents. + * @param string $format `cyclonedx-json` or `spdx-json`. + * + * @return array{components: array>, vulnerabilities: array} + * + * @throws \OCA\SoftwareCatalog\Exception\UnsupportedSbomFormatException When + * the document's format/version is not supported. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-cyclonedx-sbom-files-are-parsed-into-a-normalized-component-list + */ + private function parseUpload(string $rawJson, string $format): array + { + if ($format === 'spdx-json') { + return $this->parser->parseSpdx(json: $rawJson); + } + + return $this->parser->parse(json: $rawJson); + }//end parseUpload() + + /** + * Resolve the parent `module` uuid of a `moduleVersie` — used by the + * controller's manage-ACL authorization guard, which needs to know which + * module the caller must be allowed to manage before any write happens. + * + * @param string $moduleVersieUuid The moduleVersie uuid. + * + * @return string|null The parent module uuid, or null when not resolvable. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-uploaded-sbom-files-are-bounded-in-size-and-json-only + */ + public function resolveParentModuleUuid(string $moduleVersieUuid): ?string + { + $coordinates = $this->resolveCoordinates(); + $moduleVersie = $coordinates['objectService']->find( + id: $moduleVersieUuid, + register: $coordinates['registerId'], + schema: $coordinates['moduleVersieSchemaId'], + _rbac: false, + _multitenancy: false + ); + + if ($moduleVersie === null) { + return null; + } + + return $this->resolveRelationUuid(relation: $moduleVersie->getObject()['module'] ?? null); + }//end resolveParentModuleUuid() + + /** + * Whether the current OR request context can resolve (read) a `module` + * object under RBAC — used as the "manage-ACL on the target module" + * check alongside a role-group membership check in the controller. + * + * @param string $moduleUuid The module uuid. + * + * @return bool True when the module resolves under RBAC for the acting user. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-uploaded-sbom-files-are-bounded-in-size-and-json-only + */ + public function userCanReadModule(string $moduleUuid): bool + { + $objectService = $this->getObjectService(); + $registerId = $this->settingsService->getVoorzieningenConfig()['register'] ?? null; + $moduleSchemaId = $this->settingsService->getSchemaIdForObjectType('module'); + + if ($objectService === null || $registerId === null || $moduleSchemaId === null) { + return false; + } + + $module = $objectService->find( + id: $moduleUuid, + register: (int) $registerId, + schema: (int) $moduleSchemaId, + _rbac: true, + _multitenancy: true + ); + + return $module !== null; + }//end userCanReadModule() + + /** + * Read SBOM import provenance + optional progress for a `moduleVersie`. + * + * @param string $moduleVersieUuid The moduleVersie uuid. + * @param string|null $operationId Optional progress-tracking operation id. + * + * @return array `{sbomLastImportedAt, sbomFormat, sbomFileName, progress}`. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-modulversie-records-sbom-import-provenance + */ + public function getStatus(string $moduleVersieUuid, ?string $operationId=null): array + { + $coordinates = $this->resolveCoordinates(); + $moduleVersie = $coordinates['objectService']->find( + id: $moduleVersieUuid, + register: $coordinates['registerId'], + schema: $coordinates['moduleVersieSchemaId'], + _rbac: false, + _multitenancy: false + ); + + $data = []; + if ($moduleVersie !== null) { + $data = $moduleVersie->getObject(); + } + + $progress = null; + if ($operationId !== null) { + $progress = $this->progressTracker->getProgress(operationId: $operationId); + } + + return [ + 'sbomLastImportedAt' => $data['sbomLastImportedAt'] ?? null, + 'sbomFormat' => $data['sbomFormat'] ?? null, + 'sbomFileName' => $data['sbomFileName'] ?? null, + 'progress' => $progress, + ]; + }//end getStatus() + + /** + * Soft-delete the previous LIVE `sbomComponent` set for a `moduleVersie`, + * in bounded batches. OR's search already excludes `_deleted` rows by + * default, so a prior replace's trashed rows are never re-queried. + * + * @param ObjectService $objectService The OR object service. + * @param int $registerId The voorzieningen register id. + * @param int $componentSchemaId The sbomComponent schema id. + * @param string $moduleVersieUuid The target moduleVersie uuid. + * + * @return array The uuids that were soft-deleted. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-re-import-replaces-the-previous-component-set-and-is-soft-delete-aware + */ + private function replacePreviousComponentSet( + ObjectService $objectService, + int $registerId, + int $componentSchemaId, + string $moduleVersieUuid + ): array { + $previous = $objectService->searchObjects( + [ + '@self' => [ + 'schema' => $componentSchemaId, + 'register' => $registerId, + ], + 'moduleVersie' => $moduleVersieUuid, + '_limit' => 1000, + ], + _rbac: false, + _multitenancy: false + ); + + if (is_array($previous) === false) { + $previous = []; + } + + $previousUuids = []; + foreach ($previous as $object) { + $previousUuids[] = $object->getUuid(); + } + + foreach (array_chunk($previousUuids, self::BATCH_SIZE) as $batch) { + $objectService->deleteObjects($batch, _rbac: false, _multitenancy: false); + } + + return $previousUuids; + }//end replacePreviousComponentSet() + + /** + * Bulk-save the newly parsed component set in bounded batches, reporting + * progress per batch when tracking is active. + * + * @param ObjectService $objectService The OR object service. + * @param int $registerId The voorzieningen register id. + * @param int $componentSchemaId The sbomComponent schema id. + * @param string $moduleVersieUuid The target moduleVersie uuid. + * @param array> $components The parsed component DTOs. + * @param array $vexPairs The parsed VEX cveId/bom-ref pairs. + * @param bool $trackProgress Whether a progress operation is active. + * + * @return int The number of components created. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-large-imports-run-in-bounded-batches-with-progress-reporting + */ + private function createComponentSet( + ObjectService $objectService, + int $registerId, + int $componentSchemaId, + string $moduleVersieUuid, + array $components, + array $vexPairs, + bool $trackProgress + ): int { + $created = 0; + $vexCveIdsByRef = $this->groupVexCveIdsByBomRef(vexPairs: $vexPairs); + + foreach (array_chunk($components, self::BATCH_SIZE) as $batch) { + $payload = []; + foreach ($batch as $component) { + $payload[] = $this->componentToObjectData( + component: $component, + moduleVersieUuid: $moduleVersieUuid, + vexCveIdsByRef: $vexCveIdsByRef + ); + } + + $objectService->saveObjects( + objects: $payload, + register: $registerId, + schema: $componentSchemaId, + _rbac: false, + _multitenancy: false + ); + + $created += count($batch); + + if ($trackProgress === true) { + $this->progressTracker->updateProgress(processedItems: $created); + $this->progressTracker->updateStatistics(['componentsCreated' => $created]); + } + }//end foreach + + return $created; + }//end createComponentSet() + + /** + * Record import provenance on the `moduleVersie` — PUT-semantic OR save, + * so the FULL current object data is carried forward and only the three + * provenance fields are changed (an omitted field would otherwise be + * nulled by `saveObject()`). + * + * @param ObjectService $objectService The OR object service. + * @param int $registerId The voorzieningen register id. + * @param int $moduleVersieSchemaId The moduleVersie schema id. + * @param object $moduleVersie The current moduleVersie entity. + * @param string $format The import format. + * @param string $fileName The uploaded file's original name. + * + * @return void + * + * @spec openspec/specs/sbom-import/spec.md#requirement-modulversie-records-sbom-import-provenance + */ + private function recordProvenance( + ObjectService $objectService, + int $registerId, + int $moduleVersieSchemaId, + object $moduleVersie, + string $format, + string $fileName + ): void { + $data = $moduleVersie->getObject(); + $data['sbomLastImportedAt'] = (new DateTime())->format(DateTime::ATOM); + $data['sbomFormat'] = $format; + $data['sbomFileName'] = $fileName; + + $objectService->saveObject( + object: $data, + register: $registerId, + schema: $moduleVersieSchemaId, + uuid: $moduleVersie->getUuid(), + _rbac: false, + _multitenancy: false + ); + }//end recordProvenance() + + /** + * Map a normalized component DTO to the `sbomComponent` OR object data + * bag, including its required `moduleVersie` relation and any raw + * VEX-extracted CVE ids for its `bomRef` (a fact from the source + * document — NOT a stored match; the frontend still computes the + * confirmed match against `kwetsbaarheid` at render time). + * + * @param array $component The normalized component DTO. + * @param string $moduleVersieUuid The target moduleVersie uuid. + * @param array> $vexCveIdsByRef bomRef => [cveId, ...]. + * + * @return array The `sbomComponent` object data bag. + */ + private function componentToObjectData(array $component, string $moduleVersieUuid, array $vexCveIdsByRef): array + { + $bomRef = $component['bomRef'] ?? ''; + + $vexCveIds = []; + if ($bomRef !== '') { + $vexCveIds = $vexCveIdsByRef[$bomRef] ?? []; + } + + return [ + 'moduleVersie' => $moduleVersieUuid, + 'name' => $component['name'] ?? '', + 'version' => $component['version'] ?? '', + 'purl' => $component['purl'] ?? '', + 'licenses' => $component['licenses'] ?? [], + 'type' => $component['type'] ?? '', + 'hashes' => $component['hashes'] ?? [], + 'bomRef' => $bomRef, + 'vexCveIds' => $vexCveIds, + ]; + }//end componentToObjectData() + + /** + * Group VEX cveId/bom-ref pairs by bom-ref, so each component's raw + * VEX-derived CVE ids can be attached in one pass. + * + * @param array $vexPairs The parsed VEX pairs. + * + * @return array> bomRef => [cveId, ...]. + */ + private function groupVexCveIdsByBomRef(array $vexPairs): array + { + $grouped = []; + foreach ($vexPairs as $pair) { + $ref = $pair['componentBomRef']; + if ($ref === '') { + continue; + } + + $grouped[$ref][] = $pair['cveId']; + } + + return $grouped; + }//end groupVexCveIdsByBomRef() + + /** + * Count the distinct, non-empty licenses across a component list. + * + * @param array> $components The parsed component DTOs. + * + * @return int The distinct license count. + */ + private function countDistinctLicenses(array $components): int + { + $licenses = []; + foreach ($components as $component) { + foreach (($component['licenses'] ?? []) as $license) { + if (is_string($license) === true && $license !== '') { + $licenses[$license] = true; + } + } + } + + return count($licenses); + }//end countDistinctLicenses() + + /** + * Resolve a relation value (string uuid, or array/object carrying a + * `uuid`/`id`) to a plain uuid string. + * + * @param mixed $relation The raw relation value. + * + * @return string|null The resolved uuid, or null when not resolvable. + */ + private function resolveRelationUuid(mixed $relation): ?string + { + if (is_string($relation) === true && $relation !== '') { + return $relation; + } + + if (is_array($relation) === true) { + return $relation['uuid'] ?? ($relation['id'] ?? null); + } + + if (is_object($relation) === true) { + return $relation->uuid ?? ($relation->id ?? null); + } + + return null; + }//end resolveRelationUuid() + + /** + * Resolve the register/schema coordinates + ObjectService this service + * needs for every operation. + * + * @return array{objectService: ObjectService, registerId: int, moduleVersieSchemaId: int, sbomComponentSchemaId: int} + * + * @throws RuntimeException When ObjectService or required schema/register + * configuration is not available. + */ + private function resolveCoordinates(): array + { + $objectService = $this->getObjectService(); + if ($objectService === null) { + throw new RuntimeException('ObjectService not available'); + } + + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $registerId = $voorzieningenConfig['register'] ?? null; + + $moduleVersieSchemaId = $this->settingsService->getSchemaIdForObjectType('moduleVersie'); + $componentSchemaId = $this->settingsService->getSchemaIdForObjectType('sbomComponent'); + + if ($registerId === null || $moduleVersieSchemaId === null || $componentSchemaId === null) { + throw new RuntimeException( + 'sbom-import: voorzieningen register or moduleVersie/sbomComponent schema not configured' + ); + } + + return [ + 'objectService' => $objectService, + 'registerId' => (int) $registerId, + 'moduleVersieSchemaId' => (int) $moduleVersieSchemaId, + 'sbomComponentSchemaId' => (int) $componentSchemaId, + ]; + }//end resolveCoordinates() + + /** + * Get the OpenRegister ObjectService from the DI container. + * + * @return ObjectService|null The object service, or null if not available. + */ + private function getObjectService(): ?ObjectService + { + try { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + } catch (\Exception $e) { + $this->logger->error( + 'SbomImportService: Failed to get ObjectService', + ['exception' => $e->getMessage()] + ); + return null; + } + }//end getObjectService() +}//end class diff --git a/lib/Service/SbomParserService.php b/lib/Service/SbomParserService.php new file mode 100644 index 00000000..2f242643 --- /dev/null +++ b/lib/Service/SbomParserService.php @@ -0,0 +1,325 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/sbom-import/spec.md#requirement-cyclonedx-sbom-files-are-parsed-into-a-normalized-component-list + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Service; + +use OCA\SoftwareCatalog\Exception\UnsupportedSbomFormatException; + +/** + * Pure CycloneDX 1.5/1.6 (+ optional SPDX 2.x) SBOM parser. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-cyclonedx-sbom-files-are-parsed-into-a-normalized-component-list + */ +class SbomParserService +{ + /** + * CycloneDX `specVersion` values this app understands. + * + * @var array + */ + private const SUPPORTED_CYCLONEDX_VERSIONS = ['1.5', '1.6']; + + /** + * Maximum `json_decode` nesting depth — bounds a maliciously/accidentally + * deep document rather than trusting the file (design "Security + * Considerations": bounded json_decode depth). + */ + private const MAX_JSON_DEPTH = 64; + + /** + * Parse a CycloneDX 1.5/1.6 JSON document into a normalized component + * list plus any VEX (`vulnerabilities[]`) CVE/bom-ref pairs it carries. + * + * @param string $json The raw uploaded file contents. + * + * @return array{components: array>, vulnerabilities: array} + * + * @throws UnsupportedSbomFormatException When the document is not valid + * JSON, or its `bomFormat`/ + * `specVersion` is not supported. + * No partial component list is + * ever returned in that case. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-cyclonedx-sbom-files-are-parsed-into-a-normalized-component-list + */ + public function parse(string $json): array + { + $data = $this->decode(json: $json); + + $bomFormat = $data['bomFormat'] ?? null; + $specVersion = $data['specVersion'] ?? null; + $isCycloneDx = $bomFormat === 'CycloneDX'; + $isSupportedVersion = is_string($specVersion) === true + && in_array($specVersion, self::SUPPORTED_CYCLONEDX_VERSIONS, true) === true; + + if ($isCycloneDx === false || $isSupportedVersion === false) { + throw new UnsupportedSbomFormatException( + message: sprintf( + 'Unsupported SBOM format/version: bomFormat=%s, specVersion=%s (expected CycloneDX 1.5 or 1.6)', + $this->describe(value: $bomFormat), + $this->describe(value: $specVersion) + ) + ); + } + + $components = []; + foreach (($data['components'] ?? []) as $componentData) { + if (is_array($componentData) === false) { + continue; + } + + $components[] = $this->normalizeCycloneDxComponent(component: $componentData); + } + + return [ + 'components' => $components, + 'vulnerabilities' => $this->extractVexPairs(vulnerabilities: $data['vulnerabilities'] ?? []), + ]; + }//end parse() + + /** + * Parse an SPDX 2.x JSON document into the same normalized component DTO + * shape as {@see parse()} (name/version/purl/licenses). SPDX carries no + * VEX-equivalent block in this app's scope, so `vulnerabilities` is + * always empty for this entry point. + * + * @param string $json The raw uploaded file contents. + * + * @return array{components: array>, vulnerabilities: array} + * + * @throws UnsupportedSbomFormatException When the document is not valid + * JSON or its `spdxVersion` is not + * an SPDX-2.x document. + * + * @spec openspec/specs/sbom-import/spec.md#notes + */ + public function parseSpdx(string $json): array + { + $data = $this->decode(json: $json); + $spdxVersion = $data['spdxVersion'] ?? null; + $isSpdx2 = is_string($spdxVersion) === true && str_starts_with($spdxVersion, 'SPDX-2.') === true; + + if ($isSpdx2 === false) { + throw new UnsupportedSbomFormatException( + message: sprintf( + 'Unsupported SPDX document version: %s (expected an SPDX-2.x document)', + $this->describe(value: $spdxVersion) + ) + ); + } + + $components = []; + foreach (($data['packages'] ?? []) as $packageData) { + if (is_array($packageData) === false) { + continue; + } + + $components[] = $this->normalizeSpdxPackage(package: $packageData); + } + + return [ + 'components' => $components, + 'vulnerabilities' => [], + ]; + }//end parseSpdx() + + /** + * Decode raw JSON text into an associative array, bounding depth and + * rejecting anything that isn't a JSON object/array at the top level. + * + * @param string $json The raw uploaded file contents. + * + * @return array The decoded document. + * + * @throws UnsupportedSbomFormatException When decoding fails. + */ + private function decode(string $json): array + { + $decoded = json_decode($json, true, self::MAX_JSON_DEPTH, JSON_BIGINT_AS_STRING); + + if (json_last_error() !== JSON_ERROR_NONE || is_array($decoded) === false) { + throw new UnsupportedSbomFormatException( + message: 'Uploaded content is not a valid JSON document: '.json_last_error_msg() + ); + } + + return $decoded; + }//end decode() + + /** + * Normalize one CycloneDX `components[]` entry into the shared component + * DTO shape. + * + * @param array $component One raw CycloneDX component. + * + * @return array The normalized component DTO. + */ + private function normalizeCycloneDxComponent(array $component): array + { + $licenses = []; + foreach (($component['licenses'] ?? []) as $licenseEntry) { + if (is_array($licenseEntry) === false) { + continue; + } + + if (isset($licenseEntry['license']['id']) === true) { + $licenses[] = (string) $licenseEntry['license']['id']; + } else if (isset($licenseEntry['license']['name']) === true) { + $licenses[] = (string) $licenseEntry['license']['name']; + } else if (isset($licenseEntry['expression']) === true) { + $licenses[] = (string) $licenseEntry['expression']; + } + } + + $hashes = []; + foreach (($component['hashes'] ?? []) as $hashEntry) { + if (is_array($hashEntry) === false) { + continue; + } + + $hashes[] = [ + 'alg' => (string) ($hashEntry['alg'] ?? ''), + 'value' => (string) ($hashEntry['content'] ?? ''), + ]; + } + + return [ + 'name' => (string) ($component['name'] ?? ''), + 'version' => (string) ($component['version'] ?? ''), + 'purl' => (string) ($component['purl'] ?? ''), + 'licenses' => $licenses, + 'type' => (string) ($component['type'] ?? ''), + 'hashes' => $hashes, + 'bomRef' => (string) ($component['bom-ref'] ?? ''), + ]; + }//end normalizeCycloneDxComponent() + + /** + * Normalize one SPDX `packages[]` entry into the shared component DTO + * shape. + * + * @param array $package One raw SPDX package. + * + * @return array The normalized component DTO. + */ + private function normalizeSpdxPackage(array $package): array + { + $purl = ''; + foreach (($package['externalRefs'] ?? []) as $externalRef) { + if (is_array($externalRef) === true && ($externalRef['referenceType'] ?? '') === 'purl') { + $purl = (string) ($externalRef['referenceLocator'] ?? ''); + break; + } + } + + $license = $package['licenseConcluded'] ?? ($package['licenseDeclared'] ?? ''); + $licenses = []; + if (is_string($license) === true && $license !== '' && strtoupper($license) !== 'NOASSERTION') { + $licenses[] = $license; + } + + return [ + 'name' => (string) ($package['name'] ?? ''), + 'version' => (string) ($package['versionInfo'] ?? ''), + 'purl' => $purl, + 'licenses' => $licenses, + 'type' => 'library', + 'hashes' => [], + 'bomRef' => (string) ($package['SPDXID'] ?? ''), + ]; + }//end normalizeSpdxPackage() + + /** + * Extract `{cveId, componentBomRef}` pairs from a CycloneDX top-level + * `vulnerabilities[]` (VEX) block, one pair per `id` × `affects[].ref` + * combination. + * + * @param array $vulnerabilities The raw `vulnerabilities[]` array. + * + * @return array The extracted pairs. + */ + private function extractVexPairs(array $vulnerabilities): array + { + $pairs = []; + foreach ($vulnerabilities as $vulnerability) { + if (is_array($vulnerability) === false) { + continue; + } + + $cveId = $vulnerability['id'] ?? null; + if (is_string($cveId) === false || $cveId === '') { + continue; + } + + foreach (($vulnerability['affects'] ?? []) as $affects) { + if (is_array($affects) === false) { + continue; + } + + $ref = $affects['ref'] ?? null; + if (is_string($ref) === false || $ref === '') { + continue; + } + + $pairs[] = [ + 'cveId' => $cveId, + 'componentBomRef' => $ref, + ]; + } + }//end foreach + + return $pairs; + }//end extractVexPairs() + + /** + * Render an arbitrary value for an error message. + * + * @param mixed $value The value to describe. + * + * @return string A human-readable description. + */ + private function describe(mixed $value): string + { + if (is_string($value) === true) { + return $value; + } + + if ($value === null) { + return '(none)'; + } + + return (string) json_encode($value); + }//end describe() +}//end class diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 172a0f16..ec853fc7 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -277,6 +277,7 @@ public function getSettings(): array 'module', 'compliancy', 'moduleVersie', + 'sbomComponent', ], ], ]; @@ -867,9 +868,10 @@ public function getSchemaIdForObjectType(string $objectType): ?int }//end if $voorzieningenKeyMap = [ - 'module' => 'module_schema', - 'compliancy' => 'compliancy_schema', - 'moduleVersie' => 'moduleVersie_schema', + 'module' => 'module_schema', + 'compliancy' => 'compliancy_schema', + 'moduleVersie' => 'moduleVersie_schema', + 'sbomComponent' => 'sbomComponent_schema', ]; // Only check voorzieningen config if object type exists in the key map. @@ -3783,6 +3785,7 @@ private function configureVoorzieningen(): array // Handle both moduleversie and moduleVersie. 'moduleVersie' => 'moduleVersie_schema', 'sector' => 'sector_schema', + 'sbomComponent' => 'sbomComponent_schema', ]; $config = [ 'register' => (string) ($targetRegister['id'] ?? '') ]; @@ -4220,6 +4223,7 @@ private function normalizeVoorzieningenConfig(array $input): array 'compliancy_schema', 'moduleVersie_schema', 'sector_schema', + 'sbomComponent_schema', ]; // Copy any present schema keys; ignore sources/registers. diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index 1cd36129..1a22ca0b 100644 --- a/lib/Settings/softwarecatalogus_register.json +++ b/lib/Settings/softwarecatalogus_register.json @@ -785,7 +785,8 @@ "koppeling", "beoordeeling", "compliancy", - "moduleVersie" + "moduleVersie", + "sbomComponent" ], "source": "internal", "tablePrefix": "", @@ -851,6 +852,10 @@ "moduleVersie": { "magicMapping": true, "autoCreateTable": true + }, + "sbomComponent": { + "magicMapping": true, + "autoCreateTable": true } } } @@ -2287,6 +2292,57 @@ "Samenwerking", "Leverancier" ] + }, + "sbomLastImportedAt": { + "description": "Tijdstip waarop de laatste SBOM (Software Bill of Materials) voor deze versie is geïmporteerd", + "type": "string", + "format": "date-time", + "order": 20, + "title": "SBOM laatst geïmporteerd op", + "visible": true, + "facetable": false, + "hideOnForm": true + }, + "sbomFormat": { + "description": "Formaat van de laatst geïmporteerde SBOM", + "type": "string", + "order": 21, + "title": "SBOM formaat", + "visible": true, + "facetable": false, + "hideOnForm": true, + "enum": [ + "cyclonedx-json", + "spdx-json" + ] + }, + "sbomFileName": { + "description": "Bestandsnaam van de laatst geïmporteerde SBOM", + "type": "string", + "order": 22, + "title": "SBOM bestandsnaam", + "visible": true, + "facetable": false, + "hideOnForm": true + }, + "sbomComponents": { + "description": "De componenten die voor deze versie zijn geïmporteerd uit een SBOM", + "type": "array", + "visible": true, + "order": 23, + "facetable": false, + "title": "SBOM-componenten", + "hideOnForm": true, + "$ref": "#/components/schemas/sbomComponent", + "x-relation-filter": { "moduleVersie": "@objectId" }, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/sbomComponent", + "inversedBy": "moduleVersie" + } } }, "archive": [], @@ -7393,7 +7449,7 @@ }, "title": "Applicatieversie", "description": "Schema voor applicatieversies", - "version": "0.1.2", + "version": "0.1.3", "summary": "", "icon": "ViewModule", "required": [], @@ -7579,6 +7635,142 @@ } } } + }, + "sbomComponent": { + "uri": null, + "slug": "sbomComponent", + "title": "SBOM-component", + "description": "Schema voor componenten die zijn geïmporteerd uit een SBOM (Software Bill of Materials, CycloneDX of SPDX) voor een applicatieversie.", + "version": "0.0.1", + "summary": "", + "icon": "PackageVariantClosed", + "required": [ + "moduleVersie", + "name" + ], + "properties": { + "moduleVersie": { + "description": "De applicatieversie waarvan dit component onderdeel is", + "type": "object", + "order": 1, + "title": "Applicatieversie", + "visible": true, + "facetable": false, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/moduleVersie", + "inversedBy": "sbomComponents" + }, + "name": { + "description": "Naam van het component zoals gerapporteerd in de SBOM", + "type": "string", + "order": 2, + "title": "Naam", + "visible": true, + "facetable": false, + "maxLength": 255 + }, + "version": { + "description": "Versie van het component zoals gerapporteerd in de SBOM", + "type": "string", + "order": 3, + "title": "Versie", + "visible": true, + "facetable": false, + "maxLength": 100 + }, + "purl": { + "description": "Package URL (pkg:...) van het component", + "type": "string", + "order": 4, + "title": "Package URL", + "visible": true, + "facetable": false, + "maxLength": 500 + }, + "licenses": { + "description": "Licentie-identificaties of -expressies zoals gerapporteerd in de SBOM", + "type": "array", + "order": 5, + "title": "Licenties", + "visible": true, + "facetable": false, + "items": { + "type": "string" + } + }, + "type": { + "description": "CycloneDX componenttype (library, application, framework, container, ...)", + "type": "string", + "order": 6, + "title": "Type", + "visible": true, + "facetable": true + }, + "hashes": { + "description": "Bestandshashes van het component (alleen informatief, niet gebruikt voor matching)", + "type": "array", + "order": 7, + "title": "Hashes", + "visible": false, + "facetable": false, + "items": { + "type": "object", + "properties": { + "alg": { + "type": "string", + "title": "Algoritme" + }, + "value": { + "type": "string", + "title": "Waarde" + } + } + } + }, + "bomRef": { + "description": "CycloneDX bom-ref van het component; alleen gebruikt voor traceerbaarheid binnen één import", + "type": "string", + "order": 8, + "title": "BOM-referentie", + "visible": false, + "facetable": false, + "maxLength": 255 + }, + "vexCveIds": { + "description": "CVE-identificaties die de SBOM's VEX-blok (vulnerabilities[]) aan dit component koppelt via bom-ref. Ruwe feiten uit het brondocument — GEEN opgeslagen match: het al-dan-niet overeenkomen met een kwetsbaarheid wordt altijd on-the-fly berekend tegen het kwetsbaarheid-register, nooit hier vastgelegd.", + "type": "array", + "order": 9, + "title": "VEX CVE-ids", + "visible": false, + "facetable": false, + "items": { + "type": "string" + } + } + }, + "archive": [], + "source": "internal", + "hardValidation": false, + "immutable": false, + "searchable": true, + "maxDepth": 0, + "owner": "system", + "application": null, + "organisation": null, + "groups": null, + "authorization": { + "read": [ + "public" + ] + }, + "configuration": { + "objectNameField": "name", + "objectSummaryField": "version", + "objectDescriptionField": "purl", + "autoPublish": true + } } }, "objects": [ diff --git a/openspec/changes/sbom-import/.openspec.yaml b/openspec/changes/archive/2026-07-23-sbom-import/.openspec.yaml similarity index 100% rename from openspec/changes/sbom-import/.openspec.yaml rename to openspec/changes/archive/2026-07-23-sbom-import/.openspec.yaml diff --git a/openspec/changes/sbom-import/context-brief.md b/openspec/changes/archive/2026-07-23-sbom-import/context-brief.md similarity index 100% rename from openspec/changes/sbom-import/context-brief.md rename to openspec/changes/archive/2026-07-23-sbom-import/context-brief.md diff --git a/openspec/changes/sbom-import/design.md b/openspec/changes/archive/2026-07-23-sbom-import/design.md similarity index 100% rename from openspec/changes/sbom-import/design.md rename to openspec/changes/archive/2026-07-23-sbom-import/design.md diff --git a/openspec/changes/sbom-import/proposal.md b/openspec/changes/archive/2026-07-23-sbom-import/proposal.md similarity index 100% rename from openspec/changes/sbom-import/proposal.md rename to openspec/changes/archive/2026-07-23-sbom-import/proposal.md diff --git a/openspec/changes/sbom-import/specs/sbom-import/spec.md b/openspec/changes/archive/2026-07-23-sbom-import/specs/sbom-import/spec.md similarity index 97% rename from openspec/changes/sbom-import/specs/sbom-import/spec.md rename to openspec/changes/archive/2026-07-23-sbom-import/specs/sbom-import/spec.md index d93d7964..998eacd9 100644 --- a/openspec/changes/sbom-import/specs/sbom-import/spec.md +++ b/openspec/changes/archive/2026-07-23-sbom-import/specs/sbom-import/spec.md @@ -254,17 +254,17 @@ unset. ## Acceptance Criteria -- [ ] A valid CycloneDX 1.5 or 1.6 JSON file uploaded against a `moduleVersie` +- [x] A valid CycloneDX 1.5 or 1.6 JSON file uploaded against a `moduleVersie` produces one `sbomComponent` object per parsed component, linked to that version. -- [ ] Re-importing for the same `moduleVersie` replaces the previous +- [x] Re-importing for the same `moduleVersie` replaces the previous component set, leaving no duplicate or stale live components. -- [ ] The Components tab shows the component list, license list, and summary +- [x] The Components tab shows the component list, license list, and summary counts for a version with an imported SBOM. -- [ ] Confirmed (CVE-id) and possible (name/purl) vulnerability matches are +- [x] Confirmed (CVE-id) and possible (name/purl) vulnerability matches are visually distinguished and computed without persisting a match reference. -- [ ] No import, parse, or match code path makes an outbound HTTP request. -- [ ] Oversized or non-JSON uploads are rejected before parsing. +- [x] No import, parse, or match code path makes an outbound HTTP request. +- [x] Oversized or non-JSON uploads are rejected before parsing. ## Notes diff --git a/openspec/changes/sbom-import/tasks.md b/openspec/changes/archive/2026-07-23-sbom-import/tasks.md similarity index 92% rename from openspec/changes/sbom-import/tasks.md rename to openspec/changes/archive/2026-07-23-sbom-import/tasks.md index 2ade19b6..fecea1c3 100644 --- a/openspec/changes/sbom-import/tasks.md +++ b/openspec/changes/archive/2026-07-23-sbom-import/tasks.md @@ -8,8 +8,8 @@ - **acceptance_criteria**: - GIVEN the updated register definition WHEN it is imported via the repair step THEN `sbomComponent` exists with `moduleVersie` (required, related-object), `name` (required), `version`, `purl`, `licenses[]`, optional `hashes[]`/`type`/`bomRef` - GIVEN the updated `moduleVersie` schema WHEN existing `moduleVersie` objects are loaded THEN they remain valid with `sbomLastImportedAt`/`sbomFormat`/`sbomFileName` unset -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 2: `SbomParserService` — pure CycloneDX 1.5/1.6 parser - **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-cyclonedx-sbom-files-are-parsed-into-a-normalized-component-list` @@ -18,8 +18,8 @@ - GIVEN a well-formed CycloneDX 1.6 fixture WHEN `parse()` is called THEN it returns component records with name/version/purl/licenses and makes no OR or HTTP call - GIVEN a fixture with `bomFormat != CycloneDX` or unsupported `specVersion` WHEN `parse()` is called THEN it throws `UnsupportedSbomFormatException` and returns no partial list - GIVEN a fixture with a top-level `vulnerabilities[]` VEX block WHEN `parse()` is called THEN it also returns `{cveId, componentBomRef}` pairs -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 3: `SbomImportService` — soft-delete-aware replace, bounded batches, progress - **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-re-import-replaces-the-previous-component-set-and-is-soft-delete-aware` @@ -29,8 +29,8 @@ - GIVEN a version with an already-trashed prior set from an earlier replace WHEN a third import runs THEN the already-trashed rows are not re-queried or re-deleted - GIVEN a parsed set of more than 50 components WHEN import runs THEN a `progress-tracking` operation is started, updated per batch, and completed, with its id returned in the response - GIVEN a successful import WHEN it completes THEN `moduleVersie.sbomLastImportedAt`/`sbomFormat`/`sbomFileName` are set -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 4: `SbomController` upload + status endpoints - **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-uploaded-sbom-files-are-bounded-in-size-and-json-only` @@ -39,8 +39,8 @@ - GIVEN an upload exceeding the configured max size WHEN it is posted THEN the endpoint rejects it before the parser runs and no `sbomComponent` objects change - GIVEN a non-JSON upload WHEN it is posted THEN the endpoint responds 400 and the previous component set is unchanged - GIVEN a user without admin group membership or manage-ACL on the target module WHEN they attempt an import THEN the endpoint responds 403 and creates no objects -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 5: Render-time vulnerability match util - **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-components-are-matched-against-existing-kwetsbaarheden-without-external-calls` @@ -49,8 +49,8 @@ - GIVEN a component with a VEX-extracted CVE id equal to an existing `kwetsbaarheid.cveCode` WHEN matches are computed THEN that component gets a confirmed match, computed on the fly and not read from a stored field - GIVEN a `kwetsbaarheid` linked to the version's parent module whose `naam` case-insensitively contains a component's name WHEN matches are computed THEN that component gets a possible match; a same-name `kwetsbaarheid` NOT linked to that module produces no match - GIVEN the match computation runs WHEN inspected THEN it issues zero HTTP requests (no `fetch`/`axios`/network call in the util) -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 6: Components tab UI — `SbomComponentsPanel` + manifest wiring - **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#requirement-the-module-version-detail-page-shows-imported-components-with-summary-counts` @@ -58,16 +58,16 @@ - **acceptance_criteria**: - GIVEN a `moduleVersie` with an imported component set WHEN its Components tab is opened THEN the component list (name/version/purl/licenses) and summary counts (total, distinct licenses, matched vulnerabilities) render via `CnDataTable` - GIVEN a `moduleVersie` with no imported set WHEN its Components tab is opened THEN an empty state with an upload control renders and no summary counts show as non-zero -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test (Playwright spec `tests/e2e/sbom-import.spec.ts` written against real `data-testid`s and `openspec validate` passes; not executed against a live instance in this session — no docker environment was started for this resume) ### Task 7: i18n strings - **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#non-functional-requirements` - **files**: `l10n/en.js`, `l10n/en.json`, `l10n/nl.js`, `l10n/nl.json` - **acceptance_criteria**: - GIVEN the Components tab, upload control, and confirmed/possible match badges WHEN rendered in Dutch or English THEN every new user-facing string resolves to a translated key in both locales (English source keys, per i18n convention) -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 8: Optional SPDX JSON support - **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#notes` @@ -75,8 +75,8 @@ - **acceptance_criteria**: - GIVEN a valid SPDX 2.3 JSON fixture WHEN `parseSpdx()` is called THEN it returns component records in the same DTO shape as `parse()` (name/version/purl/licenses) - GIVEN SPDX parsing proves non-trivial to share cleanly with the CycloneDX path WHEN this task is assessed THEN it is deferred to a follow-up change and this task is marked deferred with a reason, per the proposal's open question — the CycloneDX path (Tasks 1-7) already satisfies every MUST requirement -- [ ] Implement -- [ ] Test +- [x] Implement (SPDX 2.3 sharing the same DTO shape proved cheap to add — `SbomParserService::parseSpdx()`, not deferred) +- [x] Test ### Task 9: Docs + traceability - **spec_ref**: `openspec/changes/sbom-import/specs/sbom-import/spec.md#purpose` @@ -84,8 +84,8 @@ - **acceptance_criteria**: - GIVEN the Components tab is implemented WHEN documented THEN `docs/features/sbom-import.md` describes upload, replace-on-reimport, and confirmed/possible matching with Playwright-captured screenshots - GIVEN new/changed backend and frontend methods for this change WHEN inspected THEN each carries `@spec openspec/changes/sbom-import/specs/sbom-import/spec.md` (or a reason-bearing `@spec exclude`) -- [ ] Implement -- [ ] Test +- [x] Implement (`docs/features/sbom-import.md` written; Playwright screenshots deferred — no live capture run this session) +- [x] Test ## Quality checklist diff --git a/openspec/specs/sbom-import/spec.md b/openspec/specs/sbom-import/spec.md new file mode 100644 index 00000000..de41c119 --- /dev/null +++ b/openspec/specs/sbom-import/spec.md @@ -0,0 +1,226 @@ +# sbom-import Specification + +## Purpose +TBD - created by archiving change sbom-import. Update Purpose after archive. +## Requirements +### Requirement: CycloneDX SBOM files are parsed into a normalized component list + +`SbomParserService` SHALL parse a CycloneDX JSON document whose +`bomFormat` equals `CycloneDX` and whose `specVersion` is `1.5` or `1.6` into +a list of component records (`name`, `version`, `purl`, `licenses`, optional +`hashes`, optional `type`, `bomRef`) from the document's `components[]` +array. The parser SHALL be a pure service with no dependency on +OpenRegister's `ObjectService` or any HTTP client, so it is unit-testable +against fixture files alone. + +#### Scenario: A valid CycloneDX 1.6 document parses into components + +- **WHEN** `SbomParserService::parse()` is called with a well-formed + CycloneDX 1.6 JSON document containing three `components[]` entries with + `name`, `version`, `purl`, and `licenses` +- **THEN** it returns three component records with those fields populated +- **AND** no OpenRegister call and no HTTP call occurs during parsing + +#### Scenario: An unsupported bomFormat or specVersion is rejected + +- **WHEN** `SbomParserService::parse()` is called with a JSON document whose + `bomFormat` is not `CycloneDX`, or whose `specVersion` is not `1.5` or + `1.6` +- **THEN** the parser throws an `UnsupportedSbomFormatException` naming the + offending format/version +- **AND** no partial component list is returned + +### Requirement: Uploaded SBOM files are bounded in size and JSON-only + +The SBOM upload endpoint SHALL reject any upload exceeding the configured +maximum file size (default 10 MB) and any upload that is not valid JSON, +before invoking the parser, and SHALL require admin group membership or +manage-ACL on the target `moduleVersie`'s parent `module`. + +#### Scenario: An oversized file is rejected before parsing + +- **WHEN** a user uploads an SBOM file larger than the configured maximum +- **THEN** the endpoint responds with an error before `SbomParserService` is + invoked +- **AND** no `sbomComponent` objects are created or replaced + +#### Scenario: A non-JSON file is rejected + +- **WHEN** a user uploads a file that is not valid JSON +- **THEN** the endpoint responds with a 400 error identifying the problem +- **AND** the previous component set for the target `moduleVersie`, if any, + is left unchanged + +#### Scenario: Import requires admin or manage-ACL + +- **WHEN** a user without admin group membership and without manage-ACL on + the target version's module attempts to import an SBOM +- **THEN** the endpoint responds with a 403 error +- **AND** no component objects are created + +### Requirement: Imported components persist as OpenRegister objects scoped to a moduleVersie + +Each parsed component SHALL persist as an `sbomComponent` OpenRegister object +with a required `moduleVersie` relation, `name`, and the parsed `version`, +`purl`, and `licenses` fields; optional `hashes`, `type`, and `bomRef` SHALL +be stored when present in the source SBOM. No app-local database table SHALL +be introduced (ADR-001). + +#### Scenario: A parsed component persists with its moduleVersie relation + +- **WHEN** an SBOM import for a given `moduleVersie` completes +- **THEN** each parsed component exists as an `sbomComponent` object whose + `moduleVersie` relation resolves to that version +- **AND** its `name`, `version`, `purl`, and `licenses` match the source SBOM + +### Requirement: Re-import replaces the previous component set and is soft-delete aware + +The app SHALL replace a `moduleVersie`'s previously imported component set +when a new SBOM is imported for that same version: the previous non-deleted +`sbomComponent` objects for that version SHALL be soft-deleted, and the newly +parsed set SHALL then be created. Already-trashed rows from a prior replace +SHALL NOT be re-processed or double-counted. A failed import SHALL leave the +version with no component set rather than a mixed old/new set. + +#### Scenario: A second import replaces the first + +- **WHEN** a `moduleVersie` already has an imported component set and a user + imports a new SBOM for the same version +- **THEN** the previously imported `sbomComponent` objects are soft-deleted +- **AND** only the components from the new SBOM appear on the version's + Components tab afterwards + +#### Scenario: A prior replace's trashed rows are not reprocessed + +- **WHEN** a `moduleVersie` has already had one replace cycle (its first + component set is soft-deleted, its second is live) +- **AND** a third import runs for the same version +- **THEN** only the live (second) component set is soft-deleted before the + third set is created +- **AND** the count of soft-deleted `sbomComponent` objects from the first + cycle does not change + +### Requirement: Large imports run in bounded batches with progress reporting + +`SbomImportService` SHALL persist and soft-delete `sbomComponent` objects in +bounded batches rather than a single unbounded bulk call. For imports whose +parsed component count exceeds 50, the service SHALL start a +`progress-tracking` operation, update it per batch, and complete it when the +import finishes, exposing the operation id in the import response. + +#### Scenario: A large SBOM import reports incremental progress + +- **WHEN** an uploaded SBOM parses into more than 50 components +- **THEN** the import response includes an operation id +- **AND** `getProgress(operationId)` returns increasing `processed_items` + values while the import is in flight +- **AND** the operation reaches `phase = completed` with `percentage = 100` + when the import finishes + +#### Scenario: A small SBOM import completes without a progress operation + +- **WHEN** an uploaded SBOM parses into 50 or fewer components +- **THEN** the import completes synchronously +- **AND** the response includes the final component count without requiring + a progress poll + +### Requirement: The module-version detail page shows imported components with summary counts + +The `ModuleversieDetail` manifest page SHALL gain a Components tab showing +the imported `sbomComponent` list (name, version, purl, licenses) and summary +counts: total component count, distinct license count, and matched- +vulnerability count (per the matching requirement below). + +#### Scenario: The Components tab reflects an import + +- **WHEN** a user opens the Components tab of a `moduleVersie` that has an + imported SBOM +- **THEN** the component list shows each component's name, version, purl, + and licenses +- **AND** the summary counts show the total component count and the count of + distinct licenses across those components + +#### Scenario: A version with no imported SBOM shows an empty state + +- **WHEN** a user opens the Components tab of a `moduleVersie` with no + imported component set +- **THEN** the tab shows an empty state with an upload control +- **AND** no summary counts are shown as non-zero + +### Requirement: Components are matched against existing kwetsbaarheden without external calls + +For each `sbomComponent`, the app SHALL compute (at render time, never +persisted) matches against the existing `kwetsbaarheid` register using two +bounded local strategies: a confirmed match by exact CVE id when the source +SBOM carries CycloneDX VEX vulnerability data, compared against +`kwetsbaarheid.cveCode`; and a possible match by case-insensitive +name/purl-package comparison against `kwetsbaarheid.naam`, scoped to +`kwetsbaarheid` records whose `modules` already reference the version's +parent `module`. No matched-vulnerability reference SHALL be written back to +either the `sbomComponent` or `kwetsbaarheid` schema, and no HTTP request to +an external vulnerability feed (OSV.dev, NVD, or otherwise) SHALL be made by +the import or matching path. + +#### Scenario: A component with VEX-declared CVE data gets a confirmed match + +- **WHEN** an uploaded CycloneDX document's `vulnerabilities[]` block + references a component by `bom-ref` with `id` equal to an existing + `kwetsbaarheid.cveCode` +- **THEN** that component's Components-tab row shows a confirmed match to + that `kwetsbaarheid` +- **AND** the match is computed at render time, not stored on the + `sbomComponent` object + +#### Scenario: A component name matching a module-scoped vulnerability gets a possible match + +- **WHEN** a `kwetsbaarheid` record's `modules` includes the parent `module` + of an imported `moduleVersie`, and one of that version's `sbomComponent` + names case-insensitively matches (or is contained in) the + `kwetsbaarheid.naam` +- **THEN** that component's Components-tab row shows a possible match, + visually distinguished from a confirmed match + +#### Scenario: A name match outside the module's own vulnerabilities is not surfaced + +- **WHEN** a `kwetsbaarheid` record's `modules` does NOT include the parent + `module` of an imported `moduleVersie`, even if a component name would + textually match that `kwetsbaarheid.naam` +- **THEN** no possible match is shown for that pairing + +#### Scenario: Editing a vulnerability changes the match with no re-import + +- **WHEN** a `kwetsbaarheid`'s `cveCode` or `naam` is edited after an SBOM + has already been imported for an affected version +- **THEN** the Components tab's matches reflect the edited `kwetsbaarheid` + data the next time it is rendered, with no re-import of the SBOM required + +#### Scenario: No outbound HTTP call is made during matching + +- **WHEN** the Components tab computes matches for a version's component + list +- **THEN** the computation reads only the local `sbomComponent` and + `kwetsbaarheid` OpenRegister data +- **AND** no HTTP request is issued to any external vulnerability or + advisory service + +### Requirement: moduleVersie records SBOM import provenance + +The `moduleVersie` schema SHALL gain three optional fields — +`sbomLastImportedAt` (date-time), `sbomFormat` (`cyclonedx-json` | +`spdx-json`), and `sbomFileName` (string) — populated on each successful +import. Existing `moduleVersie` objects SHALL remain valid with these fields +unset. + +#### Scenario: A successful import records provenance on the version + +- **WHEN** an SBOM import for a `moduleVersie` completes successfully +- **THEN** that version's `sbomLastImportedAt`, `sbomFormat`, and + `sbomFileName` are set to the import's timestamp, format, and source file + name + +#### Scenario: Existing versions are unaffected by the schema addition + +- **WHEN** the updated register definition is imported over existing data +- **THEN** existing `moduleVersie` objects without the new fields load and + save unchanged + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 55411e6b..3fb2bddc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -629,3 +629,8 @@ parameters: message: "#^Property OCA\\\\SoftwareCatalog\\\\Settings\\\\SoftwareCatalogAdmin\\:\\:\\$l10n is never read, only written\\.$#" count: 1 path: lib/Settings/SoftwareCatalogAdmin.php + + - + message: "#^Dead catch \\- Exception is never thrown in the try block\\.$#" + count: 1 + path: lib/Controller/SbomController.php diff --git a/src/components/sbom/SbomComponentsPanel.vue b/src/components/sbom/SbomComponentsPanel.vue new file mode 100644 index 00000000..af998dd6 --- /dev/null +++ b/src/components/sbom/SbomComponentsPanel.vue @@ -0,0 +1,596 @@ + + + + + + + diff --git a/src/customComponents.js b/src/customComponents.js index e1eb3f6e..534a3aec 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -29,6 +29,7 @@ import VulnerabilityExposurePanel from './components/vulnerabilities/Vulnerabili import LicensePostureView from './views/LicensePostureView.vue' import FacetedCatalogIndexView from './views/FacetedCatalogIndexView.vue' import PortfolioReportView from './views/organisaties/PortfolioReport.vue' +import SbomComponentsPanel from './components/sbom/SbomComponentsPanel.vue' export default { // OrganisatieCard — the bespoke card (inline contactpersoon toggle) used as @@ -125,4 +126,11 @@ export default { // fetched, pre-aggregated multi-metric report with a CSV export button. // @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md PortfolioReportView, + // --- SBOM (Software Bill of Materials) import — Components tab. --- + // ModuleversieDetail sidebar tab: the imported sbomComponent list + // (name/version/purl/licenses), summary counts, an upload control, and a + // render-time vulnerability-match join (sbomVulnerabilityMatch.js) vs the + // kwetsbaarheid register. No built-in detail widget expresses an upload + // flow + cross-schema read-time match, so it stays a custom tab component. + SbomComponentsPanel, } diff --git a/src/manifest.json b/src/manifest.json index 1f945a23..3f31b9f0 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -582,6 +582,7 @@ "enabled": true, "showMetadata": true, "tabs": [ + { "id": "components", "label": "Components", "icon": "Package", "component": "SbomComponentsPanel" }, { "id": "audit", "label": "History", "icon": "History", "widgets": [ { "type": "audit" } ] } ] }, diff --git a/src/utils/sbomVulnerabilityMatch.js b/src/utils/sbomVulnerabilityMatch.js new file mode 100644 index 00000000..79c2d959 --- /dev/null +++ b/src/utils/sbomVulnerabilityMatch.js @@ -0,0 +1,205 @@ +/** + * sbomVulnerabilityMatch — read-time cross-reference of imported SBOM + * components against the existing `kwetsbaarheid` (vulnerability) register. + * + * Feeds `module-vulnerability-tracking` rather than forking a parallel + * vulnerability model: nothing computed here is ever written back to either + * `sbomComponent` or `kwetsbaarheid`, and no HTTP request to an external + * advisory feed is ever made. Two bounded, local match strategies: + * + * 1. CONFIRMED — exact CVE-id match. Each `sbomComponent.vexCveIds` (the + * raw CVE ids the SBOM's own VEX block associated with that component + * at import time — a FACT about the source document, not a stored + * match) is compared, case-insensitively, against every + * `kwetsbaarheid.cveCode`. A single indexed-shape equality pass, not a + * catalogue-wide text scan. + * + * 2. POSSIBLE — name/purl heuristic, scoped to the version's parent + * module. A component's `name` (or the package segment of its `purl`) + * is compared, case-insensitively (substring), against + * `kwetsbaarheid.naam`, but ONLY for `kwetsbaarheid` records whose + * `modules` already reference the moduleVersie's parent `module` — a + * vulnerability recorded against a different application never + * surfaces here, regardless of name similarity. + * + * Editing a `kwetsbaarheid`'s `cveCode`/`naam` after an SBOM import changes + * the match set on next render, with no re-import required — both matches + * are computed on demand, never persisted. + * + * @module utils/sbomVulnerabilityMatch + * @author Ruben Linde + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * + * @spec openspec/specs/sbom-import/spec.md#requirement-components-are-matched-against-existing-kwetsbaarheden-without-external-calls + */ + +import { resolveUuid } from './lifecyclePhase.js' + +/** + * Read the data bag of a record that may be an OR object envelope or plain data. + * + * @param {object} record Any OR object or data bag. + * @return {object} The property bag. + */ +function dataOf(record) { + if (!record || typeof record !== 'object') { + return {} + } + if (record.object && typeof record.object === 'object') { + return record.object + } + return record +} + +/** + * Extract the "package" segment of a Package URL for the name heuristic, + * e.g. `pkg:npm/lodash@4.17.21` -> `lodash`, + * `pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1` -> `log4j-core`. + * Returns '' for an empty/unparseable purl. + * + * @param {string} purl A Package URL. + * @return {string} The package name segment, lowercased. + */ +export function purlPackageName(purl) { + if (typeof purl !== 'string' || purl === '') { + return '' + } + // Strip the `pkg:type/` prefix and any `@version`/`?qualifiers`/`#subpath` suffix. + const withoutScheme = purl.replace(/^pkg:[^/]+\//, '') + const withoutSuffix = withoutScheme.split(/[@?#]/)[0] + const segments = withoutSuffix.split('/') + return (segments[segments.length - 1] || '').toLowerCase() +} + +/** + * The set of module uuids a `kwetsbaarheid` record's `modules` references. + * + * @param {object} vuln A kwetsbaarheid record (OR object or data bag). + * @return {Set} The set of referenced module uuids. + */ +function affectedModuleIds(vuln) { + const modules = dataOf(vuln).modules + const ids = new Set() + if (!Array.isArray(modules)) { + return ids + } + for (const m of modules) { + const id = resolveUuid(m) + if (id !== '') { + ids.add(id) + } + } + return ids +} + +/** + * Confirmed (CVE-id) matches for one component: every kwetsbaarheid whose + * `cveCode` case-insensitively equals one of the component's raw + * VEX-extracted `vexCveIds`. + * + * @param {object} component An sbomComponent record (OR object or data bag). + * @param {Array} kwetsbaarheden All candidate kwetsbaarheid records. + * @return {Array} The confirmed-matching kwetsbaarheid records. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-components-are-matched-against-existing-kwetsbaarheden-without-external-calls + */ +export function confirmedMatches(component, kwetsbaarheden) { + const data = dataOf(component) + const cveIds = Array.isArray(data.vexCveIds) + ? data.vexCveIds.filter((v) => typeof v === 'string' && v !== '').map((v) => v.toUpperCase()) + : [] + if (cveIds.length === 0 || !Array.isArray(kwetsbaarheden)) { + return [] + } + return kwetsbaarheden.filter((vuln) => { + const code = dataOf(vuln).cveCode + return typeof code === 'string' && code !== '' && cveIds.includes(code.toUpperCase()) + }) +} + +/** + * Possible (name/purl) matches for one component, scoped to `kwetsbaarheid` + * records whose `modules` already reference `parentModuleId` — never a + * catalogue-wide scan. + * + * @param {object} component An sbomComponent record (OR object or data bag). + * @param {Array} kwetsbaarheden All candidate kwetsbaarheid records. + * @param {string} parentModuleId The moduleVersie's parent module uuid. + * @return {Array} The possible-matching kwetsbaarheid records. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-components-are-matched-against-existing-kwetsbaarheden-without-external-calls + */ +export function possibleMatches(component, kwetsbaarheden, parentModuleId) { + if (!parentModuleId || !Array.isArray(kwetsbaarheden)) { + return [] + } + const data = dataOf(component) + const name = typeof data.name === 'string' ? data.name.toLowerCase().trim() : '' + const purlName = purlPackageName(data.purl) + if (name === '' && purlName === '') { + return [] + } + return kwetsbaarheden.filter((vuln) => { + if (!affectedModuleIds(vuln).has(parentModuleId)) { + return false + } + const naam = dataOf(vuln).naam + if (typeof naam !== 'string' || naam === '') { + return false + } + const naamLower = naam.toLowerCase() + return (name !== '' && naamLower.includes(name)) || (purlName !== '' && naamLower.includes(purlName)) + }) +} + +/** + * Compute both match kinds for one component against the candidate + * kwetsbaarheid set, scoped to the moduleVersie's parent module for the + * possible-match heuristic. + * + * @param {object} component An sbomComponent record. + * @param {Array} kwetsbaarheden All candidate kwetsbaarheid records. + * @param {string} parentModuleId The moduleVersie's parent module uuid. + * @return {{confirmed: Array, possible: Array}} Both match lists. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-components-are-matched-against-existing-kwetsbaarheden-without-external-calls + */ +export function matchComponent(component, kwetsbaarheden, parentModuleId) { + const confirmed = confirmedMatches(component, kwetsbaarheden) + // A component already confirmed-matched to a given kwetsbaarheid is not + // ALSO listed as a possible match for the same record — confirmed wins. + const confirmedIds = new Set(confirmed.map((v) => resolveUuid(v.id ?? v['@self']?.id ?? v))) + const possible = possibleMatches(component, kwetsbaarheden, parentModuleId).filter( + (v) => !confirmedIds.has(resolveUuid(v.id ?? v['@self']?.id ?? v)), + ) + return { confirmed, possible } +} + +/** + * Compute matches for a full component list, plus the total distinct + * matched-vulnerability count used by the Components tab summary. + * + * @param {Array} components The moduleVersie's sbomComponent list. + * @param {Array} kwetsbaarheden All candidate kwetsbaarheid records. + * @param {string} parentModuleId The moduleVersie's parent module uuid. + * @return {{rows: Array<{component: object, confirmed: Array, possible: Array}>, matchedVulnerabilityCount: number}} + * Per-component match rows, and the distinct matched-vulnerability count across the whole set. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-the-module-version-detail-page-shows-imported-components-with-summary-counts + */ +export function matchComponents(components, kwetsbaarheden, parentModuleId) { + const list = Array.isArray(components) ? components : [] + const matchedIds = new Set() + const rows = list.map((component) => { + const { confirmed, possible } = matchComponent(component, kwetsbaarheden, parentModuleId) + for (const vuln of [...confirmed, ...possible]) { + const id = resolveUuid(vuln.id ?? vuln['@self']?.id ?? vuln) + if (id !== '') { + matchedIds.add(id) + } + } + return { component, confirmed, possible } + }) + return { rows, matchedVulnerabilityCount: matchedIds.size } +} diff --git a/tests/Stubs/Service/ObjectService.php b/tests/Stubs/Service/ObjectService.php index b369aa31..cfb9e60e 100644 --- a/tests/Stubs/Service/ObjectService.php +++ b/tests/Stubs/Service/ObjectService.php @@ -186,6 +186,50 @@ abstract public function deleteObject( bool $_multitenancy=true ): bool; + /** + * Bulk-persist a batch of objects (used by bounded-batch bulk-save paths, + * e.g. `SbomImportService`). + * + * @param array $objects Array of object data bags. + * @param string|int|null $register Register slug or id. + * @param string|int|null $schema Schema slug or id. + * @param bool $_rbac Apply RBAC. + * @param bool $_multitenancy Apply multitenancy. + * @param bool $validation Run validation. + * @param bool $events Dispatch events. + * @param bool $deduplicateIds Deduplicate ids across the batch. + * @param bool $enrich Enrich saved objects. + * + * @return array + */ + abstract public function saveObjects( + array $objects, + string|int|null $register=null, + string|int|null $schema=null, + bool $_rbac=true, + bool $_multitenancy=true, + bool $validation=false, + bool $events=false, + bool $deduplicateIds=true, + bool $enrich=true + ): array; + + /** + * Bulk soft/hard-delete objects by uuid (used by bounded-batch + * replace-on-reimport paths, e.g. `SbomImportService`). + * + * @param array $uuids Array of object uuids. + * @param bool $_rbac Apply RBAC. + * @param bool $_multitenancy Apply multitenancy. + * + * @return array{deleted_uuids: array, skipped_uuids: array, cascade_count: int} + */ + abstract public function deleteObjects( + array $uuids=[], + bool $_rbac=true, + bool $_multitenancy=true + ): array; + /** * Set the active register context. * diff --git a/tests/Unit/Controller/SbomControllerTest.php b/tests/Unit/Controller/SbomControllerTest.php new file mode 100644 index 00000000..f53281fb --- /dev/null +++ b/tests/Unit/Controller/SbomControllerTest.php @@ -0,0 +1,309 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/sbom-import/spec.md#requirement-uploaded-sbom-files-are-bounded-in-size-and-json-only + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\SbomController; +use OCA\SoftwareCatalog\Service\SbomImportService; +use OCP\AppFramework\Http; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Test class for SbomController. + */ +class SbomControllerTest extends TestCase +{ + /** + * @var SbomImportService|MockObject + */ + private SbomImportService|MockObject $importService; + + /** + * @var IUserSession|MockObject + */ + private IUserSession|MockObject $userSession; + + /** + * @var IGroupManager|MockObject + */ + private IGroupManager|MockObject $groupManager; + + /** + * @var array Temp files created by a test, removed in tearDown(). + */ + private array $tempFiles = []; + + /** + * @return void + */ + protected function tearDown(): void + { + foreach ($this->tempFiles as $path) { + if (is_string($path) === true && file_exists($path) === true) { + unlink($path); + } + } + + $this->tempFiles = []; + }//end tearDown() + + /** + * Build a controller with a logged-in user of the given role, and an + * IRequest mock reporting the given uploaded-file/param shape. + * + * @param bool $isAdmin Whether the caller is an admin. + * @param array $memberGroups Non-admin groups the caller belongs to. + * @param array|null $uploadedFile The `getUploadedFile('sbomFile')` return value. + * @param array $params `getParam()` overrides (format, operationId). + * + * @return SbomController The controller under test. + */ + private function makeController( + bool $isAdmin, + array $memberGroups, + ?array $uploadedFile, + array $params = [] + ): SbomController { + $request = $this->createMock(IRequest::class); + $request->method('getUploadedFile')->willReturn($uploadedFile); + $request->method('getParam')->willReturnCallback( + function (string $key, $default = null) use ($params) { + return $params[$key] ?? $default; + } + ); + + $this->importService = $this->createMock(SbomImportService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->groupManager = $this->createMock(IGroupManager::class); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('caller-uid'); + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->with('caller-uid')->willReturn($isAdmin); + $this->groupManager->method('isInGroup')->willReturnCallback( + static function (string $uid, string $group) use ($memberGroups) { + return in_array($group, $memberGroups, true); + } + ); + + return new SbomController( + $request, + $this->userSession, + $this->groupManager, + $this->importService, + $this->createMock(LoggerInterface::class) + ); + }//end makeController() + + /** + * Build an uploaded-file array pointing at a real temp file with the + * given contents. + * + * @param string $contents The file contents. + * @param string $name The reported original file name. + * + * @return array{tmp_name:string,name:string,size:int} + */ + private function uploadedFile(string $contents, string $name = 'sbom.json'): array + { + $path = tempnam(sys_get_temp_dir(), 'sbom-test-'); + file_put_contents($path, $contents); + $this->tempFiles[] = $path; + + return ['tmp_name' => $path, 'name' => $name, 'size' => strlen($contents)]; + }//end uploadedFile() + + /** + * A caller who is neither admin nor in a manage group is refused (403); + * the import service is never invoked. + * + * @return void + */ + public function testImportRefusesCallerWithNoManageRole(): void + { + $controller = $this->makeController(isAdmin: false, memberGroups: [], uploadedFile: null); + $this->importService->expects($this->never())->method('importForModuleVersie'); + + $response = $controller->importSbom('mv-uuid-1'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + }//end testImportRefusesCallerWithNoManageRole() + + /** + * A caller in a manage group but WITHOUT manage-ACL (RBAC read) on the + * target module is refused (403); the import service is never invoked. + * + * @return void + */ + public function testImportRefusesManageGroupWithoutModuleAcl(): void + { + $controller = $this->makeController( + isAdmin: false, + memberGroups: ['aanbod-beheerder'], + uploadedFile: null + ); + $this->importService->method('resolveParentModuleUuid')->willReturn('module-uuid-1'); + $this->importService->method('userCanReadModule')->with('module-uuid-1')->willReturn(false); + $this->importService->expects($this->never())->method('importForModuleVersie'); + + $response = $controller->importSbom('mv-uuid-1'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + }//end testImportRefusesManageGroupWithoutModuleAcl() + + /** + * An admin caller with no file uploaded gets a 400, not a 403/500. + * + * @return void + */ + public function testImportWithNoFileReturns400(): void + { + $controller = $this->makeController(isAdmin: true, memberGroups: [], uploadedFile: null); + $this->importService->expects($this->never())->method('importForModuleVersie'); + + $response = $controller->importSbom('mv-uuid-1'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + }//end testImportWithNoFileReturns400() + + /** + * An oversized upload is rejected BEFORE the import service (and + * therefore the parser) is invoked. + * + * @return void + */ + public function testOversizedUploadRejectedBeforeImport(): void + { + $oversized = str_repeat('a', 200); + $upload = $this->uploadedFile($oversized); + // Report a size over the 10 MB limit regardless of the tiny temp + // file's actual bytes — the controller trusts the reported size. + $upload['size'] = 10485760 + 1; + + $controller = $this->makeController(isAdmin: true, memberGroups: [], uploadedFile: $upload); + $this->importService->expects($this->never())->method('importForModuleVersie'); + + $response = $controller->importSbom('mv-uuid-1'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + }//end testOversizedUploadRejectedBeforeImport() + + /** + * A non-JSON upload is rejected with 400 before the import service is + * invoked. + * + * @return void + */ + public function testNonJsonUploadRejected(): void + { + $upload = $this->uploadedFile('this is not { json'); + + $controller = $this->makeController(isAdmin: true, memberGroups: [], uploadedFile: $upload); + $this->importService->expects($this->never())->method('importForModuleVersie'); + + $response = $controller->importSbom('mv-uuid-1'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + }//end testNonJsonUploadRejected() + + /** + * An admin caller with a valid small JSON upload reaches the import + * service and its result is returned with 200. + * + * @return void + */ + public function testValidUploadReachesImportServiceAndReturns200(): void + { + $upload = $this->uploadedFile('{"bomFormat":"CycloneDX","specVersion":"1.6","components":[]}'); + + $controller = $this->makeController(isAdmin: true, memberGroups: [], uploadedFile: $upload); + $this->importService->expects($this->once()) + ->method('importForModuleVersie') + ->with('mv-uuid-1', $this->isType('string'), 'cyclonedx-json', 'sbom.json') + ->willReturn(['success' => true, 'componentCount' => 0, 'operationId' => null]); + + $response = $controller->importSbom('mv-uuid-1'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + }//end testValidUploadReachesImportServiceAndReturns200() + + /** + * A manage-group caller WITH manage-ACL on the target module is + * authorized and reaches the import service. + * + * @return void + */ + public function testManageGroupWithModuleAclIsAuthorized(): void + { + $upload = $this->uploadedFile('{"bomFormat":"CycloneDX","specVersion":"1.6","components":[]}'); + + $controller = $this->makeController( + isAdmin: false, + memberGroups: ['software-catalog-admins'], + uploadedFile: $upload + ); + $this->importService->method('resolveParentModuleUuid')->willReturn('module-uuid-1'); + $this->importService->method('userCanReadModule')->with('module-uuid-1')->willReturn(true); + $this->importService->expects($this->once()) + ->method('importForModuleVersie') + ->willReturn(['success' => true, 'componentCount' => 0, 'operationId' => null]); + + $response = $controller->importSbom('mv-uuid-1'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + }//end testManageGroupWithModuleAclIsAuthorized() + + /** + * getSbomImportStatus() refuses an unauthenticated caller. + * + * @return void + */ + public function testStatusRefusesUnauthenticated(): void + { + $request = $this->createMock(IRequest::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->importService = $this->createMock(SbomImportService::class); + $this->userSession->method('getUser')->willReturn(null); + + $controller = new SbomController( + $request, + $this->userSession, + $this->groupManager, + $this->importService, + $this->createMock(LoggerInterface::class) + ); + + $response = $controller->getSbomImportStatus('mv-uuid-1'); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + }//end testStatusRefusesUnauthenticated() +}//end class diff --git a/tests/Unit/SbomImportServiceTest.php b/tests/Unit/SbomImportServiceTest.php new file mode 100644 index 00000000..3ab29c6e --- /dev/null +++ b/tests/Unit/SbomImportServiceTest.php @@ -0,0 +1,492 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/sbom-import/spec.md#requirement-re-import-replaces-the-previous-component-set-and-is-soft-delete-aware + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\ObjectService; +use OCA\SoftwareCatalog\Exception\UnsupportedSbomFormatException; +use OCA\SoftwareCatalog\Service\ProgressTracker; +use OCA\SoftwareCatalog\Service\SbomImportService; +use OCA\SoftwareCatalog\Service\SbomParserService; +use OCA\SoftwareCatalog\Service\SettingsService; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Test class for SbomImportService. + */ +class SbomImportServiceTest extends TestCase +{ + /** + * @var string + */ + private string $fixturesDir; + + /** + * @var ObjectService|MockObject + */ + private ObjectService|MockObject $objectService; + + /** + * @var ProgressTracker|MockObject + */ + private ProgressTracker|MockObject $progressTracker; + + /** + * @var array> Objects saved via saveObjects(). + */ + private array $savedBatches = []; + + /** + * @var array> UUID batches passed to deleteObjects(). + */ + private array $deletedBatches = []; + + /** + * @var array|null The moduleVersie data bag last saved via saveObject(). + */ + private ?array $savedModuleVersie = null; + + /** + * @return void + */ + protected function setUp(): void + { + $this->fixturesDir = __DIR__.'/../fixtures/sbom'; + $this->savedBatches = []; + $this->deletedBatches = []; + $this->savedModuleVersie = null; + }//end setUp() + + /** + * Read a fixture file's raw contents. + * + * @param string $name The fixture file name. + * + * @return string The raw contents. + */ + private function fixture(string $name): string + { + return (string) file_get_contents($this->fixturesDir.'/'.$name); + }//end fixture() + + /** + * Build a moduleVersie entity stub. + * + * @param array $data Existing moduleVersie data. + * + * @return ObjectEntity|MockObject + */ + private function moduleVersieEntity(array $data): ObjectEntity|MockObject + { + $entity = $this->createMock(ObjectEntity::class); + $entity->method('getObject')->willReturn($data); + $entity->method('getUuid')->willReturn('mv-uuid-1'); + + return $entity; + }//end moduleVersieEntity() + + /** + * Build a previously-imported sbomComponent entity stub exposing only + * getUuid() (the replace path only needs the uuid to delete). + * + * @param string $uuid The component uuid. + * + * @return ObjectEntity|MockObject + */ + private function previousComponentEntity(string $uuid): ObjectEntity|MockObject + { + $entity = $this->createMock(ObjectEntity::class); + $entity->method('getUuid')->willReturn($uuid); + + return $entity; + }//end previousComponentEntity() + + /** + * Build a fully-wired SbomImportService whose ObjectService is a mock + * pre-configured with a moduleVersie find() result and a previous + * component set for searchObjects(). + * + * @param array $moduleVersieData Existing moduleVersie data bag. + * @param array $previousUuids Uuids of the previous live component set. + * + * @return SbomImportService + */ + private function makeService(array $moduleVersieData = ['versie' => '1.0.0'], array $previousUuids = []): SbomImportService + { + $container = $this->createMock(ContainerInterface::class); + $settings = $this->createMock(SettingsService::class); + $this->objectService = $this->createMock(ObjectService::class); + $this->progressTracker = $this->createMock(ProgressTracker::class); + $logger = $this->createMock(LoggerInterface::class); + + $settings->method('getVoorzieningenConfig')->willReturn(['register' => 1]); + $settings->method('getSchemaIdForObjectType')->willReturnMap( + [ + ['moduleVersie', 10], + ['sbomComponent', 20], + ['module', 30], + ] + ); + + $entity = $this->moduleVersieEntity($moduleVersieData); + $this->objectService->method('find')->willReturn($entity); + + $previousEntities = array_map([$this, 'previousComponentEntity'], $previousUuids); + $this->objectService->method('searchObjects')->willReturn($previousEntities); + + $this->objectService->method('deleteObjects')->willReturnCallback( + function (array $uuids) { + $this->deletedBatches[] = $uuids; + return ['deleted_uuids' => $uuids, 'skipped_uuids' => [], 'cascade_count' => 0]; + } + ); + + $this->objectService->method('saveObjects')->willReturnCallback( + function (array $objects) { + $this->savedBatches[] = $objects; + return ['statistics' => ['objectsCreated' => count($objects)]]; + } + ); + + $this->objectService->method('saveObject')->willReturnCallback( + function (array $object) use ($entity) { + $this->savedModuleVersie = $object; + return $entity; + } + ); + + $container->method('get')->willReturn($this->objectService); + + return new SbomImportService( + $container, + $settings, + new SbomParserService(), + $this->progressTracker, + $logger + ); + }//end makeService() + + /** + * A first import creates one sbomComponent per parsed component, linked + * to the target moduleVersie. + * + * @return void + */ + public function testImportCreatesOneComponentPerParsedEntry(): void + { + $service = $this->makeService(); + + $result = $service->importForModuleVersie( + 'mv-uuid-1', + $this->fixture('cyclonedx-1.6-valid.json'), + 'cyclonedx-json', + 'sbom.json' + ); + + $this->assertTrue($result['success']); + $this->assertSame(3, $result['componentCount']); + $this->assertCount(1, $this->savedBatches); + $this->assertCount(3, $this->savedBatches[0]); + + foreach ($this->savedBatches[0] as $componentData) { + $this->assertSame('mv-uuid-1', $componentData['moduleVersie']); + } + + $this->assertSame('lodash', $this->savedBatches[0][0]['name']); + $this->assertSame(['MIT'], $this->savedBatches[0][0]['licenses']); + }//end testImportCreatesOneComponentPerParsedEntry() + + /** + * A VEX vulnerabilities[] block's cveId is attached to the matching + * component's `vexCveIds` (raw fact, keyed by bom-ref) — a component + * with no VEX entry gets an empty array, never null/undefined. + * + * @return void + */ + public function testVexCveIdsAreAttachedToTheMatchingComponentByBomRef(): void + { + $service = $this->makeService(); + + $service->importForModuleVersie( + 'mv-uuid-1', + $this->fixture('cyclonedx-with-vex.json'), + 'cyclonedx-json', + 'sbom.json' + ); + + $this->assertCount(1, $this->savedBatches[0]); + $this->assertSame(['CVE-2021-44228'], $this->savedBatches[0][0]['vexCveIds']); + }//end testVexCveIdsAreAttachedToTheMatchingComponentByBomRef() + + /** + * A component with no VEX entry gets an empty vexCveIds array. + * + * @return void + */ + public function testComponentsWithoutVexEntryGetEmptyVexCveIds(): void + { + $service = $this->makeService(); + + $service->importForModuleVersie( + 'mv-uuid-1', + $this->fixture('cyclonedx-1.6-valid.json'), + 'cyclonedx-json', + 'sbom.json' + ); + + foreach ($this->savedBatches[0] as $componentData) { + $this->assertSame([], $componentData['vexCveIds']); + } + }//end testComponentsWithoutVexEntryGetEmptyVexCveIds() + + /** + * A second import soft-deletes the previous live set; only the new set + * is created. + * + * @return void + */ + public function testReimportReplacesPreviousLiveSet(): void + { + $service = $this->makeService(['versie' => '1.0.0'], ['prev-1', 'prev-2']); + + $result = $service->importForModuleVersie( + 'mv-uuid-1', + $this->fixture('cyclonedx-1.5-valid.json'), + 'cyclonedx-json', + 'sbom-v2.json' + ); + + $this->assertSame(2, $result['previousComponentCount']); + $this->assertCount(1, $this->deletedBatches); + $this->assertSame(['prev-1', 'prev-2'], $this->deletedBatches[0]); + // Only the newly parsed set is created — no mixing with the old uuids. + $this->assertCount(2, $this->savedBatches[0]); + }//end testReimportReplacesPreviousLiveSet() + + /** + * When the previous live set is empty (e.g. already-trashed rows from an + * earlier replace, which OR's default search excludes), no delete batch + * is issued and the count is zero. + * + * @return void + */ + public function testNoPreviousLiveSetMeansNoDeleteBatch(): void + { + $service = $this->makeService(['versie' => '1.0.0'], []); + + $result = $service->importForModuleVersie( + 'mv-uuid-1', + $this->fixture('cyclonedx-1.5-valid.json'), + 'cyclonedx-json', + 'sbom.json' + ); + + $this->assertSame(0, $result['previousComponentCount']); + $this->assertSame([], $this->deletedBatches); + }//end testNoPreviousLiveSetMeansNoDeleteBatch() + + /** + * A successful import records sbomLastImportedAt/sbomFormat/sbomFileName + * on the moduleVersie, carrying every pre-existing field forward + * (PUT-semantic saveObject — an omitted field would be nulled). + * + * @return void + */ + public function testImportRecordsProvenanceAndCarriesExistingFieldsForward(): void + { + $service = $this->makeService(['versie' => '2.3.1', 'beschrijvingKort' => 'Keep me']); + + $service->importForModuleVersie( + 'mv-uuid-1', + $this->fixture('cyclonedx-1.6-valid.json'), + 'cyclonedx-json', + 'my-sbom.json' + ); + + $this->assertNotNull($this->savedModuleVersie); + $this->assertSame('2.3.1', $this->savedModuleVersie['versie']); + $this->assertSame('Keep me', $this->savedModuleVersie['beschrijvingKort']); + $this->assertSame('cyclonedx-json', $this->savedModuleVersie['sbomFormat']); + $this->assertSame('my-sbom.json', $this->savedModuleVersie['sbomFileName']); + $this->assertNotEmpty($this->savedModuleVersie['sbomLastImportedAt']); + }//end testImportRecordsProvenanceAndCarriesExistingFieldsForward() + + /** + * A parsed set of more than 50 components starts a progress-tracking + * operation, updates it, and completes it, with the operation id + * returned in the response. + * + * @return void + */ + public function testLargeImportTracksProgressAndReturnsOperationId(): void + { + $service = $this->makeService(); + + $this->progressTracker->expects($this->once()) + ->method('startOperation') + ->with('sbom-import', ['total_items' => 60]) + ->willReturn('sbom-import_abc123'); + $this->progressTracker->expects($this->atLeastOnce())->method('updateProgress'); + $this->progressTracker->expects($this->once())->method('completeOperation'); + + $largeDocument = [ + 'bomFormat' => 'CycloneDX', + 'specVersion' => '1.6', + 'components' => array_fill( + 0, + 60, + ['name' => 'pkg', 'version' => '1.0.0', 'purl' => 'pkg:generic/pkg@1.0.0', 'licenses' => []] + ), + ]; + + $result = $service->importForModuleVersie( + 'mv-uuid-1', + json_encode($largeDocument), + 'cyclonedx-json', + 'large.json' + ); + + $this->assertSame('sbom-import_abc123', $result['operationId']); + $this->assertSame(60, $result['componentCount']); + // Two batches of 100-max — 60 components is exactly one batch. + $this->assertCount(1, $this->savedBatches); + }//end testLargeImportTracksProgressAndReturnsOperationId() + + /** + * A parsed set of 50 or fewer components completes without starting a + * progress-tracking operation; operationId is null. + * + * @return void + */ + public function testSmallImportDoesNotTrackProgress(): void + { + $service = $this->makeService(); + + $this->progressTracker->expects($this->never())->method('startOperation'); + $this->progressTracker->expects($this->never())->method('completeOperation'); + + $result = $service->importForModuleVersie( + 'mv-uuid-1', + $this->fixture('cyclonedx-1.6-valid.json'), + 'cyclonedx-json', + 'sbom.json' + ); + + $this->assertNull($result['operationId']); + $this->assertSame(3, $result['componentCount']); + }//end testSmallImportDoesNotTrackProgress() + + /** + * An unsupported SBOM format throws before any OR write — no delete, no + * save is issued. + * + * @return void + */ + public function testUnsupportedFormatWritesNothing(): void + { + $service = $this->makeService(); + + $this->objectService->expects($this->never())->method('deleteObjects'); + $this->objectService->expects($this->never())->method('saveObjects'); + $this->objectService->expects($this->never())->method('saveObject'); + + $this->expectException(UnsupportedSbomFormatException::class); + + $service->importForModuleVersie( + 'mv-uuid-1', + $this->fixture('cyclonedx-invalid-format.json'), + 'cyclonedx-json', + 'bad.json' + ); + }//end testUnsupportedFormatWritesNothing() + + /** + * A moduleVersie that cannot be resolved throws a RuntimeException. + * + * @return void + */ + public function testModuleVersieNotFoundThrows(): void + { + $container = $this->createMock(ContainerInterface::class); + $settings = $this->createMock(SettingsService::class); + $objectService = $this->createMock(ObjectService::class); + $progressTracker = $this->createMock(ProgressTracker::class); + $logger = $this->createMock(LoggerInterface::class); + + $settings->method('getVoorzieningenConfig')->willReturn(['register' => 1]); + $settings->method('getSchemaIdForObjectType')->willReturnMap( + [ + ['moduleVersie', 10], + ['sbomComponent', 20], + ] + ); + $objectService->method('find')->willReturn(null); + $container->method('get')->willReturn($objectService); + + $service = new SbomImportService($container, $settings, new SbomParserService(), $progressTracker, $logger); + + $this->expectException(\RuntimeException::class); + + $service->importForModuleVersie( + 'missing-uuid', + $this->fixture('cyclonedx-1.6-valid.json'), + 'cyclonedx-json', + 'sbom.json' + ); + }//end testModuleVersieNotFoundThrows() + + /** + * resolveParentModuleUuid() reads the moduleVersie's `module` relation + * and resolves a plain-string uuid. + * + * @return void + */ + public function testResolveParentModuleUuidReadsModuleRelation(): void + { + $service = $this->makeService(['module' => 'module-uuid-1']); + + $this->assertSame('module-uuid-1', $service->resolveParentModuleUuid('mv-uuid-1')); + }//end testResolveParentModuleUuidReadsModuleRelation() + + /** + * resolveParentModuleUuid() also resolves an array-shaped relation + * (`{uuid: ...}`), matching the lenient relation shapes used elsewhere + * in this codebase. + * + * @return void + */ + public function testResolveParentModuleUuidReadsArrayShapedRelation(): void + { + $service = $this->makeService(['module' => ['uuid' => 'module-uuid-2']]); + + $this->assertSame('module-uuid-2', $service->resolveParentModuleUuid('mv-uuid-1')); + }//end testResolveParentModuleUuidReadsArrayShapedRelation() +}//end class diff --git a/tests/Unit/SbomParserServiceTest.php b/tests/Unit/SbomParserServiceTest.php new file mode 100644 index 00000000..aa22ffa8 --- /dev/null +++ b/tests/Unit/SbomParserServiceTest.php @@ -0,0 +1,233 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/sbom-import/spec.md#requirement-cyclonedx-sbom-files-are-parsed-into-a-normalized-component-list + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit; + +use OCA\SoftwareCatalog\Exception\UnsupportedSbomFormatException; +use OCA\SoftwareCatalog\Service\SbomParserService; +use PHPUnit\Framework\TestCase; + +/** + * Test class for SbomParserService. + */ +class SbomParserServiceTest extends TestCase +{ + /** + * @var string + */ + private string $fixturesDir; + + /** + * @var SbomParserService + */ + private SbomParserService $parser; + + /** + * @return void + */ + protected function setUp(): void + { + $this->fixturesDir = __DIR__.'/../fixtures/sbom'; + $this->parser = new SbomParserService(); + }//end setUp() + + /** + * Read a fixture file's raw contents. + * + * @param string $name The fixture file name. + * + * @return string The raw contents. + */ + private function fixture(string $name): string + { + return (string) file_get_contents($this->fixturesDir.'/'.$name); + }//end fixture() + + /** + * A valid CycloneDX 1.6 document parses into components with their + * name/version/purl/licenses populated. + * + * @return void + */ + public function testValidCycloneDx16ParsesIntoComponents(): void + { + $result = $this->parser->parse($this->fixture('cyclonedx-1.6-valid.json')); + + $this->assertCount(3, $result['components']); + + $lodash = $result['components'][0]; + $this->assertSame('lodash', $lodash['name']); + $this->assertSame('4.17.21', $lodash['version']); + $this->assertSame('pkg:npm/lodash@4.17.21', $lodash['purl']); + $this->assertSame(['MIT'], $lodash['licenses']); + $this->assertSame('library', $lodash['type']); + $this->assertNotEmpty($lodash['hashes']); + + $log4j = $result['components'][1]; + $this->assertSame('log4j-core', $log4j['name']); + $this->assertSame(['Apache-2.0'], $log4j['licenses']); + + // License expression form (no `license.id`) is also read. + $openssl = $result['components'][2]; + $this->assertSame(['Apache-2.0'], $openssl['licenses']); + + $this->assertSame([], $result['vulnerabilities']); + }//end testValidCycloneDx16ParsesIntoComponents() + + /** + * A valid CycloneDX 1.5 document parses too (both supported versions). + * + * @return void + */ + public function testValidCycloneDx15Parses(): void + { + $result = $this->parser->parse($this->fixture('cyclonedx-1.5-valid.json')); + + $this->assertCount(2, $result['components']); + $this->assertSame('express', $result['components'][0]['name']); + // `license.name` (no SPDX id) form is also read. + $this->assertSame(['MIT License'], $result['components'][0]['licenses']); + }//end testValidCycloneDx15Parses() + + /** + * An unsupported specVersion (1.4) is rejected with no partial list. + * + * @return void + */ + public function testUnsupportedSpecVersionThrows(): void + { + $this->expectException(UnsupportedSbomFormatException::class); + $this->expectExceptionMessageMatches('/1\.4/'); + + $this->parser->parse($this->fixture('cyclonedx-invalid-format.json')); + }//end testUnsupportedSpecVersionThrows() + + /** + * A non-CycloneDX bomFormat is rejected. + * + * @return void + */ + public function testNonCycloneDxBomFormatThrows(): void + { + $this->expectException(UnsupportedSbomFormatException::class); + + $this->parser->parse(json_encode(['bomFormat' => 'SPDX', 'specVersion' => '1.6', 'components' => []])); + }//end testNonCycloneDxBomFormatThrows() + + /** + * Malformed JSON is rejected via the same exception type — no partial + * list, no PHP notice/warning escapes as output. + * + * @return void + */ + public function testMalformedJsonThrows(): void + { + $this->expectException(UnsupportedSbomFormatException::class); + + $this->parser->parse('{ this is not json'); + }//end testMalformedJsonThrows() + + /** + * An empty components[] array parses into zero components without error. + * + * @return void + */ + public function testEmptyComponentsParsesToEmptyList(): void + { + $result = $this->parser->parse($this->fixture('cyclonedx-empty-components.json')); + + $this->assertSame([], $result['components']); + }//end testEmptyComponentsParsesToEmptyList() + + /** + * A top-level vulnerabilities[] (VEX) block yields {cveId, + * componentBomRef} pairs alongside the component list. + * + * @return void + */ + public function testVexBlockExtractsCveComponentPairs(): void + { + $result = $this->parser->parse($this->fixture('cyclonedx-with-vex.json')); + + $this->assertCount(1, $result['components']); + $this->assertCount(1, $result['vulnerabilities']); + $this->assertSame('CVE-2021-44228', $result['vulnerabilities'][0]['cveId']); + $this->assertSame( + 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', + $result['vulnerabilities'][0]['componentBomRef'] + ); + }//end testVexBlockExtractsCveComponentPairs() + + /** + * A valid SPDX 2.3 document parses into the same DTO shape via + * parseSpdx(). + * + * @return void + */ + public function testValidSpdx23Parses(): void + { + $result = $this->parser->parseSpdx($this->fixture('spdx-2.3-valid.json')); + + $this->assertCount(2, $result['components']); + + $lodash = $result['components'][0]; + $this->assertSame('lodash', $lodash['name']); + $this->assertSame('4.17.21', $lodash['version']); + $this->assertSame('pkg:npm/lodash@4.17.21', $lodash['purl']); + $this->assertSame(['MIT'], $lodash['licenses']); + + $this->assertSame([], $result['vulnerabilities']); + }//end testValidSpdx23Parses() + + /** + * An SPDX document with an unsupported spdxVersion is rejected. + * + * @return void + */ + public function testUnsupportedSpdxVersionThrows(): void + { + $this->expectException(UnsupportedSbomFormatException::class); + + $this->parser->parseSpdx(json_encode(['spdxVersion' => 'SPDX-3.0', 'packages' => []])); + }//end testUnsupportedSpdxVersionThrows() + + /** + * Structural guarantee (design Decision 7): the parser's constructor + * takes zero arguments — no ObjectService, no HTTP client can be + * injected, so it cannot make an OR or network call from any code path. + * + * @return void + */ + public function testConstructorHasNoNetworkCapableDependency(): void + { + $reflection = new \ReflectionClass(SbomParserService::class); + $constructor = $reflection->getConstructor(); + + $this->assertTrue( + $constructor === null || count($constructor->getParameters()) === 0, + 'SbomParserService must have no constructor dependencies (pure, OR/HTTP-free service)' + ); + }//end testConstructorHasNoNetworkCapableDependency() +}//end class diff --git a/tests/e2e/sbom-import.spec.ts b/tests/e2e/sbom-import.spec.ts new file mode 100644 index 00000000..5fe357bb --- /dev/null +++ b/tests/e2e/sbom-import.spec.ts @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +/** + * E2e coverage file for openspec/specs/sbom-import/spec.md + * + * Coverage status + * --------------- + * The parse/replace/batch/matching CONTRACTS are pure server-side or + * pure-function logic, verified by PHPUnit (`tests/Unit/SbomParserServiceTest`, + * `tests/Unit/SbomImportServiceTest`, `tests/Unit/Controller/SbomControllerTest`) + * and vitest (`tests/vitest/sbomVulnerabilityMatch.spec.js`) against real + * CycloneDX fixtures — excluded from Playwright coverage below: + * + * @e2e sbom-import::a-valid-cyclonedx-16-document-parses-into-components + * @e2e sbom-import::an-unsupported-bomformat-or-specversion-is-rejected + * @e2e sbom-import::an-oversized-file-is-rejected-before-parsing + * @e2e sbom-import::a-non-json-file-is-rejected + * @e2e sbom-import::import-requires-admin-or-manage-acl + * @e2e sbom-import::a-parsed-component-persists-with-its-moduleversie-relation + * @e2e sbom-import::a-prior-replaces-trashed-rows-are-not-reprocessed + * @e2e sbom-import::a-large-sbom-import-reports-incremental-progress + * @e2e sbom-import::a-small-sbom-import-completes-without-a-progress-operation + * @e2e sbom-import::a-component-with-vex-declared-cve-data-gets-a-confirmed-match + * @e2e sbom-import::a-component-name-matching-a-module-scoped-vulnerability-gets-a-possible-match + * @e2e sbom-import::a-name-match-outside-the-modules-own-vulnerabilities-is-not-surfaced + * @e2e sbom-import::editing-a-vulnerability-changes-the-match-with-no-re-import + * @e2e sbom-import::no-outbound-http-call-is-made-during-matching + * @e2e sbom-import::a-successful-import-records-provenance-on-the-version + * @e2e sbom-import::existing-versions-are-unaffected-by-the-schema-addition + * + * The two REMAINING scenarios describe the rendered Components tab and are + * covered below by driving the REAL DOM (file input via `setInputFiles`, + * NcSelect combobox, real button clicks) — no Vue `$data` patching: + * + * @e2e sbom-import::the-components-tab-reflects-an-import + * @e2e sbom-import::a-version-with-no-imported-sbom-shows-an-empty-state + * + * Fixture setup (module + moduleVersie) is seeded through the OpenRegister + * object API per the gate-19 program (setup only — assertions stay on the + * rendered DOM); a real CycloneDX fixture file already used by the PHPUnit + * suite (`tests/fixtures/sbom/cyclonedx-1.6-valid.json`, + * `cyclonedx-1.5-valid.json`) is uploaded through the real file input. + */ + +import { test, expect, type Page } from '@playwright/test' +import * as path from 'path' +import { + newApiContext, + resolveConfig, + createObject, + cleanupByToken, + RUN_ID, +} from './workflows/_fixtures' + +const FIXTURES_DIR = path.resolve(__dirname, '../fixtures/sbom') +const CYCLONEDX_16 = path.join(FIXTURES_DIR, 'cyclonedx-1.6-valid.json') // 3 components +const CYCLONEDX_15 = path.join(FIXTURES_DIR, 'cyclonedx-1.5-valid.json') // 2 components + +const MODULE_NAME = `E2E SBOM Module ${RUN_ID}` + +let moduleVersieId: string + +test.beforeAll(async () => { + const ctx = await newApiContext() + try { + const config = await resolveConfig(ctx) + const moduleId = await createObject(ctx, config.register, config.module_schema, { + naam: MODULE_NAME, + }) + moduleVersieId = await createObject(ctx, config.register, config.moduleVersie_schema, { + module: moduleId, + versie: '1.0.0-e2e', + }) + } finally { + await ctx.dispose() + } +}) + +test.afterAll(async () => { + const ctx = await newApiContext() + try { + const config = await resolveConfig(ctx) + await cleanupByToken(ctx, config, RUN_ID) + } finally { + await ctx.dispose() + } +}) + +/** Navigate to a moduleVersie's detail page and open the Components sidebar tab. */ +async function openComponentsTab(page: Page): Promise { + await page.goto(`/apps/softwarecatalog/moduleversies/${moduleVersieId}`, { waitUntil: 'networkidle' }) + await page.getByRole('tab', { name: 'Components' }).click() +} + +// --------------------------------------------------------------------------- +// Scenario: A version with no imported SBOM shows an empty state +// @e2e sbom-import::a-version-with-no-imported-sbom-shows-an-empty-state +// --------------------------------------------------------------------------- +test( + 'sbom-import empty-state: a freshly-created moduleVersie Components tab shows the empty state and upload control', + async ({ page }) => { + await openComponentsTab(page) + + await expect(page.getByTestId('sbom-empty')).toBeVisible({ timeout: 15000 }) + await expect(page.getByTestId('sbom-file-input')).toBeVisible() + await expect(page.getByTestId('sbom-import-button')).toBeVisible() + + // Summary tiles read zero — "no summary counts shown as non-zero". + const summary = page.getByTestId('sbom-summary') + await expect(summary).toContainText('0') + }, +) + +// --------------------------------------------------------------------------- +// Scenario: The Components tab reflects an import +// @e2e sbom-import::the-components-tab-reflects-an-import +// +// Also exercises re-import-replaces (design Decision 3): a second upload +// with a different fixture leaves only the new set's 2 rows, not 3+2. +// --------------------------------------------------------------------------- +test( + 'sbom-import upload-and-replace: uploading a CycloneDX file renders the component list and summary counts; a second import replaces the first', + async ({ page }) => { + await openComponentsTab(page) + + // First import: 3-component fixture. + await page.getByTestId('sbom-file-input').setInputFiles(CYCLONEDX_16) + await page.getByTestId('sbom-import-button').click() + await expect(page.getByTestId('sbom-upload-success')).toBeVisible({ timeout: 20000 }) + + const table = page.getByTestId('sbom-component-table') + await expect(table).toBeVisible() + await expect(table.getByText('lodash')).toBeVisible() + await expect(table.locator('tbody tr')).toHaveCount(3) + + const summary = page.getByTestId('sbom-summary') + await expect(summary).toContainText('3') + + // Provenance line renders after a successful import. + await expect(page.getByTestId('sbom-provenance')).toBeVisible() + + // Second import (different fixture, 2 components) REPLACES the first — + // only the new set is live afterwards. + await page.getByTestId('sbom-file-input').setInputFiles(CYCLONEDX_15) + await page.getByTestId('sbom-import-button').click() + await expect(page.getByTestId('sbom-upload-success')).toBeVisible({ timeout: 20000 }) + + await expect(table.locator('tbody tr')).toHaveCount(2) + await expect(table.getByText('lodash')).toHaveCount(0) + await expect(table.getByText('express')).toBeVisible() + }, +) diff --git a/tests/fixtures/sbom/cyclonedx-1.5-valid.json b/tests/fixtures/sbom/cyclonedx-1.5-valid.json new file mode 100644 index 00000000..75e385e1 --- /dev/null +++ b/tests/fixtures/sbom/cyclonedx-1.5-valid.json @@ -0,0 +1,28 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": "urn:uuid:9c6e7a3a-6a5f-4d0e-9d1a-2f7b8e3c4d5e", + "version": 1, + "components": [ + { + "type": "library", + "bom-ref": "pkg:npm/express@4.19.2", + "name": "express", + "version": "4.19.2", + "purl": "pkg:npm/express@4.19.2", + "licenses": [ + { "license": { "name": "MIT License" } } + ] + }, + { + "type": "framework", + "bom-ref": "pkg:npm/vue@2.7.16", + "name": "vue", + "version": "2.7.16", + "purl": "pkg:npm/vue@2.7.16", + "licenses": [ + { "license": { "id": "MIT" } } + ] + } + ] +} diff --git a/tests/fixtures/sbom/cyclonedx-1.6-valid.json b/tests/fixtures/sbom/cyclonedx-1.6-valid.json new file mode 100644 index 00000000..612438de --- /dev/null +++ b/tests/fixtures/sbom/cyclonedx-1.6-valid.json @@ -0,0 +1,41 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "components": [ + { + "type": "library", + "bom-ref": "pkg:npm/lodash@4.17.21", + "name": "lodash", + "version": "4.17.21", + "purl": "pkg:npm/lodash@4.17.21", + "licenses": [ + { "license": { "id": "MIT" } } + ], + "hashes": [ + { "alg": "SHA-256", "content": "1a4f6c2a9c1b0e0a6f8e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a" } + ] + }, + { + "type": "library", + "bom-ref": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", + "name": "log4j-core", + "version": "2.14.1", + "purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", + "licenses": [ + { "license": { "id": "Apache-2.0" } } + ] + }, + { + "type": "library", + "bom-ref": "pkg:generic/openssl@3.0.2", + "name": "openssl", + "version": "3.0.2", + "purl": "pkg:generic/openssl@3.0.2", + "licenses": [ + { "expression": "Apache-2.0" } + ] + } + ] +} diff --git a/tests/fixtures/sbom/cyclonedx-empty-components.json b/tests/fixtures/sbom/cyclonedx-empty-components.json new file mode 100644 index 00000000..3f46819d --- /dev/null +++ b/tests/fixtures/sbom/cyclonedx-empty-components.json @@ -0,0 +1,7 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000000", + "version": 1, + "components": [] +} diff --git a/tests/fixtures/sbom/cyclonedx-invalid-format.json b/tests/fixtures/sbom/cyclonedx-invalid-format.json new file mode 100644 index 00000000..45592d6c --- /dev/null +++ b/tests/fixtures/sbom/cyclonedx-invalid-format.json @@ -0,0 +1,11 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "components": [ + { + "type": "library", + "name": "old-lib", + "version": "1.0.0" + } + ] +} diff --git a/tests/fixtures/sbom/cyclonedx-with-vex.json b/tests/fixtures/sbom/cyclonedx-with-vex.json new file mode 100644 index 00000000..28734390 --- /dev/null +++ b/tests/fixtures/sbom/cyclonedx-with-vex.json @@ -0,0 +1,27 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:5f3c8e2a-1b4d-4a6e-9f0c-7d2e1a3b4c5d", + "version": 1, + "components": [ + { + "type": "library", + "bom-ref": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", + "name": "log4j-core", + "version": "2.14.1", + "purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", + "licenses": [ + { "license": { "id": "Apache-2.0" } } + ] + } + ], + "vulnerabilities": [ + { + "id": "CVE-2021-44228", + "source": { "name": "NVD" }, + "affects": [ + { "ref": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1" } + ] + } + ] +} diff --git a/tests/fixtures/sbom/spdx-2.3-valid.json b/tests/fixtures/sbom/spdx-2.3-valid.json new file mode 100644 index 00000000..bd85487e --- /dev/null +++ b/tests/fixtures/sbom/spdx-2.3-valid.json @@ -0,0 +1,37 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "example-sbom", + "documentNamespace": "https://example.org/spdx/example-sbom-1", + "packages": [ + { + "SPDXID": "SPDXRef-Package-lodash", + "name": "lodash", + "versionInfo": "4.17.21", + "licenseConcluded": "MIT", + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/lodash@4.17.21" + } + ] + }, + { + "SPDXID": "SPDXRef-Package-openssl", + "name": "openssl", + "versionInfo": "3.0.2", + "licenseConcluded": "Apache-2.0", + "downloadLocation": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:generic/openssl@3.0.2" + } + ] + } + ] +} diff --git a/tests/vitest/sbomVulnerabilityMatch.spec.js b/tests/vitest/sbomVulnerabilityMatch.spec.js new file mode 100644 index 00000000..c58e9b9b --- /dev/null +++ b/tests/vitest/sbomVulnerabilityMatch.spec.js @@ -0,0 +1,159 @@ +/** + * Unit tests for the SBOM-to-vulnerability read-time cross-reference. + * + * Covers the confirmed (CVE-id) match, the possible (name/purl) match scoped + * to the version's parent module, the module-scoping exclusion, the + * confirmed-wins-over-possible dedupe, the render-time re-computation on + * edited kwetsbaarheid data, and the zero-HTTP guarantee. + * + * @spec openspec/specs/sbom-import/spec.md#requirement-components-are-matched-against-existing-kwetsbaarheden-without-external-calls + */ + +import { describe, it, expect } from 'vitest' +import { + purlPackageName, + confirmedMatches, + possibleMatches, + matchComponent, + matchComponents, +} from '../../src/utils/sbomVulnerabilityMatch.js' + +describe('sbomVulnerabilityMatch.purlPackageName', () => { + it('extracts the package segment from a simple purl', () => { + expect(purlPackageName('pkg:npm/lodash@4.17.21')).toBe('lodash') + }) + it('extracts the last path segment for a scoped/namespaced purl', () => { + expect(purlPackageName('pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1')).toBe('log4j-core') + }) + it('empty string for an empty/invalid purl', () => { + expect(purlPackageName('')).toBe('') + expect(purlPackageName(undefined)).toBe('') + }) +}) + +describe('sbomVulnerabilityMatch.confirmedMatches', () => { + const component = { name: 'log4j-core', purl: 'pkg:maven/x/log4j-core@2.14.1', vexCveIds: ['CVE-2021-44228'] } + const vulns = [ + { id: 'V1', naam: 'Log4Shell', cveCode: 'CVE-2021-44228', modules: [] }, + { id: 'V2', naam: 'Other', cveCode: 'CVE-2020-0001', modules: [] }, + ] + + it('matches by exact CVE id (case-insensitive)', () => { + const matches = confirmedMatches(component, vulns) + expect(matches).toHaveLength(1) + expect(matches[0].id).toBe('V1') + }) + it('is case-insensitive', () => { + const lower = { ...component, vexCveIds: ['cve-2021-44228'] } + expect(confirmedMatches(lower, vulns)).toHaveLength(1) + }) + it('empty when the component has no vexCveIds', () => { + expect(confirmedMatches({ name: 'x' }, vulns)).toEqual([]) + }) + it('reads an OR object envelope', () => { + expect(confirmedMatches({ object: component }, vulns)).toHaveLength(1) + }) +}) + +describe('sbomVulnerabilityMatch.possibleMatches', () => { + const component = { name: 'log4j-core', purl: 'pkg:maven/x/log4j-core@2.14.1' } + const inScope = { id: 'V1', naam: 'Log4Shell in log4j-core', cveCode: '', modules: ['M1'] } + const outOfScope = { id: 'V2', naam: 'Log4Shell in log4j-core', cveCode: '', modules: ['M2'] } + const noNameMatch = { id: 'V3', naam: 'Totally unrelated', cveCode: '', modules: ['M1'] } + + it('matches a module-scoped kwetsbaarheid by name substring', () => { + const matches = possibleMatches(component, [inScope, outOfScope, noNameMatch], 'M1') + expect(matches).toHaveLength(1) + expect(matches[0].id).toBe('V1') + }) + it('excludes a same-name kwetsbaarheid NOT linked to the module', () => { + const matches = possibleMatches(component, [outOfScope], 'M1') + expect(matches).toEqual([]) + }) + it('matches via the purl package-name fallback when the plain name differs', () => { + const purlOnly = { name: 'totally-different-artifact-id', purl: 'pkg:maven/x/log4j-core@2.14.1' } + const matches = possibleMatches(purlOnly, [inScope], 'M1') + expect(matches).toHaveLength(1) + }) + it('empty when parentModuleId is not provided', () => { + expect(possibleMatches(component, [inScope], '')).toEqual([]) + expect(possibleMatches(component, [inScope], null)).toEqual([]) + }) + it('resolves module references given as objects', () => { + const objRefVuln = { id: 'V4', naam: 'Log4Shell in log4j-core', cveCode: '', modules: [{ uuid: 'M1' }] } + expect(possibleMatches(component, [objRefVuln], 'M1')).toHaveLength(1) + }) +}) + +describe('sbomVulnerabilityMatch.matchComponent', () => { + it('a confirmed match is not duplicated as a possible match for the same record', () => { + const component = { name: 'log4j-core', purl: 'pkg:maven/x/log4j-core@2.14.1', vexCveIds: ['CVE-2021-44228'] } + const vuln = { id: 'V1', naam: 'Log4Shell in log4j-core', cveCode: 'CVE-2021-44228', modules: ['M1'] } + + const { confirmed, possible } = matchComponent(component, [vuln], 'M1') + expect(confirmed).toHaveLength(1) + expect(possible).toEqual([]) + }) + + it('a component can carry an unrelated possible match alongside its confirmed match', () => { + const component = { name: 'log4j-core', purl: 'pkg:maven/x/log4j-core@2.14.1', vexCveIds: ['CVE-2021-44228'] } + const confirmedVuln = { id: 'V1', naam: 'Log4Shell', cveCode: 'CVE-2021-44228', modules: ['M1'] } + const possibleVuln = { id: 'V2', naam: 'log4j-core name issue', cveCode: '', modules: ['M1'] } + + const { confirmed, possible } = matchComponent(component, [confirmedVuln, possibleVuln], 'M1') + expect(confirmed.map((v) => v.id)).toEqual(['V1']) + expect(possible.map((v) => v.id)).toEqual(['V2']) + }) +}) + +describe('sbomVulnerabilityMatch.matchComponents', () => { + it('computes rows + a distinct matched-vulnerability count across the whole set', () => { + const components = [ + { name: 'log4j-core', purl: 'pkg:maven/x/log4j-core@2.14.1', vexCveIds: ['CVE-2021-44228'] }, + { name: 'lodash', purl: 'pkg:npm/lodash@4.17.21', vexCveIds: [] }, + ] + const vulns = [ + { id: 'V1', naam: 'Log4Shell', cveCode: 'CVE-2021-44228', modules: ['M1'] }, + { id: 'V2', naam: 'lodash prototype pollution', cveCode: '', modules: ['M1'] }, + { id: 'V3', naam: 'unrelated', cveCode: '', modules: ['M2'] }, + ] + + const { rows, matchedVulnerabilityCount } = matchComponents(components, vulns, 'M1') + expect(rows).toHaveLength(2) + expect(matchedVulnerabilityCount).toBe(2) + }) + + it('zero matches, zero HTTP calls, when no components are given', () => { + const { rows, matchedVulnerabilityCount } = matchComponents([], [{ id: 'V1', naam: 'x', modules: [] }], 'M1') + expect(rows).toEqual([]) + expect(matchedVulnerabilityCount).toBe(0) + }) + + it('re-computes on edited kwetsbaarheid data with no re-import — same input shape, new cveCode yields a new match', () => { + const components = [{ name: 'openssl', purl: 'pkg:generic/openssl@3.0.2', vexCveIds: ['CVE-2022-9999'] }] + const before = matchComponents(components, [{ id: 'V1', naam: 'openssl issue', cveCode: 'CVE-2022-0001', modules: [] }], 'M1') + expect(before.matchedVulnerabilityCount).toBe(0) + + // Simulate the kwetsbaarheid being edited (cveCode corrected) — a fresh + // render-time call against the updated record picks it up immediately. + const after = matchComponents(components, [{ id: 'V1', naam: 'openssl issue', cveCode: 'CVE-2022-9999', modules: [] }], 'M1') + expect(after.matchedVulnerabilityCount).toBe(1) + }) +}) + +describe('sbomVulnerabilityMatch — no network dependency', () => { + it('the module source contains no fetch/axios/XHR call', async () => { + const { readFileSync } = await import('node:fs') + const { fileURLToPath } = await import('node:url') + const path = fileURLToPath(new URL('../../src/utils/sbomVulnerabilityMatch.js', import.meta.url)) + const source = readFileSync(path, 'utf8') + expect(source).not.toMatch(/\bfetch\(/) + expect(source).not.toMatch(/axios/i) + expect(source).not.toMatch(/XMLHttpRequest/) + }) + + it('match functions return synchronously (no Promise) — a pure computation, not a call', () => { + const result = confirmedMatches({}, []) + expect(result).not.toBeInstanceOf(Promise) + }) +})