Skip to content

Commit 382d4c0

Browse files
authored
Merge pull request #385 from ConductionNL/wip/sbom-import
feat: sbom-import — CycloneDX/SPDX SBOM import with vulnerability matching
2 parents bb3a774 + db834e4 commit 382d4c0

37 files changed

Lines changed: 4412 additions & 31 deletions

appinfo/routes.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@
7878
// Configuration cache management
7979
['name' => 'settings#clearCache', 'url' => '/api/settings/clear-cache', 'verb' => 'POST'],
8080

81+
// SBOM (Software Bill of Materials) import routes — CycloneDX/SPDX
82+
// upload scoped to a single moduleVersie, and its status.
83+
// @spec openspec/specs/sbom-import/spec.md
84+
['name' => 'sbom#importSbom', 'url' => '/api/moduleversies/{moduleVersieUuid}/sbom', 'verb' => 'POST'],
85+
['name' => 'sbom#getSbomImportStatus', 'url' => '/api/moduleversies/{moduleVersieUuid}/sbom', 'verb' => 'GET'],
86+
8187
// ArchiMate import/export routes
8288
['name' => 'settings#importArchiMate', 'url' => '/api/archimate/import', 'verb' => 'POST'],
8389
['name' => 'settings#exportArchiMate', 'url' => '/api/archimate/export', 'verb' => 'POST'],

docs/features/sbom-import.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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.

l10n/en_US.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,27 @@ OC.L10N.register(
323323
"Unclassified" : "Unclassified",
324324
"An error occurred while changing the status" : "An error occurred while changing the status",
325325
"Approval" : "Approval",
326+
"Loading components" : "Loading components",
327+
"Components" : "Components",
328+
"Distinct licenses" : "Distinct licenses",
329+
"Matched vulnerabilities" : "Matched vulnerabilities",
330+
"SBOM format" : "SBOM format",
331+
"Choose an SBOM JSON file" : "Choose an SBOM JSON file",
332+
"Import SBOM" : "Import SBOM",
333+
"No components imported yet" : "No components imported yet",
334+
"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.",
335+
"Confirmed match" : "Confirmed match",
336+
"Possible match" : "Possible match",
337+
"Name" : "Name",
338+
"Version" : "Version",
339+
"Package URL" : "Package URL",
340+
"Licenses" : "Licenses",
341+
"Vulnerability match" : "Vulnerability match",
342+
"CycloneDX (JSON)" : "CycloneDX (JSON)",
343+
"SPDX (JSON)" : "SPDX (JSON)",
344+
"SBOM import failed" : "SBOM import failed",
345+
"Imported {count} components." : "Imported {count} components.",
346+
"Last imported {date} from {file} ({format})" : "Last imported {date} from {file} ({format})"
326347
},
327348
"nplurals=2; plural=(n != 1);"
328349
);

l10n/en_US.json

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,27 @@
364364
"Tolerate": "Tolerate",
365365
"Unclassified": "Unclassified",
366366
"An error occurred while changing the status": "An error occurred while changing the status",
367-
"Approval": "Approval"
367+
"Approval": "Approval",
368+
"Loading components": "Loading components",
369+
"Components": "Components",
370+
"Distinct licenses": "Distinct licenses",
371+
"Matched vulnerabilities": "Matched vulnerabilities",
372+
"SBOM format": "SBOM format",
373+
"Choose an SBOM JSON file": "Choose an SBOM JSON file",
374+
"Import SBOM": "Import SBOM",
375+
"No components imported yet": "No components imported yet",
376+
"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.",
377+
"Confirmed match": "Confirmed match",
378+
"Possible match": "Possible match",
379+
"Name": "Name",
380+
"Version": "Version",
381+
"Package URL": "Package URL",
382+
"Licenses": "Licenses",
383+
"Vulnerability match": "Vulnerability match",
384+
"CycloneDX (JSON)": "CycloneDX (JSON)",
385+
"SPDX (JSON)": "SPDX (JSON)",
386+
"SBOM import failed": "SBOM import failed",
387+
"Imported {count} components.": "Imported {count} components.",
388+
"Last imported {date} from {file} ({format})": "Last imported {date} from {file} ({format})"
368389
}
369390
}

