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:
154
tests/test_sbom.py
Normal file
154
tests/test_sbom.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""The generated SBOM: shape, completeness, and the properties it is relied on for.
|
||||
|
||||
The point of shipping an SBOM to an air-gapped site is answering "are we exposed
|
||||
to this CVE, and where" without scanning the box. That only works if the document
|
||||
actually lists everything that ships, at the right versions, with identifiers a
|
||||
scanner recognises. These tests hold it to that.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
GENERATOR = REPO / 'scripts' / 'generate_sbom.py'
|
||||
FIXED_TIMESTAMP = '2026-01-01T00:00:00Z'
|
||||
|
||||
|
||||
def generate(tmp_path, name='sbom.cdx.json'):
|
||||
out = tmp_path / name
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GENERATOR), str(REPO), '-o', str(out),
|
||||
'--timestamp', FIXED_TIMESTAMP],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(out.read_text()), out
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def sbom(tmp_path_factory):
|
||||
document, _ = generate(tmp_path_factory.mktemp('sbom'))
|
||||
return document
|
||||
|
||||
|
||||
def test_is_valid_cyclonedx(sbom):
|
||||
assert sbom['bomFormat'] == 'CycloneDX'
|
||||
assert sbom['specVersion'] == '1.6'
|
||||
assert sbom['serialNumber'].startswith('urn:uuid:')
|
||||
assert sbom['version'] == 1
|
||||
|
||||
|
||||
def test_carries_the_ntia_minimum_elements(sbom):
|
||||
"""Supplier, component name, version, unique identifier, dependency
|
||||
relationship, author, timestamp."""
|
||||
meta = sbom['metadata']
|
||||
assert meta['supplier']['name']
|
||||
assert meta['authors']
|
||||
assert meta['timestamp'] == FIXED_TIMESTAMP
|
||||
assert meta['component']['name'] and meta['component']['version']
|
||||
assert sbom['dependencies']
|
||||
for component in sbom['components']:
|
||||
assert component['name']
|
||||
assert component['version']
|
||||
assert component['purl'], '%s has no unique identifier' % component['name']
|
||||
|
||||
|
||||
def test_every_python_pin_is_present(sbom):
|
||||
"""The SBOM must not be quietly narrower than what the installer installs."""
|
||||
import re
|
||||
pinned = set()
|
||||
for line in (REPO / 'requirements.txt').read_text().splitlines():
|
||||
match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line.strip())
|
||||
if match:
|
||||
pinned.add((match.group(1).lower().replace('_', '-'), match.group(2)))
|
||||
listed = {(c['name'].lower().replace('_', '-'), c['version'])
|
||||
for c in sbom['components'] if c['purl'].startswith('pkg:pypi/')}
|
||||
assert pinned == listed
|
||||
|
||||
|
||||
def test_marked_out_dependencies_are_still_listed(sbom):
|
||||
"""colorama is win32-only. It installs on the target, so it must appear -
|
||||
the same blind spot that left it out of the wheelhouse."""
|
||||
names = {c['name'] for c in sbom['components'] if c['purl'].startswith('pkg:pypi/')}
|
||||
assert 'colorama' in names
|
||||
|
||||
|
||||
def test_frontend_packages_are_covered(sbom):
|
||||
"""The npm tree is the reason this exists: nothing else records what version
|
||||
of leaflet or dompurify ends up inside the compiled SPA."""
|
||||
npm = {c['name']: c for c in sbom['components'] if c['purl'].startswith('pkg:npm/')}
|
||||
assert len(npm) > 100
|
||||
for shipped in ('leaflet', 'dompurify', 'vue'):
|
||||
assert shipped in npm, '%s is missing from the SBOM' % shipped
|
||||
assert npm[shipped]['scope'] == 'required'
|
||||
|
||||
|
||||
def test_build_only_packages_are_marked_not_dropped(sbom):
|
||||
"""Dev packages do not ship. Recorded as 'excluded' rather than omitted, so a
|
||||
reader can tell 'not here' from 'not looked for'."""
|
||||
npm = {c['name']: c for c in sbom['components'] if c['purl'].startswith('pkg:npm/')}
|
||||
assert npm['vite']['scope'] == 'excluded'
|
||||
assert any(c['scope'] == 'required' for c in npm.values())
|
||||
|
||||
|
||||
def test_bom_refs_are_unique(sbom):
|
||||
"""CycloneDX forbids duplicate bom-refs, and scanners reject a document that
|
||||
has them. npm installs the same package at several depths - node_modules/vite
|
||||
and node_modules/vitest/node_modules/vite - which emitted it twice."""
|
||||
refs = [c['bom-ref'] for c in sbom['components']]
|
||||
duplicates = {ref for ref in refs if refs.count(ref) > 1}
|
||||
assert not duplicates, 'duplicate bom-refs: %s' % sorted(duplicates)[:5]
|
||||
|
||||
|
||||
def test_a_package_present_outside_the_dev_tree_counts_as_shipped(tmp_path):
|
||||
"""Merging duplicates must not mark a shipped package build-only, which would
|
||||
hide it from a CVE search on the server."""
|
||||
sys.path.insert(0, str(REPO / 'scripts'))
|
||||
import generate_sbom
|
||||
|
||||
lock = tmp_path / 'package-lock.json'
|
||||
lock.write_text(json.dumps({'lockfileVersion': 3, 'packages': {
|
||||
'': {'name': 'x', 'version': '1.0.0', 'dependencies': {'shared': '^1'}},
|
||||
'node_modules/shared': {'version': '1.0.0'},
|
||||
'node_modules/builder': {'version': '2.0.0', 'dev': True},
|
||||
'node_modules/builder/node_modules/shared': {'version': '1.0.0', 'dev': True},
|
||||
}}))
|
||||
packages, direct = generate_sbom.parse_package_lock(str(lock))
|
||||
shared = [p for p in packages if p['name'] == 'shared']
|
||||
assert len(shared) == 1, 'the two copies of shared were not merged'
|
||||
assert shared[0]['dev'] is False
|
||||
assert direct == ['shared']
|
||||
|
||||
|
||||
def test_components_carry_integrity_hashes(sbom):
|
||||
missing = [c['name'] for c in sbom['components'] if not c.get('hashes')]
|
||||
assert not missing, 'components without a hash: %s' % missing[:5]
|
||||
|
||||
|
||||
def test_dependency_graph_is_real_not_flat(sbom):
|
||||
"""A flat 'root depends on everything' graph cannot answer what was chosen
|
||||
versus what was dragged in."""
|
||||
root = sbom['metadata']['component']['bom-ref']
|
||||
edges = {entry['ref']: entry['dependsOn'] for entry in sbom['dependencies']}
|
||||
assert root in edges
|
||||
assert len(edges) > 1, 'no edges below the root'
|
||||
assert len(edges[root]) < len(sbom['components']), 'root depends on everything'
|
||||
|
||||
refs = {c['bom-ref'] for c in sbom['components']}
|
||||
for ref, children in edges.items():
|
||||
if ref == root:
|
||||
continue
|
||||
assert ref in refs, 'edge from an unknown component: %s' % ref
|
||||
for child in children:
|
||||
assert child in refs, 'edge to an unknown component: %s' % child
|
||||
|
||||
|
||||
def test_output_is_byte_identical_across_runs(tmp_path):
|
||||
"""Regenerating must not churn. A document that differs every build gets
|
||||
re-committed without being read."""
|
||||
first, first_path = generate(tmp_path, 'a.json')
|
||||
_, second_path = generate(tmp_path, 'b.json')
|
||||
assert first_path.read_bytes() == second_path.read_bytes()
|
||||
assert first['serialNumber'] == json.loads(second_path.read_text())['serialNumber']
|
||||
Reference in New Issue
Block a user