Files
ITWorx_MCP_Hub-Public/scripts/generate_sbom.py
T

117 lines
3.7 KiB
Python

#!/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())