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:
@@ -139,6 +139,32 @@ For the West Jefferson production server specifically, this is a **migration, no
|
||||
an upgrade** - prod runs Python 3.13 against a hand-built deployment, so it needs
|
||||
a deliberate window, a database backup, and web.config reconciled by hand.
|
||||
|
||||
## Bill of materials
|
||||
|
||||
Every build stages a CycloneDX 1.6 SBOM at `sbom.cdx.json`, inside the
|
||||
application tree, so it installs onto the server with the app. Both ecosystems,
|
||||
in one document:
|
||||
|
||||
- **Python** — every pin in `requirements.txt`, with the sha256 the installer
|
||||
enforces. Environment markers are ignored: a `sys_platform == 'win32'`
|
||||
dependency still installs on the target.
|
||||
- **npm** — every package in `frontend/package-lock.json`. Build-only packages
|
||||
are marked `scope: excluded` rather than dropped, so "not here" is
|
||||
distinguishable from "not looked for".
|
||||
|
||||
It ships to the server because an air-gapped site cannot be scanned from
|
||||
anywhere else. When a CVE lands, the answer is already on the box:
|
||||
|
||||
```powershell
|
||||
shopdb-admin.ps1 verify # counts, and which bundle this is
|
||||
shopdb-admin.ps1 verify -Path leaflet # is that component here, at what version
|
||||
```
|
||||
|
||||
Generated by `scripts/generate_sbom.py` from files that are already pinned and
|
||||
committed, so it is a translation rather than a scan — no network, no extra
|
||||
toolchain on the build box, and byte-identical output for the same inputs. It is
|
||||
deliberately not in `bundle-lock.json`: its provenance is git, not the payload.
|
||||
|
||||
## Client IP addresses
|
||||
|
||||
IIS does not set `X-Forwarded-For` on its own, and HttpPlatformHandler connects
|
||||
|
||||
@@ -166,6 +166,15 @@ if (Test-Path $cfgSrc) {
|
||||
Copy-Item $cfgSrc (Join-Path $AppOut 'deploy\windows') -Force
|
||||
}
|
||||
|
||||
# A CycloneDX SBOM of everything this tree depends on, Python and npm together.
|
||||
# Staged INTO the tree so it installs onto the server with the application: an
|
||||
# air-gapped site cannot be scanned remotely, so the only way to answer "are we
|
||||
# exposed to this CVE, and where" is for the answer to be sitting on the box.
|
||||
Step 'Generating SBOM'
|
||||
& $python.Source (Join-Path $RepoRoot 'scripts\generate_sbom.py') $RepoRoot `
|
||||
-o (Join-Path $AppOut 'sbom.cdx.json') | ForEach-Object { Say " $_" 'White' }
|
||||
if ($LASTEXITCODE -ne 0) { Die 'SBOM generation failed' }
|
||||
|
||||
# Stage the profile INTO the tree: `flask plugin apply-profile` at provisioning
|
||||
# reads the same profile the tree was staged from, so the installed plugin set
|
||||
# and the shipped plugin code cannot drift.
|
||||
|
||||
@@ -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