1376 lines
56 KiB
Python
1376 lines
56 KiB
Python
#!/usr/bin/env python3
|
|
"""Build a deterministic, offline-verifiable infrastructure image bundle manifest.
|
|
|
|
The tool is deliberately a pure file validator. It never invokes Docker and
|
|
never contacts a registry. Registry manifest bytes and Docker archives must
|
|
already be present below ``--artifact-root`` and are verified before a bundle
|
|
manifest is emitted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gzip
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
import sys
|
|
import tarfile
|
|
from datetime import datetime
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any, Mapping
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
if str(SCRIPT_DIR) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
|
|
from linux_release_common import ( # noqa: E402
|
|
REQUIRED_TOOL_VERSIONS,
|
|
TRIVY_DB_REPOSITORY_TRANSPORT,
|
|
ReleaseValidationError,
|
|
load_policy,
|
|
parse_rfc3339,
|
|
validate_trivy_db_metadata,
|
|
validate_trivy_license_scan,
|
|
validate_trivy_scan,
|
|
)
|
|
|
|
SOURCE_SET_SCHEMA = "yuqei.infrastructure-image-source-set.v2"
|
|
BUNDLE_SCHEMA = "yuqei.infrastructure-image-bundle.v2"
|
|
APPLICATION_MANIFEST_SCHEMA = "yuqei.installable-release-manifest.v1"
|
|
IMPORT_PLAN_SCHEMA = "yuqei.infrastructure-docker-image-import-plan.v1"
|
|
TARGET = {"os": "linux", "architecture": "amd64", "platform": "linux/amd64"}
|
|
DOCKER_CLI = "/usr/bin/docker"
|
|
DOCKER_HOST = "unix:///var/run/docker.sock"
|
|
MAX_JSON_BYTES = 8 * 1024 * 1024
|
|
MAX_ARCHIVE_MEMBERS = 100_000
|
|
MAX_LAYER_MEMBERS = 200_000
|
|
MAX_UNCOMPRESSED_LAYER_BYTES = 8 * 1024 * 1024 * 1024
|
|
|
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
REFERENCE_RE = re.compile(
|
|
r"^[a-z0-9](?:[a-z0-9._:/-]{0,510}[a-z0-9])?@sha256:[0-9a-f]{64}$"
|
|
)
|
|
TAG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._/-]{0,510}[a-z0-9])?:[^@\s]{1,128}$")
|
|
CONTENT_ADDRESSED_MEMBER_RE = re.compile(r"^blobs/sha256/([0-9a-f]{64})$")
|
|
LEGACY_CONFIG_MEMBER_RE = re.compile(r"^([0-9a-f]{64})\.json$")
|
|
LEGACY_LAYER_MEMBER_RE = re.compile(r"^([0-9a-f]{64})/layer\.tar$")
|
|
CONTENT_ADDRESSED_ARCHIVE_PROFILE = "content_addressed_gzip_sha256_v1"
|
|
LEGACY_ARCHIVE_PROFILE = "legacy_docker_save_uncompressed_v1"
|
|
|
|
APPLICATION_SPECS = (
|
|
("contract-web", "CONTRACT_WEB_IMAGE"),
|
|
("contract-api", "CONTRACT_API_IMAGE"),
|
|
("ai-platform-api", "AI_PLATFORM_API_IMAGE"),
|
|
("ai-worker", "AI_WORKER_IMAGE"),
|
|
)
|
|
INFRASTRUCTURE_SPECS = (
|
|
("postgres-runtime", "POSTGRES_IMAGE", ("postgres",)),
|
|
("postgres-client", "POSTGRES_CLIENT_IMAGE", ("postgres-init-databases",)),
|
|
("redis", "REDIS_IMAGE", ("redis", "ai-redis")),
|
|
("minio", "MINIO_IMAGE", ("minio",)),
|
|
("minio-client", "MINIO_MC_IMAGE", ("minio-backup",)),
|
|
("nginx-edge", "NGINX_IMAGE", ("edge",)),
|
|
("backup-utility", "BACKUP_UTILITY_IMAGE", ("contract-files-backup",)),
|
|
)
|
|
ALL_IMAGE_KEYS = tuple(key for _role, key in APPLICATION_SPECS) + tuple(
|
|
key for _role, key, _services in INFRASTRUCTURE_SPECS
|
|
)
|
|
|
|
|
|
class BundleValidationError(RuntimeError):
|
|
"""Raised when a staged image artifact is incomplete or ambiguous."""
|
|
|
|
|
|
def canonical_json_bytes(value: object) -> bytes:
|
|
try:
|
|
return json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
allow_nan=False,
|
|
).encode("utf-8")
|
|
except (TypeError, ValueError) as exc:
|
|
raise BundleValidationError("value cannot be encoded as canonical JSON") from exc
|
|
|
|
|
|
def canonical_sha256(value: object) -> str:
|
|
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
|
|
|
|
|
def file_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 _reject_constant(value: str) -> None:
|
|
raise BundleValidationError(f"non-finite JSON value is forbidden: {value}")
|
|
|
|
|
|
def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
result: dict[str, Any] = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise BundleValidationError(f"duplicate JSON key is forbidden: {key}")
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def load_json(path: Path, *, label: str) -> dict[str, Any]:
|
|
target = _regular_file(path, label=label, max_bytes=MAX_JSON_BYTES)
|
|
try:
|
|
payload = json.loads(
|
|
target.read_text(encoding="utf-8-sig"),
|
|
object_pairs_hook=_reject_duplicate_pairs,
|
|
parse_constant=_reject_constant,
|
|
)
|
|
except BundleValidationError:
|
|
raise
|
|
except (OSError, UnicodeError, ValueError) as exc:
|
|
raise BundleValidationError(f"{label} is not strict UTF-8 JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise BundleValidationError(f"{label} must be a JSON object")
|
|
return payload
|
|
|
|
|
|
def _absolute_lexical(path: Path) -> Path:
|
|
return Path(os.path.abspath(os.fspath(path)))
|
|
|
|
|
|
def _reject_symlink_components(path: Path, *, label: str) -> None:
|
|
absolute = _absolute_lexical(path)
|
|
current = Path(absolute.anchor)
|
|
for part in absolute.parts[1:]:
|
|
current /= part
|
|
try:
|
|
if current.exists() and current.is_symlink():
|
|
raise BundleValidationError(f"{label} contains a symbolic link: {current}")
|
|
except OSError as exc:
|
|
raise BundleValidationError(f"cannot inspect {label} path") from exc
|
|
|
|
|
|
def _regular_file(path: Path, *, label: str, max_bytes: int | None = None) -> Path:
|
|
lexical = _absolute_lexical(path)
|
|
_reject_symlink_components(lexical, label=label)
|
|
try:
|
|
resolved = lexical.resolve(strict=True)
|
|
metadata = resolved.stat()
|
|
except OSError as exc:
|
|
raise BundleValidationError(f"{label} is missing") from exc
|
|
if lexical.is_symlink() or not stat.S_ISREG(metadata.st_mode):
|
|
raise BundleValidationError(f"{label} must be a regular file")
|
|
if metadata.st_nlink != 1:
|
|
raise BundleValidationError(f"{label} must not be hard linked")
|
|
if max_bytes is not None and (metadata.st_size <= 0 or metadata.st_size > max_bytes):
|
|
raise BundleValidationError(f"{label} has an invalid size")
|
|
return resolved
|
|
|
|
|
|
def _artifact_root(path: Path) -> Path:
|
|
lexical = _absolute_lexical(path)
|
|
_reject_symlink_components(lexical, label="artifact root")
|
|
try:
|
|
resolved = lexical.resolve(strict=True)
|
|
except OSError as exc:
|
|
raise BundleValidationError("artifact root is missing") from exc
|
|
if lexical.is_symlink() or not resolved.is_dir():
|
|
raise BundleValidationError("artifact root must be a real directory")
|
|
return resolved
|
|
|
|
|
|
def _relative_path(value: object, *, label: str) -> str:
|
|
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
|
|
raise BundleValidationError(f"{label} must be a canonical POSIX relative path")
|
|
pure = PurePosixPath(value)
|
|
if pure.is_absolute() or pure.as_posix() != value or any(
|
|
part in {"", ".", ".."} for part in pure.parts
|
|
):
|
|
raise BundleValidationError(f"{label} is unsafe")
|
|
return value
|
|
|
|
|
|
def _artifact_file(root: Path, value: object, *, label: str) -> tuple[Path, str]:
|
|
relative = _relative_path(value, label=label)
|
|
lexical = root.joinpath(*PurePosixPath(relative).parts)
|
|
target = _regular_file(lexical, label=label)
|
|
try:
|
|
target.relative_to(root)
|
|
except ValueError as exc:
|
|
raise BundleValidationError(f"{label} escapes the artifact root") from exc
|
|
return target, relative
|
|
|
|
|
|
def _digest(value: object, *, label: str) -> str:
|
|
if not isinstance(value, str) or not DIGEST_RE.fullmatch(value):
|
|
raise BundleValidationError(f"{label} must be a sha256 digest")
|
|
return value
|
|
|
|
|
|
def _reference(value: object, *, label: str) -> str:
|
|
if not isinstance(value, str) or not REFERENCE_RE.fullmatch(value):
|
|
raise BundleValidationError(f"{label} must be an immutable image reference")
|
|
return value
|
|
|
|
|
|
def _tag(value: object, *, label: str) -> str:
|
|
if not isinstance(value, str) or not TAG_RE.fullmatch(value):
|
|
raise BundleValidationError(f"{label} must contain an explicit image tag")
|
|
tag = value.rsplit(":", 1)[1].casefold()
|
|
if tag == "latest":
|
|
raise BundleValidationError(f"{label} must not use latest")
|
|
return value
|
|
|
|
|
|
def _embedded_hash(payload: Mapping[str, Any], field: str, *, label: str) -> None:
|
|
expected = payload.get(field)
|
|
if not isinstance(expected, str) or not SHA256_RE.fullmatch(expected):
|
|
raise BundleValidationError(f"{label} has no valid {field}")
|
|
unsigned = {key: value for key, value in payload.items() if key != field}
|
|
if canonical_sha256(unsigned) != expected:
|
|
raise BundleValidationError(f"{label} payload hash mismatch")
|
|
|
|
|
|
def _file_record(root: Path, value: object, *, label: str) -> tuple[Path, dict[str, Any]]:
|
|
if not isinstance(value, dict) or set(value) != {"path", "sha256", "size_bytes"}:
|
|
raise BundleValidationError(f"{label} record fields are missing or unknown")
|
|
target, relative = _artifact_file(root, value.get("path"), label=label)
|
|
size = value.get("size_bytes")
|
|
digest = value.get("sha256")
|
|
if not isinstance(size, int) or isinstance(size, bool) or size <= 0:
|
|
raise BundleValidationError(f"{label} size is invalid")
|
|
if not isinstance(digest, str) or not SHA256_RE.fullmatch(digest):
|
|
raise BundleValidationError(f"{label} SHA-256 is invalid")
|
|
if target.stat().st_size != size or file_sha256(target) != digest:
|
|
raise BundleValidationError(f"{label} size or SHA-256 mismatch")
|
|
return target, {"path": relative, "sha256": digest, "size_bytes": size}
|
|
|
|
|
|
def _extended_file_record(
|
|
root: Path,
|
|
value: object,
|
|
*,
|
|
extra_fields: set[str],
|
|
label: str,
|
|
) -> tuple[Path, dict[str, Any], Mapping[str, Any]]:
|
|
if not isinstance(value, dict) or set(value) != {
|
|
"path",
|
|
"sha256",
|
|
"size_bytes",
|
|
*extra_fields,
|
|
}:
|
|
raise BundleValidationError(f"{label} record fields are missing or unknown")
|
|
base = {key: value[key] for key in ("path", "sha256", "size_bytes")}
|
|
target, normalized = _file_record(root, base, label=label)
|
|
return target, normalized, value
|
|
|
|
|
|
def _validate_database_transport(value: object) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise BundleValidationError("Trivy database repository transport must be an object")
|
|
expected_fields = {
|
|
"reference",
|
|
"transport",
|
|
"schema_version",
|
|
"resolved_digest",
|
|
"media_type",
|
|
"stable_during_download",
|
|
"before_download_inspect_sha256",
|
|
"after_download_inspect_sha256",
|
|
}
|
|
if set(value) != expected_fields:
|
|
raise BundleValidationError("Trivy database repository transport fields drifted")
|
|
if (
|
|
value.get("reference") != TRIVY_DB_REPOSITORY_TRANSPORT["reference"]
|
|
or value.get("transport") != TRIVY_DB_REPOSITORY_TRANSPORT["transport"]
|
|
or value.get("schema_version")
|
|
!= TRIVY_DB_REPOSITORY_TRANSPORT["schema_version"]
|
|
or value.get("media_type")
|
|
not in TRIVY_DB_REPOSITORY_TRANSPORT["allowed_media_types"]
|
|
or value.get("stable_during_download") is not True
|
|
or not DIGEST_RE.fullmatch(str(value.get("resolved_digest", "")))
|
|
or any(
|
|
not SHA256_RE.fullmatch(str(value.get(field, "")))
|
|
for field in (
|
|
"before_download_inspect_sha256",
|
|
"after_download_inspect_sha256",
|
|
)
|
|
)
|
|
):
|
|
raise BundleValidationError("Trivy database repository transport evidence drifted")
|
|
return dict(value)
|
|
|
|
|
|
def _validate_security_gate(
|
|
root: Path,
|
|
value: object,
|
|
*,
|
|
application_manifest: Mapping[str, Any],
|
|
) -> tuple[dict[str, Any], dict[str, Any], Mapping[str, Any]]:
|
|
if not isinstance(value, dict) or set(value) != {
|
|
"policy",
|
|
"database",
|
|
"scanner",
|
|
"scanner_version",
|
|
}:
|
|
raise BundleValidationError("infrastructure security gate fields are missing or unknown")
|
|
if (
|
|
value.get("scanner") != "trivy"
|
|
or value.get("scanner_version") != REQUIRED_TOOL_VERSIONS["trivy"]
|
|
):
|
|
raise BundleValidationError("infrastructure scanner identity is not pinned")
|
|
policy_path, policy_record = _file_record(
|
|
root,
|
|
value.get("policy"),
|
|
label="infrastructure vulnerability policy",
|
|
)
|
|
application_policy = application_manifest.get("policy")
|
|
if (
|
|
not isinstance(application_policy, Mapping)
|
|
or application_policy.get("sha256") != policy_record["sha256"]
|
|
):
|
|
raise BundleValidationError(
|
|
"infrastructure security policy differs from the application build policy"
|
|
)
|
|
try:
|
|
policy = load_policy(policy_path)
|
|
except (ReleaseValidationError, OSError) as exc:
|
|
raise BundleValidationError(
|
|
f"infrastructure vulnerability policy is invalid: {exc}"
|
|
) from exc
|
|
database_path, database_record, raw_database_record = _extended_file_record(
|
|
root,
|
|
value.get("database"),
|
|
extra_fields={"validated_at", "repository_transport"},
|
|
label="infrastructure Trivy database metadata",
|
|
)
|
|
try:
|
|
validated_at = parse_rfc3339(
|
|
raw_database_record.get("validated_at"),
|
|
label="infrastructure database.validated_at",
|
|
)
|
|
database_summary = validate_trivy_db_metadata(
|
|
load_json(database_path, label="infrastructure Trivy database metadata"),
|
|
policy,
|
|
at=validated_at,
|
|
)
|
|
except (ReleaseValidationError, BundleValidationError) as exc:
|
|
raise BundleValidationError(
|
|
f"infrastructure Trivy database evidence is invalid: {exc}"
|
|
) from exc
|
|
database_transport = _validate_database_transport(
|
|
raw_database_record.get("repository_transport")
|
|
)
|
|
return (
|
|
policy,
|
|
{
|
|
"scanner": "trivy",
|
|
"scanner_version": REQUIRED_TOOL_VERSIONS["trivy"],
|
|
"policy": policy_record,
|
|
"database": {
|
|
**database_record,
|
|
"validated_at": raw_database_record["validated_at"],
|
|
"repository_transport": database_transport,
|
|
"summary": database_summary,
|
|
},
|
|
},
|
|
load_json(database_path, label="infrastructure Trivy database metadata"),
|
|
)
|
|
|
|
|
|
def _safe_tar_name(value: str, *, label: str) -> str:
|
|
if "\\" in value or "\x00" in value:
|
|
raise BundleValidationError(f"{label} contains an unsafe member path")
|
|
pure = PurePosixPath(value)
|
|
if pure.is_absolute() or any(part in {"", ".", ".."} for part in pure.parts):
|
|
raise BundleValidationError(f"{label} contains an unsafe member path")
|
|
return pure.as_posix()
|
|
|
|
|
|
class _HashingLimitedReader:
|
|
def __init__(self, stream: Any, *, label: str) -> None:
|
|
self._stream = stream
|
|
self._label = label
|
|
self._digest = hashlib.sha256()
|
|
self.size = 0
|
|
|
|
def read(self, size: int = -1) -> bytes:
|
|
data = self._stream.read(size)
|
|
if data:
|
|
self.size += len(data)
|
|
if self.size > MAX_UNCOMPRESSED_LAYER_BYTES:
|
|
raise BundleValidationError(f"{self._label} exceeds the uncompressed size limit")
|
|
self._digest.update(data)
|
|
return data
|
|
|
|
def sha256_digest(self) -> str:
|
|
return "sha256:" + self._digest.hexdigest()
|
|
|
|
|
|
class _PrefixedReader:
|
|
"""Replay bytes consumed for format detection without buffering a layer."""
|
|
|
|
def __init__(self, prefix: bytes, stream: Any) -> None:
|
|
self._prefix = io.BytesIO(prefix)
|
|
self._stream = stream
|
|
|
|
def read(self, size: int = -1) -> bytes:
|
|
if size is None or size < 0:
|
|
return self._prefix.read() + self._stream.read()
|
|
if size == 0:
|
|
return b""
|
|
head = self._prefix.read(size)
|
|
if len(head) == size:
|
|
return head
|
|
return head + self._stream.read(size - len(head))
|
|
|
|
|
|
def _validate_layer_link_target(value: object, *, label: str) -> str:
|
|
# Layer links are image-root metadata, not host filesystem paths. Absolute
|
|
# symlinks (for example /bin/busybox) and parent-relative symlinks are
|
|
# common in genuine Linux base images, so rejecting them creates false
|
|
# negatives. Docker remains responsible for safe extraction; this pure-file
|
|
# validator only rejects malformed/control-bearing link metadata.
|
|
if (
|
|
not isinstance(value, str)
|
|
or not value
|
|
or len(value.encode("utf-8", errors="surrogatepass")) > 4096
|
|
or "\x00" in value
|
|
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
|
):
|
|
raise BundleValidationError(f"{label} contains an invalid link target")
|
|
return value
|
|
|
|
|
|
def _content_addressed_member_digest(name: str) -> str | None:
|
|
match = CONTENT_ADDRESSED_MEMBER_RE.fullmatch(name)
|
|
return f"sha256:{match.group(1)}" if match else None
|
|
|
|
|
|
def _docker_archive_profile(
|
|
*,
|
|
config_name: str,
|
|
expected_config_hex: str,
|
|
layers: list[str],
|
|
) -> str:
|
|
"""Lock one archive layout from its config member and reject mixed layouts."""
|
|
|
|
content_addressed_config = f"blobs/sha256/{expected_config_hex}"
|
|
legacy_config = f"{expected_config_hex}.json"
|
|
if config_name == content_addressed_config:
|
|
if any(CONTENT_ADDRESSED_MEMBER_RE.fullmatch(layer) is None for layer in layers):
|
|
raise BundleValidationError(
|
|
"content-addressed Docker archive config requires every layer to use "
|
|
"the blobs/sha256/<64lower> layout"
|
|
)
|
|
return CONTENT_ADDRESSED_ARCHIVE_PROFILE
|
|
if config_name == legacy_config and LEGACY_CONFIG_MEMBER_RE.fullmatch(config_name):
|
|
if any(LEGACY_LAYER_MEMBER_RE.fullmatch(layer) is None for layer in layers):
|
|
raise BundleValidationError(
|
|
"legacy Docker archive config requires every layer to use the "
|
|
"<64lower>/layer.tar layout"
|
|
)
|
|
return LEGACY_ARCHIVE_PROFILE
|
|
raise BundleValidationError(
|
|
"Docker archive config member does not match a supported archive profile"
|
|
)
|
|
|
|
|
|
def _inspect_uncompressed_layer(
|
|
stream: Any,
|
|
*,
|
|
expected_diff_id: str,
|
|
label: str,
|
|
) -> dict[str, Any]:
|
|
reader = _HashingLimitedReader(stream, label=label)
|
|
names: set[str] = set()
|
|
folded: set[str] = set()
|
|
member_count = 0
|
|
link_count = 0
|
|
try:
|
|
with tarfile.open(fileobj=reader, mode="r|") as layer:
|
|
for member in layer:
|
|
member_count += 1
|
|
if member_count > MAX_LAYER_MEMBERS:
|
|
raise BundleValidationError(f"{label} has too many members")
|
|
name = _safe_tar_name(member.name, label=label)
|
|
if name in names or name.casefold() in folded:
|
|
raise BundleValidationError(f"{label} has duplicate member paths")
|
|
names.add(name)
|
|
folded.add(name.casefold())
|
|
if member.issym():
|
|
_validate_layer_link_target(member.linkname, label=label)
|
|
link_count += 1
|
|
elif member.islnk():
|
|
hardlink_target = _validate_layer_link_target(member.linkname, label=label)
|
|
_safe_tar_name(hardlink_target, label=f"{label} hardlink")
|
|
link_count += 1
|
|
elif not (member.isfile() or member.isdir()):
|
|
raise BundleValidationError(f"{label} contains a link or special entry")
|
|
while reader.read(1024 * 1024):
|
|
pass
|
|
except BundleValidationError:
|
|
raise
|
|
except (tarfile.TarError, OSError, EOFError) as exc:
|
|
raise BundleValidationError(f"{label} is not a valid uncompressed tar layer") from exc
|
|
if member_count == 0:
|
|
raise BundleValidationError(f"{label} is an empty tar layer")
|
|
observed = reader.sha256_digest()
|
|
if observed != expected_diff_id:
|
|
raise BundleValidationError(f"{label} diff ID mismatch")
|
|
return {
|
|
"diff_id": observed,
|
|
"uncompressed_size_bytes": reader.size,
|
|
"member_count": member_count,
|
|
"link_count": link_count,
|
|
}
|
|
|
|
|
|
def _inspect_docker_archive_layer(
|
|
stream: Any,
|
|
*,
|
|
expected_diff_id: str,
|
|
expected_blob_digest: str | None,
|
|
label: str,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
compressed_reader = _HashingLimitedReader(stream, label=f"{label} archive blob")
|
|
prefix = compressed_reader.read(2)
|
|
replay = _PrefixedReader(prefix, compressed_reader)
|
|
if prefix == b"\x1f\x8b":
|
|
with gzip.GzipFile(fileobj=replay, mode="rb") as expanded:
|
|
summary = {
|
|
"archive_encoding": "gzip",
|
|
**_inspect_uncompressed_layer(
|
|
expanded,
|
|
expected_diff_id=expected_diff_id,
|
|
label=label,
|
|
),
|
|
}
|
|
else:
|
|
summary = {
|
|
"archive_encoding": "uncompressed",
|
|
**_inspect_uncompressed_layer(
|
|
replay,
|
|
expected_diff_id=expected_diff_id,
|
|
label=label,
|
|
),
|
|
}
|
|
while compressed_reader.read(1024 * 1024):
|
|
pass
|
|
observed_blob_digest = compressed_reader.sha256_digest()
|
|
if expected_blob_digest is not None and observed_blob_digest != expected_blob_digest:
|
|
raise BundleValidationError(f"{label} content-addressed blob digest mismatch")
|
|
return {
|
|
**summary,
|
|
"archive_blob_digest": observed_blob_digest,
|
|
"archive_blob_size_bytes": compressed_reader.size,
|
|
"content_addressed": expected_blob_digest is not None,
|
|
}
|
|
except BundleValidationError:
|
|
raise
|
|
except (OSError, EOFError, gzip.BadGzipFile) as exc:
|
|
raise BundleValidationError(f"{label} is not a valid gzip layer") from exc
|
|
|
|
|
|
def _inspect_registry_blob(
|
|
path: Path,
|
|
*,
|
|
media_type: str,
|
|
expected_diff_id: str,
|
|
label: str,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
with path.open("rb") as raw:
|
|
if media_type in {
|
|
"application/vnd.docker.image.rootfs.diff.tar.gzip",
|
|
"application/vnd.oci.image.layer.v1.tar+gzip",
|
|
}:
|
|
with gzip.GzipFile(fileobj=raw, mode="rb") as expanded:
|
|
summary = _inspect_uncompressed_layer(
|
|
expanded,
|
|
expected_diff_id=expected_diff_id,
|
|
label=label,
|
|
)
|
|
elif media_type in {
|
|
"application/vnd.oci.image.layer.v1.tar",
|
|
"application/vnd.docker.image.rootfs.diff.tar",
|
|
}:
|
|
summary = _inspect_uncompressed_layer(
|
|
raw,
|
|
expected_diff_id=expected_diff_id,
|
|
label=label,
|
|
)
|
|
else:
|
|
raise BundleValidationError(f"{label} uses an unsupported layer media type")
|
|
except BundleValidationError:
|
|
raise
|
|
except (OSError, EOFError, gzip.BadGzipFile) as exc:
|
|
raise BundleValidationError(f"{label} cannot be decompressed") from exc
|
|
return {"media_type": media_type, **summary}
|
|
|
|
|
|
def inspect_docker_archive(
|
|
path: Path,
|
|
*,
|
|
expected_repo_tag: str,
|
|
expected_config_digest: str,
|
|
) -> dict[str, Any]:
|
|
"""Inspect a Docker save archive without extracting it or invoking Docker."""
|
|
|
|
try:
|
|
with tarfile.open(path, mode="r:*") as archive:
|
|
members = archive.getmembers()
|
|
if not members or len(members) > MAX_ARCHIVE_MEMBERS:
|
|
raise BundleValidationError("Docker archive has an invalid member count")
|
|
by_name: dict[str, tarfile.TarInfo] = {}
|
|
folded: set[str] = set()
|
|
for member in members:
|
|
name = _safe_tar_name(member.name, label="Docker archive")
|
|
if name in by_name or name.casefold() in folded:
|
|
raise BundleValidationError("Docker archive has duplicate member paths")
|
|
folded.add(name.casefold())
|
|
if not (member.isfile() or member.isdir()):
|
|
raise BundleValidationError("Docker archive contains a link or special entry")
|
|
by_name[name] = member
|
|
manifest_member = by_name.get("manifest.json")
|
|
if manifest_member is None or not manifest_member.isfile():
|
|
raise BundleValidationError("Docker archive has no manifest.json")
|
|
stream = archive.extractfile(manifest_member)
|
|
if stream is None:
|
|
raise BundleValidationError("Docker archive manifest cannot be read")
|
|
manifest_raw = stream.read(MAX_JSON_BYTES + 1)
|
|
if len(manifest_raw) > MAX_JSON_BYTES:
|
|
raise BundleValidationError("Docker archive manifest is too large")
|
|
try:
|
|
manifest = json.loads(
|
|
manifest_raw.decode("utf-8"),
|
|
object_pairs_hook=_reject_duplicate_pairs,
|
|
parse_constant=_reject_constant,
|
|
)
|
|
except BundleValidationError:
|
|
raise
|
|
except (UnicodeError, ValueError) as exc:
|
|
raise BundleValidationError("Docker archive manifest is invalid") from exc
|
|
if not isinstance(manifest, list) or len(manifest) != 1 or not isinstance(manifest[0], dict):
|
|
raise BundleValidationError("Docker archive must contain exactly one image")
|
|
item = manifest[0]
|
|
if set(item) != {"Config", "RepoTags", "Layers"}:
|
|
raise BundleValidationError("Docker archive manifest fields are invalid")
|
|
repo_tags = item.get("RepoTags")
|
|
if repo_tags != [expected_repo_tag]:
|
|
raise BundleValidationError("Docker archive repository tag is not exact")
|
|
config_name = _safe_tar_name(item.get("Config"), label="Docker config")
|
|
config_member = by_name.get(config_name)
|
|
if config_member is None or not config_member.isfile():
|
|
raise BundleValidationError("Docker archive config is missing")
|
|
config_stream = archive.extractfile(config_member)
|
|
if config_stream is None:
|
|
raise BundleValidationError("Docker archive config cannot be read")
|
|
config_raw = config_stream.read(MAX_JSON_BYTES + 1)
|
|
if len(config_raw) > MAX_JSON_BYTES:
|
|
raise BundleValidationError("Docker archive config is too large")
|
|
observed_config_digest = "sha256:" + hashlib.sha256(config_raw).hexdigest()
|
|
if observed_config_digest != expected_config_digest:
|
|
raise BundleValidationError("Docker archive config digest mismatch")
|
|
expected_config_hex = expected_config_digest.removeprefix("sha256:")
|
|
try:
|
|
config = json.loads(
|
|
config_raw.decode("utf-8"),
|
|
object_pairs_hook=_reject_duplicate_pairs,
|
|
parse_constant=_reject_constant,
|
|
)
|
|
except BundleValidationError:
|
|
raise
|
|
except (UnicodeError, ValueError) as exc:
|
|
raise BundleValidationError("Docker archive config JSON is invalid") from exc
|
|
if not isinstance(config, dict) or config.get("os") != "linux" or config.get("architecture") != "amd64":
|
|
raise BundleValidationError("Docker archive platform must be linux/amd64")
|
|
rootfs = config.get("rootfs")
|
|
diff_ids = rootfs.get("diff_ids") if isinstance(rootfs, dict) else None
|
|
if (
|
|
not isinstance(rootfs, dict)
|
|
or rootfs.get("type") != "layers"
|
|
or not isinstance(diff_ids, list)
|
|
or not diff_ids
|
|
or any(not isinstance(item, str) or not DIGEST_RE.fullmatch(item) for item in diff_ids)
|
|
):
|
|
raise BundleValidationError("Docker archive config rootfs diff IDs are invalid")
|
|
layers = item.get("Layers")
|
|
if not isinstance(layers, list) or not layers or any(not isinstance(layer, str) for layer in layers):
|
|
raise BundleValidationError("Docker archive layer inventory is invalid")
|
|
normalized_layers = [_safe_tar_name(layer, label="Docker layer") for layer in layers]
|
|
if len(normalized_layers) != len(set(normalized_layers)):
|
|
raise BundleValidationError("Docker archive repeats a layer path")
|
|
archive_profile = _docker_archive_profile(
|
|
config_name=config_name,
|
|
expected_config_hex=expected_config_hex,
|
|
layers=normalized_layers,
|
|
)
|
|
if any(name not in by_name or not by_name[name].isfile() for name in normalized_layers):
|
|
raise BundleValidationError("Docker archive is missing a declared layer")
|
|
if len(normalized_layers) != len(diff_ids):
|
|
raise BundleValidationError("Docker archive layer order/count differs from config diff IDs")
|
|
layer_inspections: list[dict[str, Any]] = []
|
|
for sequence, (name, diff_id) in enumerate(
|
|
zip(normalized_layers, diff_ids, strict=True), start=1
|
|
):
|
|
layer_stream = archive.extractfile(by_name[name])
|
|
if layer_stream is None:
|
|
raise BundleValidationError("Docker archive layer cannot be read")
|
|
expected_blob_digest = (
|
|
_content_addressed_member_digest(name)
|
|
if archive_profile == CONTENT_ADDRESSED_ARCHIVE_PROFILE
|
|
else None
|
|
)
|
|
layer_inspection = _inspect_docker_archive_layer(
|
|
layer_stream,
|
|
expected_diff_id=diff_id,
|
|
expected_blob_digest=expected_blob_digest,
|
|
label=f"Docker layer {sequence}",
|
|
)
|
|
if (
|
|
archive_profile == CONTENT_ADDRESSED_ARCHIVE_PROFILE
|
|
and layer_inspection["archive_encoding"] != "gzip"
|
|
):
|
|
raise BundleValidationError(
|
|
"content-addressed Docker archive layers must be gzip-compressed blobs"
|
|
)
|
|
if (
|
|
archive_profile == LEGACY_ARCHIVE_PROFILE
|
|
and layer_inspection["archive_encoding"] != "uncompressed"
|
|
):
|
|
raise BundleValidationError(
|
|
"legacy Docker archive layers must be uncompressed layer.tar files"
|
|
)
|
|
layer_inspections.append(
|
|
{
|
|
"sequence": sequence,
|
|
"path": name,
|
|
**layer_inspection,
|
|
}
|
|
)
|
|
except (tarfile.TarError, OSError) as exc:
|
|
raise BundleValidationError("Docker archive cannot be inspected") from exc
|
|
return {
|
|
"archive_profile": archive_profile,
|
|
"config_digest": observed_config_digest,
|
|
"config_size_bytes": len(config_raw),
|
|
"expected_repo_tag": expected_repo_tag,
|
|
"platform": TARGET["platform"],
|
|
"layer_count": len(normalized_layers),
|
|
"diff_ids": list(diff_ids),
|
|
"layers": layer_inspections,
|
|
}
|
|
|
|
|
|
def _validate_registry_manifest(
|
|
path: Path,
|
|
*,
|
|
expected_manifest_digest: str,
|
|
expected_config_digest: str,
|
|
) -> dict[str, Any]:
|
|
raw = path.read_bytes()
|
|
if "sha256:" + hashlib.sha256(raw).hexdigest() != expected_manifest_digest:
|
|
raise BundleValidationError("registry manifest digest mismatch")
|
|
try:
|
|
payload = json.loads(
|
|
raw.decode("utf-8"),
|
|
object_pairs_hook=_reject_duplicate_pairs,
|
|
parse_constant=_reject_constant,
|
|
)
|
|
except BundleValidationError:
|
|
raise
|
|
except (UnicodeError, ValueError) as exc:
|
|
raise BundleValidationError("registry manifest is invalid JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise BundleValidationError("registry manifest must be an object")
|
|
media_type = payload.get("mediaType")
|
|
if media_type not in {
|
|
"application/vnd.docker.distribution.manifest.v2+json",
|
|
"application/vnd.oci.image.manifest.v1+json",
|
|
}:
|
|
raise BundleValidationError("registry artifact is not a resolved image manifest")
|
|
config = payload.get("config")
|
|
if (
|
|
not isinstance(config, dict)
|
|
or config.get("digest") != expected_config_digest
|
|
or not isinstance(config.get("size"), int)
|
|
or isinstance(config.get("size"), bool)
|
|
or config["size"] <= 0
|
|
):
|
|
raise BundleValidationError("registry manifest config digest mismatch")
|
|
layers = payload.get("layers")
|
|
if not isinstance(layers, list) or not layers:
|
|
raise BundleValidationError("registry manifest layer inventory is missing")
|
|
normalized_layers: list[dict[str, Any]] = []
|
|
for layer in layers:
|
|
if (
|
|
not isinstance(layer, dict)
|
|
or not DIGEST_RE.fullmatch(str(layer.get("digest", "")))
|
|
or not isinstance(layer.get("size"), int)
|
|
or isinstance(layer.get("size"), bool)
|
|
or layer["size"] <= 0
|
|
or not isinstance(layer.get("mediaType"), str)
|
|
):
|
|
raise BundleValidationError("registry manifest has an invalid layer digest")
|
|
normalized_layers.append(
|
|
{
|
|
"digest": layer["digest"],
|
|
"size_bytes": layer["size"],
|
|
"media_type": layer["mediaType"],
|
|
}
|
|
)
|
|
return {
|
|
"media_type": media_type,
|
|
"config_size_bytes": config["size"],
|
|
"layer_count": len(layers),
|
|
"layers": normalized_layers,
|
|
}
|
|
|
|
|
|
def _validate_sbom(
|
|
path: Path,
|
|
*,
|
|
role: str,
|
|
expected_manifest_digest: str,
|
|
) -> dict[str, Any]:
|
|
payload = load_json(path, label=f"{role} SBOM")
|
|
if payload.get("spdxVersion") != "SPDX-2.3" or payload.get("SPDXID") != "SPDXRef-DOCUMENT":
|
|
raise BundleValidationError(f"{role} SBOM must be an SPDX-2.3 document")
|
|
described = payload.get("documentDescribes")
|
|
packages = payload.get("packages")
|
|
if not isinstance(described, list) or len(described) != 1 or not isinstance(packages, list) or not packages:
|
|
raise BundleValidationError(f"{role} SBOM must identify exactly one image root package")
|
|
package_ids: set[str] = set()
|
|
root_packages: list[Mapping[str, Any]] = []
|
|
for package in packages:
|
|
if not isinstance(package, dict):
|
|
raise BundleValidationError(f"{role} SBOM package inventory is invalid")
|
|
spdx_id = package.get("SPDXID")
|
|
if not isinstance(spdx_id, str) or not spdx_id or spdx_id in package_ids:
|
|
raise BundleValidationError(f"{role} SBOM package identity is invalid")
|
|
package_ids.add(spdx_id)
|
|
if spdx_id == described[0]:
|
|
root_packages.append(package)
|
|
if len(root_packages) != 1 or root_packages[0].get("versionInfo") != expected_manifest_digest:
|
|
raise BundleValidationError(f"{role} SBOM root does not bind the image manifest digest")
|
|
creation = payload.get("creationInfo")
|
|
creators = creation.get("creators") if isinstance(creation, dict) else None
|
|
if not isinstance(creators, list) or not any("syft" in str(item).casefold() for item in creators):
|
|
raise BundleValidationError(f"{role} SBOM generator identity is missing")
|
|
return {
|
|
"spdx_version": "SPDX-2.3",
|
|
"package_count": len(packages),
|
|
"root_package_spdx_id": described[0],
|
|
"root_manifest_digest": expected_manifest_digest,
|
|
}
|
|
|
|
|
|
def _validate_vulnerability_scan(
|
|
path: Path,
|
|
*,
|
|
role: str,
|
|
expected_config_digest: str,
|
|
expected_artifact_basename: str,
|
|
policy: dict[str, Any],
|
|
evaluated_at: datetime,
|
|
) -> dict[str, Any]:
|
|
payload = load_json(path, label=f"{role} vulnerability scan")
|
|
try:
|
|
summary = validate_trivy_scan(
|
|
payload,
|
|
policy,
|
|
expected_config_digest=expected_config_digest,
|
|
expected_artifact_basename=expected_artifact_basename,
|
|
role=role,
|
|
at=evaluated_at,
|
|
)
|
|
return {**summary, "config_digest": expected_config_digest}
|
|
except ReleaseValidationError as exc:
|
|
raise BundleValidationError(
|
|
f"{role} vulnerability gate failed: {exc}"
|
|
) from exc
|
|
|
|
|
|
def _validate_license_scan(
|
|
path: Path,
|
|
*,
|
|
role: str,
|
|
expected_config_digest: str,
|
|
expected_artifact_basename: str,
|
|
) -> dict[str, Any]:
|
|
payload = load_json(path, label=f"{role} license scan")
|
|
try:
|
|
summary = validate_trivy_license_scan(
|
|
payload,
|
|
expected_config_digest=expected_config_digest,
|
|
expected_artifact_basename=expected_artifact_basename,
|
|
)
|
|
except ReleaseValidationError as exc:
|
|
raise BundleValidationError(f"{role} license gate failed: {exc}") from exc
|
|
return {**summary, "config_digest": expected_config_digest}
|
|
|
|
|
|
def _validate_application_manifest(root: Path, payload: Mapping[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
if payload.get("schema_version") != APPLICATION_MANIFEST_SCHEMA:
|
|
raise BundleValidationError("unsupported application installable manifest schema")
|
|
_embedded_hash(payload, "manifest_payload_sha256", label="application manifest")
|
|
candidate = payload.get("candidate")
|
|
if not isinstance(candidate, dict):
|
|
raise BundleValidationError("application manifest candidate is missing")
|
|
images = payload.get("images")
|
|
if not isinstance(images, list) or len(images) != len(APPLICATION_SPECS):
|
|
raise BundleValidationError("application manifest must contain four images")
|
|
overrides: dict[str, str] = {}
|
|
normalized: list[dict[str, Any]] = []
|
|
for raw, (role, environment_key) in zip(images, APPLICATION_SPECS, strict=True):
|
|
if not isinstance(raw, dict) or raw.get("role") != role or raw.get("environment_key") != environment_key:
|
|
raise BundleValidationError("application image role or order drifted")
|
|
if raw.get("platform") != TARGET["platform"]:
|
|
raise BundleValidationError("application image platform must be linux/amd64")
|
|
config_digest = _digest(raw.get("config_digest"), label=f"{role} config digest")
|
|
manifest_digest = _digest(raw.get("manifest_digest"), label=f"{role} manifest digest")
|
|
archive_path, archive_record = _file_record(
|
|
root, raw.get("docker_archive"), label=f"{role} Docker archive"
|
|
)
|
|
expected_tag = _tag(raw.get("image_ref"), label=f"{role} image tag")
|
|
inspected = inspect_docker_archive(
|
|
archive_path,
|
|
expected_repo_tag=expected_tag,
|
|
expected_config_digest=config_digest,
|
|
)
|
|
overrides[environment_key] = config_digest
|
|
normalized.append(
|
|
{
|
|
"role": role,
|
|
"environment_key": environment_key,
|
|
"manifest_digest": manifest_digest,
|
|
"config_digest": config_digest,
|
|
"docker_archive": archive_record,
|
|
"expected_repo_tag": expected_tag,
|
|
"archive_profile": inspected["archive_profile"],
|
|
"archive_inspection": inspected,
|
|
}
|
|
)
|
|
return dict(candidate), normalized
|
|
|
|
|
|
def build_bundle(
|
|
*,
|
|
artifact_root: Path,
|
|
source_set: Mapping[str, Any],
|
|
application_manifest: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
root = _artifact_root(artifact_root)
|
|
if source_set.get("schema_version") != SOURCE_SET_SCHEMA:
|
|
raise BundleValidationError("unsupported infrastructure source-set schema")
|
|
_embedded_hash(source_set, "source_set_payload_sha256", label="infrastructure source set")
|
|
if set(source_set) != {
|
|
"schema_version",
|
|
"candidate",
|
|
"target",
|
|
"security_gate",
|
|
"images",
|
|
"source_set_payload_sha256",
|
|
}:
|
|
raise BundleValidationError("infrastructure source-set fields are missing or unknown")
|
|
if source_set.get("target") != TARGET:
|
|
raise BundleValidationError("infrastructure source target must be linux/amd64")
|
|
source_candidate = source_set.get("candidate")
|
|
if not isinstance(source_candidate, dict) or set(source_candidate) != {"commit", "tag"}:
|
|
raise BundleValidationError("infrastructure source candidate is invalid")
|
|
app_candidate, application_images = _validate_application_manifest(
|
|
root, application_manifest
|
|
)
|
|
if app_candidate.get("commit") != source_candidate.get("commit") or app_candidate.get("tag") != source_candidate.get("tag"):
|
|
raise BundleValidationError("application and infrastructure candidate bindings differ")
|
|
policy, security_gate, database_payload = _validate_security_gate(
|
|
root,
|
|
source_set.get("security_gate"),
|
|
application_manifest=application_manifest,
|
|
)
|
|
|
|
raw_images = source_set.get("images")
|
|
if not isinstance(raw_images, list) or len(raw_images) != len(INFRASTRUCTURE_SPECS):
|
|
raise BundleValidationError("infrastructure source set must contain exactly seven images")
|
|
seen_archives: set[str] = set()
|
|
seen_configs: set[str] = set()
|
|
seen_security_artifacts: set[str] = {
|
|
security_gate["policy"]["path"],
|
|
security_gate["database"]["path"],
|
|
}
|
|
seen_registry_blobs: set[str] = set()
|
|
infrastructure_images: list[dict[str, Any]] = []
|
|
import_images: list[dict[str, Any]] = []
|
|
aggregate_vulnerability_counts = {
|
|
bucket: {
|
|
severity: 0
|
|
for severity in policy["vulnerability_scan"]["thresholds"]
|
|
}
|
|
for bucket in ("total", "exempted", "blocking")
|
|
}
|
|
applied_waiver_ids: set[str] = set()
|
|
overrides = {item["environment_key"]: item["config_digest"] for item in application_images}
|
|
for sequence, (raw, (role, environment_key, services)) in enumerate(
|
|
zip(raw_images, INFRASTRUCTURE_SPECS, strict=True), start=1
|
|
):
|
|
if not isinstance(raw, dict):
|
|
raise BundleValidationError("infrastructure image entries must be objects")
|
|
expected_fields = {
|
|
"role",
|
|
"environment_key",
|
|
"services",
|
|
"canonical_source_tag",
|
|
"canonical_reference",
|
|
"transport_reference",
|
|
"manifest_digest",
|
|
"config_digest",
|
|
"registry_manifest",
|
|
"registry_blobs",
|
|
"docker_archive",
|
|
"expected_repo_tag",
|
|
"sbom",
|
|
"vulnerability_scan",
|
|
"license_scan",
|
|
}
|
|
if set(raw) != expected_fields:
|
|
raise BundleValidationError(f"{role} fields are missing or unknown")
|
|
if raw.get("role") != role or raw.get("environment_key") != environment_key or raw.get("services") != list(services):
|
|
raise BundleValidationError("infrastructure image role, service mapping, or order drifted")
|
|
source_tag = _tag(raw.get("canonical_source_tag"), label=f"{role} source tag")
|
|
expected_tag = _tag(raw.get("expected_repo_tag"), label=f"{role} offline tag")
|
|
manifest_digest = _digest(raw.get("manifest_digest"), label=f"{role} manifest digest")
|
|
config_digest = _digest(raw.get("config_digest"), label=f"{role} config digest")
|
|
canonical_reference = _reference(raw.get("canonical_reference"), label=f"{role} canonical reference")
|
|
transport_reference = _reference(raw.get("transport_reference"), label=f"{role} transport reference")
|
|
if canonical_reference.rsplit("@", 1)[1] != manifest_digest or transport_reference.rsplit("@", 1)[1] != manifest_digest:
|
|
raise BundleValidationError(f"{role} references do not match the resolved manifest digest")
|
|
if canonical_reference.split("@", 1)[0] != source_tag.rsplit(":", 1)[0]:
|
|
raise BundleValidationError(f"{role} canonical source repository drifted")
|
|
manifest_path, manifest_record = _file_record(
|
|
root, raw.get("registry_manifest"), label=f"{role} registry manifest"
|
|
)
|
|
registry = _validate_registry_manifest(
|
|
manifest_path,
|
|
expected_manifest_digest=manifest_digest,
|
|
expected_config_digest=config_digest,
|
|
)
|
|
archive_path, archive_record = _file_record(
|
|
root, raw.get("docker_archive"), label=f"{role} Docker archive"
|
|
)
|
|
if archive_record["path"] in seen_archives or config_digest in seen_configs:
|
|
raise BundleValidationError("infrastructure archives and config digests must be unique")
|
|
seen_archives.add(archive_record["path"])
|
|
seen_configs.add(config_digest)
|
|
inspected = inspect_docker_archive(
|
|
archive_path,
|
|
expected_repo_tag=expected_tag,
|
|
expected_config_digest=config_digest,
|
|
)
|
|
if registry["config_size_bytes"] != inspected["config_size_bytes"]:
|
|
raise BundleValidationError(
|
|
f"{role} registry manifest config size differs from the Docker archive"
|
|
)
|
|
raw_blobs = raw.get("registry_blobs")
|
|
if not isinstance(raw_blobs, list) or len(raw_blobs) != registry["layer_count"]:
|
|
raise BundleValidationError(f"{role} registry blob inventory is incomplete")
|
|
registry_blobs: list[dict[str, Any]] = []
|
|
for layer_sequence, (raw_blob, layer_identity, diff_id) in enumerate(
|
|
zip(raw_blobs, registry["layers"], inspected["diff_ids"], strict=True),
|
|
start=1,
|
|
):
|
|
blob_path, blob_record = _file_record(
|
|
root,
|
|
raw_blob,
|
|
label=f"{role} registry layer blob {layer_sequence}",
|
|
)
|
|
if blob_record["path"] in seen_registry_blobs:
|
|
raise BundleValidationError("registry layer blob paths must be unique")
|
|
seen_registry_blobs.add(blob_record["path"])
|
|
if (
|
|
"sha256:" + blob_record["sha256"] != layer_identity["digest"]
|
|
or blob_record["size_bytes"] != layer_identity["size_bytes"]
|
|
):
|
|
raise BundleValidationError(
|
|
f"{role} registry layer blob digest/size differs from the manifest"
|
|
)
|
|
registry_blobs.append(
|
|
{
|
|
"sequence": layer_sequence,
|
|
**blob_record,
|
|
"manifest_digest": layer_identity["digest"],
|
|
"manifest_size_bytes": layer_identity["size_bytes"],
|
|
"inspection": _inspect_registry_blob(
|
|
blob_path,
|
|
media_type=layer_identity["media_type"],
|
|
expected_diff_id=diff_id,
|
|
label=f"{role} registry layer blob {layer_sequence}",
|
|
),
|
|
}
|
|
)
|
|
sbom_path, sbom_record = _file_record(
|
|
root, raw.get("sbom"), label=f"{role} SBOM"
|
|
)
|
|
(
|
|
vulnerability_path,
|
|
vulnerability_record,
|
|
raw_vulnerability_record,
|
|
) = _extended_file_record(
|
|
root,
|
|
raw.get("vulnerability_scan"),
|
|
extra_fields={
|
|
"scanner",
|
|
"scanner_version",
|
|
"waiver_evaluated_at",
|
|
"passed",
|
|
},
|
|
label=f"{role} vulnerability scan",
|
|
)
|
|
license_path, license_record, raw_license_record = _extended_file_record(
|
|
root,
|
|
raw.get("license_scan"),
|
|
extra_fields={"scanner", "scanner_version"},
|
|
label=f"{role} license scan",
|
|
)
|
|
security_paths = {
|
|
sbom_record["path"],
|
|
vulnerability_record["path"],
|
|
license_record["path"],
|
|
}
|
|
if len(security_paths) != 3 or seen_security_artifacts.intersection(security_paths):
|
|
raise BundleValidationError("infrastructure security artifact paths must be unique")
|
|
seen_security_artifacts.update(security_paths)
|
|
sbom_summary = _validate_sbom(
|
|
sbom_path,
|
|
role=role,
|
|
expected_manifest_digest=manifest_digest,
|
|
)
|
|
if (
|
|
raw_vulnerability_record.get("scanner") != "trivy"
|
|
or raw_vulnerability_record.get("scanner_version")
|
|
!= REQUIRED_TOOL_VERSIONS["trivy"]
|
|
or raw_vulnerability_record.get("passed") is not True
|
|
or raw_license_record.get("scanner") != "trivy"
|
|
or raw_license_record.get("scanner_version")
|
|
!= REQUIRED_TOOL_VERSIONS["trivy"]
|
|
):
|
|
raise BundleValidationError(f"{role} scanner identity or pass state drifted")
|
|
try:
|
|
scan_evaluated_at = parse_rfc3339(
|
|
raw_vulnerability_record.get("waiver_evaluated_at"),
|
|
label=f"{role} vulnerability waiver_evaluated_at",
|
|
)
|
|
validate_trivy_db_metadata(
|
|
database_payload,
|
|
policy,
|
|
at=scan_evaluated_at,
|
|
)
|
|
except ReleaseValidationError as exc:
|
|
raise BundleValidationError(
|
|
f"{role} vulnerability scan database/freshness evidence is invalid: {exc}"
|
|
) from exc
|
|
vulnerability_summary = _validate_vulnerability_scan(
|
|
vulnerability_path,
|
|
role=role,
|
|
expected_config_digest=config_digest,
|
|
expected_artifact_basename=archive_path.name,
|
|
policy=policy,
|
|
evaluated_at=scan_evaluated_at,
|
|
)
|
|
license_summary = _validate_license_scan(
|
|
license_path,
|
|
role=role,
|
|
expected_config_digest=config_digest,
|
|
expected_artifact_basename=archive_path.name,
|
|
)
|
|
for bucket in aggregate_vulnerability_counts:
|
|
for severity in aggregate_vulnerability_counts[bucket]:
|
|
aggregate_vulnerability_counts[bucket][severity] += (
|
|
vulnerability_summary["counts"][bucket][severity]
|
|
)
|
|
applied_waiver_ids.update(vulnerability_summary["waiver_ids"])
|
|
image = {
|
|
"sequence": sequence,
|
|
"role": role,
|
|
"environment_key": environment_key,
|
|
"services": list(services),
|
|
"platform": TARGET["platform"],
|
|
"canonical_source_tag": source_tag,
|
|
"canonical_reference": canonical_reference,
|
|
"transport_reference": transport_reference,
|
|
"manifest_digest": manifest_digest,
|
|
"config_digest": config_digest,
|
|
"registry_manifest": manifest_record,
|
|
"registry_blobs": registry_blobs,
|
|
"docker_archive": archive_record,
|
|
"expected_repo_tag": expected_tag,
|
|
"archive_profile": inspected["archive_profile"],
|
|
"archive_inspection": inspected,
|
|
"archive_import_validation": "requires_controlled_docker_load_and_inspect_receipt",
|
|
"sbom": sbom_record,
|
|
"sbom_summary": sbom_summary,
|
|
"vulnerability_scan": {
|
|
**vulnerability_record,
|
|
"scanner": "trivy",
|
|
"scanner_version": REQUIRED_TOOL_VERSIONS["trivy"],
|
|
"waiver_evaluated_at": raw_vulnerability_record[
|
|
"waiver_evaluated_at"
|
|
],
|
|
"passed": True,
|
|
},
|
|
"vulnerability_scan_summary": vulnerability_summary,
|
|
"license_scan": {
|
|
**license_record,
|
|
"scanner": "trivy",
|
|
"scanner_version": REQUIRED_TOOL_VERSIONS["trivy"],
|
|
},
|
|
"license_scan_summary": license_summary,
|
|
}
|
|
infrastructure_images.append(image)
|
|
import_images.append(
|
|
{
|
|
"sequence": sequence,
|
|
"role": role,
|
|
"environment_key": environment_key,
|
|
"archive": archive_record,
|
|
"archive_profile": inspected["archive_profile"],
|
|
"load_command_argv": [
|
|
DOCKER_CLI,
|
|
"--host",
|
|
DOCKER_HOST,
|
|
"image",
|
|
"load",
|
|
"--input",
|
|
archive_record["path"],
|
|
],
|
|
"tag_command_argv": [
|
|
DOCKER_CLI,
|
|
"--host",
|
|
DOCKER_HOST,
|
|
"image",
|
|
"tag",
|
|
config_digest,
|
|
expected_tag,
|
|
],
|
|
"inspect_command_argv": [
|
|
DOCKER_CLI,
|
|
"--host",
|
|
DOCKER_HOST,
|
|
"image",
|
|
"inspect",
|
|
"--format",
|
|
"{{json .}}",
|
|
expected_tag,
|
|
],
|
|
"expected_loaded_image_id": config_digest,
|
|
"expected_manifest_digest": manifest_digest,
|
|
"expected_platform": TARGET["platform"],
|
|
"offline_compose_override": config_digest,
|
|
"file_validation_status": "passed",
|
|
"controlled_docker_load_and_inspect_receipt_required": True,
|
|
}
|
|
)
|
|
overrides[environment_key] = config_digest
|
|
|
|
if tuple(overrides) != ALL_IMAGE_KEYS or set(overrides) != set(ALL_IMAGE_KEYS):
|
|
raise BundleValidationError("offline override map does not cover exactly all eleven images")
|
|
security_gate.update(
|
|
{
|
|
"thresholds": policy["vulnerability_scan"]["thresholds"],
|
|
"exemptions": policy["vulnerability_scan"]["exemptions"],
|
|
"vulnerability_counts": aggregate_vulnerability_counts,
|
|
"waiver_ids": sorted(applied_waiver_ids),
|
|
"passed": True,
|
|
"fail_closed": True,
|
|
}
|
|
)
|
|
unsigned: dict[str, Any] = {
|
|
"schema_version": BUNDLE_SCHEMA,
|
|
"candidate": {"commit": source_candidate["commit"], "tag": source_candidate["tag"]},
|
|
"target": TARGET,
|
|
"application_images": application_images,
|
|
"infrastructure_images": infrastructure_images,
|
|
"security_gate": security_gate,
|
|
"offline_compose_overrides": overrides,
|
|
"all_release_image_keys": list(ALL_IMAGE_KEYS),
|
|
"docker_import_plan": {
|
|
"schema_version": IMPORT_PLAN_SCHEMA,
|
|
"implicit_preloaded_images_allowed": False,
|
|
"network_access_required": False,
|
|
"docker_required_for_validation": False,
|
|
"controlled_docker_load_and_inspect_receipt_required": True,
|
|
"import_verified": False,
|
|
"images": import_images,
|
|
},
|
|
"claims_not_made": [
|
|
"This file-only validator did not contact a registry or invoke Docker.",
|
|
"A controlled Docker load/tag/inspect receipt is still required before import is verified.",
|
|
"This bundle does not prove installation, snapshot capture, restore, UAT, production, or public-release approval.",
|
|
],
|
|
}
|
|
unsigned["bundle_payload_sha256"] = canonical_sha256(unsigned)
|
|
return unsigned
|
|
|
|
|
|
def _write_exclusive(path: Path, payload: Mapping[str, Any]) -> None:
|
|
target = _absolute_lexical(path)
|
|
if target.exists() or target.is_symlink():
|
|
raise BundleValidationError("refusing to overwrite bundle output")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
data = canonical_json_bytes(payload) + b"\n"
|
|
descriptor = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as stream:
|
|
descriptor = -1
|
|
stream.write(data)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
finally:
|
|
if descriptor >= 0:
|
|
os.close(descriptor)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--artifact-root", type=Path, required=True)
|
|
parser.add_argument("--source-set", type=Path, required=True)
|
|
parser.add_argument("--application-manifest", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
try:
|
|
payload = build_bundle(
|
|
artifact_root=args.artifact_root,
|
|
source_set=load_json(args.source_set, label="infrastructure source set"),
|
|
application_manifest=load_json(
|
|
args.application_manifest, label="application installable manifest"
|
|
),
|
|
)
|
|
_write_exclusive(args.output, payload)
|
|
except (BundleValidationError, OSError) as exc:
|
|
print(
|
|
canonical_json_bytes(
|
|
{"schema_version": BUNDLE_SCHEMA, "status": "rejected", "error": str(exc)}
|
|
).decode("utf-8"),
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
print(canonical_json_bytes({"status": "verified", "output": str(args.output)}).decode("utf-8"))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|