Skip to content

feat: sbom-import — CycloneDX/SPDX SBOM import with vulnerability matching - #385

Merged
rubenvdlinde merged 6 commits into
developmentfrom
wip/sbom-import
Jul 23, 2026
Merged

feat: sbom-import — CycloneDX/SPDX SBOM import with vulnerability matching#385
rubenvdlinde merged 6 commits into
developmentfrom
wip/sbom-import

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Implements the strict-validated sbom-import change (2026-07-23 market-gap wave). The dependency-management research domain had zero implementation; reference architecture is OWASP Dependency-Track (CycloneDX = ECMA-424, SPDX = ISO 5962).

Pure CycloneDX 1.5/1.6 + SPDX 2.3 parser (unit-testable without OpenRegister), import service with soft-delete-aware idempotent replace and bounded batches + progress tracking, IDOR-guarded upload endpoint with size bounding, sbomComponent schema + moduleVersie provenance, Components tab with render-time vulnerability matching against existing kwetsbaarheden (confirmed/possible badges). No outbound HTTP.

Two real defects found while finishing: sbomComponent was never added to the voorzieningen register's schemas list or magic-mapping (the repair step would never have created it), and it was missing from SettingsService's schema-id allowlist (admin config silently dropped). Both fixed.

Tests: 390 PHP / 0 fail (30 SBOM-specific against real fixtures), 192/192 vitest, manifest Ajv PASS. openspec validate --specs --strict 48/48. Archived.

Follow-up: the new Playwright spec lists correctly but needs a live-instance run to confirm the sidebar-tab selector.

🤖 Generated with Claude Code

…venance

Adds the sbomComponent schema (purl, name, version, licenses[], hashes[],
type, bomRef, vexCveIds, moduleVersie relation) to the voorzieningen
register — wired into both the register's schemas list and its
configuration.schemas magic-mapping map, not just the schema definition
(the register-config gotcha that silently drops a schema from repair-step
import otherwise). moduleVersie gains three optional provenance fields
(sbomLastImportedAt, sbomFormat, sbomFileName) and an inverse sbomComponents
relation. Fully additive — existing objects remain valid unset.

@SPEC openspec/specs/sbom-import/spec.md
…roller

SbomParserService: a pure CycloneDX 1.5/1.6 (+ optional SPDX 2.3) JSON
parser with no OpenRegister/HTTP dependency — unit-testable against fixture
files alone. Rejects unsupported bomFormat/specVersion via
UnsupportedSbomFormatException, no partial component list. Also extracts
VEX vulnerabilities[] cveId/bom-ref pairs.

SbomImportService: parse → soft-delete-aware replace of the previous
sbomComponent set → bounded-batch (100/batch) bulk-save of the new set →
progress-tracking for imports over 50 components → provenance recorded on
moduleVersie (PUT-semantic save, existing fields carried forward). A failed
create leaves the version with no component set rather than a mixed
old/new one.

SbomController: importSbom/getSbomImportStatus routes. Upload is bounded
(10MB default) and validated as JSON before the parser ever runs. Auth:
admin group OR manage-tier group + manage-ACL (RBAC read) on the target
version's parent module — explicit body guard, not just the route
annotation (no-admin-idor).

Also: SettingsService gains the sbomComponent schema-id key-map entries in
all three places moduleVersie already had them (including the
normalizeVoorzieningenConfig() allowlist, which was missing sbomComponent
even though the other two maps had it — closes a gap where an admin-set
schema id would be silently dropped). ObjectService test stub gains
saveObjects()/deleteObjects() signatures matching the real OpenRegister API.
phpstan-baseline.neon gets one new entry for a structural false-positive
(a slim try-block whose only call is to a well-typed local method, so
static analysis can't see the OpenRegister-side exceptions the outer
catch (\Exception) is deliberately guarding against).

30 PHPUnit tests (8 controller + 12 import service + 10 parser), all
passing. PHPCS/PHPMD/PHPStan/Psalm clean on all four new files.

@SPEC openspec/specs/sbom-import/spec.md
…ty match

SbomComponentsPanel: ModuleversieDetail sidebar tab ("Components", registered
in customComponents.js and manifest.json) showing the imported sbomComponent
list (name/version/purl/licenses via CnDataTable) with summary counts (total
components, distinct licenses, matched vulnerabilities), an upload control
(format select + file input + Import button posting to SbomController), the
"last imported" provenance line, and an empty state when nothing has been
imported yet.

