scripts/m2image_sbom.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Generate a deterministic SPDX 2.3 M2Image SBOM from a built guest package database.
The builders can feed this script the package-manager state that exists in the
staged root filesystem immediately before it is converted to ext4. Keeping the
parser separate from the privileged builders makes the release artifact easy to
test and gives CI one normalized contract for Alpine, Ubuntu, and Rocky.
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import sys
from pathlib import Path
from urllib.parse import quote
RPM_KEY_METADATA_DISPOSITION = "rpm-key-metadata"
def parse_alpine(text: str) -> list[dict[str, str]]:
packages: list[dict[str, str]] = []
for paragraph in re.split(r"\n\s*\n", text.strip()):
fields: dict[str, str] = {}
for line in paragraph.splitlines():
if len(line) >= 2 and line[1] == ":":
fields[line[0]] = line[2:]
if fields.get("P") and fields.get("V"):
packages.append(
{
"name": fields["P"],
"version": fields["V"],
"arch": fields.get("A", "unknown"),
"license": fields.get("L", ""),
"source": fields.get("o") or fields["P"],
"source_version": fields["V"],
"source_commit": fields.get("c", ""),
}
)
return packages
def parse_dpkg(text: str) -> list[dict[str, str]]:
packages: list[dict[str, str]] = []
for paragraph in re.split(r"\n\s*\n", text.strip()):
fields: dict[str, str] = {}
key: str | None = None
for line in paragraph.splitlines():
if line.startswith((" ", "\t")):
if key:
fields[key] = f"{fields[key]} {line.strip()}".strip()
continue
if ":" not in line:
continue
key, value = line.split(":", 1)
fields[key] = value.strip()
if fields.get("Status") != "install ok installed":
continue
if not fields.get("Package") or not fields.get("Version"):
continue
source_field = fields.get("Source", "")
source = fields["Package"]
source_version = fields["Version"]
if source_field:
match = re.fullmatch(r"([^\s(]+)(?:\s+\(([^)]+)\))?", source_field)
if not match:
raise ValueError(f"invalid dpkg Source field: {source_field!r}")
source = match.group(1)
source_version = match.group(2) or source_version
packages.append(
{
"name": fields["Package"],
"version": fields["Version"],
"arch": fields.get("Architecture", "unknown"),
"license": "",
"source": source,
"source_version": source_version,
"source_commit": "",
}
)
return packages
def parse_rpm_tsv(text: str) -> list[dict[str, str]]:
packages: list[dict[str, str]] = []
for lineno, line in enumerate(text.splitlines(), start=1):
if not line.strip():
continue
parts = line.split("\t")
if len(parts) != 5:
raise ValueError(f"rpm TSV line {lineno}: expected 5 fields, got {len(parts)}")
name, version, arch, license_text, source = parts
if not name or not version:
raise ValueError(f"rpm TSV line {lineno}: package name/version may not be empty")
if source == "(none)" or not source:
if name != "gpg-pubkey":
raise ValueError(
f"rpm TSV line {lineno}: {name}@{version} has no SOURCERPM"
)
packages.append(
{
"name": name,
"version": version,
"arch": arch or "unknown",
"license": license_text,
"source": "",
"source_version": "",
"source_commit": "",
"source_disposition": RPM_KEY_METADATA_DISPOSITION,
}
)
continue
packages.append(
{
"name": name,
"version": version,
"arch": arch or "unknown",
"license": license_text,
"source": source,
"source_version": version,
"source_commit": "",
}
)
return packages
PARSERS = {
"alpine": parse_alpine,
"dpkg": parse_dpkg,
"rpm-tsv": parse_rpm_tsv,
}
def stable_package_id(pkg: dict[str, str]) -> str:
seed = "\0".join((pkg["name"], pkg["version"], pkg["arch"]))
digest = hashlib.sha256(seed.encode()).hexdigest()[:16]
clean = re.sub(r"[^A-Za-z0-9.-]+", "-", pkg["name"]).strip("-") or "package"
return f"SPDXRef-Package-{clean}-{digest}"
def purl_for(distribution: str, pkg: dict[str, str]) -> str:
distro = distribution.lower()
if distro == "alpine":
kind, namespace = "apk", "alpine"
elif distro == "ubuntu":
kind, namespace = "deb", "ubuntu"
elif distro == "rocky":
kind, namespace = "rpm", "rocky-linux"
else:
kind, namespace = "generic", distro
name = quote(pkg["name"], safe="._+-")
version = quote(pkg["version"], safe="._+:-~")
arch = quote(pkg["arch"], safe="._+-")
return f"pkg:{kind}/{namespace}/{name}@{version}?arch={arch}"
def created_timestamp() -> str:
epoch = os.environ.get("SOURCE_DATE_EPOCH")
if epoch is not None:
when = dt.datetime.fromtimestamp(int(epoch), tz=dt.timezone.utc)
else:
when = dt.datetime.now(dt.timezone.utc)
return when.replace(microsecond=0).isoformat().replace("+00:00", "Z")
def kernel_packages(distribution: str, packages: list[dict[str, str]]) -> list[dict[str, str]]:
distro = distribution.lower()
result = []
for pkg in packages:
name = pkg["name"]
if distro == "alpine" and name == "linux-virt":
result.append(pkg)
elif distro == "ubuntu" and name.startswith("linux-image-"):
result.append(pkg)
elif distro == "rocky" and name in {"kernel", "kernel-core", "kernel-modules", "kernel-modules-core"}:
result.append(pkg)
return result
def make_spdx(
*,
distribution: str,
image_alias: str,
image_version: str,
architecture: str,
packages: list[dict[str, str]],
) -> dict:
normalized = sorted(packages, key=lambda p: (p["name"], p["version"], p["arch"]))
if not normalized:
raise ValueError("package database contained no installed packages")
if len({(p["name"], p["version"], p["arch"]) for p in normalized}) != len(normalized):
raise ValueError("package database contains duplicate package records")
fingerprint = hashlib.sha256(
json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
image_id = "SPDXRef-M2Image"
doc: dict = {
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": f"FireCrab M2Image {image_alias} {architecture}",
"documentNamespace": (
"https://firecrab.dev/spdx/m2image/"
f"{quote(image_alias, safe='._+-')}/{quote(architecture, safe='._+-')}/{fingerprint}"
),
"creationInfo": {
"created": created_timestamp(),
"creators": ["Tool: firecrab scripts/m2image_sbom.py"],
},
"packages": [
{
"name": image_alias,
"SPDXID": image_id,
"versionInfo": image_version,
"downloadLocation": "NOASSERTION",
"filesAnalyzed": False,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"comment": f"distribution={distribution}; architecture={architecture}",
}
],
"relationships": [
{
"spdxElementId": "SPDXRef-DOCUMENT",
"relationshipType": "DESCRIBES",
"relatedSpdxElement": image_id,
}
],
"annotations": [],
}
for pkg in normalized:
spdx_id = stable_package_id(pkg)
entry = {
"name": pkg["name"],
"SPDXID": spdx_id,
"versionInfo": pkg["version"],
"downloadLocation": "NOASSERTION",
"filesAnalyzed": False,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": purl_for(distribution, pkg),
}
],
}
comments = []
if pkg.get("license"):
comments.append(f"package-manager-license={pkg['license']}")
if pkg.get("source"):
comments.append(f"source-package={pkg['source']}")
if pkg.get("source_version"):
comments.append(f"source-version={pkg['source_version']}")
if pkg.get("source_commit"):
comments.append(f"source-commit={pkg['source_commit']}")
if pkg.get("source_disposition"):
comments.append(f"source-disposition={pkg['source_disposition']}")
if comments:
entry["comment"] = "; ".join(comments)
doc["packages"].append(entry)
doc["relationships"].append(
{
"spdxElementId": image_id,
"relationshipType": "CONTAINS",
"relatedSpdxElement": spdx_id,
}
)
kernels = kernel_packages(distribution, normalized)
if kernels:
doc["annotations"].append(
{
"annotationDate": doc["creationInfo"]["created"],
"annotationType": "OTHER",
"annotator": "Tool: firecrab scripts/m2image_sbom.py",
"comment": "kernel-packages="
+ ",".join(f"{p['name']}@{p['version']}" for p in kernels),
}
)
if not doc["annotations"]:
del doc["annotations"]
return doc
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--format", choices=sorted(PARSERS), required=True)
parser.add_argument("--distribution", required=True)
parser.add_argument("--image-alias", required=True)
parser.add_argument("--image-version", required=True)
parser.add_argument("--architecture", required=True)
parser.add_argument("--package-db", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args(argv)
try:
packages = PARSERS[args.format](args.package_db.read_text(encoding="utf-8"))
document = make_spdx(
distribution=args.distribution,
image_alias=args.image_alias,
image_version=args.image_version,
architecture=args.architecture,
packages=packages,
)
except (OSError, ValueError) as exc:
print(f"m2image SBOM: {exc}", file=sys.stderr)
return 2
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"m2image SBOM: {len(packages)} packages -> {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())