|
| 1 | +# sbom-import Specification |
| 2 | + |
| 3 | +## Purpose |
| 4 | +TBD - created by archiving change sbom-import. Update Purpose after archive. |
| 5 | +## Requirements |
| 6 | +### Requirement: CycloneDX SBOM files are parsed into a normalized component list |
| 7 | + |
| 8 | +`SbomParserService` SHALL parse a CycloneDX JSON document whose |
| 9 | +`bomFormat` equals `CycloneDX` and whose `specVersion` is `1.5` or `1.6` into |
| 10 | +a list of component records (`name`, `version`, `purl`, `licenses`, optional |
| 11 | +`hashes`, optional `type`, `bomRef`) from the document's `components[]` |
| 12 | +array. The parser SHALL be a pure service with no dependency on |
| 13 | +OpenRegister's `ObjectService` or any HTTP client, so it is unit-testable |
| 14 | +against fixture files alone. |
| 15 | + |
| 16 | +#### Scenario: A valid CycloneDX 1.6 document parses into components |
| 17 | + |
| 18 | +- **WHEN** `SbomParserService::parse()` is called with a well-formed |
| 19 | + CycloneDX 1.6 JSON document containing three `components[]` entries with |
| 20 | + `name`, `version`, `purl`, and `licenses` |
| 21 | +- **THEN** it returns three component records with those fields populated |
| 22 | +- **AND** no OpenRegister call and no HTTP call occurs during parsing |
| 23 | + |
| 24 | +#### Scenario: An unsupported bomFormat or specVersion is rejected |
| 25 | + |
| 26 | +- **WHEN** `SbomParserService::parse()` is called with a JSON document whose |
| 27 | + `bomFormat` is not `CycloneDX`, or whose `specVersion` is not `1.5` or |
| 28 | + `1.6` |
| 29 | +- **THEN** the parser throws an `UnsupportedSbomFormatException` naming the |
| 30 | + offending format/version |
| 31 | +- **AND** no partial component list is returned |
| 32 | + |
| 33 | +### Requirement: Uploaded SBOM files are bounded in size and JSON-only |
| 34 | + |
| 35 | +The SBOM upload endpoint SHALL reject any upload exceeding the configured |
| 36 | +maximum file size (default 10 MB) and any upload that is not valid JSON, |
| 37 | +before invoking the parser, and SHALL require admin group membership or |
| 38 | +manage-ACL on the target `moduleVersie`'s parent `module`. |
| 39 | + |
| 40 | +#### Scenario: An oversized file is rejected before parsing |
| 41 | + |
| 42 | +- **WHEN** a user uploads an SBOM file larger than the configured maximum |
| 43 | +- **THEN** the endpoint responds with an error before `SbomParserService` is |
| 44 | + invoked |
| 45 | +- **AND** no `sbomComponent` objects are created or replaced |
| 46 | + |
| 47 | +#### Scenario: A non-JSON file is rejected |
| 48 | + |
| 49 | +- **WHEN** a user uploads a file that is not valid JSON |
| 50 | +- **THEN** the endpoint responds with a 400 error identifying the problem |
| 51 | +- **AND** the previous component set for the target `moduleVersie`, if any, |
| 52 | + is left unchanged |
| 53 | + |
| 54 | +#### Scenario: Import requires admin or manage-ACL |
| 55 | + |
| 56 | +- **WHEN** a user without admin group membership and without manage-ACL on |
| 57 | + the target version's module attempts to import an SBOM |
| 58 | +- **THEN** the endpoint responds with a 403 error |
| 59 | +- **AND** no component objects are created |
| 60 | + |
| 61 | +### Requirement: Imported components persist as OpenRegister objects scoped to a moduleVersie |
| 62 | + |
| 63 | +Each parsed component SHALL persist as an `sbomComponent` OpenRegister object |
| 64 | +with a required `moduleVersie` relation, `name`, and the parsed `version`, |
| 65 | +`purl`, and `licenses` fields; optional `hashes`, `type`, and `bomRef` SHALL |
| 66 | +be stored when present in the source SBOM. No app-local database table SHALL |
| 67 | +be introduced (ADR-001). |
| 68 | + |
| 69 | +#### Scenario: A parsed component persists with its moduleVersie relation |
| 70 | + |
| 71 | +- **WHEN** an SBOM import for a given `moduleVersie` completes |
| 72 | +- **THEN** each parsed component exists as an `sbomComponent` object whose |
| 73 | + `moduleVersie` relation resolves to that version |
| 74 | +- **AND** its `name`, `version`, `purl`, and `licenses` match the source SBOM |
| 75 | + |
| 76 | +### Requirement: Re-import replaces the previous component set and is soft-delete aware |
| 77 | + |
| 78 | +The app SHALL replace a `moduleVersie`'s previously imported component set |
| 79 | +when a new SBOM is imported for that same version: the previous non-deleted |
| 80 | +`sbomComponent` objects for that version SHALL be soft-deleted, and the newly |
| 81 | +parsed set SHALL then be created. Already-trashed rows from a prior replace |
| 82 | +SHALL NOT be re-processed or double-counted. A failed import SHALL leave the |
| 83 | +version with no component set rather than a mixed old/new set. |
| 84 | + |
| 85 | +#### Scenario: A second import replaces the first |
| 86 | + |
| 87 | +- **WHEN** a `moduleVersie` already has an imported component set and a user |
| 88 | + imports a new SBOM for the same version |
| 89 | +- **THEN** the previously imported `sbomComponent` objects are soft-deleted |
| 90 | +- **AND** only the components from the new SBOM appear on the version's |
| 91 | + Components tab afterwards |
| 92 | + |
| 93 | +#### Scenario: A prior replace's trashed rows are not reprocessed |
| 94 | + |
| 95 | +- **WHEN** a `moduleVersie` has already had one replace cycle (its first |
| 96 | + component set is soft-deleted, its second is live) |
| 97 | +- **AND** a third import runs for the same version |
| 98 | +- **THEN** only the live (second) component set is soft-deleted before the |
| 99 | + third set is created |
| 100 | +- **AND** the count of soft-deleted `sbomComponent` objects from the first |
| 101 | + cycle does not change |
| 102 | + |
| 103 | +### Requirement: Large imports run in bounded batches with progress reporting |
| 104 | + |
| 105 | +`SbomImportService` SHALL persist and soft-delete `sbomComponent` objects in |
| 106 | +bounded batches rather than a single unbounded bulk call. For imports whose |
| 107 | +parsed component count exceeds 50, the service SHALL start a |
| 108 | +`progress-tracking` operation, update it per batch, and complete it when the |
| 109 | +import finishes, exposing the operation id in the import response. |
| 110 | + |
| 111 | +#### Scenario: A large SBOM import reports incremental progress |
| 112 | + |
| 113 | +- **WHEN** an uploaded SBOM parses into more than 50 components |
| 114 | +- **THEN** the import response includes an operation id |
| 115 | +- **AND** `getProgress(operationId)` returns increasing `processed_items` |
| 116 | + values while the import is in flight |
| 117 | +- **AND** the operation reaches `phase = completed` with `percentage = 100` |
| 118 | + when the import finishes |
| 119 | + |
| 120 | +#### Scenario: A small SBOM import completes without a progress operation |
| 121 | + |
| 122 | +- **WHEN** an uploaded SBOM parses into 50 or fewer components |
| 123 | +- **THEN** the import completes synchronously |
| 124 | +- **AND** the response includes the final component count without requiring |
| 125 | + a progress poll |
| 126 | + |
| 127 | +### Requirement: The module-version detail page shows imported components with summary counts |
| 128 | + |
| 129 | +The `ModuleversieDetail` manifest page SHALL gain a Components tab showing |
| 130 | +the imported `sbomComponent` list (name, version, purl, licenses) and summary |
| 131 | +counts: total component count, distinct license count, and matched- |
| 132 | +vulnerability count (per the matching requirement below). |
| 133 | + |
| 134 | +#### Scenario: The Components tab reflects an import |
| 135 | + |
| 136 | +- **WHEN** a user opens the Components tab of a `moduleVersie` that has an |
| 137 | + imported SBOM |
| 138 | +- **THEN** the component list shows each component's name, version, purl, |
| 139 | + and licenses |
| 140 | +- **AND** the summary counts show the total component count and the count of |
| 141 | + distinct licenses across those components |
| 142 | + |
| 143 | +#### Scenario: A version with no imported SBOM shows an empty state |
| 144 | + |
| 145 | +- **WHEN** a user opens the Components tab of a `moduleVersie` with no |
| 146 | + imported component set |
| 147 | +- **THEN** the tab shows an empty state with an upload control |
| 148 | +- **AND** no summary counts are shown as non-zero |
| 149 | + |
| 150 | +### Requirement: Components are matched against existing kwetsbaarheden without external calls |
| 151 | + |
| 152 | +For each `sbomComponent`, the app SHALL compute (at render time, never |
| 153 | +persisted) matches against the existing `kwetsbaarheid` register using two |
| 154 | +bounded local strategies: a confirmed match by exact CVE id when the source |
| 155 | +SBOM carries CycloneDX VEX vulnerability data, compared against |
| 156 | +`kwetsbaarheid.cveCode`; and a possible match by case-insensitive |
| 157 | +name/purl-package comparison against `kwetsbaarheid.naam`, scoped to |
| 158 | +`kwetsbaarheid` records whose `modules` already reference the version's |
| 159 | +parent `module`. No matched-vulnerability reference SHALL be written back to |
| 160 | +either the `sbomComponent` or `kwetsbaarheid` schema, and no HTTP request to |
| 161 | +an external vulnerability feed (OSV.dev, NVD, or otherwise) SHALL be made by |
| 162 | +the import or matching path. |
| 163 | + |
| 164 | +#### Scenario: A component with VEX-declared CVE data gets a confirmed match |
| 165 | + |
| 166 | +- **WHEN** an uploaded CycloneDX document's `vulnerabilities[]` block |
| 167 | + references a component by `bom-ref` with `id` equal to an existing |
| 168 | + `kwetsbaarheid.cveCode` |
| 169 | +- **THEN** that component's Components-tab row shows a confirmed match to |
| 170 | + that `kwetsbaarheid` |
| 171 | +- **AND** the match is computed at render time, not stored on the |
| 172 | + `sbomComponent` object |
| 173 | + |
| 174 | +#### Scenario: A component name matching a module-scoped vulnerability gets a possible match |
| 175 | + |
| 176 | +- **WHEN** a `kwetsbaarheid` record's `modules` includes the parent `module` |
| 177 | + of an imported `moduleVersie`, and one of that version's `sbomComponent` |
| 178 | + names case-insensitively matches (or is contained in) the |
| 179 | + `kwetsbaarheid.naam` |
| 180 | +- **THEN** that component's Components-tab row shows a possible match, |
| 181 | + visually distinguished from a confirmed match |
| 182 | + |
| 183 | +#### Scenario: A name match outside the module's own vulnerabilities is not surfaced |
| 184 | + |
| 185 | +- **WHEN** a `kwetsbaarheid` record's `modules` does NOT include the parent |
| 186 | + `module` of an imported `moduleVersie`, even if a component name would |
| 187 | + textually match that `kwetsbaarheid.naam` |
| 188 | +- **THEN** no possible match is shown for that pairing |
| 189 | + |
| 190 | +#### Scenario: Editing a vulnerability changes the match with no re-import |
| 191 | + |
| 192 | +- **WHEN** a `kwetsbaarheid`'s `cveCode` or `naam` is edited after an SBOM |
| 193 | + has already been imported for an affected version |
| 194 | +- **THEN** the Components tab's matches reflect the edited `kwetsbaarheid` |
| 195 | + data the next time it is rendered, with no re-import of the SBOM required |
| 196 | + |
| 197 | +#### Scenario: No outbound HTTP call is made during matching |
| 198 | + |
| 199 | +- **WHEN** the Components tab computes matches for a version's component |
| 200 | + list |
| 201 | +- **THEN** the computation reads only the local `sbomComponent` and |
| 202 | + `kwetsbaarheid` OpenRegister data |
| 203 | +- **AND** no HTTP request is issued to any external vulnerability or |
| 204 | + advisory service |
| 205 | + |
| 206 | +### Requirement: moduleVersie records SBOM import provenance |
| 207 | + |
| 208 | +The `moduleVersie` schema SHALL gain three optional fields — |
| 209 | +`sbomLastImportedAt` (date-time), `sbomFormat` (`cyclonedx-json` | |
| 210 | +`spdx-json`), and `sbomFileName` (string) — populated on each successful |
| 211 | +import. Existing `moduleVersie` objects SHALL remain valid with these fields |
| 212 | +unset. |
| 213 | + |
| 214 | +#### Scenario: A successful import records provenance on the version |
| 215 | + |
| 216 | +- **WHEN** an SBOM import for a `moduleVersie` completes successfully |
| 217 | +- **THEN** that version's `sbomLastImportedAt`, `sbomFormat`, and |
| 218 | + `sbomFileName` are set to the import's timestamp, format, and source file |
| 219 | + name |
| 220 | + |
| 221 | +#### Scenario: Existing versions are unaffected by the schema addition |
| 222 | + |
| 223 | +- **WHEN** the updated register definition is imported over existing data |
| 224 | +- **THEN** existing `moduleVersie` objects without the new fields load and |
| 225 | + save unchanged |
| 226 | + |
0 commit comments