sbomVulnerabilityMatch.js: the read-time join against kwetsbaarheid —
confirmed (CVE-id) matches via a component's VEX-extracted vexCveIds, and
possible (name/purl) matches scoped to kwetsbaarheid records whose modules
already reference the version's parent module (never a catalogue-wide
scan). Nothing is persisted; editing a kwetsbaarheid changes the match set
on next render with no re-import. Structurally verified to make zero
HTTP/fetch/axios calls.

19 vitest tests, all passing (177/177 across the full suite). Manifest
passes `npm run check:manifest` (ajv schema validation).

@SPEC openspec/specs/sbom-import/spec.md
Every new user-facing string from the Components tab, upload control, and
confirmed/possible match badges, added to both l10n/nl.{js,json} and
l10n/en_US.{js,json} (English source keys per this app's i18n convention).

@SPEC openspec/specs/sbom-import/spec.md
docs/features/sbom-import.md describes the upload flow, replace-on-reimport
semantics, what gets stored, and the confirmed/possible vulnerability-match
model.

tests/e2e/sbom-import.spec.ts covers the two UI-only scenarios (empty state;
Components tab reflects an import + a second import replaces the first) by
driving the real file input, upload button, and rendered table via
data-testid selectors; every other scenario is tagged @e2e with a pointer to
its existing PHPUnit/vitest coverage. NOTE: this spec was written and
type-checks / lists correctly under `playwright test --list`, but was not
executed against a live Nextcloud instance in this session (no docker
environment was started for this resume) — screenshots for the docs page are
likewise deferred to a live-capture pass.

@SPEC openspec/specs/sbom-import/spec.md
openspec archive sbom-import: change moved to
openspec/changes/archive/2026-07-23-sbom-import, canonical spec created at
openspec/specs/sbom-import/spec.md (8 requirements). `openspec validate
--specs --strict` passes for all 48 specs.
@rubenvdlinde
rubenvdlinde merged commit 382d4c0 into development Jul 23, 2026
@rubenvdlinde
rubenvdlinde deleted the wip/sbom-import branch July 23, 2026 21:58
rubenvdlinde added a commit that referenced this pull request Jul 25, 2026
The SBOM provenance properties (sbomLastImportedAt, sbomFormat,
sbomFileName, sbomComponents) were defined on the *organisatie* schema
instead of *moduleVersie*. SbomImportService::recordProvenance() writes
them onto the moduleVersie object and getStatus() reads them back from
it, but because the moduleVersie magic table had no such columns the
writes were silently dropped by the magic mapper and the import-status
endpoint always reported 'never imported' even after a successful
import. Moved all four properties to moduleVersie (their
descriptions and inversedBy already referenced 'deze versie' /
moduleVersie) and bumped the register version to 2.4.2 so the columns
are provisioned on upgrade.

Live-verified on 8080: import 200 -> component persisted in magic table
11_5037, provenance columns populated on 11_52, status endpoint returns
sbomLastImportedAt/Format/FileName, replace-previous soft-deletes the
prior set, and CycloneDX VEX + license extraction populate vex_cve_ids
and licenses.
rubenvdlinde added a commit that referenced this pull request Jul 25, 2026
…guard (#385)

The sbom-import Playwright spec listed but had never run against a live
instance (tracked in #386). Executing it surfaced four issues that all had
to be fixed before the spec could exercise the real import flow:

- API fixtures returned 401: Nextcloud answers an unauthenticated API
  request with 401 but no WWW-Authenticate challenge, so Playwright's
  reactive httpCredentials never sent the Authorization header. Send basic
  auth pre-emptively (httpCredentials send: 'always').
- The Components tab was reached via a bare /apps/softwarecatalog/... path,
  which the hash-router treats as an empty hash and falls back to the
  Dashboard — the tab never mounts. Deep-link with the '#' route.
- The first-run onboarding walkthrough overlay intercepted the tab click;
  dismiss it after navigation.
- The Components / History tabs live inside a right-hand sidebar that mounts
  collapsed, so the tab was present but not visible; open the sidebar first.
- upload-and-replace runs two full import round-trips back-to-back; on a
  loaded shared instance each approaches the default slice, so mark it
  test.slow() and give the success assertions a 40s budget.

Also adds SbomRegisterShapeTest — a pure register-file guard asserting the
SBOM provenance properties live on moduleVersie (not organisatie, the
misplacement fixed in the prior commit), are optional, and that
sbomComponent is a member of the voorzieningen register. 4 tests / 28
assertions.

Live-verified on 8080: sbom-import 2/2 and compliance-matrix 2/2 green;
SbomRegisterShapeTest 4/4 green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant