feat(security): stage runtime cache canary in Harbor
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled

This commit is contained in:
ogt
2026-07-14 21:37:32 +08:00
parent c8edd73971
commit e54e0f3a27
4 changed files with 561 additions and 7 deletions

View File

@@ -19,6 +19,8 @@ from typing import Any, Sequence
SCHEMA_VERSION = "awoooi_runtime_image_mirror_policy_v1"
RECEIPT_SCHEMA_VERSION = "awoooi_runtime_image_mirror_receipt_v1"
STAGING_SCHEMA_VERSION = "awoooi_runtime_image_mirror_staging_policy_v1"
STAGING_RECEIPT_SCHEMA_VERSION = "awoooi_runtime_image_mirror_staging_receipt_v1"
ALLOWED_KINDS = {"deployment", "daemonset", "statefulset"}
ALLOWED_CONTAINER_KINDS = {"container", "init_container"}
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
@@ -50,6 +52,17 @@ class ImagePolicy:
workload: Workload
@dataclass(frozen=True)
class StagingImage:
asset_id: str
source_index_digest: str
platform_digest: str
source_config_digest: str
push_ref: str
runtime_repository: str
workload: Workload
@dataclass(frozen=True)
class Policy:
work_item_id: str
@@ -61,6 +74,24 @@ class Policy:
checksum: str
@dataclass(frozen=True)
class StagingPolicy:
work_item_id: str
risk_level: str
cache_host: str
cache_user: str
platform: str
runtime_registry_prefix: str
images: tuple[StagingImage, ...]
checksum: str
@dataclass(frozen=True)
class RegistryDescriptor:
digest: str
config_digest: str
def _require_text(value: Any, label: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ControllerError(f"invalid_{label}")
@@ -193,6 +224,121 @@ def load_policy(path: Path) -> Policy:
)
def load_staging_policy(path: Path) -> StagingPolicy:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ControllerError("staging_policy_unreadable") from exc
if (
not isinstance(payload, dict)
or payload.get("schema_version") != STAGING_SCHEMA_VERSION
):
raise ControllerError("staging_policy_schema_invalid")
source = payload.get("source_contract")
target = payload.get("target_contract")
if not isinstance(source, dict) or not isinstance(target, dict):
raise ControllerError("staging_policy_contract_missing")
if source.get("origin") != "runtime_cache_only":
raise ControllerError("runtime_cache_only_required")
if source.get("external_pull_allowed") is not False:
raise ControllerError("external_pull_must_be_disabled")
if source.get("platform") != "linux/amd64":
raise ControllerError("unsupported_runtime_platform")
if target.get("digest_pin_required") is not True:
raise ControllerError("digest_pin_required")
push_prefix = _require_text(target.get("push_registry_prefix"), "push_prefix")
runtime_prefix = _require_text(
target.get("runtime_registry_prefix"), "runtime_prefix"
)
if push_prefix != "registry.wooo.work/awoooi/runtime-mirror/":
raise ControllerError("push_registry_not_internal")
if runtime_prefix != "192.168.0.110:5000/awoooi/runtime-mirror/":
raise ControllerError("runtime_registry_not_internal")
image_rows = payload.get("images")
if not isinstance(image_rows, list) or not image_rows:
raise ControllerError("staging_image_policy_empty")
images: list[StagingImage] = []
asset_ids: set[str] = set()
workloads: set[tuple[str, str, str, str, str]] = set()
for row in image_rows:
if not isinstance(row, dict) or not isinstance(row.get("workload"), dict):
raise ControllerError("staging_image_policy_invalid")
asset_id = _require_text(row.get("asset_id"), "asset_id")
if asset_id in asset_ids:
raise ControllerError("duplicate_asset_id")
asset_ids.add(asset_id)
push_ref = _require_text(row.get("push_ref"), "push_ref")
if not push_ref.startswith(push_prefix) or "@" in push_ref:
raise ControllerError("push_ref_invalid")
push_suffix = push_ref.removeprefix(push_prefix)
if ":" not in push_suffix:
raise ControllerError("push_ref_tag_required")
runtime_repository, push_tag = push_suffix.rsplit(":", 1)
if not runtime_repository or any(
not NAME_PATTERN.fullmatch(part) for part in runtime_repository.split("/")
):
raise ControllerError("runtime_repository_invalid")
_require_name(push_tag, "push_tag")
workload_row = row["workload"]
kind = _require_text(workload_row.get("kind"), "workload_kind").lower()
if kind not in ALLOWED_KINDS:
raise ControllerError("workload_kind_not_allowed")
workload = Workload(
kind=kind,
namespace=_require_name(workload_row.get("namespace"), "namespace"),
name=_require_name(workload_row.get("name"), "workload_name"),
container=_require_name(workload_row.get("container"), "container"),
container_kind=_require_text(
workload_row.get("container_kind", "container"), "container_kind"
).lower(),
)
if workload.container_kind not in ALLOWED_CONTAINER_KINDS:
raise ControllerError("container_kind_not_allowed")
workload_key = (
workload.kind,
workload.namespace,
workload.name,
workload.container,
workload.container_kind,
)
if workload_key in workloads:
raise ControllerError("duplicate_workload_target")
workloads.add(workload_key)
images.append(
StagingImage(
asset_id=asset_id,
source_index_digest=_require_digest(
row.get("source_index_digest"), "source_index_digest"
),
platform_digest=_require_digest(
row.get("platform_digest"), "platform_digest"
),
source_config_digest=_require_digest(
row.get("source_config_digest"), "source_config_digest"
),
push_ref=push_ref,
runtime_repository=runtime_repository,
workload=workload,
)
)
return StagingPolicy(
work_item_id=_require_text(payload.get("work_item_id"), "work_item_id"),
risk_level=_require_text(payload.get("risk_level"), "risk_level"),
cache_host=_require_text(source.get("cache_host"), "cache_host"),
cache_user=_require_name(source.get("cache_user"), "cache_user"),
platform="linux/amd64",
runtime_registry_prefix=runtime_prefix,
images=tuple(images),
checksum=_policy_checksum(payload),
)
def _run(
command: Sequence[str],
*,
@@ -222,7 +368,9 @@ def _run(
class RuntimeCache:
def __init__(self, policy: Policy, ssh_key: Path, known_hosts: Path) -> None:
def __init__(
self, policy: Policy | StagingPolicy, ssh_key: Path, known_hosts: Path
) -> None:
self.policy = policy
self.ssh = [
"ssh",
@@ -254,7 +402,7 @@ class RuntimeCache:
raise ControllerError("runtime_cache_digest_missing")
return sorted(set(matches))[0]
def verify_source(self, image: ImagePolicy) -> str:
def verify_source(self, image: ImagePolicy | StagingImage) -> str:
source_ref = self.source_ref(image.source_index_digest)
check = _run(self._command("images", "check", f"name=={source_ref}"))
if "complete" not in (check.stdout or ""):
@@ -277,6 +425,17 @@ class RuntimeCache:
raise ControllerError("runtime_cache_platform_digest_mismatch")
return source_ref
def source_config_digest(self, platform_digest: str) -> str:
content = _run(self._command("content", "get", platform_digest)).stdout
try:
manifest = json.loads(content or "{}")
except json.JSONDecodeError as exc:
raise ControllerError("runtime_cache_platform_manifest_invalid") from exc
config = manifest.get("config")
if not isinstance(config, dict):
raise ControllerError("runtime_cache_config_digest_missing")
return _require_digest(config.get("digest"), "runtime_cache_config_digest")
def export(self, source_ref: str, output: Path) -> None:
with output.open("wb") as handle:
completed = subprocess.run(
@@ -304,7 +463,7 @@ def _target_digest_ref(image: ImagePolicy) -> str:
return f"{_target_repository(image.push_ref)}@{image.target_registry_digest}"
def _target_digest_present(image: ImagePolicy) -> bool:
def _registry_digest_present(digest_ref: str) -> bool:
try:
completed = subprocess.run(
[
@@ -312,7 +471,7 @@ def _target_digest_present(image: ImagePolicy) -> bool:
"manifest",
"inspect",
"--insecure",
_target_digest_ref(image),
digest_ref,
],
check=False,
stdout=subprocess.DEVNULL,
@@ -324,6 +483,52 @@ def _target_digest_present(image: ImagePolicy) -> bool:
return completed.returncode == 0
def _target_digest_present(image: ImagePolicy) -> bool:
return _registry_digest_present(_target_digest_ref(image))
def _registry_descriptor(image_ref: str) -> RegistryDescriptor | None:
try:
completed = subprocess.run(
["docker", "manifest", "inspect", "--verbose", image_ref],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=20,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ControllerError("internal_registry_descriptor_unavailable") from exc
if completed.returncode != 0:
return None
try:
payload = json.loads(completed.stdout or "{}")
except json.JSONDecodeError as exc:
raise ControllerError("internal_registry_descriptor_invalid") from exc
if not isinstance(payload, dict):
raise ControllerError("internal_registry_descriptor_invalid")
descriptor = payload.get("Descriptor")
manifest = payload.get("SchemaV2Manifest")
if not isinstance(descriptor, dict) or not isinstance(manifest, dict):
raise ControllerError("internal_registry_descriptor_invalid")
config = manifest.get("config")
if not isinstance(config, dict):
raise ControllerError("internal_registry_descriptor_invalid")
return RegistryDescriptor(
digest=_require_digest(
descriptor.get("digest"), "internal_registry_descriptor_digest"
),
config_digest=_require_digest(
config.get("digest"), "internal_registry_config_digest"
),
)
def _local_config_digest(image_ref: str) -> str:
output = _run(["docker", "image", "inspect", "--format={{.Id}}", image_ref]).stdout
return _require_digest((output or "").strip(), "local_config_digest")
def _local_image_present(image_ref: str) -> bool:
try:
completed = subprocess.run(
@@ -445,6 +650,127 @@ def mirror_images(
return receipt
def stage_images(
policy: StagingPolicy,
*,
trace_id: str,
receipt_path: Path,
ssh_key: Path,
known_hosts: Path,
apply: bool,
) -> dict[str, Any]:
cache = RuntimeCache(policy, ssh_key, known_hosts)
image_receipts: list[dict[str, Any]] = []
missing_count = 0
for image in policy.images:
source_ref = cache.verify_source(image)
source_fingerprint = "sha256:" + hashlib.sha256(source_ref.encode()).hexdigest()
source_config_digest = cache.source_config_digest(image.platform_digest)
if source_config_digest != image.source_config_digest:
raise ControllerError("runtime_cache_config_digest_mismatch")
descriptor = _registry_descriptor(image.push_ref)
execution = "already_present"
if descriptor is None:
missing_count += 1
execution = "check_only_missing"
if apply:
source_preexisting = _local_image_present(source_ref)
try:
with tempfile.TemporaryDirectory(
prefix="awoooi-runtime-image-stage-"
) as temp:
archive = Path(temp) / "image.oci.tar"
cache.export(source_ref, archive)
if not _archive_contains_digest(archive, image.platform_digest):
raise ControllerError("runtime_cache_export_digest_missing")
_run(
["docker", "load", "--input", str(archive)],
quiet=True,
)
if _local_config_digest(source_ref) != source_config_digest:
raise ControllerError("local_image_config_digest_mismatch")
_run(
["docker", "tag", source_ref, image.push_ref],
quiet=True,
)
_run(["docker", "push", image.push_ref], quiet=True)
finally:
_remove_local_image(image.push_ref)
if not source_preexisting:
_remove_local_image(source_ref)
descriptor = _registry_descriptor(image.push_ref)
if descriptor is None:
raise ControllerError("internal_registry_descriptor_missing")
execution = "staged_from_runtime_cache"
if descriptor is not None and descriptor.config_digest != source_config_digest:
raise ControllerError("internal_registry_provenance_mismatch")
target_digest_ref = None
runtime_ref = None
target_verified = False
if descriptor is not None:
target_digest_ref = (
f"{_target_repository(image.push_ref)}@{descriptor.digest}"
)
runtime_ref = (
f"{policy.runtime_registry_prefix}{image.runtime_repository}"
f"@{descriptor.digest}"
)
target_verified = _registry_digest_present(target_digest_ref)
if apply and not target_verified:
raise ControllerError("internal_registry_digest_verifier_failed")
image_receipts.append(
{
"asset_id": image.asset_id,
"source_ref_fingerprint": source_fingerprint,
"source_index_digest": image.source_index_digest,
"platform_digest": image.platform_digest,
"source_config_digest": source_config_digest,
"target_registry_digest": (
descriptor.digest if descriptor is not None else None
),
"target_digest_ref": target_digest_ref,
"runtime_ref": runtime_ref,
"source_cache_verified": True,
"target_digest_verified": target_verified,
"provenance_config_digest_match": descriptor is not None,
"execution": execution,
"workload": {
"kind": image.workload.kind,
"namespace": image.workload.namespace,
"name": image.workload.name,
"container": image.workload.container,
"container_kind": image.workload.container_kind,
},
}
)
verified = all(row["target_digest_verified"] for row in image_receipts)
receipt = {
"schema_version": STAGING_RECEIPT_SCHEMA_VERSION,
"trace_id": trace_id,
"work_item_id": policy.work_item_id,
"policy_checksum": policy.checksum,
"stage": "runtime_cache_staging",
"mode": "apply" if apply else "check",
"risk_level": policy.risk_level,
"external_pull_allowed": False,
"source_cache_only": True,
"candidate_count": len(image_receipts),
"missing_before_count": missing_count,
"verified_count": sum(
1 for row in image_receipts if row["target_digest_verified"]
),
"state": "verified" if verified else "blocked_with_safe_next_action",
"images": image_receipts,
}
receipt_path.write_text(
json.dumps(receipt, ensure_ascii=True, indent=2) + "\n", encoding="utf-8"
)
return receipt
class Kubernetes:
def __init__(
self,
@@ -745,6 +1071,12 @@ def build_parser() -> argparse.ArgumentParser:
mirror.add_argument("--ssh-key", type=Path, required=True)
mirror.add_argument("--known-hosts", type=Path, required=True)
stage = subparsers.add_parser("stage")
stage.add_argument("--apply", action="store_true")
stage.add_argument("--receipt", type=Path, required=True)
stage.add_argument("--ssh-key", type=Path, required=True)
stage.add_argument("--known-hosts", type=Path, required=True)
apply_parser = subparsers.add_parser("apply")
apply_parser.add_argument("--apply", action="store_true")
apply_parser.add_argument("--mirror-receipt", type=Path, required=True)
@@ -757,7 +1089,18 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
policy = load_policy(args.policy)
if args.command == "stage":
staging_policy = load_staging_policy(args.policy)
receipt = stage_images(
staging_policy,
trace_id=args.trace_id,
receipt_path=args.receipt,
ssh_key=args.ssh_key,
known_hosts=args.known_hosts,
apply=args.apply,
)
else:
policy = load_policy(args.policy)
if args.command == "mirror":
receipt = mirror_images(
policy,
@@ -767,7 +1110,7 @@ def main(argv: Sequence[str] | None = None) -> int:
known_hosts=args.known_hosts,
apply=args.apply,
)
else:
elif args.command == "apply":
receipt = apply_policy(
policy,
trace_id=args.trace_id,
@@ -783,7 +1126,11 @@ def main(argv: Sequence[str] | None = None) -> int:
print(
json.dumps(
{
"schema_version": RECEIPT_SCHEMA_VERSION,
"schema_version": (
STAGING_RECEIPT_SCHEMA_VERSION
if args.command == "stage"
else RECEIPT_SCHEMA_VERSION
),
"state": "blocked_with_safe_next_action",
"error_code": str(exc),
"secret_value_exposed": False,