429 lines
16 KiB
Python
Executable File
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())
|