From c34892f6ea0297737d5a1e906bd95b35d5f61f0b Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 21 Jul 2026 12:24:49 +0200 Subject: [PATCH] feat(release): generate reproducible CycloneDX SBOM --- tests/test_release_sbom.py | 126 +++++++++++++ tools/release/generate-release-sbom.py | 112 ++++++++++++ tools/release/govoplan_release/sbom.py | 235 +++++++++++++++++++++++++ 3 files changed, 473 insertions(+) create mode 100644 tests/test_release_sbom.py create mode 100644 tools/release/generate-release-sbom.py create mode 100644 tools/release/govoplan_release/sbom.py diff --git a/tests/test_release_sbom.py b/tests/test_release_sbom.py new file mode 100644 index 0000000..cb1b082 --- /dev/null +++ b/tests/test_release_sbom.py @@ -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() diff --git a/tools/release/generate-release-sbom.py b/tools/release/generate-release-sbom.py new file mode 100644 index 0000000..bdb7e55 --- /dev/null +++ b/tools/release/generate-release-sbom.py @@ -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()) diff --git a/tools/release/govoplan_release/sbom.py b/tools/release/govoplan_release/sbom.py new file mode 100644 index 0000000..1a3e295 --- /dev/null +++ b/tools/release/govoplan_release/sbom.py @@ -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