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.
155 lines
6.4 KiB
Python
155 lines
6.4 KiB
Python
"""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']
|