security: bind application SBOM roots to image manifests

This commit is contained in:
2026-09-27 04:43:48 +08:00
parent bb692ed4bf
commit a69dd9093b
5 changed files with 285 additions and 10 deletions
@@ -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]
+103 -2
View File
@@ -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(
+20 -1
View File
@@ -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
@@ -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")
@@ -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 {