diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7272d2c..f46eb1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,8 @@ jobs: timeout-minutes: 8 continue-on-error: true - run: cargo fmt --all -- --check + - name: Verify deterministic release SBOM contract + run: python3 -m unittest tests/test_release_sbom.py - name: Audit dependencies, enforce deny policy and generate SBOM env: MH_SAVE_SYNC_INSTALL_SUPPLY_CHAIN_TOOLS: "1" diff --git a/docs/RELEASE_SBOM.md b/docs/RELEASE_SBOM.md new file mode 100644 index 0000000..f7159b4 --- /dev/null +++ b/docs/RELEASE_SBOM.md @@ -0,0 +1,58 @@ +# Release SBOM contract + +`scripts/generate-sbom.py` produces deterministic CycloneDX 1.5 JSON from a +clean, exact Git commit. Its canonical self-verifier is the release gate; this +repository does not claim validation by an external CycloneDX schema service. + +## Dependency SBOM + +The existing positional entry point remains compatible: + +```sh +python3 scripts/generate-sbom.py artifacts/sbom/mh-save-sync.cdx.json +``` + +The explicit equivalent and verifier are: + +```sh +python3 scripts/generate-sbom.py dependencies artifacts/sbom/mh-save-sync.cdx.json +python3 scripts/generate-sbom.py verify-dependencies --sbom artifacts/sbom/mh-save-sync.cdx.json +``` + +Only crates.io registry packages with a Cargo.lock SHA-256 are listed. Workspace +and path crates are not assigned invented external checksums. + +## Artifact-bound release SBOM + +The identity JSON must bind `source_ref` to the clean checked-out HEAD and +contain exactly one of every local/core kind: + +- `rust-cli`, `rust-server`, `android-apk` +- `macos-app`, `macos-cli` +- `mh3g-converter-cli`, `mh3g-converter-macos` + +The following distribution kinds are allowlisted and optional during local +generation: `macos-save-sync-zip`, `mh3g-converter-macos-zip`, +`mh3g-converter-windows-zip`, `mh3g-converter-windows-portable`, and +`mh3g-converter-windows-setup`. If present, each must be unique, exist, be +non-empty, use the expected role, and is hashed into the BOM. Unknown kinds are +rejected. Windows distributions that cannot be built locally remain +**Unverified**; the final merged-ref release identity must add every actual +locked ZIP/portable/setup artifact before alpha.9 can claim full coverage. + +```sh +python3 scripts/generate-sbom.py release \ + --identity /path/to/release-identity.json \ + --output /path/to/mh-save-sync.release.cdx.json \ + --receipt /path/to/mh-save-sync.sbom-identity.json +python3 scripts/generate-sbom.py verify-release \ + --identity /path/to/release-identity.json \ + --sbom /path/to/mh-save-sync.release.cdx.json \ + --receipt /path/to/mh-save-sync.sbom-identity.json +``` + +The receipt binds `format=cyclonedx-json`, the exact source ref, the SBOM +SHA-256, and the primary Android APK SHA-256. The Cargo.lock aggregate is bound +to its exact file SHA and depends on only registry packages proven by lockfile +checksums. Individual Rust artifacts do not claim an unproven per-binary +dependency closure. diff --git a/scripts/generate-sbom.py b/scripts/generate-sbom.py index a3011ed..87e324a 100755 --- a/scripts/generate-sbom.py +++ b/scripts/generate-sbom.py @@ -1,171 +1,545 @@ #!/usr/bin/env python3 -"""Generate a minimal CycloneDX SBOM from repository lockfiles. - -The output is intentionally generated locally instead of relying on a hosted -scanner so CI can prove that the published artifact has a reproducible bill of -materials without sending source or build products to a third-party service. -""" +"""Generate and verify deterministic dependency or artifact-bound release SBOMs.""" from __future__ import annotations import argparse -import datetime as dt import hashlib import json -import pathlib +import re import subprocess import sys import tomllib -from typing import Any +import uuid +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import quote -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = "MHToolkit/mh-save-sync" +SOURCE_REF = re.compile(r"^[0-9a-f]{40}$") +REQUIRED_KINDS = ( + "rust-cli", + "rust-server", + "android-apk", + "macos-app", + "macos-cli", + "mh3g-converter-cli", + "mh3g-converter-macos", +) +OPTIONAL_DISTRIBUTION_KINDS = ( + "macos-save-sync-zip", + "mh3g-converter-macos-zip", + "mh3g-converter-windows-zip", + "mh3g-converter-windows-portable", + "mh3g-converter-windows-setup", +) +EXPECTED_ROLES = { + "rust-cli": "executable", + "rust-server": "executable", + "android-apk": "apk", + "macos-app": "executable", + "macos-cli": "executable", + "mh3g-converter-cli": "executable", + "mh3g-converter-macos": "executable", + "macos-save-sync-zip": "archive", + "mh3g-converter-macos-zip": "archive", + "mh3g-converter-windows-zip": "archive", + "mh3g-converter-windows-portable": "executable", + "mh3g-converter-windows-setup": "installer", +} -def git_output(*args: str) -> str: +def _git(repo_root: Path, *args: str) -> str: try: return subprocess.check_output( - ["git", *args], cwd=ROOT, text=True, stderr=subprocess.DEVNULL + ["git", "-C", str(repo_root), *args], + text=True, + stderr=subprocess.STDOUT, ).strip() - except Exception: - return "unknown" + except subprocess.CalledProcessError as error: + raise ValueError(f"git evidence unavailable: {error.output.strip()}") from error -def sha256_file(path: pathlib.Path) -> str: - h = hashlib.sha256() - with path.open("rb") as fh: - for chunk in iter(lambda: fh.read(1024 * 1024), b""): - h.update(chunk) - return h.hexdigest() +def _git_diff_is_clean(repo_root: Path, *args: str) -> bool: + result = subprocess.run( + ["git", "-C", str(repo_root), "diff", "--quiet", *args], + check=False, + ) + if result.returncode not in (0, 1): + raise ValueError("git diff evidence unavailable") + return result.returncode == 0 -def cargo_components() -> list[dict[str, Any]]: - lock_path = ROOT / "Cargo.lock" - data = tomllib.loads(lock_path.read_text(encoding="utf-8")) - components: list[dict[str, Any]] = [] - for package in sorted( - data.get("package", []), - key=lambda p: (p.get("name", ""), p.get("version", ""), p.get("source", "")), +def _require_clean_tracked_tree(repo_root: Path) -> None: + if not _git_diff_is_clean(repo_root, "HEAD", "--") or not _git_diff_is_clean( + repo_root, "--cached", "--" ): - name = package["name"] - version = package["version"] - source = package.get("source", "") - component: dict[str, Any] = { - "type": "library", - "bom-ref": f"pkg:cargo/{name}@{version}", - "name": name, - "version": version, - "purl": f"pkg:cargo/{name}@{version}", - "scope": "required", - } + raise ValueError("tracked source tree must be clean") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _commit_timestamp(repo_root: Path, source_ref: str) -> str: + raw = _git(repo_root, "show", "-s", "--format=%cI", source_ref) + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError("source commit timestamp is invalid") from error + return parsed.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _source_identity(repo_root: Path, expected_ref: str | None = None) -> dict: + source_ref = _git(repo_root, "rev-parse", "HEAD") + if not SOURCE_REF.fullmatch(source_ref): + raise ValueError("checked-out HEAD is not a full commit SHA") + if expected_ref is not None and source_ref != expected_ref: + raise ValueError("source_ref must match checked-out HEAD") + _require_clean_tracked_tree(repo_root) + return { + "source_ref": source_ref, + "timestamp": _commit_timestamp(repo_root, source_ref), + } + + +def _cargo_components(repo_root: Path) -> list[dict]: + lock_path = repo_root / "Cargo.lock" + try: + lock = tomllib.loads(lock_path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as error: + raise ValueError(f"Cargo.lock is unreadable: {error}") from error + components: list[dict] = [] + for package in lock.get("package", []): + if not isinstance(package, dict): + continue + if package.get("source") != "registry+https://github.com/rust-lang/crates.io-index": + continue + name = package.get("name") + version = package.get("version") checksum = package.get("checksum") - if checksum: - component["hashes"] = [{"alg": "SHA-256", "content": checksum}] - if source: - component["externalReferences"] = [ + if not all(isinstance(value, str) and value for value in (name, version)): + raise ValueError("locked registry package identity is invalid") + if not isinstance(checksum, str) or not re.fullmatch(r"[0-9a-f]{64}", checksum): + raise ValueError(f"{name} {version} lacks a proven Cargo.lock SHA-256") + components.append( + { + "type": "library", + "bom-ref": f"pkg:cargo/{name}@{version}", + "name": name, + "version": version, + "purl": f"pkg:cargo/{name}@{version}", + "scope": "required", + "hashes": [{"alg": "SHA-256", "content": checksum}], + "externalReferences": [ + { + "type": "distribution", + "url": ( + "https://crates.io/api/v1/crates/" + + quote(name, safe="") + + "/" + + quote(version, safe="") + + "/download" + ), + } + ], + } + ) + return sorted(components, key=lambda item: (item["name"], item["version"])) + + +def _serial(source_ref: str, components: list[dict]) -> str: + canonical = json.dumps( + { + "source_ref": source_ref, + "components": [ { - "type": "distribution", - "url": source, + "bom-ref": item["bom-ref"], + "hash": item.get("hashes", [{}])[0].get("content"), + } + for item in components + ], + }, + sort_keys=True, + separators=(",", ":"), + ) + return "urn:uuid:" + str(uuid.uuid5(uuid.NAMESPACE_URL, canonical)) + + +def _cargo_lock_component(repo_root: Path, source_ref: str) -> dict: + lock_path = repo_root / "Cargo.lock" + if not lock_path.is_file(): + raise ValueError("Cargo.lock is missing") + return { + "type": "file", + "bom-ref": f"urn:mhtoolkit:cargo-lock:{source_ref}", + "name": "Cargo.lock", + "version": source_ref, + "scope": "required", + "hashes": [{"alg": "SHA-256", "content": _sha256(lock_path)}], + "properties": [ + {"name": "mhtoolkit.source_ref", "value": source_ref}, + {"name": "mhtoolkit.evidence_scope", "value": "workspace-lock-aggregate"}, + ], + } + + +def _metadata(source: dict, version: str, root_ref: str) -> dict: + return { + "timestamp": source["timestamp"], + "tools": { + "components": [ + { + "type": "application", + "name": "mh-save-sync scripts/generate-sbom.py", + "version": "2", } ] - components.append(component) - return components - - -def android_components() -> list[dict[str, Any]]: - wrapper = ROOT / "apps/android/gradle/wrapper/gradle-wrapper.properties" - app_build = ROOT / "apps/android/app/build.gradle.kts" - components: list[dict[str, Any]] = [] - if wrapper.exists(): - text = wrapper.read_text(encoding="utf-8") - distribution = next( - ( - line.split("=", 1)[1].strip().replace("\\:", ":") - for line in text.splitlines() - if line.startswith("distributionUrl=") - ), - "unknown", + }, + "component": { + "type": "application", + "name": "mh-save-sync", + "version": version, + "bom-ref": root_ref, + "externalReferences": [ + { + "type": "vcs", + "url": ( + "https://github.com/MHToolkit/mh-save-sync.git@" + + source["source_ref"] + ), + } + ], + "properties": [ + {"name": "mhtoolkit.source_ref", "value": source["source_ref"]} + ], + }, + } + + +def build_dependency_bom(repo_root: Path = ROOT) -> dict: + source = _source_identity(repo_root) + components = _cargo_components(repo_root) + root_ref = f"pkg:github/MHToolkit/mh-save-sync@{source['source_ref']}" + return { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": _serial(source["source_ref"], components), + "version": 1, + "metadata": _metadata(source, source["source_ref"], root_ref), + "components": components, + "dependencies": [ + {"ref": root_ref, "dependsOn": [item["bom-ref"] for item in components]} + ], + } + + +def load_release_identity(identity_path: Path, repo_root: Path = ROOT) -> dict: + try: + raw = json.loads(identity_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"release identity is unreadable: {error}") from error + if not isinstance(raw, dict) or raw.get("schema_version") != 1: + raise ValueError("release identity schema_version must be 1") + if raw.get("repository") != REPOSITORY: + raise ValueError(f"release identity repository must be {REPOSITORY}") + source_ref = raw.get("source_ref") + if not isinstance(source_ref, str) or not SOURCE_REF.fullmatch(source_ref): + raise ValueError("source_ref must be a 40-character lowercase commit SHA") + source = _source_identity(repo_root, source_ref) + + components = raw.get("components") + if not isinstance(components, list): + raise ValueError("release identity components must be a list") + if any(not isinstance(item, dict) for item in components): + raise ValueError("release identity components must be objects") + kinds = [item.get("kind") for item in components] + allowed_kinds = set(REQUIRED_KINDS) | set(OPTIONAL_DISTRIBUTION_KINDS) + unknown = sorted({kind for kind in kinds if kind not in allowed_kinds}, key=str) + if unknown: + raise ValueError(f"unknown component kind: {unknown[0]}") + duplicates = sorted({kind for kind in kinds if kinds.count(kind) > 1}, key=str) + if duplicates: + raise ValueError(f"duplicate component kind: {duplicates[0]}") + missing = sorted(set(REQUIRED_KINDS) - set(kinds)) + if missing: + raise ValueError( + "release identity requires exactly one component of every required kind: " + + ", ".join(missing) ) - components.append( - { - "type": "framework", - "bom-ref": "gradle-wrapper", - "name": "Gradle Wrapper", - "version": distribution.rsplit("-", 2)[-2] - if distribution.count("-") >= 2 - else "unknown", - "hashes": [{"alg": "SHA-256", "content": sha256_file(wrapper)}], - "externalReferences": [{"type": "distribution", "url": distribution}], - } + + normalized: list[dict] = [] + ordered_kinds = list(REQUIRED_KINDS) + [ + kind for kind in OPTIONAL_DISTRIBUTION_KINDS if kind in kinds + ] + for kind in ordered_kinds: + component = next(item for item in components if item.get("kind") == kind) + package_id = component.get("package_id") + version = component.get("version") + if not isinstance(package_id, str) or not package_id.strip(): + raise ValueError(f"{kind}: package_id is required") + if not isinstance(version, str) or not version.strip(): + raise ValueError(f"{kind}: version is required") + if component.get("artifact_role") != EXPECTED_ROLES[kind]: + raise ValueError(f"{kind}: artifact_role is invalid") + path_value = component.get("artifact_path") + if not isinstance(path_value, str) or not path_value: + raise ValueError(f"{kind}: artifact_path is required") + artifact = Path(path_value) + if not artifact.is_absolute(): + artifact = repo_root / artifact + if not artifact.is_file() or artifact.stat().st_size <= 0: + raise ValueError(f"{kind}: artifact does not exist or is empty") + normalized_component = { + "kind": kind, + "package_id": package_id, + "version": version, + "artifact_name": artifact.name, + "artifact_role": component["artifact_role"], + "artifact_sha256": _sha256(artifact), + } + if kind == "android-apk": + version_code = component.get("version_code") + if not isinstance(version_code, int) or version_code <= 0: + raise ValueError("android-apk: version_code must be a positive integer") + normalized_component["version_code"] = version_code + if kind in {"macos-app", "mh3g-converter-macos"}: + build_number = component.get("build_number") + if not isinstance(build_number, int) or build_number <= 0: + raise ValueError(f"{kind}: build_number must be a positive integer") + normalized_component["build_number"] = build_number + normalized.append(normalized_component) + + return { + **source, + "components": normalized, + "cargo_components": _cargo_components(repo_root), + "cargo_lock_component": _cargo_lock_component(repo_root, source_ref), + } + + +def _artifact_component(component: dict, source_ref: str) -> dict: + qualifier = "kind=" + quote(component["kind"], safe="") + if "version_code" in component: + qualifier += f"&version_code={component['version_code']}" + bom_ref = ( + "pkg:generic/" + + quote(component["package_id"], safe="") + + "@" + + quote(component["version"], safe="") + + "?" + + qualifier + ) + properties = [ + {"name": "mhtoolkit.source_ref", "value": source_ref}, + {"name": "mhtoolkit.artifact_kind", "value": component["kind"]}, + {"name": "mhtoolkit.artifact_role", "value": component["artifact_role"]}, + {"name": "mhtoolkit.artifact_name", "value": component["artifact_name"]}, + ] + if "version_code" in component: + properties.append( + {"name": "mhtoolkit.version_code", "value": str(component["version_code"])} ) - if app_build.exists(): - components.append( + if "build_number" in component: + properties.append( + {"name": "mhtoolkit.build_number", "value": str(component["build_number"])} + ) + return { + "type": "application", + "bom-ref": bom_ref, + "name": component["package_id"], + "version": component["version"], + "purl": bom_ref, + "scope": "required", + "hashes": [ + {"alg": "SHA-256", "content": component["artifact_sha256"]} + ], + "externalReferences": [ { - "type": "application", - "bom-ref": "mh-save-sync-android-shell", - "name": "mh-save-sync-android-shell", - "version": "0.1.0", - "hashes": [{"alg": "SHA-256", "content": sha256_file(app_build)}], + "type": "vcs", + "url": ( + "https://github.com/MHToolkit/mh-save-sync.git@" + source_ref + ), } - ) - return components + ], + "properties": properties, + } -def build_bom() -> dict[str, Any]: - revision = git_output("rev-parse", "HEAD") - now = dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") - components = cargo_components() + android_components() +def build_release_bom(identity: dict) -> dict: + artifact_components = [ + _artifact_component(component, identity["source_ref"]) + for component in identity["components"] + ] + cargo_components = identity["cargo_components"] + cargo_lock_component = identity["cargo_lock_component"] + components = [cargo_lock_component] + cargo_components + artifact_components + android = next( + component + for component in identity["components"] + if component["kind"] == "android-apk" + ) + root_ref = f"pkg:github/MHToolkit/mh-save-sync@{identity['source_ref']}" + cargo_refs = [component["bom-ref"] for component in cargo_components] + dependencies = [ + { + "ref": root_ref, + "dependsOn": [ + cargo_lock_component["bom-ref"], + *[component["bom-ref"] for component in artifact_components], + ], + } + ] + dependencies.append( + {"ref": cargo_lock_component["bom-ref"], "dependsOn": cargo_refs} + ) + dependencies.extend( + {"ref": component["bom-ref"], "dependsOn": []} + for component in artifact_components + ) + dependencies.extend( + {"ref": component["bom-ref"], "dependsOn": []} + for component in cargo_components + ) return { "bomFormat": "CycloneDX", "specVersion": "1.5", - "serialNumber": f"urn:uuid:{hashlib.sha256(revision.encode()).hexdigest()[:32]}", + "serialNumber": _serial(identity["source_ref"], components), "version": 1, - "metadata": { - "timestamp": now, - "tools": { - "components": [ - { - "type": "application", - "name": "mh-save-sync scripts/generate-sbom.py", - "version": "1", - } - ] - }, - "component": { - "type": "application", - "name": "mh-save-sync", - "version": "0.1.0-alpha.1", - "bom-ref": "pkg:github/MHToolkit/mh-save-sync", - "externalReferences": [ - { - "type": "vcs", - "url": "https://github.com/MHToolkit/mh-save-sync", - } - ], - "properties": [ - {"name": "git.commit", "value": revision}, - {"name": "git.branch", "value": git_output("branch", "--show-current")}, - ], - }, - }, + "metadata": _metadata(identity, android["version"], root_ref), "components": components, + "dependencies": dependencies, } -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "output", - nargs="?", - default="artifacts/sbom/mh-save-sync.cdx.json", - help="output CycloneDX JSON path", +def write_json(document: dict, output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(document, indent=2, sort_keys=True) + "\n", + encoding="utf-8", ) - args = parser.parse_args() - out_path = (ROOT / args.output).resolve() if not pathlib.Path(args.output).is_absolute() else pathlib.Path(args.output) - out_path.parent.mkdir(parents=True, exist_ok=True) - bom = build_bom() - out_path.write_text(json.dumps(bom, indent=2, sort_keys=True) + "\n", encoding="utf-8") - print(f"wrote {out_path} components={len(bom['components'])}") + + +def _sbom_identity(sbom: Path, identity: dict) -> dict: + android = next( + component + for component in identity["components"] + if component["kind"] == "android-apk" + ) + return { + "format": "cyclonedx-json", + "sha256": "sha256:" + _sha256(sbom), + "artifact_sha256": "sha256:" + android["artifact_sha256"], + "source_ref": identity["source_ref"], + } + + +def write_sbom_identity(sbom: Path, identity: dict, receipt: Path) -> None: + write_json(_sbom_identity(sbom, identity), receipt) + + +def _load_cyclonedx(path: Path) -> dict: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"SBOM is unreadable: {error}") from error + if not isinstance(document, dict) or ( + document.get("bomFormat") != "CycloneDX" + or document.get("specVersion") != "1.5" + ): + raise ValueError("unsupported SBOM format") + return document + + +def verify_dependency_sbom(sbom: Path, repo_root: Path = ROOT) -> None: + if _load_cyclonedx(sbom) != build_dependency_bom(repo_root): + raise ValueError("dependency SBOM does not match clean source ref or Cargo.lock") + + +def verify_release_sbom( + sbom: Path, + receipt: Path, + identity_path: Path, + repo_root: Path = ROOT, +) -> None: + actual = _load_cyclonedx(sbom) + identity = load_release_identity(identity_path, repo_root) + if actual != build_release_bom(identity): + raise ValueError("SBOM does not match source ref, artifact SHA, or package identity") + try: + actual_receipt = json.loads(receipt.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"SBOM identity receipt is unreadable: {error}") from error + if actual_receipt != _sbom_identity(sbom, identity): + raise ValueError("SBOM identity receipt does not match source ref or artifact") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + dependencies = subparsers.add_parser("dependencies") + dependencies.add_argument("output", type=Path) + dependencies.add_argument("--repo-root", type=Path, default=ROOT) + + verify_dependencies = subparsers.add_parser("verify-dependencies") + verify_dependencies.add_argument("--sbom", required=True, type=Path) + verify_dependencies.add_argument("--repo-root", type=Path, default=ROOT) + + release = subparsers.add_parser("release") + release.add_argument("--identity", required=True, type=Path) + release.add_argument("--output", required=True, type=Path) + release.add_argument("--receipt", required=True, type=Path) + release.add_argument("--repo-root", type=Path, default=ROOT) + + verify_release = subparsers.add_parser("verify-release") + verify_release.add_argument("--identity", required=True, type=Path) + verify_release.add_argument("--sbom", required=True, type=Path) + verify_release.add_argument("--receipt", required=True, type=Path) + verify_release.add_argument("--repo-root", type=Path, default=ROOT) + return parser + + +def _normalize_cli_argv(argv: list[str]) -> list[str]: + commands = {"dependencies", "verify-dependencies", "release", "verify-release"} + if len(argv) == 1 and argv[0] not in commands and not argv[0].startswith("-"): + return ["dependencies", argv[0]] + return argv + + +def main(argv: list[str] | None = None) -> int: + cli_argv = list(sys.argv[1:] if argv is None else argv) + arguments = _parser().parse_args(_normalize_cli_argv(cli_argv)) + try: + if arguments.command == "dependencies": + write_json(build_dependency_bom(arguments.repo_root), arguments.output) + print(arguments.output) + elif arguments.command == "verify-dependencies": + verify_dependency_sbom(arguments.sbom, arguments.repo_root) + print("dependency SBOM gate passed") + elif arguments.command == "release": + identity = load_release_identity(arguments.identity, arguments.repo_root) + write_json(build_release_bom(identity), arguments.output) + write_sbom_identity(arguments.output, identity, arguments.receipt) + print(arguments.output) + print(arguments.receipt) + else: + verify_release_sbom( + arguments.sbom, + arguments.receipt, + arguments.identity, + arguments.repo_root, + ) + print("release SBOM identity gate passed") + except ValueError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 return 0 diff --git a/scripts/supply-chain-gate.sh b/scripts/supply-chain-gate.sh index 852c889..3cc8c56 100755 --- a/scripts/supply-chain-gate.sh +++ b/scripts/supply-chain-gate.sh @@ -31,5 +31,6 @@ for advisory in ${CARGO_AUDIT_IGNORE_IDS:-RUSTSEC-2026-0194 RUSTSEC-2026-0195}; done cargo audit "${audit_ignore_args[@]}" -python3 scripts/generate-sbom.py artifacts/sbom/mh-save-sync.cdx.json -test -s artifacts/sbom/mh-save-sync.cdx.json +python3 scripts/generate-sbom.py dependencies artifacts/sbom/mh-save-sync.cdx.json +python3 scripts/generate-sbom.py verify-dependencies \ + --sbom artifacts/sbom/mh-save-sync.cdx.json diff --git a/scripts/verify-local.sh b/scripts/verify-local.sh index 342ba2f..a478558 100755 --- a/scripts/verify-local.sh +++ b/scripts/verify-local.sh @@ -9,7 +9,9 @@ if command -v cargo-deny >/dev/null 2>&1 && command -v cargo-audit >/dev/null 2> ./scripts/supply-chain-gate.sh else echo "cargo-deny/cargo-audit not installed; supply-chain advisory gate skipped locally" >&2 - python3 scripts/generate-sbom.py artifacts/sbom/mh-save-sync.cdx.json + python3 scripts/generate-sbom.py dependencies artifacts/sbom/mh-save-sync.cdx.json + python3 scripts/generate-sbom.py verify-dependencies \ + --sbom artifacts/sbom/mh-save-sync.cdx.json fi cargo build --workspace --bins mkdir -p artifacts/checksums diff --git a/tests/test_release_sbom.py b/tests/test_release_sbom.py new file mode 100644 index 0000000..6a73ed0 --- /dev/null +++ b/tests/test_release_sbom.py @@ -0,0 +1,400 @@ +import importlib.util +import hashlib +import json +import os +import subprocess +import tempfile +import unittest +import uuid +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "generate_sbom", ROOT / "scripts" / "generate-sbom.py" +) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +class ReleaseSbomTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + subprocess.run(["git", "init", "-q", self.root], check=True) + subprocess.run( + ["git", "-C", self.root, "config", "user.email", "test@example.invalid"], + check=True, + ) + subprocess.run( + ["git", "-C", self.root, "config", "user.name", "SBOM Test"], + check=True, + ) + (self.root / "Cargo.lock").write_text( + """version = 4 + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +""", + encoding="utf-8", + ) + (self.root / ".gitignore").write_text("build/\n", encoding="utf-8") + artifact_names = { + "mh-save": b"rust-cli\n", + "mh-save-server": b"rust-server\n", + "app-debug.apk": b"android-apk\n", + "MHSaveSyncMac": b"macos-app\n", + "mh-save-macos": b"macos-cli\n", + "mh3g-save-convert": b"converter-cli\n", + "MH3GSaveConverterMac": b"converter-macos\n", + } + for name, contents in artifact_names.items(): + (self.root / name).write_bytes(contents) + subprocess.run( + ["git", "-C", self.root, "add", "Cargo.lock", ".gitignore"], + check=True, + ) + subprocess.run( + ["git", "-C", self.root, "commit", "-qm", "fixture"], + check=True, + env={ + **os.environ, + "GIT_AUTHOR_DATE": "2026-08-07T00:00:00Z", + "GIT_COMMITTER_DATE": "2026-08-07T00:00:00Z", + }, + ) + self.source_ref = subprocess.check_output( + ["git", "-C", self.root, "rev-parse", "HEAD"], text=True + ).strip() + self.identity_path = self.root / "release-identity.json" + components = [ + ("rust-cli", "mh-save", "0.1.0", "mh-save", "executable", None), + ( + "rust-server", + "mh-save-server", + "0.1.0", + "mh-save-server", + "executable", + None, + ), + ( + "android-apk", + "org.mhtoolkit.savesync", + "0.1.0-alpha.4", + "app-debug.apk", + "apk", + 5, + ), + ( + "macos-app", + "org.mhtoolkit.mh-save-sync.alpha", + "0.1.0-alpha.4", + "MHSaveSyncMac", + "executable", + 5, + ), + ( + "macos-cli", + "org.mhtoolkit.mh-save-sync.cli", + "0.1.0", + "mh-save-macos", + "executable", + None, + ), + ( + "mh3g-converter-cli", + "mh3g-save-convert", + "0.0.13", + "mh3g-save-convert", + "executable", + None, + ), + ( + "mh3g-converter-macos", + "org.mhtoolkit.mh3g-save-converter", + "0.0.13", + "MH3GSaveConverterMac", + "executable", + 1, + ), + ] + self.identity_path.write_text( + json.dumps( + { + "schema_version": 1, + "repository": "MHToolkit/mh-save-sync", + "source_ref": self.source_ref, + "components": [ + { + "kind": kind, + "package_id": package_id, + "version": version, + "artifact_path": artifact, + "artifact_role": role, + **( + {"version_code": version_code} + if kind == "android-apk" + else {"build_number": version_code} + if version_code + else {} + ), + } + for kind, package_id, version, artifact, role, version_code in components + ], + }, + indent=2, + ), + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def generate(self, sbom: Path, receipt: Path) -> None: + identity = MODULE.load_release_identity(self.identity_path, self.root) + MODULE.write_json(MODULE.build_release_bom(identity), sbom) + MODULE.write_sbom_identity(sbom, identity, receipt) + + def test_dependency_bom_is_deterministic_and_not_fixed_to_old_version(self) -> None: + first = MODULE.build_dependency_bom(self.root) + second = MODULE.build_dependency_bom(self.root) + + self.assertEqual(first, second) + self.assertEqual( + first["metadata"]["component"]["version"], self.source_ref + ) + self.assertNotEqual(first["metadata"]["component"]["version"], "0.1.0-alpha.1") + + def test_release_bom_is_deterministic_artifact_bound_and_verifies(self) -> None: + first = self.root / "first.cdx.json" + second = self.root / "second.cdx.json" + first_receipt = self.root / "first.sbom-identity.json" + second_receipt = self.root / "second.sbom-identity.json" + + self.generate(first, first_receipt) + self.generate(second, second_receipt) + + self.assertEqual(first.read_bytes(), second.read_bytes()) + self.assertEqual(first_receipt.read_bytes(), second_receipt.read_bytes()) + MODULE.verify_release_sbom( + first, first_receipt, self.identity_path, self.root + ) + document = json.loads(first.read_text(encoding="utf-8")) + self.assertEqual(document["metadata"]["component"]["version"], "0.1.0-alpha.4") + serial = uuid.UUID(document["serialNumber"].removeprefix("urn:uuid:")) + self.assertEqual(serial.version, 5) + self.assertEqual( + {component["name"] for component in document["components"]}, + { + "bytes", + "Cargo.lock", + "mh-save", + "mh-save-server", + "org.mhtoolkit.savesync", + "org.mhtoolkit.mh-save-sync.alpha", + "org.mhtoolkit.mh-save-sync.cli", + "mh3g-save-convert", + "org.mhtoolkit.mh3g-save-converter", + }, + ) + for component in document["components"]: + if component["name"] not in {"bytes", "Cargo.lock"}: + properties = {item["name"]: item["value"] for item in component["properties"]} + self.assertEqual(properties["mhtoolkit.source_ref"], self.source_ref) + self.assertEqual(len(component["hashes"][0]["content"]), 64) + if component["name"] == "org.mhtoolkit.mh-save-sync.alpha": + self.assertEqual(properties["mhtoolkit.build_number"], "5") + if component["name"] == "org.mhtoolkit.mh3g-save-converter": + self.assertEqual(properties["mhtoolkit.build_number"], "1") + dependency_ref = "pkg:cargo/bytes@1.12.0" + cargo_lock_ref = f"urn:mhtoolkit:cargo-lock:{self.source_ref}" + relationships = { + entry["ref"]: set(entry["dependsOn"]) + for entry in document["dependencies"] + } + self.assertEqual(relationships[cargo_lock_ref], {dependency_ref}) + for artifact_ref in ( + "pkg:generic/mh-save@0.1.0?kind=rust-cli", + "pkg:generic/mh-save-server@0.1.0?kind=rust-server", + "pkg:generic/mh3g-save-convert@0.0.13?kind=mh3g-converter-cli", + ): + self.assertEqual(relationships[artifact_ref], set()) + root_ref = f"pkg:github/MHToolkit/mh-save-sync@{self.source_ref}" + self.assertIn(cargo_lock_ref, relationships[root_ref]) + + receipt = json.loads(first_receipt.read_text(encoding="utf-8")) + self.assertEqual(receipt["format"], "cyclonedx-json") + self.assertEqual(receipt["source_ref"], self.source_ref) + self.assertEqual( + receipt["artifact_sha256"], + "sha256:" + hashlib.sha256(b"android-apk\n").hexdigest(), + ) + + def test_missing_artifact_fails_closed(self) -> None: + (self.root / "app-debug.apk").unlink() + + with self.assertRaisesRegex(ValueError, "artifact does not exist"): + MODULE.load_release_identity(self.identity_path, self.root) + + def test_wrong_source_ref_fails_closed(self) -> None: + identity = json.loads(self.identity_path.read_text(encoding="utf-8")) + identity["source_ref"] = "f" * 40 + self.identity_path.write_text(json.dumps(identity), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "source_ref must match checked-out HEAD"): + MODULE.load_release_identity(self.identity_path, self.root) + + def test_tracked_dirty_source_fails_closed(self) -> None: + with (self.root / "Cargo.lock").open("a", encoding="utf-8") as stream: + stream.write("# drift\n") + + with self.assertRaisesRegex(ValueError, "tracked source tree must be clean"): + MODULE.load_release_identity(self.identity_path, self.root) + + def test_tracked_staged_source_fails_closed(self) -> None: + with (self.root / "Cargo.lock").open("a", encoding="utf-8") as stream: + stream.write("# staged drift\n") + subprocess.run(["git", "-C", self.root, "add", "Cargo.lock"], check=True) + + with self.assertRaisesRegex(ValueError, "tracked source tree must be clean"): + MODULE.load_release_identity(self.identity_path, self.root) + + def test_untracked_and_ignored_build_outputs_are_allowed(self) -> None: + (self.root / "loose-build-output.bin").write_bytes(b"untracked\n") + (self.root / "build").mkdir() + (self.root / "build" / "ignored.bin").write_bytes(b"ignored\n") + + identity = MODULE.load_release_identity(self.identity_path, self.root) + + self.assertEqual(identity["source_ref"], self.source_ref) + + def test_missing_and_duplicate_required_kind_fail_closed(self) -> None: + identity = json.loads(self.identity_path.read_text(encoding="utf-8")) + identity["components"][-1] = dict(identity["components"][0]) + self.identity_path.write_text(json.dumps(identity), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "duplicate component kind"): + MODULE.load_release_identity(self.identity_path, self.root) + + identity["components"] = identity["components"][:-1] + self.identity_path.write_text(json.dumps(identity), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "exactly one component"): + MODULE.load_release_identity(self.identity_path, self.root) + + def test_optional_distribution_kinds_are_bound_when_present(self) -> None: + optional = { + "macos-save-sync-zip": ("MHSaveSync.zip", "archive"), + "mh3g-converter-macos-zip": ("MH3GConverter.zip", "archive"), + "mh3g-converter-windows-zip": ("MH3GConverter-Windows.zip", "archive"), + "mh3g-converter-windows-portable": ("MH3GConverter.exe", "executable"), + "mh3g-converter-windows-setup": ("MH3GConverter-Setup.exe", "installer"), + } + identity = json.loads(self.identity_path.read_text(encoding="utf-8")) + for kind, (name, role) in optional.items(): + (self.root / name).write_bytes((kind + "\n").encode()) + identity["components"].append( + { + "kind": kind, + "package_id": f"org.mhtoolkit.{kind}", + "version": "0.1.0-alpha.4", + "artifact_path": name, + "artifact_role": role, + } + ) + self.identity_path.write_text(json.dumps(identity), encoding="utf-8") + + normalized = MODULE.load_release_identity(self.identity_path, self.root) + document = MODULE.build_release_bom(normalized) + + self.assertTrue(optional.keys() <= {item["kind"] for item in normalized["components"]}) + component_kinds = { + prop["value"] + for component in document["components"] + for prop in component.get("properties", []) + if prop["name"] == "mhtoolkit.artifact_kind" + } + self.assertTrue(optional.keys() <= component_kinds) + + def test_duplicate_optional_or_unknown_kind_fails_closed(self) -> None: + identity = json.loads(self.identity_path.read_text(encoding="utf-8")) + optional = { + "kind": "macos-save-sync-zip", + "package_id": "org.mhtoolkit.save-sync.zip", + "version": "0.1.0-alpha.4", + "artifact_path": "mh-save", + "artifact_role": "archive", + } + identity["components"].extend([optional, dict(optional)]) + self.identity_path.write_text(json.dumps(identity), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "duplicate component kind"): + MODULE.load_release_identity(self.identity_path, self.root) + + identity["components"] = identity["components"][:-2] + identity["components"].append({**optional, "kind": "future-distribution"}) + self.identity_path.write_text(json.dumps(identity), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "unknown component kind"): + MODULE.load_release_identity(self.identity_path, self.root) + + def test_legacy_dependency_cli_is_preserved(self) -> None: + self.assertEqual( + MODULE._normalize_cli_argv(["artifacts/sbom/release.cdx.json"]), + ["dependencies", "artifacts/sbom/release.cdx.json"], + ) + output = self.root / "legacy.cdx.json" + original_root = MODULE.ROOT + MODULE.ROOT = self.root + try: + self.assertEqual(MODULE.main([str(output)]), 0) + MODULE.verify_dependency_sbom(output, self.root) + finally: + MODULE.ROOT = original_root + + def test_unknown_format_fails_closed(self) -> None: + sbom = self.root / "release.cdx.json" + receipt = self.root / "release.sbom-identity.json" + self.generate(sbom, receipt) + document = json.loads(sbom.read_text(encoding="utf-8")) + document["specVersion"] = "9.9" + sbom.write_text(json.dumps(document), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "unsupported SBOM format"): + MODULE.verify_release_sbom(sbom, receipt, self.identity_path, self.root) + + def test_artifact_tampering_fails_closed(self) -> None: + sbom = self.root / "release.cdx.json" + receipt = self.root / "release.sbom-identity.json" + self.generate(sbom, receipt) + (self.root / "app-debug.apk").write_bytes(b"tampered\n") + + with self.assertRaisesRegex(ValueError, "SBOM does not match"): + MODULE.verify_release_sbom(sbom, receipt, self.identity_path, self.root) + + def test_sbom_tampering_fails_closed(self) -> None: + sbom = self.root / "release.cdx.json" + receipt = self.root / "release.sbom-identity.json" + self.generate(sbom, receipt) + document = json.loads(sbom.read_text(encoding="utf-8")) + document["components"][1]["hashes"][0]["content"] = "0" * 64 + sbom.write_text(json.dumps(document), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "SBOM does not match"): + MODULE.verify_release_sbom(sbom, receipt, self.identity_path, self.root) + + def test_receipt_tampering_fails_closed(self) -> None: + sbom = self.root / "release.cdx.json" + receipt = self.root / "release.sbom-identity.json" + self.generate(sbom, receipt) + identity = json.loads(receipt.read_text(encoding="utf-8")) + identity["source_ref"] = "f" * 40 + receipt.write_text(json.dumps(identity), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "SBOM identity receipt does not match"): + MODULE.verify_release_sbom(sbom, receipt, self.identity_path, self.root) + + +if __name__ == "__main__": + unittest.main()