"""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