l10n/nl.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,27 @@ OC.L10N.register(
360360
"Unclassified" : "Ongeclassificeerd",
361361
"An error occurred while changing the status" : "Er is een fout opgetreden bij het wijzigen van de status",
362362
"Approval" : "Goedkeuring",
363+
"Loading components" : "Componenten laden",
364+
"Components" : "Componenten",
365+
"Distinct licenses" : "Unieke licenties",
366+
"Matched vulnerabilities" : "Overeenkomende kwetsbaarheden",
367+
"SBOM format" : "SBOM-formaat",
368+
"Choose an SBOM JSON file" : "Kies een SBOM JSON-bestand",
369+
"Import SBOM" : "SBOM importeren",
370+
"No components imported yet" : "Nog geen componenten geïmporteerd",
371+
"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.",
372+
"Confirmed match" : "Bevestigde overeenkomst",
373+
"Possible match" : "Mogelijke overeenkomst",
374+
"Name" : "Naam",
375+
"Version" : "Versie",
376+
"Package URL" : "Package-URL",
377+
"Licenses" : "Licenties",
378+
"Vulnerability match" : "Kwetsbaarheid-overeenkomst",
379+
"CycloneDX (JSON)" : "CycloneDX (JSON)",
380+
"SPDX (JSON)" : "SPDX (JSON)",
381+
"SBOM import failed" : "SBOM-import mislukt",
382+
"Imported {count} components." : "{count} componenten geïmporteerd.",
383+
"Last imported {date} from {file} ({format})" : "Laatst geïmporteerd op {date} vanuit {file} ({format})"
363384
},
364385
"nplurals=2; plural=(n != 1);"
365386
);

l10n/nl.json

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,27 @@
507507
"TIME quadrant counts": "TIME-kwadrantaantallen",
508508
"Tolerate": "Tolereren",
509509
"Unclassified": "Ongeclassificeerd",
510-
"Approval": "Goedkeuring"
510+
"Approval": "Goedkeuring",
511+
"Loading components": "Componenten laden",
512+
"Components": "Componenten",
513+
"Distinct licenses": "Unieke licenties",
514+
"Matched vulnerabilities": "Overeenkomende kwetsbaarheden",
515+
"SBOM format": "SBOM-formaat",
516+
"Choose an SBOM JSON file": "Kies een SBOM JSON-bestand",
517+
"Import SBOM": "SBOM importeren",
518+
"No components imported yet": "Nog geen componenten geïmporteerd",
519+
"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.",
520+
"Confirmed match": "Bevestigde overeenkomst",
521+
"Possible match": "Mogelijke overeenkomst",
522+
"Name": "Naam",
523+
"Version": "Versie",
524+
"Package URL": "Package-URL",
525+
"Licenses": "Licenties",
526+
"Vulnerability match": "Kwetsbaarheid-overeenkomst",
527+
"CycloneDX (JSON)": "CycloneDX (JSON)",
528+
"SPDX (JSON)": "SPDX (JSON)",
529+
"SBOM import failed": "SBOM-import mislukt",
530+
"Imported {count} components.": "{count} componenten geïmporteerd.",
531+
"Last imported {date} from {file} ({format})": "Laatst geïmporteerd op {date} vanuit {file} ({format})"
511532
}
512533
}

lib/AppInfo/Application.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
use OCA\SoftwareCatalog\Service\OrganisatieService;
5252
use OCA\SoftwareCatalog\Service\OrganizationSyncService;
5353
use OCA\SoftwareCatalog\Service\ProgressTracker;
54+
use OCA\SoftwareCatalog\Service\SbomImportService;
55+
use OCA\SoftwareCatalog\Service\SbomParserService;
5456
use OCA\SoftwareCatalog\Service\SettingsService;
5557
use OCA\SoftwareCatalog\Service\SoftwareCatalogContactSyncService;
5658
use OCA\SoftwareCatalog\Service\SoftwareCatalogue\ContactPersonHandler;
@@ -434,6 +436,29 @@ function ($container) {
434436
}
435437
);
436438

439+
// Register the pure SBOM parser (no OR/HTTP dependency — ADR-008).
440+
$context->registerService(
441+
SbomParserService::class,
442+
function ($container) {
443+
return new SbomParserService();
444+
}
445+
);
446+
447+
// Register the SBOM import orchestrator (parse → replace previous
448+
// component set → bulk-save new set → record provenance).
449+
$context->registerService(
450+
SbomImportService::class,
451+
function ($container) {
452+
return new SbomImportService(
453+
container: $container,
454+
settingsService: $container->get(SettingsService::class),
455+
parser: $container->get(SbomParserService::class),
456+
progressTracker: $container->get(ProgressTracker::class),
457+
logger: $container->get('Psr\Log\LoggerInterface')
458+
);
459+
}
460+
);
461+
437462
// Register ArchiMate import service.
438463
$context->registerService(
439464
ArchiMateImportService::class,

0 commit comments

Comments
 (0)