feat(sbom): ship a CycloneDX bill of materials with every build
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

An air-gapped site cannot be scanned from anywhere else, so when a CVE lands the
only way to answer 'is that component here, and at what version' was to RDP in
and go looking. The frontend was the real blind spot: nothing recorded which
version of leaflet, dompurify, jspdf or html2canvas ends up inside the compiled
SPA.

scripts/generate_sbom.py emits CycloneDX 1.6 covering both ecosystems - every pin
in requirements.txt with the sha256 the installer enforces, and every package in
package-lock.json. Build-only npm packages are marked scope 'excluded' rather
than dropped, so 'not here' stays distinguishable from 'not looked for'.
Dependency edges are real: uv's '# via' comments give the Python graph and
package-lock gives the npm one.

Hand-rolled rather than cyclonedx-py plus cyclonedx-npm because both inputs are
already pinned and committed - this is a format translation, not a scan - and
because the build box may be a work PC with nothing but Python and Node. It is
deterministic by construction: same inputs, byte-identical output, so
regenerating does not churn.

Staged into the application tree by both builders, so it installs onto the
server with the app. shopdb-admin.ps1 verify reports it and searches it by
component name, which is the question actually being asked.

Packages appearing at several depths in package-lock (node_modules/vite and
node_modules/vitest/node_modules/vite) are merged, and a copy reachable outside
the dev tree makes the component count as shipped. Emitting both produced
duplicate bom-refs, which CycloneDX forbids and scanners reject; getting the dev
merge backwards would have hidden a shipped package from a CVE search.

Not covered by bundle-lock.json on purpose: its provenance is git, not the
third-party payload.
This commit is contained in:
cproudlock
2026-08-03 13:15:27 -04:00
parent 1bf3cb2e1c
commit 3606d8d696
6 changed files with 579 additions and 0 deletions

View File

@@ -496,6 +496,39 @@ function Invoke-Verify {
Say ' no bundle-lock.json recorded (installed before payload locking, or by hand)' 'Yellow'
}
# The SBOM travels with the application, because a server on a vaulted
# network cannot be scanned from anywhere else. When a CVE lands, this is
# what answers "is that component here, and at what version" without needing
# the build box, the internet, or anyone's memory.
$sbom = Join-Path $AppRoot 'sbom.cdx.json'
if (Test-Path $sbom) {
try {
$b = Get-Content $sbom -Raw | ConvertFrom-Json
$shipped = @($b.components | Where-Object { $_.scope -eq 'required' }).Count
Say (" components : {0} ({1} shipped), CycloneDX {2}" -f `
$b.components.Count, $shipped, $b.specVersion)
Say (" bill of materials: {0}" -f $sbom) 'DarkGray'
Say ' search it with : shopdb-admin.ps1 verify -Path <name>' 'DarkGray'
} catch { Say ' sbom.cdx.json is present but unreadable' 'Yellow' }
# A named component turns this into the actual question being asked.
# Guarded on $b: an unreadable SBOM leaves it unset, and querying it then
# would report 'none', which reads as "you are not affected".
if ($Path -and $b) {
Say ''
Say (" matches for '{0}':" -f $Path) 'Cyan'
$hits = @($b.components | Where-Object { $_.name -like ('*' + $Path + '*') })
if (-not $hits) { Say ' none - this server does not carry it' 'Green' }
foreach ($h in $hits) {
$tag = if ($h.scope -eq 'required') { 'SHIPPED' } else { 'build only' }
Say (" {0,-40} {1,-14} {2}" -f $h.name, $h.version, $tag) `
$(if ($h.scope -eq 'required') { 'Yellow' } else { 'DarkGray' })
}
}
} else {
Say ' no SBOM recorded (installed before SBOMs shipped, or by hand)' 'Yellow'
}
# pip's own audit. It re-reads the metadata of what is actually installed and
# reports anything missing or version-inconsistent, which is the part that
# can still drift after install - a hand-run `pip install` on the server.