Files
govoplan/tools/release/govoplan_release/sbom.py

236 lines
8.4 KiB
Python

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