feat(ops): verify release provenance evidence pairs
This commit is contained in:
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -68,10 +69,39 @@ _SENSITIVE_VALUE = re.compile(
|
||||
)
|
||||
_FULL_GIT_REVISION = re.compile(r"[0-9a-f]{40}")
|
||||
_IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
||||
_SHA256 = re.compile(r"[0-9a-f]{64}")
|
||||
_SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
||||
_REQUIRED_CAPTURE_KEYS = frozenset(
|
||||
{"pending_slots", "reconciliation_slots", "queue_consumers"}
|
||||
)
|
||||
_OBSERVED_EVIDENCE_KEYS = frozenset(
|
||||
{
|
||||
"schema_version",
|
||||
"target_id",
|
||||
"target_kind",
|
||||
"image_id",
|
||||
"image_digest",
|
||||
"oci_revision",
|
||||
"observed_at",
|
||||
}
|
||||
)
|
||||
_RECORD_KEYS = frozenset(
|
||||
{
|
||||
"schema_version",
|
||||
"timestamp_utc",
|
||||
"actor",
|
||||
"reason",
|
||||
"instance",
|
||||
"command_class",
|
||||
"operation_id",
|
||||
"phase",
|
||||
"observed_evidence",
|
||||
"checkout",
|
||||
"runtime_file_verification",
|
||||
"state_capture",
|
||||
"gate",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _run_git(checkout_dir: Path, *args: str) -> str:
|
||||
@@ -153,6 +183,80 @@ def _sanitize_json(value: object) -> object:
|
||||
return value
|
||||
|
||||
|
||||
def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
result: dict[str, object] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate JSON key: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _load_strict_json(path: str | Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(
|
||||
Path(path).read_text(encoding="utf-8"),
|
||||
object_pairs_hook=_reject_duplicate_keys,
|
||||
)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("invalid JSON input") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError("JSON input must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _is_utc_timestamp(value: object) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return parsed.tzinfo is not None and parsed.utcoffset() == datetime.now(tz=UTC).utcoffset()
|
||||
|
||||
|
||||
def _contains_sensitive_material(value: object) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return any(
|
||||
_SENSITIVE_KEY.search(str(key)) or _contains_sensitive_material(item)
|
||||
for key, item in value.items()
|
||||
)
|
||||
if isinstance(value, list):
|
||||
return any(_contains_sensitive_material(item) for item in value)
|
||||
return isinstance(value, str) and _sanitize_text(value) != value
|
||||
|
||||
|
||||
def _validate_observed_evidence(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or set(value) != _OBSERVED_EVIDENCE_KEYS:
|
||||
raise ValueError("observed evidence keys must match schema version 1")
|
||||
if type(value["schema_version"]) is not int or value["schema_version"] != 1:
|
||||
raise ValueError("observed evidence schema_version must be integer 1")
|
||||
if not all(
|
||||
isinstance(value[key], str) and bool(value[key])
|
||||
for key in ("target_id", "target_kind")
|
||||
):
|
||||
raise ValueError("observed target fields must be non-empty strings")
|
||||
if not all(
|
||||
isinstance(value[key], str) and _IMAGE_DIGEST.fullmatch(value[key])
|
||||
for key in ("image_id", "image_digest")
|
||||
):
|
||||
raise ValueError("observed image identity must use lowercase sha256 digests")
|
||||
if not isinstance(value["oci_revision"], str) or not _FULL_GIT_REVISION.fullmatch(
|
||||
value["oci_revision"]
|
||||
):
|
||||
raise ValueError("observed oci_revision must be a full lowercase git SHA")
|
||||
if not _is_utc_timestamp(value["observed_at"]):
|
||||
raise ValueError("observed_at must be a UTC RFC 3339 timestamp")
|
||||
if _contains_sensitive_material(value):
|
||||
raise ValueError("observed evidence must not contain sensitive material")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def load_observed_evidence(path: str | Path) -> dict[str, object]:
|
||||
"""Load strict caller-observed provenance without claiming runtime observation."""
|
||||
return _validate_observed_evidence(_load_strict_json(path))
|
||||
|
||||
|
||||
def _validate_capture_commands(
|
||||
capture_commands: dict[str, list[str]], *, instance: str
|
||||
) -> None:
|
||||
@@ -297,8 +401,7 @@ def collect_release_provenance(
|
||||
command_class: str,
|
||||
operation_id: str,
|
||||
phase: str,
|
||||
expected_oci_revision: str,
|
||||
image_digest: str,
|
||||
observed_evidence: dict[str, object],
|
||||
runtime_files: list[str],
|
||||
capture_commands: dict[str, list[str]],
|
||||
checkout_dir: str | Path = ".",
|
||||
@@ -313,10 +416,8 @@ def collect_release_provenance(
|
||||
raise ValueError("instance and operation_id must be safe identifiers")
|
||||
if phase not in {"pre", "post"}:
|
||||
raise ValueError("phase must be pre or post")
|
||||
if not _FULL_GIT_REVISION.fullmatch(expected_oci_revision):
|
||||
raise ValueError("expected_oci_revision must be a full lowercase git SHA")
|
||||
if not _IMAGE_DIGEST.fullmatch(image_digest):
|
||||
raise ValueError("image_digest must be sha256:<64 lowercase hex>")
|
||||
observed = _validate_observed_evidence(observed_evidence)
|
||||
expected_oci_revision = str(observed["oci_revision"])
|
||||
if not runtime_files:
|
||||
raise ValueError("at least one runtime file is required")
|
||||
_validate_capture_commands(capture_commands, instance=instance)
|
||||
@@ -378,6 +479,7 @@ def collect_release_provenance(
|
||||
and broker_logs_ok
|
||||
)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"timestamp_utc": datetime.now(tz=UTC).isoformat(),
|
||||
"actor": actor,
|
||||
"reason": reason,
|
||||
@@ -393,11 +495,7 @@ def collect_release_provenance(
|
||||
"git_clean": git_clean,
|
||||
"dirty_paths": dirty_paths,
|
||||
},
|
||||
"artifact_identity": {
|
||||
"expected_oci_revision": expected_oci_revision,
|
||||
"oci_revision_matches_head": oci_revision_matches_head,
|
||||
"image_digest": image_digest,
|
||||
},
|
||||
"observed_evidence": {**observed, "source": "operator_observed"},
|
||||
"runtime_file_verification": runtime_reports,
|
||||
"state_capture": {
|
||||
"command_outputs": command_outputs,
|
||||
@@ -413,38 +511,296 @@ def collect_release_provenance(
|
||||
}
|
||||
|
||||
|
||||
def _exact_object(value: object, keys: set[str] | frozenset[str], label: str) -> dict:
|
||||
if not isinstance(value, dict) or set(value) != set(keys):
|
||||
raise ValueError(f"{label} keys do not match schema version 1")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_success_record(record: dict[str, object], *, phase: str) -> dict[str, object]:
|
||||
_exact_object(record, _RECORD_KEYS, "gate record")
|
||||
if type(record["schema_version"]) is not int or record["schema_version"] != 1:
|
||||
raise ValueError("unsupported gate record schema")
|
||||
if record["phase"] != phase:
|
||||
raise ValueError(f"record phase must be {phase}")
|
||||
if not _is_utc_timestamp(record["timestamp_utc"]):
|
||||
raise ValueError("record timestamp must be UTC RFC 3339")
|
||||
for key in ("actor", "reason", "instance", "command_class", "operation_id"):
|
||||
if not isinstance(record[key], str) or not record[key]:
|
||||
raise ValueError(f"record {key} must be a non-empty string")
|
||||
|
||||
observed = _exact_object(
|
||||
record["observed_evidence"], _OBSERVED_EVIDENCE_KEYS | {"source"}, "observed evidence"
|
||||
)
|
||||
if observed["source"] != "operator_observed":
|
||||
raise ValueError("observed evidence source must be operator_observed")
|
||||
_validate_observed_evidence({key: observed[key] for key in _OBSERVED_EVIDENCE_KEYS})
|
||||
|
||||
checkout = _exact_object(
|
||||
record["checkout"],
|
||||
{
|
||||
"path",
|
||||
"head_revision",
|
||||
"detached_head",
|
||||
"git_status_porcelain",
|
||||
"git_clean",
|
||||
"dirty_paths",
|
||||
},
|
||||
"checkout",
|
||||
)
|
||||
if not (
|
||||
checkout["detached_head"] is True
|
||||
and checkout["git_clean"] is True
|
||||
and checkout["dirty_paths"] == []
|
||||
and checkout["head_revision"] == observed["oci_revision"]
|
||||
):
|
||||
raise ValueError("checkout evidence does not satisfy pair success invariants")
|
||||
|
||||
runtime_reports = record["runtime_file_verification"]
|
||||
runtime_keys = {
|
||||
"path",
|
||||
"path_within_checkout",
|
||||
"exists",
|
||||
"current_sha256",
|
||||
"expected_revision",
|
||||
"expected_sha256",
|
||||
"matches_expected_revision",
|
||||
}
|
||||
if not isinstance(runtime_reports, list) or not runtime_reports:
|
||||
raise ValueError("runtime verification must be a non-empty list")
|
||||
for item in runtime_reports:
|
||||
report = _exact_object(item, runtime_keys, "runtime verification")
|
||||
if not (
|
||||
isinstance(report["path"], str)
|
||||
and report["path"]
|
||||
and report["path_within_checkout"] is True
|
||||
and report["exists"] is True
|
||||
and report["matches_expected_revision"] is True
|
||||
and report["expected_revision"] == observed["oci_revision"]
|
||||
and isinstance(report["current_sha256"], str)
|
||||
and _SHA256.fullmatch(report["current_sha256"])
|
||||
and report["current_sha256"] == report["expected_sha256"]
|
||||
):
|
||||
raise ValueError("runtime verification does not satisfy pair success invariants")
|
||||
|
||||
state_capture = _exact_object(
|
||||
record["state_capture"], {"command_outputs", "broker_side_effect_logs"}, "state capture"
|
||||
)
|
||||
command_outputs = _exact_object(
|
||||
state_capture["command_outputs"], _REQUIRED_CAPTURE_KEYS, "command outputs"
|
||||
)
|
||||
output_keys = {"command", "returncode", "stdout", "stdout_format", "stderr"}
|
||||
for output in command_outputs.values():
|
||||
captured = _exact_object(output, output_keys, "command output")
|
||||
if captured["returncode"] != 0 or type(captured["returncode"]) is not int:
|
||||
raise ValueError("state capture command did not succeed")
|
||||
|
||||
broker = state_capture["broker_side_effect_logs"]
|
||||
broker_success_keys = {
|
||||
"path",
|
||||
"exists",
|
||||
"capture_status",
|
||||
"file_sha256",
|
||||
"scanned_lines",
|
||||
"matches",
|
||||
}
|
||||
broker = _exact_object(broker, broker_success_keys, "broker capture")
|
||||
if broker["capture_status"] not in {"captured", "captured_empty"}:
|
||||
raise ValueError("broker capture did not succeed")
|
||||
|
||||
gate = _exact_object(
|
||||
record["gate"],
|
||||
{
|
||||
"passed",
|
||||
"deployment_blocked",
|
||||
"runtime_files_verified",
|
||||
"state_captures_succeeded",
|
||||
"broker_log_captured",
|
||||
},
|
||||
"gate",
|
||||
)
|
||||
if [
|
||||
gate[key]
|
||||
for key in (
|
||||
"passed",
|
||||
"deployment_blocked",
|
||||
"runtime_files_verified",
|
||||
"state_captures_succeeded",
|
||||
"broker_log_captured",
|
||||
)
|
||||
] != [True, False, True, True, True]:
|
||||
raise ValueError("gate summary does not satisfy pair success invariants")
|
||||
return record
|
||||
|
||||
|
||||
def _record_digest(path: Path, expected: str) -> str:
|
||||
if not _SHA256.fullmatch(expected):
|
||||
raise ValueError("expected record digest must be lowercase SHA-256")
|
||||
try:
|
||||
payload = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise ValueError("record path is not readable") from exc
|
||||
actual = _sha256_bytes(payload)
|
||||
if actual != expected:
|
||||
raise ValueError("record bytes do not match expected SHA-256")
|
||||
return actual
|
||||
|
||||
|
||||
def verify_release_provenance_pair(
|
||||
*,
|
||||
pre_path: str | Path,
|
||||
pre_sha256: str,
|
||||
post_path: str | Path,
|
||||
post_sha256: str,
|
||||
output_path: str | Path,
|
||||
) -> dict[str, object]:
|
||||
"""Verify one explicit PRE/POST pair and write an immutable manifest."""
|
||||
pre = Path(pre_path)
|
||||
post = Path(post_path)
|
||||
if not pre.is_absolute() or not post.is_absolute():
|
||||
raise ValueError("record paths must be canonical absolute paths")
|
||||
pre = pre.resolve()
|
||||
post = post.resolve()
|
||||
if pre == post:
|
||||
raise ValueError("PRE and POST record paths must differ")
|
||||
pre_digest = _record_digest(pre, pre_sha256)
|
||||
post_digest = _record_digest(post, post_sha256)
|
||||
pre_record = _validate_success_record(_load_strict_json(pre), phase="pre")
|
||||
post_record = _validate_success_record(_load_strict_json(post), phase="post")
|
||||
|
||||
for key in ("schema_version", "operation_id", "instance"):
|
||||
if pre_record[key] != post_record[key]:
|
||||
raise ValueError(f"PRE/POST {key} mismatch")
|
||||
pre_observed = pre_record["observed_evidence"]
|
||||
post_observed = post_record["observed_evidence"]
|
||||
for key in ("target_kind", "target_id"):
|
||||
if pre_observed[key] != post_observed[key]:
|
||||
raise ValueError(f"PRE/POST {key} mismatch")
|
||||
|
||||
destination = Path(output_path).resolve()
|
||||
for checkout_path in (pre_record["checkout"]["path"], post_record["checkout"]["path"]):
|
||||
checkout = Path(checkout_path).resolve()
|
||||
try:
|
||||
destination.relative_to(checkout)
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError("pair manifest must be outside the audit checkout")
|
||||
|
||||
def manifest_record(
|
||||
path: Path, digest: str, record: dict[str, object], observed: dict[str, object]
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"record_path": str(path),
|
||||
"record_sha256": digest,
|
||||
"phase": record["phase"],
|
||||
"command_class": record["command_class"],
|
||||
"image_id": observed["image_id"],
|
||||
"image_digest": observed["image_digest"],
|
||||
"oci_revision": observed["oci_revision"],
|
||||
"observed_at": observed["observed_at"],
|
||||
}
|
||||
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"verified_at": datetime.now(tz=UTC).isoformat(),
|
||||
"operation_id": pre_record["operation_id"],
|
||||
"instance": pre_record["instance"],
|
||||
"target_kind": pre_observed["target_kind"],
|
||||
"target_id": pre_observed["target_id"],
|
||||
"pre": manifest_record(pre, pre_digest, pre_record, pre_observed),
|
||||
"post": manifest_record(post, post_digest, post_record, post_observed),
|
||||
}
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with destination.open("x", encoding="utf-8") as fp:
|
||||
fp.write(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||
destination.chmod(0o444)
|
||||
return manifest
|
||||
|
||||
|
||||
def isolate_dirty_checkout_files(
|
||||
*,
|
||||
checkout_dir: str | Path,
|
||||
isolate_dir: str | Path,
|
||||
) -> dict:
|
||||
"""Copy dirty/untracked checkout files to an isolation directory."""
|
||||
"""Copy dirty files without mutating the checkout and verify copy integrity."""
|
||||
checkout = Path(checkout_dir).resolve()
|
||||
dst_root = Path(isolate_dir).resolve()
|
||||
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
||||
dst = dst_root / f"{stamp}--{uuid.uuid4().hex[:12]}"
|
||||
_, dirty_paths = _git_status(checkout)
|
||||
copied: list[str] = []
|
||||
entries: list[dict[str, object]] = []
|
||||
|
||||
for rel in dirty_paths:
|
||||
src = _resolve_under(checkout, rel)
|
||||
if src is None or not src.exists() or src.is_dir():
|
||||
continue
|
||||
target = (dst / rel).resolve()
|
||||
rel_path = Path(rel)
|
||||
if rel_path.is_absolute() or ".." in rel_path.parts:
|
||||
raise ValueError(f"unsafe dirty path: {rel}")
|
||||
src = checkout / rel_path
|
||||
try:
|
||||
target.relative_to(dst)
|
||||
except ValueError:
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, target)
|
||||
copied.append(rel)
|
||||
src.parent.resolve().relative_to(checkout)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"dirty path parent escapes checkout: {rel}") from exc
|
||||
|
||||
if src.is_symlink():
|
||||
source_type = "symlink"
|
||||
source_target = os.readlink(src)
|
||||
source_target_path = Path(source_target)
|
||||
if source_target_path.is_absolute():
|
||||
raise ValueError(f"symlink target escapes checkout: {rel}")
|
||||
resolved_source_target = (src.parent / source_target_path).resolve(strict=False)
|
||||
try:
|
||||
resolved_source_target.relative_to(checkout)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"symlink target escapes checkout: {rel}") from exc
|
||||
source_bytes = source_target.encode("utf-8")
|
||||
target = dst / rel_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.symlink(source_target, target)
|
||||
copied_type = "symlink" if target.is_symlink() else "unsupported"
|
||||
copied_bytes = os.readlink(target).encode("utf-8") if target.is_symlink() else b""
|
||||
elif src.is_file():
|
||||
source_type = "regular"
|
||||
source_bytes = src.read_bytes()
|
||||
target = dst / rel_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, target, follow_symlinks=False)
|
||||
copied_type = "regular" if target.is_file() and not target.is_symlink() else "unsupported"
|
||||
copied_bytes = target.read_bytes() if copied_type == "regular" else b""
|
||||
else:
|
||||
raise ValueError(f"unsupported dirty path type: {rel}")
|
||||
|
||||
source_sha = _sha256_bytes(source_bytes)
|
||||
copied_sha = _sha256_bytes(copied_bytes)
|
||||
matches = (
|
||||
copied_type == source_type
|
||||
and len(copied_bytes) == len(source_bytes)
|
||||
and copied_sha == source_sha
|
||||
)
|
||||
if not matches:
|
||||
raise ValueError(f"isolated copy integrity mismatch: {rel}")
|
||||
entries.append(
|
||||
{
|
||||
"path": rel,
|
||||
"source_type": source_type,
|
||||
"source_size": len(source_bytes),
|
||||
"source_sha256": source_sha,
|
||||
"copied_path": str(target.resolve(strict=False)) if source_type == "regular" else str(target),
|
||||
"copied_type": copied_type,
|
||||
"copied_size": len(copied_bytes),
|
||||
"copied_sha256": copied_sha,
|
||||
"matches": True,
|
||||
}
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"timestamp_utc": datetime.now(tz=UTC).isoformat(),
|
||||
"checkout_dir": str(checkout),
|
||||
"isolate_dir": str(dst),
|
||||
"dirty_paths": dirty_paths,
|
||||
"copied_paths": copied,
|
||||
"entries": entries,
|
||||
}
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
with (dst / "manifest.json").open("x", encoding="utf-8") as fp:
|
||||
manifest_path = dst / "manifest.json"
|
||||
with manifest_path.open("x", encoding="utf-8") as fp:
|
||||
fp.write(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||
manifest_path.chmod(0o444)
|
||||
return manifest
|
||||
|
||||
Reference in New Issue
Block a user