#!/usr/bin/env python3 """Generate a dependency and image SBOM from locked manifests and Compose.""" from __future__ import annotations import argparse import json import shutil import subprocess import tomllib from pathlib import Path from typing import Any import yaml # type: ignore[import-untyped] ROOT = Path(__file__).resolve().parents[1] def _purl(kind: str, name: str, version: str) -> str: return f"pkg:{kind}/{name}@{version}" def _python_components(repo_root: Path = ROOT) -> list[dict[str, str]]: document = tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8")) values = [*document["project"]["dependencies"], *document["dependency-groups"]["dev"]] components = [] for value in values: name, version = value.split("==", 1) normalized = name.split("[", 1)[0] components.append( { "type": "library", "name": normalized, "version": version, "purl": _purl("pypi", normalized, version), } ) return components def _node_components(repo_root: Path = ROOT) -> list[dict[str, str]]: components: list[dict[str, str]] = [] for path in sorted(repo_root.glob("**/package.json")): if set(path.parts) & {"node_modules", "dist"}: continue document = json.loads(path.read_text(encoding="utf-8")) for section in ("dependencies", "devDependencies"): for name, version in sorted(document.get(section, {}).items()): components.append( { "type": "library", "name": name, "version": version, "purl": _purl("npm", name, version), } ) return components def _image_components(repo_root: Path = ROOT) -> list[dict[str, str]]: compose_path = repo_root / "infrastructure/compose/docker-compose.yml" if not compose_path.is_file(): return [] document = yaml.safe_load(compose_path.read_text(encoding="utf-8")) return [ {"type": "container", "name": name, "version": str(service["image"])} for name, service in sorted(document["services"].items()) if service.get("image") ] def generate(repo_root: Path = ROOT) -> dict[str, Any]: git_executable = shutil.which("git") if git_executable is None: raise RuntimeError("git is required to generate the SBOM") commit = subprocess.run( # noqa: S603 [git_executable, "rev-parse", "HEAD"], cwd=repo_root, check=True, capture_output=True, text=True, ).stdout.strip() return { "bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1, "metadata": { "component": {"type": "application", "name": "itworx-mcp-hub", "version": "0.1.0"}, "sourceCommit": commit, }, "components": [ *_python_components(repo_root), *_node_components(repo_root), *_image_components(repo_root), ], "scanScope": { "lockfiles": ["uv.lock", "pnpm-lock.yaml"], "images": "declared Compose images; local release digests require built artifacts", }, } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text( json.dumps(generate(), indent=2, sort_keys=True) + "\n", encoding="utf-8" ) print(f"wrote SBOM: {args.output}") return 0 if __name__ == "__main__": raise SystemExit(main())