Ctrl K
ComunidadGuías…

scripts/m2image_sbom.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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())