#!/usr/bin/env python3 """Guard post-reboot recovery declarations against false green claims. Read-only by design. It consumes post-reboot-readiness-summary.sh output, or runs that summary when no file is provided. It never repairs services, writes markers, sends owner requests, or changes 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] SUMMARY_SCRIPT = ROOT / "scripts" / "reboot-recovery" / "post-reboot-readiness-summary.sh" SCHEMA_VERSION = "awoooi_post_reboot_declaration_guard_v1" def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Validate what may be declared after reboot recovery.", ) parser.add_argument( "--summary-file", type=Path, help="Read an existing post-reboot readiness summary key/value file.", ) parser.add_argument( "--proposed", action="append", default=[], help="Optional declaration to validate. Repeatable.", ) parser.add_argument( "--json", action="store_true", help="Print JSON instead of a compact status line.", ) parser.add_argument( "--output", type=Path, help="Write JSON payload to this path.", ) parser.add_argument( "--no-color", action="store_true", help="Pass --no-color when running the readiness summary.", ) return parser.parse_args() def run_summary(no_color: bool) -> str: cmd = [str(SUMMARY_SCRIPT)] 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, 2): raise SystemExit( "POST_REBOOT_DECLARATION_GUARD_FAILED " f"summary_rc={completed.returncode}\n{completed.stdout}" ) return completed.stdout def load_summary(args: argparse.Namespace) -> str: if args.summary_file: return args.summary_file.read_text(encoding="utf-8") return run_summary(no_color=args.no_color) def parse_summary(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) values[key.strip()] = value.strip() return values def truthy(value: str | None) -> bool: return value in {"1", "true", "True", "yes", "YES"} def nonzero(value: str | None) -> bool: if value is None or value in {"", "unknown"}: return False try: return int(value) != 0 except ValueError: return False 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 build_payload(summary: dict[str, str], proposed: list[str]) -> dict[str, Any]: allowed: list[str] = [] forbidden: list[dict[str, str]] = [] service_green = truthy(summary.get("SERVICE_GREEN")) product_data_green = truthy(summary.get("PRODUCT_DATA_GREEN")) backup_core_green = truthy(summary.get("BACKUP_CORE_GREEN")) dr_escrow_blocked = truthy(summary.get("DR_ESCROW_BLOCKED")) or nonzero( summary.get("ESCROW_MISSING_COUNT") ) host_188_hygiene_blocked = truthy(summary.get("HOST_188_HYGIENE_BLOCKED")) wazuh_registry_accepted = truthy(summary.get("WAZUH_MANAGER_REGISTRY_ACCEPTED")) runtime_authorized = truthy(summary.get("RUNTIME_ACTION_AUTHORIZED")) next_required_gates = split_csv(summary.get("NEXT_REQUIRED_GATES")) overall_declaration = summary.get("OVERALL_DECLARATION", "unknown") if service_green: allowed.append("SERVICE_RECOVERY_GREEN") else: forbidden.append( { "declaration": "SERVICE_RECOVERY_GREEN", "reason": "service_green_not_1", } ) if product_data_green: allowed.append("PRODUCT_DATA_GREEN") else: forbidden.append( { "declaration": "PRODUCT_DATA_GREEN", "reason": "product_data_green_not_1", } ) if backup_core_green: allowed.append("BACKUP_CORE_GREEN") else: forbidden.append( { "declaration": "BACKUP_CORE_GREEN", "reason": "backup_core_green_not_1", } ) if overall_declaration != "unknown": allowed.append(overall_declaration) if ( service_green and product_data_green and backup_core_green and not dr_escrow_blocked and not host_188_hygiene_blocked and wazuh_registry_accepted and not runtime_authorized ): allowed.append("FULL_STACK_GREEN") else: full_stack_reasons: list[str] = [] if not service_green: full_stack_reasons.append("service_green_not_1") if not product_data_green: full_stack_reasons.append("product_data_green_not_1") if not backup_core_green: full_stack_reasons.append("backup_core_green_not_1") if dr_escrow_blocked: full_stack_reasons.append( f"escrow_missing_count:{summary.get('ESCROW_MISSING_COUNT', 'unknown')}" ) if host_188_hygiene_blocked: full_stack_reasons.append("host_188_hygiene_blocked:1") if not wazuh_registry_accepted: full_stack_reasons.append("wazuh_manager_registry_accepted:0") if runtime_authorized: full_stack_reasons.append("runtime_action_authorized:1") forbidden.append( { "declaration": "FULL_STACK_GREEN", "reason": ",".join(full_stack_reasons) or "unknown", } ) if dr_escrow_blocked: forbidden.append( { "declaration": "DR_COMPLETE", "reason": f"escrow_missing_count:{summary.get('ESCROW_MISSING_COUNT', 'unknown')}", } ) else: allowed.append("DR_COMPLETE") if host_188_hygiene_blocked: forbidden.append( { "declaration": "HOST_188_FULLY_GREEN", "reason": "host_188_hygiene_blocked:1", } ) else: allowed.append("HOST_188_FULLY_GREEN") if not wazuh_registry_accepted: forbidden.append( { "declaration": "WAZUH_REGISTRY_RECOVERED", "reason": "wazuh_manager_registry_accepted:0", } ) else: allowed.append("WAZUH_REGISTRY_RECOVERED") if not runtime_authorized: forbidden.append( { "declaration": "RUNTIME_ACTION_AUTHORIZED", "reason": "runtime_action_authorized:0", } ) else: allowed.append("RUNTIME_ACTION_AUTHORIZED") allowed_set = set(allowed) forbidden_map = {item["declaration"]: item["reason"] for item in forbidden} rejected_proposed = [ {"declaration": item, "reason": forbidden_map[item]} for item in proposed if item in forbidden_map ] accepted_proposed = [item for item in proposed if item in allowed_set] unknown_proposed = [ item for item in proposed if item not in allowed_set and item not in forbidden_map ] if not service_green: status = "blocked_service_recovery" elif not product_data_green: status = "blocked_product_data_recovery" else: status = "allowed_with_boundary_blockers" if rejected_proposed: status = "blocked_false_green_proposal" return { "schema_version": SCHEMA_VERSION, "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "source": { "summary_script": str(SUMMARY_SCRIPT.relative_to(ROOT)), "artifact_dir": summary.get("ARTIFACT_DIR", "unknown"), "overall_declaration": overall_declaration, }, "status": status, "allowed_declarations": sorted(set(allowed)), "forbidden_declarations": forbidden, "next_required_gates": next_required_gates, "counts": { "allowed_count": len(set(allowed)), "forbidden_count": len(forbidden), "next_required_gate_count": len(next_required_gates), "proposed_count": len(proposed), "accepted_proposed_count": len(accepted_proposed), "rejected_proposed_count": len(rejected_proposed), "unknown_proposed_count": len(unknown_proposed), }, "proposed": { "accepted": accepted_proposed, "rejected": rejected_proposed, "unknown": unknown_proposed, }, "evidence": { "service_green": summary.get("SERVICE_GREEN", "unknown"), "product_data_green": summary.get("PRODUCT_DATA_GREEN", "unknown"), "stock_freshness_status": summary.get("STOCK_FRESHNESS_STATUS", "unknown"), "stock_latest_trading_date": summary.get( "STOCK_LATEST_TRADING_DATE", "unknown", ), "stock_blockers": summary.get("STOCK_BLOCKERS", "unknown"), "stock_eod_window_pending": summary.get( "STOCK_EOD_WINDOW_PENDING", "unknown", ), "stock_eod_classification": summary.get( "STOCK_EOD_CLASSIFICATION", "unknown", ), "stock_eod_next_action": summary.get("STOCK_EOD_NEXT_ACTION", "unknown"), "backup_core_green": summary.get("BACKUP_CORE_GREEN", "unknown"), "escrow_missing_count": summary.get("ESCROW_MISSING_COUNT", "unknown"), "host_188_hygiene_blocked": summary.get("HOST_188_HYGIENE_BLOCKED", "unknown"), "wazuh_manager_registry_accepted": summary.get( "WAZUH_MANAGER_REGISTRY_ACCEPTED", "unknown", ), "wazuh_route_code": summary.get("WAZUH_ROUTE_CODE", "unknown"), "wazuh_transport_count": summary.get("WAZUH_TRANSPORT_COUNT", "unknown"), "wazuh_coverage_scope": summary.get("WAZUH_COVERAGE_SCOPE", "unknown"), "wazuh_direct_active": summary.get("WAZUH_DIRECT_ACTIVE", "unknown"), "wazuh_no_transport": summary.get("WAZUH_NO_TRANSPORT", "unknown"), "wazuh_ssh_blocked": summary.get("WAZUH_SSH_BLOCKED", "unknown"), "wazuh_dashboard_api_connection": summary.get( "WAZUH_DASHBOARD_API_CONNECTION", "unknown", ), "wazuh_dashboard_index_ok": summary.get("WAZUH_DASHBOARD_INDEX_OK", "unknown"), "runtime_action_authorized": summary.get("RUNTIME_ACTION_AUTHORIZED", "unknown"), }, "runtime_write_performed": False, } def main() -> int: args = parse_args() summary = parse_summary(load_summary(args)) payload = build_payload(summary, args.proposed) serialized = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(serialized + "\n", encoding="utf-8") if args.json: print(serialized) else: prefix = "POST_REBOOT_DECLARATION_GUARD_OK" if payload["proposed"]["rejected"]: prefix = "POST_REBOOT_DECLARATION_GUARD_REJECTED" elif payload["status"] == "blocked_service_recovery": prefix = "POST_REBOOT_DECLARATION_GUARD_BLOCKED" print( f"{prefix} " f"status={payload['status']} " f"allowed={payload['counts']['allowed_count']} " f"forbidden={payload['counts']['forbidden_count']} " f"next_gates={payload['counts']['next_required_gate_count']} " f"rejected_proposed={payload['counts']['rejected_proposed_count']}" ) if payload["proposed"]["rejected"]: return 2 if payload["status"] == "blocked_service_recovery": return 2 return 0 if __name__ == "__main__": sys.exit(main())