diff --git a/.gitignore b/.gitignore index 16206fcca..053ecc096 100644 --- a/.gitignore +++ b/.gitignore @@ -240,6 +240,8 @@ scripts/release_control/* !scripts/release_control/format_staged_go_test.py !scripts/release_control/governance_stage_guard.py !scripts/release_control/governance_stage_guard_test.py +!scripts/release_control/live_runtime_proof.py +!scripts/release_control/live_runtime_proof_test.py !scripts/release_control/mobile_relay_auth_approvals_proof.py !scripts/release_control/mobile_release_gate.py !scripts/release_control/mobile_release_gate_test.py diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index 68828fcf8..5726aa4d5 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -41,6 +41,62 @@ Cloud, and self-hosted production users. line; release automation must resolve branch requirements from that file instead of assuming `main`. +## Runtime Verification Claim Levels + +Release status language is an evidence contract. Use these three levels and do +not promote a claim beyond the evidence that exists: + +1. `implemented` + - The source change and targeted regression proof exist. + - This does not mean a release artifact contains the change or that a live + installation is running it. +2. `release-validated` + - The immutable release artifact containing the change passed its governed + build, artifact, install, and release-pipeline checks. + - Publication, a successful installer, and a displayed version number do + not by themselves prove the affected behavior on real hardware. +3. `live-verified` + - The exact release is running on the named target and a fresh, + machine-readable assertion against the affected live API passed. + - A saved receipt must be independently verified for the expected target, + exact version, assertion, and allowed age before anyone describes the + issue as fixed on hardware, fixed in production, or live verified. + +For the Proxmox protection-posture persistence regression, the canonical +collector and verifier are `scripts/release_control/live_runtime_proof.py`. +The collector must query `/api/version` and every page of +`/api/recovery/postures`, require a non-empty successful-posture cohort, and +fail while any workload with `lastSuccessfulPointAt` remains `unknown`. It must +record the target origin, TLS-verification state, exact expected and observed +versions, packaged-versus-development runtime state, UTC collection time, +posture counts, failing resource IDs, and raw response SHA-256 values in a +sealed JSON receipt. Authentication values must be read from named environment +variables rather than command-line values or receipt content. + +Example post-install proof: + +```bash +export PULSE_LIVE_PROOF_AUTHORIZATION='Bearer ' +python3 scripts/release_control/live_runtime_proof.py collect \ + --base-url https://pulse.example.net \ + --expected-version 6.2.0-rc.8 \ + --authorization-env PULSE_LIVE_PROOF_AUTHORIZATION \ + --minimum-postures 1 \ + --minimum-successful-postures 1 \ + --output /secure/release-evidence/pulse-6.2.0-rc.8-live.json +python3 scripts/release_control/live_runtime_proof.py verify \ + --receipt /secure/release-evidence/pulse-6.2.0-rc.8-live.json \ + --expected-origin https://pulse.example.net \ + --expected-version 6.2.0-rc.8 \ + --max-age-seconds 3600 +``` + +The receipt contains operational target identity and may contain resource IDs, +so retain it in the restricted release-evidence location rather than a public +release body. Missing, stale, failed, edited, wrong-target, wrong-version, or +TLS-unverified receipts leave the claim at `implemented` or +`release-validated`; an operator statement cannot substitute for the receipt. + ## Prerelease Rules 1. Every candidate intended for broad customer use must ship to `rc` before it diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 43f6f90e0..287959762 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -101,40 +101,42 @@ TLS floor in the dynamic config. 59. `scripts/release_control/resolve_release_promotion.py` 60. `scripts/release_control/mobile_release_gate.py` 61. `scripts/release_control/mobile_release_gate_test.py` -62. `scripts/release_candidate_manifest.py` -63. `scripts/release_control/validate_artifact_release_line.py` -63. `scripts/release_ldflags.sh` -64. `scripts/run_cloud_public_signup_smoke.sh` -65. `scripts/run_demo_public_browser_smoke.sh` -66. `scripts/demo_public_browser_smoke.cjs` -67. `scripts/run_hosted_staging_smoke.sh` -68. `scripts/trigger-release-dry-run.sh` -69. `scripts/trigger-release.sh` -70. `scripts/toggle-mock.sh` -71. `deploy/provider-msp/` -72. `deploy/helm/pulse/` -73. `tests/integration/playwright.config.ts` -74. `tests/integration/QUICK_START.md` -75. `tests/integration/README.md` -76. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs` -77. `tests/integration/scripts/hosted-mobile-token-runtime.mjs` -78. `tests/integration/scripts/hosted-tenant-approval-store.mjs` -79. `tests/integration/scripts/hosted-tenant-runtime.mjs` -80. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs` -81. `tests/integration/scripts/managed-dev-runtime.mjs` -82. `tests/integration/scripts/relay-mobile-token-helper.go` -83. `tests/integration/tests/helpers.ts` -84. `tests/integration/tests/runtime-defaults.ts` -85. `docker-compose.yml` -86. `scripts/install-docker.sh` -87. `scripts/validate-published-release.sh` -88. `scripts/validate-release.sh` -89. `scripts/release_asset_common.sh` -90. `scripts/backfill-release-assets.sh` -91. `.github/workflows/backfill-release-assets.yml` -92. `.github/scripts/check-demo-reachability.sh` -93. `.github/scripts/setup-demo-ssh.sh` -94. `scripts/trigger-stable-patch.sh` +62. `scripts/release_control/live_runtime_proof.py` +63. `scripts/release_control/live_runtime_proof_test.py` +64. `scripts/release_candidate_manifest.py` +65. `scripts/release_control/validate_artifact_release_line.py` +66. `scripts/release_ldflags.sh` +67. `scripts/run_cloud_public_signup_smoke.sh` +68. `scripts/run_demo_public_browser_smoke.sh` +69. `scripts/demo_public_browser_smoke.cjs` +70. `scripts/run_hosted_staging_smoke.sh` +71. `scripts/trigger-release-dry-run.sh` +72. `scripts/trigger-release.sh` +73. `scripts/toggle-mock.sh` +74. `deploy/provider-msp/` +75. `deploy/helm/pulse/` +76. `tests/integration/playwright.config.ts` +77. `tests/integration/QUICK_START.md` +78. `tests/integration/README.md` +79. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs` +80. `tests/integration/scripts/hosted-mobile-token-runtime.mjs` +81. `tests/integration/scripts/hosted-tenant-approval-store.mjs` +82. `tests/integration/scripts/hosted-tenant-runtime.mjs` +83. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs` +84. `tests/integration/scripts/managed-dev-runtime.mjs` +85. `tests/integration/scripts/relay-mobile-token-helper.go` +86. `tests/integration/tests/helpers.ts` +87. `tests/integration/tests/runtime-defaults.ts` +88. `docker-compose.yml` +89. `scripts/install-docker.sh` +90. `scripts/validate-published-release.sh` +91. `scripts/validate-release.sh` +92. `scripts/release_asset_common.sh` +93. `scripts/backfill-release-assets.sh` +94. `.github/workflows/backfill-release-assets.yml` +95. `.github/scripts/check-demo-reachability.sh` +96. `.github/scripts/setup-demo-ssh.sh` +97. `scripts/trigger-stable-patch.sh` ## Shared Boundaries @@ -389,7 +391,7 @@ upgrade, update, release, or artifact-selection behavior. ## Extension Points 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` -2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` +2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` Normal releases are single-build promotions. The exact pushed SHA must produce one release candidate with the policy-required native signing lanes through `.github/workflows/build-release-candidate.yml` while independent @@ -1603,6 +1605,22 @@ with build evidence; and `mobile-candidate-required` must fail closed until the mobile candidate exists. This gate does not auto-submit App Store/TestFlight or Play builds, but it prevents release packets from silently ignoring the mobile track. +That same release-trust boundary owns the distinction between source proof, +release-artifact proof, and post-install live-runtime proof. Publication or +successful installation may establish `release-validated`; neither may be +described as `live-verified` or fixed on hardware without a fresh passing +receipt from `scripts/release_control/live_runtime_proof.py` for the named +target and exact running version. For the Proxmox protection-posture +persistence regression, collection must fail on an empty successful-posture +cohort or while any posture with `lastSuccessfulPointAt` remains `unknown`. +The receipt must bind the expected and observed versions, normalized target +origin, packaged-versus-development runtime state, TLS verification, UTC +collection time, aggregate posture results, failing resource IDs, and response +SHA-256 values. The verifier must reject failed, stale, edited, source-build, +development-build, TLS-unverified, wrong-target, and wrong-version receipts. +Credential values belong only in named environment variables and must never be +written to command arguments or receipts. A missing or failed receipt is an +enforced lower claim level, not an operator-waivable proof gap. That same upload boundary must tolerate transient GitHub release-asset API failures. `.github/workflows/create-release.yml` must retry every `gh release upload` operation with bounded backoff before failing the release diff --git a/scripts/release_control/live_runtime_proof.py b/scripts/release_control/live_runtime_proof.py new file mode 100755 index 000000000..15c6b3040 --- /dev/null +++ b/scripts/release_control/live_runtime_proof.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 +"""Collect and verify machine-readable proof from a running Pulse release.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import ssl +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +SCHEMA_VERSION = 1 +PROOF_TYPE = "pulse-live-runtime" +ASSERTION = "successful-restore-points-have-evaluated-posture" +DEFAULT_TIMEOUT_SECONDS = 20.0 +MAX_RESPONSE_BYTES = 16 * 1024 * 1024 +ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class ProofError(RuntimeError): + """Raised when live proof cannot be collected or verified.""" + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _receipt_digest(receipt: dict[str, Any]) -> str: + unsigned = dict(receipt) + unsigned.pop("receiptSha256", None) + return _sha256(_canonical_json(unsigned)) + + +def seal_receipt(receipt: dict[str, Any]) -> dict[str, Any]: + sealed = dict(receipt) + sealed["receiptSha256"] = _receipt_digest(sealed) + return sealed + + +def _normalize_version(value: str) -> str: + normalized = value.strip() + if normalized.lower().startswith("v"): + normalized = normalized[1:] + return normalized + + +def normalize_origin(value: str) -> str: + parsed = urllib.parse.urlsplit(value.strip()) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ProofError("base URL must be an absolute http:// or https:// URL") + if parsed.username or parsed.password: + raise ProofError("base URL must not contain credentials") + if parsed.query or parsed.fragment: + raise ProofError("base URL must not contain a query or fragment") + if parsed.path not in {"", "/"}: + raise ProofError("base URL must contain only the target origin") + return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", "")) + + +def _secret_header(env_name: str, header_name: str) -> dict[str, str]: + if not env_name: + return {} + if not ENV_NAME_PATTERN.fullmatch(env_name): + raise ProofError(f"{header_name} environment variable name is invalid") + value = os.environ.get(env_name, "").strip() + if not value: + raise ProofError(f"{header_name} environment variable {env_name!r} is empty") + return {header_name: value} + + +def build_headers(authorization_env: str, cookie_env: str) -> dict[str, str]: + headers = {"Accept": "application/json"} + headers.update(_secret_header(authorization_env, "Authorization")) + headers.update(_secret_header(cookie_env, "Cookie")) + return headers + + +def _read_json_response( + url: str, + *, + headers: dict[str, str], + timeout_seconds: float, + tls_verified: bool, +) -> tuple[dict[str, Any], str]: + request = urllib.request.Request(url, headers=headers, method="GET") + context = None + if urllib.parse.urlsplit(url).scheme == "https" and not tls_verified: + context = ssl._create_unverified_context() # noqa: SLF001 - explicit operator option + try: + with urllib.request.urlopen(request, timeout=timeout_seconds, context=context) as response: + body = response.read(MAX_RESPONSE_BYTES + 1) + if len(body) > MAX_RESPONSE_BYTES: + raise ProofError(f"response exceeded {MAX_RESPONSE_BYTES} bytes") + if response.status != 200: + raise ProofError(f"GET {urllib.parse.urlsplit(url).path} returned HTTP {response.status}") + except urllib.error.HTTPError as exc: + raise ProofError( + f"GET {urllib.parse.urlsplit(url).path} returned HTTP {exc.code}" + ) from exc + except urllib.error.URLError as exc: + reason = str(exc.reason) if exc.reason else exc.__class__.__name__ + raise ProofError(f"GET {urllib.parse.urlsplit(url).path} failed: {reason}") from exc + except TimeoutError as exc: + raise ProofError(f"GET {urllib.parse.urlsplit(url).path} timed out") from exc + + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + raise ProofError(f"GET {urllib.parse.urlsplit(url).path} returned invalid JSON") from exc + if not isinstance(payload, dict): + raise ProofError(f"GET {urllib.parse.urlsplit(url).path} did not return a JSON object") + return payload, _sha256(body) + + +def _collect_postures( + origin: str, + *, + headers: dict[str, str], + timeout_seconds: float, + tls_verified: bool, +) -> tuple[list[dict[str, Any]], list[str], int]: + postures: list[dict[str, Any]] = [] + response_hashes: list[str] = [] + page = 1 + expected_total: int | None = None + + while True: + url = f"{origin}/api/recovery/postures?page={page}&limit=200" + payload, response_hash = _read_json_response( + url, + headers=headers, + timeout_seconds=timeout_seconds, + tls_verified=tls_verified, + ) + data = payload.get("data") + meta = payload.get("meta") + if not isinstance(data, list) or not isinstance(meta, dict): + raise ProofError("posture response must contain data[] and meta{}") + if not all(isinstance(item, dict) for item in data): + raise ProofError("posture response data must contain JSON objects") + + total = meta.get("total") + total_pages = meta.get("totalPages") + response_page = meta.get("page") + if not isinstance(total, int) or total < 0: + raise ProofError("posture response meta.total must be a non-negative integer") + if not isinstance(total_pages, int) or total_pages < 0: + raise ProofError("posture response meta.totalPages must be a non-negative integer") + if response_page != page: + raise ProofError(f"posture response returned page {response_page!r}, expected {page}") + if expected_total is None: + expected_total = total + elif total != expected_total: + raise ProofError("posture total changed while proof was being collected; retry") + + postures.extend(data) + response_hashes.append(response_hash) + if page >= total_pages: + break + page += 1 + if page > 1000: + raise ProofError("posture pagination exceeded 1000 pages") + + if expected_total is None: + expected_total = 0 + if len(postures) != expected_total: + raise ProofError( + f"posture pagination returned {len(postures)} rows, expected {expected_total}" + ) + return postures, response_hashes, expected_total + + +def evaluate_live_runtime( + *, + expected_version: str, + observed_version: str, + postures: list[dict[str, Any]], + minimum_postures: int, + minimum_successful_postures: int, +) -> tuple[dict[str, Any], list[str]]: + failures: list[str] = [] + expected_normalized = _normalize_version(expected_version) + observed_normalized = _normalize_version(observed_version) + if not observed_normalized: + failures.append("running version response was empty") + elif observed_normalized != expected_normalized: + failures.append( + f"running version {observed_version!r} does not match expected {expected_version!r}" + ) + + state_counts: Counter[str] = Counter() + successful_count = 0 + unknown_with_success: list[str] = [] + malformed_subjects: list[str] = [] + for index, posture in enumerate(postures): + subject_id = posture.get("subjectResourceId") + state = posture.get("state") + if not isinstance(subject_id, str) or not subject_id.strip() or not isinstance(state, str): + malformed_subjects.append(str(index)) + continue + state_counts[state] += 1 + if posture.get("lastSuccessfulPointAt"): + successful_count += 1 + if state == "unknown": + unknown_with_success.append(subject_id) + + if malformed_subjects: + failures.append( + f"{len(malformed_subjects)} posture rows lacked a valid subjectResourceId or state" + ) + if len(postures) < minimum_postures: + failures.append( + f"posture count {len(postures)} is below required minimum {minimum_postures}" + ) + if successful_count < minimum_successful_postures: + failures.append( + "successful-posture count " + f"{successful_count} is below required minimum {minimum_successful_postures}" + ) + if unknown_with_success: + failures.append( + f"{len(unknown_with_success)} workloads with successful restore points remain unknown" + ) + + observed = { + "version": observed_version, + "postureTotal": len(postures), + "stateCounts": dict(sorted(state_counts.items())), + "successfulPostureCount": successful_count, + "unknownWithSuccessfulPointCount": len(unknown_with_success), + "unknownWithSuccessfulPointResourceIds": sorted(unknown_with_success), + } + return observed, failures + + +def collect_receipt(args: argparse.Namespace) -> dict[str, Any]: + origin = normalize_origin(args.base_url) + headers = build_headers(args.authorization_env, args.cookie_env) + tls_verified = urllib.parse.urlsplit(origin).scheme == "https" and not args.insecure + version_payload, version_hash = _read_json_response( + f"{origin}/api/version", + headers=headers, + timeout_seconds=args.timeout_seconds, + tls_verified=tls_verified, + ) + observed_version = version_payload.get("version") + if not isinstance(observed_version, str): + raise ProofError("version response must contain a string version") + postures, posture_hashes, posture_total = _collect_postures( + origin, + headers=headers, + timeout_seconds=args.timeout_seconds, + tls_verified=tls_verified, + ) + observed, failures = evaluate_live_runtime( + expected_version=args.expected_version, + observed_version=observed_version, + postures=postures, + minimum_postures=args.minimum_postures, + minimum_successful_postures=args.minimum_successful_postures, + ) + is_source_build = version_payload.get("isSourceBuild") is True + is_development = version_payload.get("isDevelopment") is True + if is_source_build: + failures.append("running target reports a source build, not a release artifact") + if is_development: + failures.append("running target reports a development build, not a release artifact") + observed.update( + { + "build": version_payload.get("build", ""), + "channel": version_payload.get("channel", ""), + "deploymentType": version_payload.get("deploymentType", ""), + "isSourceBuild": is_source_build, + "isDevelopment": is_development, + "versionResponseSha256": version_hash, + "postureResponseSha256": posture_hashes, + } + ) + if posture_total != observed["postureTotal"]: + raise ProofError("posture response total changed during evaluation") + + receipt = { + "schemaVersion": SCHEMA_VERSION, + "proofType": PROOF_TYPE, + "assertion": ASSERTION, + "result": "passed" if not failures else "failed", + "collectedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "target": { + "origin": origin, + "tlsVerified": tls_verified, + }, + "expected": { + "version": args.expected_version, + "minimumPostures": args.minimum_postures, + "minimumSuccessfulPostures": args.minimum_successful_postures, + }, + "observed": observed, + "failures": failures, + } + return seal_receipt(receipt) + + +def verify_receipt( + receipt: dict[str, Any], + *, + expected_version: str, + expected_origin: str, + max_age_seconds: int, + now: datetime | None = None, +) -> list[str]: + errors: list[str] = [] + if receipt.get("schemaVersion") != SCHEMA_VERSION: + errors.append(f"schemaVersion must be {SCHEMA_VERSION}") + if receipt.get("proofType") != PROOF_TYPE: + errors.append(f"proofType must be {PROOF_TYPE!r}") + if receipt.get("assertion") != ASSERTION: + errors.append(f"assertion must be {ASSERTION!r}") + if receipt.get("receiptSha256") != _receipt_digest(receipt): + errors.append("receiptSha256 does not match receipt content") + if receipt.get("result") != "passed": + errors.append("receipt result is not passed") + if receipt.get("failures") != []: + errors.append("receipt contains assertion failures") + + expected = receipt.get("expected") + observed = receipt.get("observed") + target = receipt.get("target") + if not isinstance(expected, dict) or not isinstance(observed, dict): + errors.append("receipt expected and observed fields must be objects") + else: + receipt_expected = expected.get("version") + observed_version = observed.get("version") + if _normalize_version(str(receipt_expected or "")) != _normalize_version(expected_version): + errors.append("receipt expected version does not match verifier expectation") + if _normalize_version(str(observed_version or "")) != _normalize_version(expected_version): + errors.append("receipt observed version does not match verifier expectation") + if observed.get("unknownWithSuccessfulPointCount") != 0: + errors.append("receipt reports unknown postures with successful restore points") + if observed.get("isSourceBuild") is not False: + errors.append("receipt does not prove a packaged release artifact") + if observed.get("isDevelopment") is not False: + errors.append("receipt reports a development runtime") + minimum_postures = expected.get("minimumPostures") + minimum_successful = expected.get("minimumSuccessfulPostures") + posture_total = observed.get("postureTotal") + successful_total = observed.get("successfulPostureCount") + state_counts = observed.get("stateCounts") + unknown_ids = observed.get("unknownWithSuccessfulPointResourceIds") + if not isinstance(minimum_postures, int) or minimum_postures < 1: + errors.append("receipt minimumPostures must be a positive integer") + elif not isinstance(posture_total, int) or posture_total < minimum_postures: + errors.append("receipt posture total is below its required minimum") + if not isinstance(minimum_successful, int) or minimum_successful < 1: + errors.append("receipt minimumSuccessfulPostures must be a positive integer") + elif not isinstance(successful_total, int) or successful_total < minimum_successful: + errors.append("receipt successful-posture total is below its required minimum") + if not isinstance(state_counts, dict) or not all( + isinstance(state, str) and isinstance(count, int) and count >= 0 + for state, count in state_counts.items() + ): + errors.append("receipt stateCounts must contain non-negative integer counts") + elif isinstance(posture_total, int) and sum(state_counts.values()) != posture_total: + errors.append("receipt stateCounts do not sum to postureTotal") + if unknown_ids != []: + errors.append("receipt contains unknown successful-point resource IDs") + version_hash = observed.get("versionResponseSha256") + posture_hashes = observed.get("postureResponseSha256") + sha256_pattern = re.compile(r"^[0-9a-f]{64}$") + if not isinstance(version_hash, str) or not sha256_pattern.fullmatch(version_hash): + errors.append("receipt version response SHA-256 is invalid") + if not isinstance(posture_hashes, list) or not posture_hashes or not all( + isinstance(value, str) and sha256_pattern.fullmatch(value) + for value in posture_hashes + ): + errors.append("receipt posture response SHA-256 list is invalid") + + try: + normalized_expected_origin = normalize_origin(expected_origin) + except ProofError as exc: + errors.append(str(exc)) + normalized_expected_origin = "" + if not isinstance(target, dict): + errors.append("receipt target must be an object") + else: + if target.get("origin") != normalized_expected_origin: + errors.append("receipt target origin does not match verifier expectation") + if target.get("tlsVerified") is not True: + errors.append("receipt did not verify target TLS") + + collected_at = receipt.get("collectedAt") + try: + collected = datetime.fromisoformat(str(collected_at).replace("Z", "+00:00")) + if collected.tzinfo is None: + raise ValueError("timestamp has no timezone") + reference_now = now or datetime.now(timezone.utc) + age_seconds = (reference_now - collected).total_seconds() + if age_seconds < -300: + errors.append("receipt timestamp is more than five minutes in the future") + if age_seconds > max_age_seconds: + errors.append( + f"receipt age {int(age_seconds)}s exceeds maximum {max_age_seconds}s" + ) + except (TypeError, ValueError): + errors.append("receipt collectedAt is not a valid timezone-aware timestamp") + return errors + + +def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + temporary = Path(handle.name) + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + os.replace(temporary, path) + + +def _load_receipt(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise ProofError(f"could not read receipt: {exc}") from exc + except json.JSONDecodeError as exc: + raise ProofError("receipt is not valid JSON") from exc + if not isinstance(payload, dict): + raise ProofError("receipt must be a JSON object") + return payload + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Collect or verify live hardware proof. A published or installed release is not " + "live-verified until this command produces a passing receipt." + ) + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + collect = subparsers.add_parser("collect", help="query a running Pulse target") + collect.add_argument("--base-url", required=True, help="target origin, without a path") + collect.add_argument("--expected-version", required=True) + collect.add_argument("--output", required=True, type=Path) + collect.add_argument("--authorization-env", default="") + collect.add_argument("--cookie-env", default="") + collect.add_argument("--minimum-postures", type=int, default=1) + collect.add_argument("--minimum-successful-postures", type=int, default=1) + collect.add_argument("--timeout-seconds", type=float, default=DEFAULT_TIMEOUT_SECONDS) + collect.add_argument( + "--insecure", + action="store_true", + help="disable TLS verification; receipts collected this way cannot be verified", + ) + + verify = subparsers.add_parser("verify", help="validate a saved proof receipt") + verify.add_argument("--receipt", required=True, type=Path) + verify.add_argument("--expected-version", required=True) + verify.add_argument("--expected-origin", required=True) + verify.add_argument("--max-age-seconds", required=True, type=int) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + if args.command == "collect": + if args.minimum_postures < 1 or args.minimum_successful_postures < 1: + raise ProofError("minimum posture counts must be positive") + if args.timeout_seconds <= 0: + raise ProofError("timeout must be positive") + receipt = collect_receipt(args) + _write_json_atomic(args.output, receipt) + print(f"[{receipt['result'].upper()}] Live runtime proof: {args.output}") + for failure in receipt["failures"]: + print(f"ERROR: {failure}", file=sys.stderr) + return 0 if receipt["result"] == "passed" else 1 + + if args.max_age_seconds <= 0: + raise ProofError("max age must be positive") + receipt = _load_receipt(args.receipt) + errors = verify_receipt( + receipt, + expected_version=args.expected_version, + expected_origin=args.expected_origin, + max_age_seconds=args.max_age_seconds, + ) + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print(f"[PASSED] Verified live runtime proof: {args.receipt}") + return 0 + except ProofError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/live_runtime_proof_test.py b/scripts/release_control/live_runtime_proof_test.py new file mode 100755 index 000000000..03cb8635c --- /dev/null +++ b/scripts/release_control/live_runtime_proof_test.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Tests for live runtime release proof collection and verification.""" + +from __future__ import annotations + +import argparse +import json +import threading +import unittest +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest import mock + +from live_runtime_proof import collect_receipt, evaluate_live_runtime, seal_receipt, verify_receipt + + +class _ProofHandler(BaseHTTPRequestHandler): + authorization = "Bearer proof-token" + + def do_GET(self) -> None: # noqa: N802 - stdlib handler API + if self.headers.get("Authorization") != self.authorization: + self.send_response(401) + self.end_headers() + return + if self.path == "/api/version": + self._json( + { + "version": "6.2.0-rc.8", + "build": "proof-build", + "channel": "rc", + "isSourceBuild": False, + "isDevelopment": False, + } + ) + return + if self.path == "/api/recovery/postures?page=1&limit=200": + self._json( + { + "data": [ + { + "subjectResourceId": "vm-100", + "state": "protected", + "lastSuccessfulPointAt": "2026-08-04T10:00:00Z", + }, + { + "subjectResourceId": "vm-101", + "state": "attention", + "lastSuccessfulPointAt": "2026-08-03T10:00:00Z", + }, + ], + "meta": {"page": 1, "limit": 200, "total": 2, "totalPages": 1}, + } + ) + return + self.send_response(404) + self.end_headers() + + def _json(self, payload: object) -> None: + encoded = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, format: str, *args: object) -> None: + return + + +class LiveRuntimeProofTest(unittest.TestCase): + def test_evaluation_rejects_unknown_with_successful_point(self) -> None: + observed, failures = evaluate_live_runtime( + expected_version="6.2.0-rc.8", + observed_version="6.2.0-rc.8", + postures=[ + { + "subjectResourceId": "vm-100", + "state": "unknown", + "lastSuccessfulPointAt": "2026-08-04T10:00:00Z", + } + ], + minimum_postures=1, + minimum_successful_postures=1, + ) + self.assertEqual(observed["unknownWithSuccessfulPointCount"], 1) + self.assertTrue(any("remain unknown" in failure for failure in failures)) + + def test_evaluation_rejects_version_mismatch_and_empty_dataset(self) -> None: + _, failures = evaluate_live_runtime( + expected_version="6.2.0-rc.8", + observed_version="6.2.0-rc.7", + postures=[], + minimum_postures=1, + minimum_successful_postures=1, + ) + self.assertEqual(len(failures), 3) + self.assertTrue(any("does not match" in failure for failure in failures)) + + def test_collects_passing_receipt_from_running_target(self) -> None: + server = ThreadingHTTPServer(("127.0.0.1", 0), _ProofHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + + with mock.patch.dict( + "os.environ", {"PULSE_PROOF_TEST_AUTH": _ProofHandler.authorization} + ): + receipt = collect_receipt( + argparse.Namespace( + base_url=f"http://127.0.0.1:{server.server_port}", + expected_version="6.2.0-rc.8", + authorization_env="PULSE_PROOF_TEST_AUTH", + cookie_env="", + insecure=False, + timeout_seconds=2.0, + minimum_postures=2, + minimum_successful_postures=2, + ) + ) + + self.assertEqual(receipt["result"], "passed") + self.assertEqual(receipt["observed"]["stateCounts"], {"attention": 1, "protected": 1}) + self.assertEqual(receipt["observed"]["unknownWithSuccessfulPointCount"], 0) + self.assertEqual(len(receipt["observed"]["postureResponseSha256"]), 1) + + def test_verifier_rejects_tampering(self) -> None: + receipt = self._passing_receipt() + receipt["observed"]["unknownWithSuccessfulPointCount"] = 3 + errors = verify_receipt( + receipt, + expected_version="6.2.0-rc.8", + expected_origin="https://pulse.example.test", + max_age_seconds=3600, + now=datetime(2026, 8, 4, 12, 30, tzinfo=timezone.utc), + ) + self.assertIn("receiptSha256 does not match receipt content", errors) + self.assertIn("receipt reports unknown postures with successful restore points", errors) + + def test_verifier_rejects_wrong_target_and_stale_receipt(self) -> None: + receipt = self._passing_receipt() + errors = verify_receipt( + receipt, + expected_version="6.2.0-rc.8", + expected_origin="https://other.example.test", + max_age_seconds=60, + now=datetime(2026, 8, 4, 12, 30, tzinfo=timezone.utc), + ) + self.assertIn("receipt target origin does not match verifier expectation", errors) + self.assertTrue(any("exceeds maximum" in error for error in errors)) + + def test_verifier_accepts_fresh_sealed_receipt(self) -> None: + errors = verify_receipt( + self._passing_receipt(), + expected_version="v6.2.0-rc.8", + expected_origin="https://pulse.example.test/", + max_age_seconds=3600, + now=datetime(2026, 8, 4, 12, 30, tzinfo=timezone.utc), + ) + self.assertEqual(errors, []) + + @staticmethod + def _passing_receipt() -> dict[str, object]: + return seal_receipt( + { + "schemaVersion": 1, + "proofType": "pulse-live-runtime", + "assertion": "successful-restore-points-have-evaluated-posture", + "result": "passed", + "collectedAt": "2026-08-04T12:00:00Z", + "target": {"origin": "https://pulse.example.test", "tlsVerified": True}, + "expected": { + "version": "6.2.0-rc.8", + "minimumPostures": 1, + "minimumSuccessfulPostures": 1, + }, + "observed": { + "version": "6.2.0-rc.8", + "postureTotal": 2, + "stateCounts": {"attention": 1, "protected": 1}, + "successfulPostureCount": 2, + "unknownWithSuccessfulPointCount": 0, + "unknownWithSuccessfulPointResourceIds": [], + "isSourceBuild": False, + "isDevelopment": False, + "versionResponseSha256": "a" * 64, + "postureResponseSha256": ["b" * 64], + }, + "failures": [], + } + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 1ca1efd69..d0127d2ea 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -10,6 +10,7 @@ import unittest import json import record_rc_to_ga_blocked as blocked_record +from live_runtime_proof import evaluate_live_runtime from release_promotion_policy_support import ( REQUIRED_STAGED_GOVERNANCE_INPUTS, promotion_metadata_envelope, @@ -157,6 +158,28 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("public RC tag", content) self.assertIn("license.pulserelay.pro/pulse-pro:6.0.0", content) self.assertIn("moving branch", content) + self.assertIn("`implemented`", content) + self.assertIn("`release-validated`", content) + self.assertIn("`live-verified`", content) + self.assertIn("scripts/release_control/live_runtime_proof.py", content) + self.assertIn("an operator statement cannot substitute for the receipt", content) + + def test_live_runtime_claim_rejects_unknown_successful_posture(self) -> None: + observed, failures = evaluate_live_runtime( + expected_version="6.2.0-rc.8", + observed_version="6.2.0-rc.8", + postures=[ + { + "subjectResourceId": "vm-100", + "state": "unknown", + "lastSuccessfulPointAt": "2026-08-04T10:00:00Z", + } + ], + minimum_postures=1, + minimum_successful_postures=1, + ) + self.assertEqual(observed["unknownWithSuccessfulPointCount"], 1) + self.assertTrue(any("remain unknown" in failure for failure in failures)) def test_v6_ga_owner_risk_exception_is_bounded_and_packet_aligned(self) -> None: policy = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md")