feat(release): generate reproducible CycloneDX SBOM
This commit is contained in:
112
tools/release/generate-release-sbom.py
Normal file
112
tools/release/generate-release-sbom.py
Normal 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())
|
||||
235
tools/release/govoplan_release/sbom.py
Normal file
235
tools/release/govoplan_release/sbom.py
Normal 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
|
||||
Reference in New Issue
Block a user