|
| 1 | +<!-- |
| 2 | + - SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl> |
| 3 | + - SPDX-License-Identifier: EUPL-1.2 |
| 4 | + --> |
| 5 | + |
| 6 | +# SBOM import |
| 7 | + |
| 8 | +Imports a Software Bill of Materials (SBOM) — CycloneDX 1.5/1.6 JSON, with |
| 9 | +SPDX 2.3 JSON as an optional second format — for a specific `moduleVersie` |
| 10 | +(a released version of an application), parsing its components into |
| 11 | +`sbomComponent` OpenRegister objects and surfacing them on a **Components** |
| 12 | +tab with licenses, summary counts, and a render-time cross-reference against |
| 13 | +the existing `kwetsbaarheid` (vulnerability) register. |
| 14 | + |
| 15 | +Specification: [`openspec/specs/sbom-import/spec.md`](../../openspec/specs/sbom-import/spec.md). |
| 16 | + |
| 17 | +## Uploading an SBOM |
| 18 | + |
| 19 | +On a module version's detail page, open the **Components** sidebar tab. |
| 20 | +Choose a format (CycloneDX or SPDX, both JSON) and a file, then **Import |
| 21 | +SBOM**: |
| 22 | + |
| 23 | +``` |
| 24 | +POST /apps/softwarecatalog/api/moduleversies/{moduleVersieUuid}/sbom |
| 25 | +multipart/form-data: sbomFile=<file>, format=cyclonedx-json|spdx-json |
| 26 | +``` |
| 27 | + |
| 28 | +The upload is rejected — before the parser ever runs — when it exceeds the |
| 29 | +configured maximum size (10 MB by default) or is not valid JSON. Importing |
| 30 | +requires admin group membership, or membership of a manage-tier group |
| 31 | +**and** manage-ACL (RBAC read) on the version's parent application; anyone |
| 32 | +else gets a 403 and no objects change. |
| 33 | + |
| 34 | +```json |
| 35 | +{ |
| 36 | + "success": true, |
| 37 | + "operationId": null, |
| 38 | + "moduleVersieUuid": "b2c3d4e5-...", |
| 39 | + "componentCount": 3, |
| 40 | + "previousComponentCount": 0, |
| 41 | + "distinctLicenseCount": 2, |
| 42 | + "vulnerabilityPairCount": 0, |
| 43 | + "sbomFormat": "cyclonedx-json", |
| 44 | + "sbomFileName": "sbom.json" |
| 45 | +} |
| 46 | +``` |
| 47 | + |
| 48 | +## Re-import replaces, never accumulates |
| 49 | + |
| 50 | +Importing a second SBOM for the same version **replaces** the previous |
| 51 | +component set: the previous live `sbomComponent` objects are soft-deleted |
| 52 | +and the newly parsed set is created. Already-trashed rows from an earlier |
| 53 | +replace are never re-queried or re-deleted (OpenRegister's default search |
| 54 | +already excludes `_deleted` rows). If the create step fails partway through, |
| 55 | +the version is left with no live component set rather than a mixed |
| 56 | +old/new one — a re-run of the import starts clean either way. This mirrors |
| 57 | +the same replace-not-accumulate model used elsewhere in this app rather than |
| 58 | +introducing an import-history/audit-log concept. |
| 59 | + |
| 60 | +Both the soft-delete and the create step run in bounded batches (~100 |
| 61 | +objects per OpenRegister call). Imports whose parsed component count |
| 62 | +exceeds 50 start a `progress-tracking` operation, update it per batch, and |
| 63 | +complete it — the operation id is returned in the response so the frontend |
| 64 | +can poll `GET .../sbom?operationId=...` for `{ phase, percentage, |
| 65 | +processed_items }`. Smaller imports complete synchronously and the response |
| 66 | +already carries the final counts. |
| 67 | + |
| 68 | +## What gets stored |
| 69 | + |
| 70 | +Each parsed component persists as one `sbomComponent` OpenRegister object, |
| 71 | +related to its `moduleVersie`: |
| 72 | + |
| 73 | +| Field | Source | |
| 74 | +|---|---| |
| 75 | +| `name`, `version` | CycloneDX/SPDX component name + version | |
| 76 | +| `purl` | Package URL (`pkg:...`) | |
| 77 | +| `licenses` | SPDX license id(s)/expression(s), or free text | |
| 78 | +| `type` | CycloneDX component type (`library`, `application`, …) | |
| 79 | +| `hashes` | Informational file hashes — never used for matching | |
| 80 | +| `bomRef` | CycloneDX `bom-ref` — within-import traceability only | |
| 81 | +| `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 | |
| 82 | + |
| 83 | +Three optional provenance fields are set on the `moduleVersie` itself on |
| 84 | +every successful import: `sbomLastImportedAt`, `sbomFormat`, `sbomFileName` |
| 85 | +— shown as a "last imported ⟨date⟩ from ⟨file⟩" line on the Components tab. |
| 86 | + |
| 87 | +## Vulnerability matching — computed, never stored |
| 88 | + |
| 89 | +The Components tab cross-references each imported component against the |
| 90 | +existing `kwetsbaarheid` register using two bounded, local strategies — |
| 91 | +never an outbound HTTP call to an external advisory feed (OSV.dev, NVD, …): |
| 92 | + |
| 93 | +1. **Confirmed match** — a component's VEX-extracted `vexCveIds` compared, |
| 94 | + case-insensitively, against `kwetsbaarheid.cveCode`. |
| 95 | +2. **Possible match** — the component's `name` (or the package segment of |
| 96 | + its `purl`) compared, case-insensitively (substring), against |
| 97 | + `kwetsbaarheid.naam`, scoped to `kwetsbaarheid` records whose `modules` |
| 98 | + already reference the version's parent `module`. A same-name |
| 99 | + vulnerability recorded against a *different* application never surfaces |
| 100 | + here. |
| 101 | + |
| 102 | +Both matches are computed at render time by |
| 103 | +[`src/utils/sbomVulnerabilityMatch.js`](../../src/utils/sbomVulnerabilityMatch.js) |
| 104 | +— nothing is written back to either `sbomComponent` or `kwetsbaarheid`. |
| 105 | +Editing a `kwetsbaarheid`'s `cveCode`/`naam` after an import changes the |
| 106 | +match set on next render, with no re-import required. This feeds |
| 107 | +`module-vulnerability-tracking` rather than forking a parallel vulnerability |
| 108 | +model. |
| 109 | + |
| 110 | +## Components tab |
| 111 | + |
| 112 | +The **Components** tab on a module version's detail page (`SbomComponentsPanel`) |
| 113 | +shows: |
| 114 | + |
| 115 | +- Summary counts — total components, distinct licenses, matched |
| 116 | + vulnerabilities. |
| 117 | +- The "last imported" provenance line, when an import has happened. |
| 118 | +- The upload control (format select + file input + Import button). |
| 119 | +- The component table (name, version, package URL, licenses) with a |
| 120 | + **Confirmed match** / **Possible match** badge per matched component. |
| 121 | +- An empty state with the upload control when no SBOM has been imported yet. |
| 122 | + |
| 123 | +## Out of scope |
| 124 | + |
| 125 | +- Outbound calls to an external vulnerability/advisory service — that |
| 126 | + integration, if built, belongs in `openconnector` (per |
| 127 | + `feedback_integrations-not-leaves`). |
| 128 | +- SBOM generation/export — this feature only imports. |
| 129 | +- License-policy evaluation (allow/deny lists, obligations) — only the raw |
| 130 | + license identifiers are captured. |
| 131 | +- Transitive dependency graphs — the component **list** only; `bomRef` is |
| 132 | + captured for future use but no dependency-edge graph is parsed or |
| 133 | + rendered. |
0 commit comments