220 lines
7.6 KiB
Python
Executable File
220 lines
7.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Build machine-readable owner packets from post-reboot next-gate dispatch.
|
|
|
|
Read-only by design. This script may run the read-only dispatch checklist, but
|
|
it never sends owner requests, reads secrets, writes credential markers, or
|
|
modifies host/runtime state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
DISPATCH_SCRIPT = ROOT / "scripts" / "reboot-recovery" / "post-reboot-next-gate-dispatch.sh"
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Convert post-reboot gate dispatch output into owner packet JSON.",
|
|
)
|
|
parser.add_argument(
|
|
"--dispatch-file",
|
|
type=Path,
|
|
help="Use an existing post-reboot-next-gate-dispatch output file.",
|
|
)
|
|
parser.add_argument(
|
|
"--summary-file",
|
|
type=Path,
|
|
help="Pass an existing readiness summary file to the delegated dispatch checklist.",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
help="Write JSON to this path instead of stdout.",
|
|
)
|
|
parser.add_argument(
|
|
"--no-color",
|
|
action="store_true",
|
|
help="Pass --no-color to the delegated dispatch script.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def run_dispatch(no_color: bool, summary_file: Path | None) -> str:
|
|
cmd = [str(DISPATCH_SCRIPT)]
|
|
if summary_file:
|
|
cmd.extend(["--summary-file", str(summary_file)])
|
|
if no_color:
|
|
cmd.append("--no-color")
|
|
completed = subprocess.run(
|
|
cmd,
|
|
cwd=ROOT,
|
|
check=False,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
if completed.returncode not in (0,):
|
|
raise SystemExit(
|
|
f"dispatch_failed rc={completed.returncode}\n{completed.stdout}"
|
|
)
|
|
return completed.stdout
|
|
|
|
|
|
def load_dispatch(args: argparse.Namespace) -> str:
|
|
if args.dispatch_file:
|
|
return args.dispatch_file.read_text(encoding="utf-8")
|
|
return run_dispatch(no_color=args.no_color, summary_file=args.summary_file)
|
|
|
|
|
|
def split_csv(value: str) -> list[str]:
|
|
if not value or value == "none":
|
|
return []
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
|
|
|
|
def parse_dispatch(text: str) -> dict[str, Any]:
|
|
summary: dict[str, str] = {}
|
|
gates: list[dict[str, Any]] = []
|
|
current_gate: dict[str, Any] | None = None
|
|
|
|
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()
|
|
value = value.strip()
|
|
|
|
if key == "GATE_ID":
|
|
if current_gate:
|
|
gates.append(current_gate)
|
|
current_gate = {"gate_id": value}
|
|
continue
|
|
|
|
target = current_gate if current_gate is not None else summary
|
|
target[key.lower()] = value
|
|
|
|
if current_gate:
|
|
gates.append(current_gate)
|
|
|
|
for gate in gates:
|
|
for key in (
|
|
"owner_group",
|
|
"required_items",
|
|
"required_evidence",
|
|
"required_decisions",
|
|
"required_export",
|
|
"forbidden_payloads",
|
|
"forbidden_action",
|
|
"forbidden_actions",
|
|
"done_criteria",
|
|
):
|
|
if key in gate:
|
|
gate[key] = split_csv(str(gate[key]))
|
|
|
|
return {"summary": summary, "gates": gates}
|
|
|
|
|
|
def build_packet(parsed: dict[str, Any]) -> dict[str, Any]:
|
|
summary = parsed["summary"]
|
|
gates = parsed["gates"]
|
|
next_required = split_csv(summary.get("next_required_gates", ""))
|
|
|
|
return {
|
|
"schema_version": "awoooi_post_reboot_next_gate_owner_packets_v1",
|
|
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
"source": {
|
|
"dispatch_script": str(DISPATCH_SCRIPT.relative_to(ROOT)),
|
|
"summary_file": summary.get("summary_file", "unknown"),
|
|
"summary_artifact_dir": summary.get("summary_artifact_dir", "unknown"),
|
|
"overall_declaration": summary.get("overall_declaration", "unknown"),
|
|
"next_required_gates": next_required,
|
|
},
|
|
"status": {
|
|
"service_green": summary.get("service_green", "unknown"),
|
|
"runtime_action_authorized": 0,
|
|
"dispatch_authorized": 0,
|
|
"request_sent_count": 0,
|
|
"owner_response_received_count": 0,
|
|
"owner_response_accepted_count": 0,
|
|
"host_write_authorized": 0,
|
|
"secret_value_collection_allowed": 0,
|
|
"runtime_gate_count": 0,
|
|
},
|
|
"owner_packets": [
|
|
{
|
|
"packet_id": gate.get("gate_id", "unknown"),
|
|
"title": gate.get("gate_title", "unknown"),
|
|
"priority": gate.get("gate_priority", "unknown"),
|
|
"status": gate.get("gate_status", "unknown"),
|
|
"current_evidence": gate.get("current_evidence", "unknown"),
|
|
"owner_group": gate.get("owner_group", []),
|
|
"required_items": gate.get("required_items", []),
|
|
"required_evidence": gate.get("required_evidence", []),
|
|
"required_decisions": gate.get("required_decisions", []),
|
|
"required_export": gate.get("required_export", []),
|
|
"allowed_action": gate.get("allowed_action", "unknown"),
|
|
"forbidden_payloads": gate.get("forbidden_payloads", []),
|
|
"forbidden_actions": gate.get("forbidden_actions")
|
|
or gate.get("forbidden_action", []),
|
|
"done_criteria": gate.get("done_criteria", []),
|
|
"request_sent": False,
|
|
"response_received": False,
|
|
"response_accepted": False,
|
|
"runtime_action_authorized": False,
|
|
}
|
|
for gate in gates
|
|
],
|
|
"counts": {
|
|
"next_gate_count": len(gates),
|
|
"p0_gate_count": sum(1 for gate in gates if gate.get("gate_priority") == "P0"),
|
|
"request_sent_count": 0,
|
|
"owner_response_received_count": 0,
|
|
"owner_response_accepted_count": 0,
|
|
"runtime_action_authorized_count": 0,
|
|
},
|
|
"no_false_green_rules": [
|
|
"product_route_green_does_not_equal_product_data_green",
|
|
"service_green_does_not_equal_dr_complete",
|
|
"backup_fresh_does_not_equal_credential_escrow_complete",
|
|
"host_188_service_green_does_not_equal_host_hygiene_green",
|
|
"wazuh_route_or_transport_does_not_equal_manager_registry_accepted",
|
|
],
|
|
"forbidden_global_actions": [
|
|
"send_owner_request_without_review",
|
|
"write_credential_marker_without_non_secret_evidence",
|
|
"collect_secret_value_hash_prefix_suffix_or_raw_payload",
|
|
"pg_resetwal_or_db_restore_without_maintenance_window",
|
|
"nginx_reload_or_certbot_renew_without_owner_gate",
|
|
"wazuh_active_response_reenroll_restart_or_secret_patch",
|
|
"host_write_or_kali_active_scan_without_explicit_approval",
|
|
],
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
parsed = parse_dispatch(load_dispatch(args))
|
|
packet = build_packet(parsed)
|
|
payload = json.dumps(packet, ensure_ascii=False, indent=2, sort_keys=True)
|
|
if args.output:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(payload + "\n", encoding="utf-8")
|
|
else:
|
|
print(payload)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|