Files
awoooi/scripts/reboot-recovery/post-reboot-credential-escrow-intake-scorecard.py

429 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Summarize credential escrow intake readiness without opening runtime gates.
Read-only by design. This script consumes sanitized artifacts such as the
post-reboot summary, owner packet, placeholder/owner response, offsite report,
and escrow marker status. It never reads secret values, writes credential
markers, sends owner requests, or modifies host/runtime state.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
OWNER_PACKET_GENERATOR = (
ROOT / "scripts" / "reboot-recovery" / "post-reboot-next-gate-owner-packets.py"
)
OWNER_RESPONSE_PREFLIGHT = (
ROOT / "scripts" / "reboot-recovery" / "post-reboot-owner-response-preflight.py"
)
EXPECTED_OWNER_PACKET_SCHEMA = "awoooi_post_reboot_next_gate_owner_packets_v1"
RESPONSE_SCHEMA = "awoooi_post_reboot_next_gate_owner_response_v1"
ESCROW_GATE_ID = "credential_escrow_evidence"
ESCROW_ITEM_IDS = {
"restic_repository_password",
"offsite_provider_credentials",
"break_glass_admin_credentials",
"dns_registrar_recovery",
"oauth_ai_provider_recovery",
}
FORBIDDEN_TRUE_FIELDS = {
"runtime_action_requested",
"runtime_action_authorized",
"host_write_requested",
"host_write_authorized",
"secret_value_included",
"secret_value_collection_allowed",
"credential_marker_write_requested",
"credential_marker_write_authorized",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Read credential escrow intake artifacts into a no-secret scorecard.",
)
parser.add_argument("--summary-file", type=Path, help="Post-reboot readiness summary.")
parser.add_argument("--owner-packet-file", type=Path, help="Post-reboot owner packet JSON.")
parser.add_argument("--response-file", type=Path, help="Owner response or placeholder JSON.")
parser.add_argument("--offsite-report-file", type=Path, help="offsite-escrow-evidence-report output.")
parser.add_argument("--escrow-status-file", type=Path, help="mark-credential-escrow-verified --status output.")
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
parser.add_argument("--no-color", action="store_true", help="Accepted for command symmetry; output is plain text.")
return parser.parse_args()
def read_text(path: Path | None) -> str:
if not path:
return ""
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise SystemExit(f"artifact_not_found={path}") from exc
def load_json(path: Path, label: str) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise SystemExit(f"{label}_not_found={path}") from exc
except json.JSONDecodeError as exc:
raise SystemExit(f"{label}_json_invalid={exc}") from exc
if not isinstance(payload, dict):
raise SystemExit(f"{label}_json_not_object")
return payload
def parse_key_values(text: str) -> dict[str, str]:
values: dict[str, str] = {}
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if re.fullmatch(r"[A-Z0-9_]+", key):
values[key] = value.strip()
return values
def split_csv(value: str | None) -> list[str]:
if not value or value == "none":
return []
return [item.strip() for item in value.split(",") if item.strip()]
def as_list(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def as_int(value: Any) -> int | None:
if value is None:
return None
try:
return int(str(value))
except (TypeError, ValueError):
return None
def int_or_unknown(value: int | None) -> int | str:
return value if value is not None else "unknown"
def bool_as_int(value: Any) -> int:
return 1 if value is True else 0
def load_owner_packet(args: argparse.Namespace) -> dict[str, Any]:
if args.owner_packet_file:
return load_json(args.owner_packet_file, "owner_packet_file")
if not args.summary_file:
return {}
cmd = [
str(OWNER_PACKET_GENERATOR),
"--no-color",
"--summary-file",
str(args.summary_file),
]
completed = subprocess.run(
cmd,
cwd=ROOT,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
if completed.returncode != 0:
raise SystemExit(
"owner_packet_generation_failed "
f"rc={completed.returncode}\n{completed.stdout}"
)
try:
packet = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise SystemExit(f"owner_packet_json_invalid={exc}") from exc
if not isinstance(packet, dict):
raise SystemExit("owner_packet_json_not_object")
return packet
def owner_packet_gate_ids(packet: dict[str, Any]) -> list[str]:
if not packet:
return []
if packet.get("schema_version") != EXPECTED_OWNER_PACKET_SCHEMA:
raise SystemExit(f"owner_packet_schema={packet.get('schema_version')!r}")
return [
str(item.get("packet_id"))
for item in as_list(packet.get("owner_packets"))
if isinstance(item, dict) and item.get("packet_id")
]
def credential_required_items(packet: dict[str, Any]) -> set[str]:
for item in as_list(packet.get("owner_packets")):
if not isinstance(item, dict) or item.get("packet_id") != ESCROW_GATE_ID:
continue
return {
str(raw_item)
for raw_item in as_list(item.get("required_items"))
if str(raw_item) in ESCROW_ITEM_IDS
}
return set()
def response_gate_ids(response: dict[str, Any]) -> list[str]:
if not response:
return []
if response.get("schema_version") != RESPONSE_SCHEMA:
raise SystemExit(f"response_schema={response.get('schema_version')!r}")
return [
str(item.get("gate_id"))
for item in as_list(response.get("responses"))
if isinstance(item, dict) and item.get("gate_id")
]
def count_true_fields(value: Any) -> dict[str, int]:
counts = {key: 0 for key in FORBIDDEN_TRUE_FIELDS}
if isinstance(value, dict):
for key, child in value.items():
if key in counts and child is not False:
counts[key] += 1
child_counts = count_true_fields(child)
for child_key, child_value in child_counts.items():
counts[child_key] += child_value
elif isinstance(value, list):
for child in value:
child_counts = count_true_fields(child)
for child_key, child_value in child_counts.items():
counts[child_key] += child_value
return counts
def run_preflight(args: argparse.Namespace) -> dict[str, Any]:
if not args.response_file:
return {}
cmd = [str(OWNER_RESPONSE_PREFLIGHT), "--json", "--no-color"]
if args.owner_packet_file:
cmd.extend(["--owner-packet-file", str(args.owner_packet_file)])
elif args.summary_file:
cmd.extend(["--summary-file", str(args.summary_file)])
else:
return {}
cmd.extend(["--response-file", str(args.response_file)])
completed = subprocess.run(
cmd,
cwd=ROOT,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
if completed.returncode != 0:
raise SystemExit(
"owner_response_preflight_failed "
f"rc={completed.returncode}\n{completed.stdout}"
)
try:
result = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise SystemExit(f"owner_response_preflight_json_invalid={exc}") from exc
if not isinstance(result, dict):
raise SystemExit("owner_response_preflight_json_not_object")
return result
def parse_escrow_status(text: str) -> dict[str, int]:
seen = 0
missing = 0
present = 0
for raw_line in text.splitlines():
parts = raw_line.strip().split()
if len(parts) < 2 or parts[0] not in ESCROW_ITEM_IDS:
continue
seen += 1
state = parts[1].lower()
if state == "missing":
missing += 1
elif state in {"present", "verified", "fresh", "ok"}:
present += 1
return {"seen": seen, "missing": missing, "present": present}
def first_known(*values: int | None) -> int | None:
for value in values:
if value is not None:
return value
return None
def evaluate(args: argparse.Namespace) -> dict[str, Any]:
summary = parse_key_values(read_text(args.summary_file))
offsite = parse_key_values(read_text(args.offsite_report_file))
escrow_status = parse_escrow_status(read_text(args.escrow_status_file))
packet = load_owner_packet(args)
response = load_json(args.response_file, "response_file") if args.response_file else {}
preflight = run_preflight(args)
summary_gates = split_csv(summary.get("NEXT_REQUIRED_GATES"))
packet_gates = owner_packet_gate_ids(packet)
response_gates = response_gate_ids(response)
unexpected_packet_gates = sorted(set(packet_gates) - {ESCROW_GATE_ID})
unexpected_response_gates = sorted(set(response_gates) - set(packet_gates or summary_gates))
required_items = credential_required_items(packet)
missing_required_items = sorted(ESCROW_ITEM_IDS - required_items) if ESCROW_GATE_ID in packet_gates else []
summary_missing = as_int(summary.get("ESCROW_MISSING_COUNT"))
offsite_missing = first_known(
as_int(offsite.get("MISSING_ESCROW_MARKER_COUNT")),
as_int(offsite.get("ESCROW_MISSING_COUNT")),
)
status_missing = escrow_status["missing"] if escrow_status["seen"] else None
effective_missing = first_known(offsite_missing, summary_missing, status_missing)
true_counts = count_true_fields(response)
forbidden_true_total = sum(true_counts.values())
preflight_status = str(preflight.get("status", "not_run"))
preflight_blockers = as_list(preflight.get("blockers"))
active_gate_present = ESCROW_GATE_ID in set(summary_gates or packet_gates)
if not active_gate_present:
status = "not_required_current_summary"
next_step = "rerun_post_reboot_summary_when_next_required_gates_change"
elif forbidden_true_total:
status = "blocked_forbidden_runtime_or_marker_request"
next_step = "strip_runtime_secret_host_write_or_marker_write_fields_before_preflight"
elif unexpected_packet_gates or unexpected_response_gates:
status = "blocked_owner_packet_or_response_gate_mismatch"
next_step = "regenerate_owner_packet_and_response_template_from_same_summary"
elif preflight_status == "ready_for_independent_reviewer_acceptance":
status = "ready_for_independent_reviewer_acceptance"
if effective_missing == 0:
next_step = "rerun_post_reboot_summary_to_close_dr_gate"
else:
next_step = "independent_reviewer_acceptance_then_marker_dry_run"
else:
status = "blocked_waiting_non_secret_credential_escrow_evidence"
next_step = "collect_redacted_non_secret_evidence_refs_then_rerun_preflight"
result = {
"schema_version": "awoooi_post_reboot_credential_escrow_intake_scorecard_v1",
"status": status,
"next_step": next_step,
"active_gate_present": active_gate_present,
"summary_next_required_gates": summary_gates,
"owner_packet_gate_count": len(packet_gates),
"owner_packet_gates": packet_gates,
"unexpected_owner_packet_gate_count": len(unexpected_packet_gates),
"unexpected_owner_packet_gates": unexpected_packet_gates,
"response_gate_count": len(response_gates),
"response_gates": response_gates,
"unexpected_response_gate_count": len(unexpected_response_gates),
"unexpected_response_gates": unexpected_response_gates,
"required_item_count": len(required_items),
"missing_required_item_count": len(missing_required_items),
"missing_required_items": missing_required_items,
"summary_escrow_missing_count": int_or_unknown(summary_missing),
"offsite_escrow_missing_count": int_or_unknown(offsite_missing),
"escrow_status_seen_count": escrow_status["seen"],
"escrow_status_missing_count": int_or_unknown(status_missing),
"effective_escrow_missing_count": int_or_unknown(effective_missing),
"script_missing_count": int_or_unknown(as_int(offsite.get("SCRIPT_MISSING_COUNT"))),
"offsite_configured": int_or_unknown(as_int(offsite.get("OFFSITE_CONFIGURED"))),
"rclone_configured": int_or_unknown(as_int(offsite.get("RCLONE_CONFIGURED"))),
"preflight_status": preflight_status,
"preflight_blocker_count": len(preflight_blockers),
"owner_response_received_count": preflight.get("owner_response_received_count", 0),
"owner_response_accepted_count": preflight.get("owner_response_accepted_count", 0),
"runtime_gate_count": preflight.get("runtime_gate_count", 0),
"runtime_action_authorized": bool_as_int(preflight.get("runtime_action_authorized")),
"host_write_authorized": bool_as_int(preflight.get("host_write_authorized")),
"secret_value_collection_allowed": bool_as_int(preflight.get("secret_value_collection_allowed")),
"runtime_action_requested_count": true_counts["runtime_action_requested"],
"host_write_requested_count": true_counts["host_write_requested"],
"secret_value_included_count": true_counts["secret_value_included"],
"secret_value_collection_allowed_count": true_counts["secret_value_collection_allowed"],
"credential_marker_write_requested_count": true_counts["credential_marker_write_requested"],
"credential_marker_write_authorized_count": true_counts["credential_marker_write_authorized"],
"forbidden_true_field_count": forbidden_true_total,
}
return result
def csv_value(value: Any) -> str:
if isinstance(value, list):
return ",".join(str(item) for item in value) if value else "none"
if isinstance(value, bool):
return "1" if value else "0"
return str(value)
def print_key_values(result: dict[str, Any]) -> None:
print("POST_REBOOT_CREDENTIAL_ESCROW_INTAKE_SCORECARD=1")
ordered_keys = [
"status",
"next_step",
"active_gate_present",
"summary_next_required_gates",
"owner_packet_gate_count",
"owner_packet_gates",
"unexpected_owner_packet_gate_count",
"response_gate_count",
"response_gates",
"unexpected_response_gate_count",
"required_item_count",
"missing_required_item_count",
"summary_escrow_missing_count",
"offsite_escrow_missing_count",
"escrow_status_seen_count",
"escrow_status_missing_count",
"effective_escrow_missing_count",
"script_missing_count",
"offsite_configured",
"rclone_configured",
"preflight_status",
"preflight_blocker_count",
"owner_response_received_count",
"owner_response_accepted_count",
"runtime_gate_count",
"runtime_action_authorized",
"host_write_authorized",
"secret_value_collection_allowed",
"runtime_action_requested_count",
"host_write_requested_count",
"secret_value_included_count",
"credential_marker_write_requested_count",
"credential_marker_write_authorized_count",
"forbidden_true_field_count",
]
for key in ordered_keys:
print(f"{key.upper()}={csv_value(result.get(key))}")
def main() -> int:
args = parse_args()
result = evaluate(args)
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
else:
print_key_values(result)
return 0
if __name__ == "__main__":
sys.exit(main())