feat(release): generate reproducible CycloneDX SBOM

This commit is contained in:
2026-07-21 12:24:49 +02:00
parent 397c87a012
commit c34892f6ea
3 changed files with 473 additions and 0 deletions

126
tests/test_release_sbom.py Normal file
View File

@@ -0,0 +1,126 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime
import hashlib
from pathlib import Path
import sys
import unittest
META_ROOT = Path(__file__).resolve().parents[1]
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
if str(RELEASE_TOOLS_ROOT) not in sys.path:
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
from govoplan_release.sbom import build_release_sbom, render_sbom, sbom_sha256, validate_sbom # noqa: E402
class ReleaseSbomTests(unittest.TestCase):
@staticmethod
def _build_at(timestamp: datetime) -> dict:
return build_release_sbom(
product_version="1.2.3",
pip_inspect={
"version": "1",
"installed": [{"metadata": {"name": "FastAPI", "version": "0.100.0"}}],
},
npm_lock={"packages": {"": {"name": "govoplan", "version": "1.2.3"}}},
timestamp=timestamp,
)
def test_builds_valid_deduplicated_python_and_npm_components(self) -> None:
integrity = base64.b64encode(b"example digest").decode("ascii")
payload = build_release_sbom(
product_version="1.2.3",
pip_inspect={
"version": "1",
"installed": [
{"metadata": {"name": "FastAPI", "version": "0.100.0"}},
{"metadata": {"name": "FastAPI", "version": "0.100.0"}},
],
},
npm_lock={
"packages": {
"": {"name": "govoplan", "version": "1.2.3"},
"node_modules/@scope/example": {
"name": "@scope/example",
"version": "4.5.6",
"integrity": f"sha512-{integrity}",
},
}
},
)
validate_sbom(payload)
self.assertEqual(2, len(payload["components"]))
npm = next(item for item in payload["components"] if item["name"] == "@scope/example")
self.assertEqual("SHA-512", npm["hashes"][0]["alg"])
self.assertIn("pkg:npm/%40scope/example@4.5.6", npm["purl"])
self.assertEqual(
hashlib.sha256(render_sbom(payload).encode("utf-8")).hexdigest(),
sbom_sha256(payload),
)
def test_rejects_empty_component_inventory(self) -> None:
payload = build_release_sbom(
product_version="1.2.3",
pip_inspect={"version": "1", "installed": []},
npm_lock={"packages": {"": {"version": "1.2.3"}}},
)
with self.assertRaisesRegex(ValueError, "no dependency components"):
validate_sbom(payload)
def test_fixed_timestamp_produces_identical_bytes_and_digest(self) -> None:
timestamp = datetime(2026, 7, 21, 8, 30, tzinfo=UTC)
first = self._build_at(timestamp)
second = self._build_at(timestamp)
self.assertEqual(render_sbom(first), render_sbom(second))
self.assertEqual(sbom_sha256(first), sbom_sha256(second))
def test_timestamp_changes_serial_and_digest(self) -> None:
first = self._build_at(datetime(2026, 7, 21, 8, 30, tzinfo=UTC))
second = self._build_at(datetime(2026, 7, 21, 8, 31, tzinfo=UTC))
self.assertNotEqual(first["serialNumber"], second["serialNumber"])
self.assertNotEqual(sbom_sha256(first), sbom_sha256(second))
def test_local_and_credential_bearing_distribution_urls_are_omitted(self) -> None:
payload = build_release_sbom(
product_version="1.2.3",
pip_inspect={
"version": "1",
"installed": [
{
"metadata": {"name": "LocalPackage", "version": "1.0.0"},
"direct_url": {"url": "file:///private/build/govoplan-core"},
},
{
"metadata": {"name": "PrivatePackage", "version": "1.0.0"},
"direct_url": {"url": "https://user:secret@example.test/private.whl"},
},
{
"metadata": {"name": "PublicPackage", "version": "1.0.0"},
"direct_url": {"url": "https://example.test/public.whl"},
},
],
},
npm_lock={"packages": {"": {"name": "govoplan", "version": "1.2.3"}}},
timestamp=datetime(2026, 7, 21, 8, 30, tzinfo=UTC),
)
by_name = {component["name"]: component for component in payload["components"]}
self.assertNotIn("externalReferences", by_name["LocalPackage"])
self.assertNotIn("externalReferences", by_name["PrivatePackage"])
self.assertEqual(
[{"type": "distribution", "url": "https://example.test/public.whl"}],
by_name["PublicPackage"]["externalReferences"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Generate a CycloneDX SBOM from the resolved GovOPlaN release environment."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
import tomllib
META_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
from govoplan_release.sbom import ( # noqa: E402
build_release_sbom,
parse_sbom_timestamp,
render_sbom,
sbom_sha256,
timestamp_from_source_date_epoch,
validate_sbom,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--python", default=sys.executable, help="Python executable for pip inspect.")
parser.add_argument("--core-root", type=Path, default=META_ROOT.parent / "govoplan-core")
parser.add_argument(
"--output",
type=Path,
default=META_ROOT / "runtime" / "release-artifacts" / "govoplan-sbom.cdx.json",
)
parser.add_argument("--pip-inspect", type=Path, help="Use an existing pip inspect report instead of executing pip.")
parser.add_argument(
"--timestamp",
help="ISO-8601 build timestamp. Defaults to SOURCE_DATE_EPOCH, then the current time.",
)
args = parser.parse_args()
core_root = args.core_root.expanduser().resolve()
product_version = _project_version(core_root / "pyproject.toml")
npm_lock_path = core_root / "webui" / "package-lock.release.json"
if not npm_lock_path.exists():
parser.error(f"release npm lockfile does not exist: {npm_lock_path}")
pip_inspect = _read_json(args.pip_inspect) if args.pip_inspect else _run_pip_inspect(args.python)
npm_lock = _read_json(npm_lock_path)
try:
timestamp = _resolved_timestamp(args.timestamp)
payload = build_release_sbom(
product_version=product_version,
pip_inspect=pip_inspect,
npm_lock=npm_lock,
timestamp=timestamp,
)
except ValueError as exc:
parser.error(str(exc))
validate_sbom(payload)
output = args.output.expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(render_sbom(payload), encoding="utf-8")
print(f"Wrote {len(payload['components'])} components to {output}")
print(f"SHA-256: {sbom_sha256(payload)}")
return 0
def _run_pip_inspect(python: str) -> dict:
result = subprocess.run(
[python, "-m", "pip", "inspect", "--local"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=120,
)
if result.returncode != 0:
raise SystemExit(f"pip inspect failed: {result.stderr.strip()}")
return json.loads(result.stdout)
def _read_json(path: Path) -> dict:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"expected a JSON object in {path}")
return payload
def _project_version(path: Path) -> str:
with path.open("rb") as handle:
payload = tomllib.load(handle)
project = payload.get("project")
if not isinstance(project, dict) or not isinstance(project.get("version"), str):
raise ValueError(f"project.version is missing from {path}")
return project["version"]
def _resolved_timestamp(explicit: str | None):
if explicit is not None:
return parse_sbom_timestamp(explicit)
source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH")
if source_date_epoch is not None:
return timestamp_from_source_date_epoch(source_date_epoch)
return None
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,235 @@
"""CycloneDX SBOM construction from resolved Python and npm environments."""
from __future__ import annotations
import base64
from datetime import UTC, datetime
import hashlib
import json
from pathlib import Path
from typing import Any
from urllib.parse import quote, urlsplit
import uuid
def build_release_sbom(
*,
product_version: str,
pip_inspect: dict[str, Any],
npm_lock: dict[str, Any],
timestamp: datetime | None = None,
) -> dict[str, Any]:
components = _deduplicate_components(
[
*python_components(pip_inspect),
*npm_components(npm_lock),
]
)
root_ref = f"pkg:generic/govoplan@{quote(product_version, safe='') }"
resolved_timestamp = _normalized_timestamp(timestamp)
timestamp_text = resolved_timestamp.isoformat().replace("+00:00", "Z")
serial_seed = json.dumps(
{
"product_version": product_version,
"timestamp": timestamp_text,
"components": components,
},
sort_keys=True,
separators=(",", ":"),
)
serial = uuid.uuid5(uuid.NAMESPACE_URL, f"https://govoplan.add-ideas.de/sbom/{serial_seed}")
return {
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": f"urn:uuid:{serial}",
"version": 1,
"metadata": {
"timestamp": timestamp_text,
"lifecycles": [{"phase": "build"}],
"component": {
"type": "application",
"bom-ref": root_ref,
"group": "ADD ideas UG",
"name": "GovOPlaN",
"version": product_version,
"purl": root_ref,
},
},
"components": components,
"dependencies": [
{
"ref": root_ref,
"dependsOn": [component["bom-ref"] for component in components],
}
],
}
def python_components(report: dict[str, Any]) -> list[dict[str, Any]]:
if str(report.get("version")) != "1":
raise ValueError("unsupported pip inspect report version")
installed = report.get("installed")
if not isinstance(installed, list):
raise ValueError("pip inspect report is missing installed distributions")
components: list[dict[str, Any]] = []
for item in installed:
if not isinstance(item, dict):
continue
metadata = item.get("metadata")
if not isinstance(metadata, dict):
continue
name = metadata.get("name")
version = metadata.get("version")
if not isinstance(name, str) or not isinstance(version, str):
continue
normalized = name.lower().replace("_", "-")
purl = f"pkg:pypi/{quote(normalized, safe='')}@{quote(version, safe='')}"
component: dict[str, Any] = {
"type": "library",
"bom-ref": purl,
"name": name,
"version": version,
"purl": purl,
"scope": "required",
}
direct_url = item.get("direct_url")
if isinstance(direct_url, dict) and isinstance(direct_url.get("url"), str):
distribution_url = _public_distribution_url(direct_url["url"])
if distribution_url is not None:
component["externalReferences"] = [{"type": "distribution", "url": distribution_url}]
components.append(component)
return components
def npm_components(lock: dict[str, Any]) -> list[dict[str, Any]]:
packages = lock.get("packages")
if not isinstance(packages, dict):
raise ValueError("npm lockfile is missing the packages map")
components: list[dict[str, Any]] = []
for path, item in packages.items():
if not path or not isinstance(item, dict):
continue
name = item.get("name") or _npm_name_from_lock_path(str(path))
version = item.get("version")
if not isinstance(name, str) or not isinstance(version, str):
continue
purl = f"pkg:npm/{quote(name, safe='/')}@{quote(version, safe='')}"
component: dict[str, Any] = {
"type": "library",
"bom-ref": purl,
"name": name,
"version": version,
"purl": purl,
"scope": "optional" if item.get("dev") is True else "required",
}
hashes = _sri_hashes(item.get("integrity"))
if hashes:
component["hashes"] = hashes
resolved = item.get("resolved")
if isinstance(resolved, str):
distribution_url = _public_distribution_url(resolved)
if distribution_url is not None:
component["externalReferences"] = [{"type": "distribution", "url": distribution_url}]
components.append(component)
return components
def validate_sbom(payload: dict[str, Any]) -> None:
if payload.get("bomFormat") != "CycloneDX" or payload.get("specVersion") != "1.6":
raise ValueError("not a supported CycloneDX 1.6 SBOM")
metadata = payload.get("metadata")
if not isinstance(metadata, dict) or not isinstance(metadata.get("component"), dict):
raise ValueError("SBOM metadata component is missing")
components = payload.get("components")
if not isinstance(components, list) or not components:
raise ValueError("SBOM has no dependency components")
refs = [item.get("bom-ref") for item in components if isinstance(item, dict)]
if any(not isinstance(ref, str) or not ref for ref in refs):
raise ValueError("SBOM component is missing bom-ref")
if len(refs) != len(set(refs)):
raise ValueError("SBOM contains duplicate component references")
def sbom_sha256(payload: dict[str, Any]) -> str:
return hashlib.sha256(render_sbom(payload).encode("utf-8")).hexdigest()
def render_sbom(payload: dict[str, Any]) -> str:
return json.dumps(payload, indent=2, sort_keys=True) + "\n"
def timestamp_from_source_date_epoch(value: str) -> datetime:
"""Resolve the reproducible-build timestamp convention to UTC."""
try:
epoch = int(value)
except ValueError as exc:
raise ValueError("SOURCE_DATE_EPOCH must be an integer Unix timestamp") from exc
try:
return datetime.fromtimestamp(epoch, tz=UTC)
except (OverflowError, OSError) as exc:
raise ValueError("SOURCE_DATE_EPOCH is outside the supported timestamp range") from exc
def parse_sbom_timestamp(value: str) -> datetime:
"""Parse an explicit ISO-8601 SBOM timestamp and normalize it to UTC."""
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("timestamp must be an ISO-8601 value with a timezone") from exc
if parsed.tzinfo is None:
raise ValueError("timestamp must include a timezone")
return parsed.astimezone(UTC)
def _normalized_timestamp(value: datetime | None) -> datetime:
resolved = value or datetime.now(tz=UTC)
if resolved.tzinfo is None:
raise ValueError("SBOM timestamp must include a timezone")
return resolved.astimezone(UTC).replace(microsecond=0)
def _deduplicate_components(components: list[dict[str, Any]]) -> list[dict[str, Any]]:
unique = {component["bom-ref"]: component for component in components}
return [unique[key] for key in sorted(unique)]
def _npm_name_from_lock_path(path: str) -> str:
marker = "node_modules/"
if marker not in path:
return Path(path).name
return path.rsplit(marker, 1)[-1]
def _sri_hashes(value: object) -> list[dict[str, str]]:
if not isinstance(value, str) or "-" not in value:
return []
algorithm, encoded = value.split("-", 1)
algorithm_name = {"sha256": "SHA-256", "sha384": "SHA-384", "sha512": "SHA-512"}.get(algorithm.lower())
if algorithm_name is None:
return []
try:
content = base64.b64decode(encoded, validate=True).hex()
except ValueError:
return []
return [{"alg": algorithm_name, "content": content}]
def _public_distribution_url(value: str) -> str | None:
"""Keep stable public provenance without leaking local paths or credentials."""
try:
parsed = urlsplit(value)
except ValueError:
return None
if parsed.scheme.lower() not in {"git", "git+https", "http", "https"}:
return None
if not parsed.hostname or parsed.username is not None or parsed.password is not None:
return None
if parsed.query:
return None
return value