security: bind application SBOM roots to image manifests
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user