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
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user