diff --git a/yuqei-infra/scripts/build-installable-release.py b/yuqei-infra/scripts/build-installable-release.py index a8e99a08..f45b9c8f 100644 --- a/yuqei-infra/scripts/build-installable-release.py +++ b/yuqei-infra/scripts/build-installable-release.py @@ -713,7 +713,12 @@ def assemble_installable_release( raise ReleaseValidationError(f"Docker archive role labels drift for {expected['role']}") sbom_path = verify_file_record(artifact_root, sbom_record, label=f"{expected['role']} SBOM") - sbom_summary = validate_spdx(load_json_file(sbom_path, label=f"{expected['role']} SBOM"), policy) + sbom_summary = validate_spdx( + load_json_file(sbom_path, label=f"{expected['role']} SBOM"), + policy, + expected_role=expected["role"], + expected_manifest_digest=oci_summary["manifest_digest"], + ) _compare_summary(sbom_record, sbom_summary, label=f"{expected['role']} SBOM") scan_path = verify_file_record(artifact_root, scan_record, label=f"{expected['role']} scan") @@ -798,7 +803,21 @@ def assemble_installable_release( "docker_archive": { key: docker_record[key] for key in ("path", "sha256", "size_bytes") }, - "sbom": {key: sbom_record[key] for key in ("path", "sha256", "size_bytes")}, + "sbom": { + key: sbom_record[key] + for key in ( + "path", + "sha256", + "size_bytes", + "spdx_version", + "package_count", + "non_root_package_count", + "root_package_spdx_id", + "root_package_name", + "root_role", + "root_manifest_digest", + ) + }, "scan": {key: scan_record[key] for key in ("path", "sha256", "size_bytes")}, "license_scan": { key: license_scan_record[key] diff --git a/yuqei-infra/scripts/linux_release_common.py b/yuqei-infra/scripts/linux_release_common.py index 437c0729..c0066a19 100644 --- a/yuqei-infra/scripts/linux_release_common.py +++ b/yuqei-infra/scripts/linux_release_common.py @@ -1407,7 +1407,63 @@ def verify_file_record(root: Path, record: Any, *, label: str) -> Path: return path -def validate_spdx(payload: Any, policy: dict[str, Any]) -> dict[str, Any]: +def spdx_image_root_package(role: str, manifest_digest: str) -> dict[str, Any]: + if role not in REQUIRED_ROLES: + raise ReleaseValidationError(f"unsupported SPDX image role: {role!r}") + if not isinstance(manifest_digest, str) or not re.fullmatch( + r"sha256:[0-9a-f]{64}", manifest_digest + ): + raise ReleaseValidationError("SPDX image root manifest digest is invalid") + return { + "SPDXID": f"SPDXRef-Yuqei-Release-Image-{role}", + "name": f"yuqei-release-image/{role}", + "versionInfo": manifest_digest, + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "CONTAINER", + "supplier": "NOASSERTION", + } + + +def bind_spdx_image_root( + payload: Any, + *, + role: str, + manifest_digest: str, +) -> dict[str, Any]: + """Append one deterministic image root without removing Syft packages.""" + + document = _require_mapping(payload, "SPDX SBOM") + if document.get("spdxVersion") != "SPDX-2.3": + raise ReleaseValidationError("SBOM SPDX version mismatch before image root binding") + packages = _require_list(document.get("packages"), "SPDX packages") + if not packages: + raise ReleaseValidationError("SBOM package inventory is empty before image root binding") + root = spdx_image_root_package(role, manifest_digest) + root_id = root["SPDXID"] + for index, raw_package in enumerate(packages): + package = _require_mapping(raw_package, f"SPDX package {index}") + if package.get("SPDXID") == root_id: + raise ReleaseValidationError("Syft SBOM already contains the reserved image root SPDXID") + if package.get("versionInfo") == manifest_digest: + raise ReleaseValidationError( + "Syft SBOM already contains the release manifest digest and would create a duplicate root" + ) + packages.append(root) + document["documentDescribes"] = [root_id] + return document + + +def validate_spdx( + payload: Any, + policy: dict[str, Any], + *, + expected_role: str | None = None, + expected_manifest_digest: str | None = None, +) -> dict[str, Any]: document = _require_mapping(payload, "SPDX SBOM") if document.get("spdxVersion") != policy["sbom"]["spdx_version"]: raise ReleaseValidationError("SBOM SPDX version mismatch") @@ -1423,7 +1479,52 @@ def validate_spdx(payload: Any, policy: dict[str, Any]) -> dict[str, Any]: packages = _require_list(document.get("packages"), "SPDX packages") if not packages: raise ReleaseValidationError("SBOM package inventory is empty") - return {"spdx_version": document["spdxVersion"], "package_count": len(packages)} + if (expected_role is None) != (expected_manifest_digest is None): + raise ReleaseValidationError( + "SPDX expected role and manifest digest must be supplied together" + ) + if expected_role is None: + return {"spdx_version": document["spdxVersion"], "package_count": len(packages)} + + expected_root = spdx_image_root_package(expected_role, expected_manifest_digest) + package_ids: list[str] = [] + root_by_id: list[dict[str, Any]] = [] + root_by_digest: list[dict[str, Any]] = [] + for index, raw_package in enumerate(packages): + package = _require_mapping(raw_package, f"SPDX package {index}") + spdx_id = package.get("SPDXID") + if not isinstance(spdx_id, str) or not spdx_id: + raise ReleaseValidationError(f"SPDX package {index} identity is missing") + package_ids.append(spdx_id) + if spdx_id == expected_root["SPDXID"]: + root_by_id.append(package) + if package.get("versionInfo") == expected_manifest_digest: + root_by_digest.append(package) + if len(package_ids) != len(set(package_ids)): + raise ReleaseValidationError("SPDX package identities are duplicated") + if len(root_by_id) != 1 or len(root_by_digest) != 1 or root_by_id[0] is not root_by_digest[0]: + raise ReleaseValidationError( + "SPDX SBOM must contain exactly one deterministic root bound to the OCI manifest digest" + ) + root = root_by_id[0] + if set(root) != set(expected_root): + raise ReleaseValidationError("SPDX image root fields are missing or unknown") + for key, value in expected_root.items(): + if root.get(key) != value: + raise ReleaseValidationError(f"SPDX image root field drift: {key}") + if document.get("documentDescribes") != [expected_root["SPDXID"]]: + raise ReleaseValidationError( + "SPDX documentDescribes must identify exactly the deterministic image root" + ) + return { + "spdx_version": document["spdxVersion"], + "package_count": len(packages), + "non_root_package_count": len(packages) - 1, + "root_package_spdx_id": expected_root["SPDXID"], + "root_package_name": expected_root["name"], + "root_role": expected_role, + "root_manifest_digest": expected_manifest_digest, + } def validate_trivy_scan( diff --git a/yuqei-infra/scripts/run-linux-release-build.py b/yuqei-infra/scripts/run-linux-release-build.py index 699b622d..ad5fd3dc 100644 --- a/yuqei-infra/scripts/run-linux-release-build.py +++ b/yuqei-infra/scripts/run-linux-release-build.py @@ -45,6 +45,7 @@ from linux_release_common import ( # noqa: E402 TARGET, TRIVY_DB_REPOSITORY_TRANSPORT, ReleaseValidationError, + bind_spdx_image_root, canonical_json_bytes, controlled_subprocess_env, file_record, @@ -1479,7 +1480,25 @@ def execute_build( timeout_seconds=COMMAND_TIMEOUTS_SECONDS["sbom_generation"], ) sbom_payload = load_json_file(sbom_path, label=f"{role} SPDX SBOM") - sbom_summary = validate_spdx(sbom_payload, policy) + sbom_payload = bind_spdx_image_root( + sbom_payload, + role=role, + manifest_digest=oci_summary["manifest_digest"], + ) + canonical_sbom_path = sbom_path.with_name(sbom_path.name + ".canonical") + _write_bytes_exclusive( + canonical_sbom_path, canonical_json_bytes(sbom_payload) + ) + os.replace(canonical_sbom_path, sbom_path) + sbom_payload = load_json_file( + sbom_path, label=f"{role} canonical SPDX SBOM" + ) + sbom_summary = validate_spdx( + sbom_payload, + policy, + expected_role=role, + expected_manifest_digest=oci_summary["manifest_digest"], + ) # A database that was fresh when the build began may expire while # four images are built. Re-evaluate freshness immediately before diff --git a/yuqei-infra/tests/test_installable_release_assembler_hardening.py b/yuqei-infra/tests/test_installable_release_assembler_hardening.py index 31c7f8ab..416f57b9 100644 --- a/yuqei-infra/tests/test_installable_release_assembler_hardening.py +++ b/yuqei-infra/tests/test_installable_release_assembler_hardening.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util import errno +import json import os import stat import sys @@ -133,6 +134,42 @@ def test_receipt_license_inventory_aggregate_drift_is_rejected(tmp_path: Path) - ) +@pytest.mark.parametrize("mode", ["missing", "wrong-digest", "duplicate-root"]) +def test_installable_rejects_invalid_sbom_image_root( + tmp_path: Path, + mode: str, +) -> None: + bundle = build_helpers._bundle(tmp_path) + image = bundle["receipt"]["images"][0] + sbom_record = image["sbom"] + sbom_path = bundle["root"] / sbom_record["path"] + payload = json.loads(sbom_path.read_text(encoding="utf-8")) + root_id = sbom_record["root_package_spdx_id"] + root = next(item for item in payload["packages"] if item["SPDXID"] == root_id) + if mode == "missing": + payload["packages"].remove(root) + payload["documentDescribes"] = [payload["packages"][0]["SPDXID"]] + elif mode == "wrong-digest": + root["versionInfo"] = "sha256:" + "f" * 64 + else: + duplicate = dict(root) + duplicate["SPDXID"] = "SPDXRef-Yuqei-Duplicate-Image-Root" + payload["packages"].append(duplicate) + sbom_path.write_bytes(build_helpers.common.canonical_json_bytes(payload)) + sbom_record["sha256"] = build_helpers.common.sha256_file(sbom_path) + sbom_record["size_bytes"] = sbom_path.stat().st_size + build_helpers._rewrite_receipt(bundle) + + with pytest.raises( + assembler.ReleaseValidationError, + match="exactly one deterministic root", + ): + build_helpers._assemble_bundle( + bundle, + output=bundle["root"] / "installable-release-manifest.json", + ) + + def test_exclusive_writer_refuses_to_overwrite_existing_file(tmp_path: Path) -> None: output = tmp_path / "installable-release-manifest.json" output.write_bytes(b"trusted-existing-bytes") diff --git a/yuqei-infra/tests/test_linux_release_build_tools.py b/yuqei-infra/tests/test_linux_release_build_tools.py index a09e93c8..eb5a653c 100644 --- a/yuqei-infra/tests/test_linux_release_build_tools.py +++ b/yuqei-infra/tests/test_linux_release_build_tools.py @@ -373,15 +373,29 @@ def _write_docker_archive( return common.inspect_docker_archive(path, expected_repo_tag=image_ref) -def _spdx(role: str) -> dict[str, Any]: - return { +def _spdx(role: str, manifest_digest: str | None = None) -> dict[str, Any]: + payload = { "spdxVersion": "SPDX-2.3", "SPDXID": "SPDXRef-DOCUMENT", "name": role, "documentNamespace": f"https://example.invalid/spdx/{role}", "creationInfo": {"creators": ["Tool: syft-1.52.0"], "created": "2026-09-20T00:05:00Z"}, - "packages": [{"SPDXID": f"SPDXRef-Package-{role}", "name": role}], + "documentDescribes": [f"SPDXRef-Package-{role}"], + "packages": [ + { + "SPDXID": f"SPDXRef-Package-{role}", + "name": f"fixture-component-{role}", + "versionInfo": "1.0.0", + } + ], } + if manifest_digest is not None: + common.bind_spdx_image_root( + payload, + role=role, + manifest_digest=manifest_digest, + ) + return payload def _scan( @@ -755,8 +769,14 @@ def _bundle(tmp_path: Path) -> dict[str, Any]: == oci_summary["rootfs_identity_sha256"] ) sbom_path = root / f"sbom/{role}.spdx.json" - _write(sbom_path, common.canonical_json_bytes(_spdx(role))) - sbom_summary = common.validate_spdx(_spdx(role), policy) + sbom_payload = _spdx(role, oci_summary["manifest_digest"]) + _write(sbom_path, common.canonical_json_bytes(sbom_payload)) + sbom_summary = common.validate_spdx( + sbom_payload, + policy, + expected_role=role, + expected_manifest_digest=oci_summary["manifest_digest"], + ) scan_path = root / f"scans/{role}.trivy.json" scan_payload = _scan(role + ".docker.tar", image_id=oci_summary["config_digest"]) _write(scan_path, common.canonical_json_bytes(scan_payload)) @@ -1776,6 +1796,66 @@ def test_trivy_scan_fails_closed_on_parse_error_or_high_critical() -> None: common.validate_trivy_scan(report, policy) +def test_spdx_image_root_binding_preserves_all_syft_packages() -> None: + role = "contract-api" + manifest_digest = "sha256:" + "a" * 64 + payload = _spdx(role) + original_packages = json.loads(json.dumps(payload["packages"])) + + bound = common.bind_spdx_image_root( + payload, + role=role, + manifest_digest=manifest_digest, + ) + summary = common.validate_spdx( + bound, + common.load_policy(POLICY_PATH), + expected_role=role, + expected_manifest_digest=manifest_digest, + ) + + assert bound["packages"][:-1] == original_packages + assert len(bound["packages"]) == len(original_packages) + 1 + assert summary["package_count"] == len(original_packages) + 1 + assert summary["non_root_package_count"] == len(original_packages) + assert summary["root_role"] == role + assert summary["root_manifest_digest"] == manifest_digest + assert bound["documentDescribes"] == [summary["root_package_spdx_id"]] + assert common.load_json_bytes( + common.canonical_json_bytes(bound), label="canonical bound fixture SBOM" + ) == bound + + +@pytest.mark.parametrize("mode", ["missing", "wrong-digest", "duplicate-root"]) +def test_spdx_image_root_binding_fails_closed(mode: str) -> None: + role = "contract-api" + expected_digest = "sha256:" + "a" * 64 + payload = _spdx(role) + if mode == "wrong-digest": + common.bind_spdx_image_root( + payload, + role=role, + manifest_digest="sha256:" + "b" * 64, + ) + elif mode == "duplicate-root": + common.bind_spdx_image_root( + payload, + role=role, + manifest_digest=expected_digest, + ) + duplicate = dict(payload["packages"][-1]) + duplicate["SPDXID"] = "SPDXRef-Yuqei-Duplicate-Image-Root" + payload["packages"].append(duplicate) + + with pytest.raises(common.ReleaseValidationError, match="exactly one deterministic root"): + common.validate_spdx( + payload, + common.load_policy(POLICY_PATH), + expected_role=role, + expected_manifest_digest=expected_digest, + ) + + @pytest.mark.parametrize( ("mutator", "expected"), [ @@ -2144,9 +2224,28 @@ def test_installable_manifest_revalidates_complete_bundle(tmp_path: Path) -> Non and image["license_scan"]["passed"] is True for image in manifest["images"] ) + assert all( + image["sbom"]["root_role"] == image["role"] + and image["sbom"]["root_manifest_digest"] == image["manifest_digest"] + and image["sbom"]["package_count"] + == image["sbom"]["non_root_package_count"] + 1 + and image["sbom"]["root_package_spdx_id"] + == f"SPDXRef-Yuqei-Release-Image-{image['role']}" + for image in manifest["images"] + ) for manifest_image, receipt_image in zip( manifest["images"], bundle["receipt"]["images"], strict=True ): + manifest_sbom = manifest_image["sbom"] + receipt_sbom = receipt_image["sbom"] + assert { + key: manifest_sbom[key] for key in ("path", "sha256", "size_bytes") + } == { + key: receipt_sbom[key] for key in ("path", "sha256", "size_bytes") + } + sbom_path = bundle["root"] / manifest_sbom["path"] + assert common.sha256_file(sbom_path) == manifest_sbom["sha256"] + assert sbom_path.stat().st_size == manifest_sbom["size_bytes"] manifest_record = manifest_image["license_scan"] receipt_record = receipt_image["license_scan"] assert {