feat(sbom): ship a CycloneDX bill of materials with every build
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user