#!/usr/bin/env python3 """ IwoooS Backup / Restore / Escrow post-incident readback 只讀計畫產生器。 本工具讀取 Backup / Restore / Escrow owner response acceptance snapshot, 建立事故後回讀計畫:誰觸發或觀察到 backup / restore / offsite / escrow 異常、改前改後 freshness / restore / offsite / retention 狀態、是否有 no-false-green 與 rollback / stop condition。它不執行 backup、不 restore、 不 rclone / restic / Velero、不寫 escrow marker、不 SSH、不 kubectl、不讀 secret value、不保存 raw backup listing、不寫 production。 """ from __future__ import annotations import argparse import json import subprocess import sys from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any TAIPEI = timezone(timedelta(hours=8)) READBACK_FIELDS = [ "post_incident_readback_candidate_id", "source_acceptance_candidate_id", "request_id", "surface_id", "config_kind", "backup_scope", "control_tier", "write_capable_surface", "requires_live_evidence", "backup_incident_or_change_ref", "actor_attribution_ref", "change_or_outage_time_window_ref", "change_intent_or_break_glass_ref", "before_backup_freshness_state_ref", "after_backup_freshness_state_ref", "backup_status_readback_ref", "restore_drill_readback_ref", "restore_target_isolation_readback_ref", "offsite_sync_readback_ref", "offsite_remote_delete_guard_readback_ref", "credential_escrow_non_secret_readback_ref", "credential_recovery_drill_readback_ref", "retention_runway_readback_ref", "retention_or_prune_decision_ref", "backup_dependency_map_readback_ref", "data_classification_readback_ref", "restore_observer_stop_condition_ref", "backup_health_no_false_green_readback_ref", "alert_textfile_readback_ref", "cold_start_dr_scorecard_ref", "cross_project_sync_ref", "rollback_validation_ref", "post_change_monitoring_ref", "postcheck_readback_ref", "recurrence_guard_ref", "maintenance_window", "rollback_owner", "followup_owner", "redacted_evidence_refs", "reviewer_outcome", "no_secret_value_attestation", "no_raw_backup_payload_attestation", "no_production_restore_attestation", "no_false_green_attestation", "not_approval", ] REQUIRED_READBACK_FIELDS = [ "backup_incident_or_change_ref", "actor_attribution_ref", "change_or_outage_time_window_ref", "change_intent_or_break_glass_ref", "before_backup_freshness_state_ref", "after_backup_freshness_state_ref", "backup_status_readback_ref", "restore_drill_readback_ref", "restore_target_isolation_readback_ref", "offsite_sync_readback_ref", "offsite_remote_delete_guard_readback_ref", "credential_escrow_non_secret_readback_ref", "credential_recovery_drill_readback_ref", "retention_runway_readback_ref", "retention_or_prune_decision_ref", "backup_dependency_map_readback_ref", "data_classification_readback_ref", "restore_observer_stop_condition_ref", "backup_health_no_false_green_readback_ref", "alert_textfile_readback_ref", "cold_start_dr_scorecard_ref", "cross_project_sync_ref", "rollback_validation_ref", "post_change_monitoring_ref", "postcheck_readback_ref", "recurrence_guard_ref", "maintenance_window", "rollback_owner", "followup_owner", "redacted_evidence_refs", "no_secret_value_attestation", "no_raw_backup_payload_attestation", "no_production_restore_attestation", "no_false_green_attestation", ] REVIEWER_CHECKS = [ {"check_id": "source_owner_response_acceptance_current", "instruction": "來源 owner response acceptance snapshot 必須是目前版本。"}, {"check_id": "incident_or_change_ref_present", "instruction": "必須有 incident、change、outage、ticket 或 maintenance ref,不能只寫備份正常。"}, {"check_id": "actor_attribution_present", "instruction": "必須標示 actor role / team,不接受匿名 backup、restore、sync、prune 或 escrow marker write。"}, {"check_id": "change_or_outage_time_window_present", "instruction": "必須有變更 / 異常時間窗,供 freshness、offsite、alert 與 cold-start scorecard 對齊。"}, {"check_id": "intent_or_break_glass_present", "instruction": "正常變更需有 change intent;緊急變更需有 break-glass reason,但 break-glass 不等於事前批准。"}, {"check_id": "before_after_freshness_present", "instruction": "必須有 before / after backup freshness state ref,不得只看最新一次成功。"}, {"check_id": "backup_status_readback_present", "instruction": "backup status 只能收 owner-provided redacted ref,不得讀 raw backup store 或未脫敏 listing。"}, {"check_id": "restore_drill_readback_present", "instruction": "restore drill 必須是 readback / approval metadata,不得是 production restore 執行要求。"}, {"check_id": "restore_target_isolation_present", "instruction": "必須有隔離 restore target 或明確 no-production-write 邊界。"}, {"check_id": "offsite_sync_readback_present", "instruction": "offsite sync 必須有 redacted state ref,不得保存 raw remote path、object listing 或 rclone config。"}, {"check_id": "remote_delete_guard_present", "instruction": "任何 latest-only、remote delete 或 retention policy 必須有 remote delete guard 與 owner ref。"}, {"check_id": "credential_escrow_non_secret_present", "instruction": "credential escrow 只能是 non-secret proof / marker ref,不得包含 value、hash、seed、recovery code 或 partial token。"}, {"check_id": "credential_recovery_drill_metadata_only", "instruction": "credential recovery drill 必須以 metadata / evidence id 呈現,不保存 secret derivative。"}, {"check_id": "retention_runway_present", "instruction": "retention / prune 必須有可恢復窗口、runway、撤回條件與 owner。"}, {"check_id": "retention_or_prune_decision_present", "instruction": "retention 或 prune decision 必須獨立列出,不得與 offsite sync 成功混在一起。"}, {"check_id": "dependency_map_present", "instruction": "必須列出 DB、repo、registry、object storage、K8s、monitoring、credential escrow 與 DR runbook 依賴圖 ref。"}, {"check_id": "data_classification_present", "instruction": "必須標示備份集資料分級;不得要求 raw customer data、payload 或 unredacted archive listing。"}, {"check_id": "restore_observer_stop_condition_present", "instruction": "restore / drill 必須有 observer、stop condition、rollback owner 與停損條件。"}, {"check_id": "backup_health_no_false_green_present", "instruction": "backup health / textfile / alert evidence 必須防止 false green,不能只看單一 healthy、route 200 或 dashboard up。"}, {"check_id": "alert_textfile_readback_present", "instruction": "若涉及告警或 textfile exporter,必須回讀 stale / partial / missing marker,不得只看 alert quiet。"}, {"check_id": "cold_start_scorecard_present", "instruction": "若影響冷啟動或 DR,必須有 cold-start / scorecard ref,且不能偽造 credential escrow evidence。"}, {"check_id": "cross_project_sync_present", "instruction": "若影響 AwoooP、IwoooS、momo、Gitea、Harbor、monitoring 或公開站,需有跨專案同步 ref。"}, {"check_id": "rollback_validation_present", "instruction": "必須提供 rollback validation ref,包含 rollback owner 與回復方式。"}, {"check_id": "post_change_monitoring_present", "instruction": "必須有 post-change monitoring window,觀察 freshness、offsite、restore target、alerts 與 scorecard。"}, {"check_id": "postcheck_independent", "instruction": "post-check 必須獨立於原操作人、單一 backup status 與 UI 顯示。"}, {"check_id": "recurrence_guard_present", "instruction": "必須提出防再發 guard、owner review、retention freeze、remote-delete block 或 automation block。"}, {"check_id": "maintenance_window_present", "instruction": "後續任何 backup、restore、offsite sync、prune、escrow marker 或 Velero 動作都需維護窗口。"}, {"check_id": "redacted_refs_only", "instruction": "evidence 只能是脫敏 ref、hash、ticket、commit 或 artifact pointer。"}, {"check_id": "secret_value_absent", "instruction": "不得出現 token、private key、rclone config、restic password、seed、kubeconfig、recovery code、credential URL 或 partial secret。"}, {"check_id": "raw_payload_absent", "instruction": "不得保存 raw backup listing、raw restore payload、raw archive content、raw object path、raw DB dump 或未脫敏 screenshot。"}, {"check_id": "no_false_green", "instruction": "不得只用 backup success、route 200、dashboard up、alert quiet、textfile present、UI 可見或 CD success 當 DR / backup 驗收。"}, {"check_id": "runtime_stays_zero", "instruction": "readback plan 不得觸發 backup、restore、offsite sync、remote delete、retention change、prune、Velero、kubectl、SSH 或 production write。"}, ] OUTCOME_LANES = [ {"lane_id": "waiting_post_incident_readback", "meaning": "尚未收到 Backup / Restore / Escrow 事故回讀包;所有 accepted / runtime count 維持 0。"}, {"lane_id": "request_actor_or_time_supplement", "meaning": "缺 actor、change / outage time window、intent 或 break-glass reason 時要求補件。"}, {"lane_id": "request_backup_freshness_supplement", "meaning": "缺 before / after freshness、backup status、alert textfile 或 scorecard 時要求補件。"}, {"lane_id": "request_restore_isolation_supplement", "meaning": "缺 restore drill、隔離目標、observer、stop condition 或 rollback validation 時要求補件。"}, {"lane_id": "request_offsite_retention_supplement", "meaning": "缺 offsite sync、remote delete guard、retention runway 或 prune decision 時要求補件。"}, {"lane_id": "request_escrow_non_secret_supplement", "meaning": "缺 credential escrow non-secret proof 或 recovery drill metadata 時要求補件。"}, {"lane_id": "quarantine_raw_payload", "meaning": "收到 secret、raw backup listing、raw restore payload、raw DB dump、rclone config 或未脫敏截圖時只能隔離。"}, {"lane_id": "reject_false_green_claim", "meaning": "把 backup success、route 200、dashboard up、alert quiet、textfile present 或 UI 可見當驗收時拒收。"}, {"lane_id": "ready_for_backup_restore_post_incident_review", "meaning": "metadata 合格後,只能進 backup / restore reviewer review。"}, {"lane_id": "recurrence_guard_backfill_required", "meaning": "需補防再發 guard、retention freeze、remote-delete block、owner review 或 automation block。"}, {"lane_id": "waiting_runtime_gate", "meaning": "即使 readback accepted,runtime gate 仍需獨立人工批准。"}, ] BLOCKED_ACTIONS = [ "backup_run", "restore_run", "restore_drill", "production_restore", "offsite_sync", "offsite_remote_delete", "credential_escrow_marker_write", "credential_recovery_execution", "retention_change", "retention_prune", "restic_prune", "rclone_config_read", "rclone_config_change", "velero_restore", "velero_backup", "kubectl_action", "ssh_read", "ssh_write", "secret_value_collection", "secret_hash_collection", "partial_token_collection", "restic_password_collection", "rclone_token_collection", "kubeconfig_collection", "host_write", "active_scan", "production_write", "runtime_gate_open", "raw_backup_payload_storage", "raw_restore_payload_storage", "raw_object_listing_storage", "raw_db_dump_storage", "accept_secret_value_evidence", "accept_credential_derivative_evidence", "mark_readback_accepted_without_reviewer_record", "accept_backup_success_without_freshness_slo", "accept_restore_without_isolated_target", "accept_offsite_without_remote_delete_guard", "accept_retention_without_runway", "accept_credential_recovery_without_non_secret_proof", "accept_backup_health_false_green", "accept_alert_quiet_as_backup_healthy", "accept_textfile_present_as_dr_complete", "skip_dependency_map_review", "skip_data_classification_review", "skip_restore_observer_stop_condition", "skip_cross_project_sync", "skip_rollback_validation", "skip_post_change_monitoring", "forge_credential_escrow_evidence", "add_action_button", ] def git_short_sha(root: Path) -> str: try: result = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], cwd=root, check=True, capture_output=True, text=True, ) return result.stdout.strip() except Exception: return "unknown" def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def readback_candidate(item: dict[str, Any]) -> dict[str, Any]: surface_id = item["surface_id"] candidate_id = f"backup_restore_post_incident_readback:{surface_id}" return { "post_incident_readback_candidate_id": candidate_id, "status": "waiting_post_incident_readback", "source_acceptance_candidate_id": item["acceptance_candidate_id"], "request_id": item["request_id"], "surface_id": surface_id, "config_kind": item["config_kind"], "backup_scope": item["backup_scope"], "control_tier": item["control_tier"], "write_capable_surface": item["write_capable_surface"], "requires_live_evidence": True, "backup_incident_or_change_ref": None, "actor_attribution_ref": None, "change_or_outage_time_window_ref": None, "change_intent_or_break_glass_ref": None, "before_backup_freshness_state_ref": None, "after_backup_freshness_state_ref": None, "backup_status_readback_ref": None, "restore_drill_readback_ref": None, "restore_target_isolation_readback_ref": None, "offsite_sync_readback_ref": None, "offsite_remote_delete_guard_readback_ref": None, "credential_escrow_non_secret_readback_ref": None, "credential_recovery_drill_readback_ref": None, "retention_runway_readback_ref": None, "retention_or_prune_decision_ref": None, "backup_dependency_map_readback_ref": None, "data_classification_readback_ref": None, "restore_observer_stop_condition_ref": None, "backup_health_no_false_green_readback_ref": None, "alert_textfile_readback_ref": None, "cold_start_dr_scorecard_ref": None, "cross_project_sync_ref": None, "rollback_validation_ref": None, "post_change_monitoring_ref": None, "postcheck_readback_ref": None, "recurrence_guard_ref": None, "maintenance_window": "pending_post_incident_readback", "rollback_owner": "pending_post_incident_readback", "followup_owner": "pending_post_incident_readback", "redacted_evidence_refs": [], "reviewer_outcome": "waiting_post_incident_readback", "readback_fields": READBACK_FIELDS, "required_readback_fields": REQUIRED_READBACK_FIELDS, "reviewer_checks": [entry["check_id"] for entry in REVIEWER_CHECKS], "outcome_lanes": [entry["lane_id"] for entry in OUTCOME_LANES], "blocked_actions": BLOCKED_ACTIONS, "not_approval": True, "post_incident_readback_received": False, "post_incident_readback_accepted": False, "actor_attribution_accepted": False, "before_after_freshness_accepted": False, "backup_status_readback_accepted": False, "restore_drill_readback_accepted": False, "restore_target_isolation_readback_accepted": False, "offsite_sync_readback_accepted": False, "offsite_remote_delete_guard_accepted": False, "credential_escrow_non_secret_readback_accepted": False, "credential_recovery_drill_readback_accepted": False, "retention_runway_readback_accepted": False, "retention_or_prune_decision_accepted": False, "backup_dependency_map_readback_accepted": False, "data_classification_readback_accepted": False, "restore_observer_stop_condition_accepted": False, "backup_health_no_false_green_readback_accepted": False, "alert_textfile_readback_accepted": False, "cold_start_dr_scorecard_accepted": False, "cross_project_sync_accepted": False, "rollback_validation_accepted": False, "post_change_monitoring_accepted": False, "postcheck_readback_accepted": False, "recurrence_guard_accepted": False, "no_false_green_accepted": False, "backup_run_authorized": False, "restore_run_authorized": False, "offsite_sync_authorized": False, "offsite_remote_delete_authorized": False, "credential_escrow_marker_write_authorized": False, "credential_recovery_execution_authorized": False, "retention_change_authorized": False, "restic_prune_authorized": False, "rclone_config_authorized": False, "velero_restore_authorized": False, "velero_backup_authorized": False, "kubectl_action_authorized": False, "ssh_read_authorized": False, "ssh_write_authorized": False, "secret_value_collection_allowed": False, "host_write_authorized": False, "active_scan_authorized": False, "production_write_authorized": False, "runtime_gate": False, "action_buttons_allowed": False, } def searchable_candidate_text(item: dict[str, Any]) -> str: scopes = item.get("backup_scope", []) return " ".join( [ str(item.get("surface_id", "")), str(item.get("config_kind", "")), " ".join(str(scope) for scope in scopes), ] ).lower() def candidate_has_any(item: dict[str, Any], keywords: set[str]) -> bool: text = searchable_candidate_text(item) return any(keyword in text for keyword in keywords) def build_report(root: Path, source: dict[str, Any], generated_at: str | None) -> dict[str, Any]: report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds") source_candidates = source.get("acceptance_candidates", []) candidates = [readback_candidate(item) for item in source_candidates] def count_true(key: str) -> int: return sum(1 for item in candidates if item[key]) summary = { "source_acceptance_candidate_count": source["summary"]["acceptance_candidate_count"], "source_write_capable_acceptance_candidate_count": source["summary"]["write_capable_acceptance_candidate_count"], "source_live_evidence_required_candidate_count": source["summary"]["live_evidence_required_candidate_count"], "source_required_owner_field_count": source["summary"]["required_owner_field_count"], "source_reviewer_check_count": source["summary"]["reviewer_check_count"], "source_blocked_action_count": source["summary"]["blocked_action_count"], "source_owner_response_accepted_count": source["summary"]["owner_response_accepted_count"], "source_runtime_gate_count": source["summary"]["runtime_gate_count"], "readback_candidate_count": len(candidates), "write_capable_readback_candidate_count": count_true("write_capable_surface"), "live_evidence_required_readback_candidate_count": count_true("requires_live_evidence"), "restore_drill_readback_required_candidate_count": len(candidates), "offsite_or_escrow_readback_required_candidate_count": sum( 1 for item in candidates if candidate_has_any( item, { "offsite", "escrow", "credential", "rclone", "b2", "velero", "object storage", }, ) ), "retention_or_remote_delete_readback_required_candidate_count": sum( 1 for item in candidates if candidate_has_any( item, { "retention", "prune", "remote_delete", "latest-only", "restic", "offsite", }, ) ), "cross_project_sync_required_candidate_count": len(candidates), "no_false_green_required_candidate_count": len(candidates), "readback_field_count": len(READBACK_FIELDS), "required_readback_field_count": len(REQUIRED_READBACK_FIELDS), "reviewer_check_count": len(REVIEWER_CHECKS), "outcome_lane_count": len(OUTCOME_LANES), "blocked_action_count": len(BLOCKED_ACTIONS), "post_incident_readback_received_count": 0, "post_incident_readback_accepted_count": 0, "actor_attribution_accepted_count": 0, "before_after_freshness_accepted_count": 0, "backup_status_readback_accepted_count": 0, "restore_drill_readback_accepted_count": 0, "restore_target_isolation_readback_accepted_count": 0, "offsite_sync_readback_accepted_count": 0, "offsite_remote_delete_guard_accepted_count": 0, "credential_escrow_non_secret_readback_accepted_count": 0, "credential_recovery_drill_readback_accepted_count": 0, "retention_runway_readback_accepted_count": 0, "retention_or_prune_decision_accepted_count": 0, "backup_dependency_map_readback_accepted_count": 0, "data_classification_readback_accepted_count": 0, "restore_observer_stop_condition_accepted_count": 0, "backup_health_no_false_green_readback_accepted_count": 0, "alert_textfile_readback_accepted_count": 0, "cold_start_dr_scorecard_accepted_count": 0, "cross_project_sync_accepted_count": 0, "rollback_validation_accepted_count": 0, "post_change_monitoring_accepted_count": 0, "postcheck_readback_accepted_count": 0, "recurrence_guard_accepted_count": 0, "no_false_green_accepted_count": 0, "backup_run_authorized_count": 0, "restore_run_authorized_count": 0, "offsite_sync_authorized_count": 0, "offsite_remote_delete_authorized_count": 0, "credential_escrow_marker_write_authorized_count": 0, "credential_recovery_execution_authorized_count": 0, "retention_change_authorized_count": 0, "restic_prune_authorized_count": 0, "rclone_config_authorized_count": 0, "velero_restore_authorized_count": 0, "velero_backup_authorized_count": 0, "kubectl_action_authorized_count": 0, "ssh_read_authorized_count": 0, "ssh_write_authorized_count": 0, "secret_value_collection_allowed_count": 0, "host_write_authorized_count": 0, "active_scan_authorized_count": 0, "production_write_authorized_count": 0, "runtime_gate_count": 0, "action_button_count": 0, "coverage_percent_after_readback_plan": 66, } return { "schema_version": "backup_restore_post_incident_readback_plan_v1", "generated_at": report_time, "git_commit": git_short_sha(root), "mode": "metadata_only_no_backup_no_restore_no_secret_value", "source_schema_version": source["schema_version"], "source_status": source["status"], "source_paths": [ "docs/security/BACKUP-RESTORE-ESCROW-INVENTORY.md", "docs/security/backup-restore-escrow-inventory.snapshot.json", "docs/security/BACKUP-RESTORE-OWNER-RESPONSE-ACCEPTANCE.md", "docs/security/backup-restore-owner-response-acceptance.snapshot.json", ], "status": "post_incident_readback_plan_ready_no_runtime_action", "readback_candidates": candidates, "readback_fields": READBACK_FIELDS, "required_readback_fields": REQUIRED_READBACK_FIELDS, "reviewer_checks": REVIEWER_CHECKS, "outcome_lanes": OUTCOME_LANES, "blocked_actions": BLOCKED_ACTIONS, "execution_boundaries": { "not_authorization": True, "backup_run_authorized": False, "restore_run_authorized": False, "offsite_sync_authorized": False, "offsite_remote_delete_authorized": False, "credential_escrow_marker_write_authorized": False, "credential_recovery_execution_authorized": False, "retention_change_authorized": False, "restic_prune_authorized": False, "rclone_config_authorized": False, "velero_restore_authorized": False, "velero_backup_authorized": False, "kubectl_action_authorized": False, "ssh_read_authorized": False, "ssh_write_authorized": False, "secret_value_collection_allowed": False, "host_write_authorized": False, "active_scan_authorized": False, "production_write_authorized": False, "runtime_gate": False, "action_buttons_allowed": False, "raw_backup_payload_storage_allowed": False, }, "operator_interpretation": [ "此計畫只定義 Backup / Restore / Escrow 事故後回讀欄位,不代表 live backup store 已讀取或可讀取。", "backup success、route 200、dashboard up、alert quiet、textfile present、UI 可見或 CD success 都不能單獨當成 DR / backup 健康。", "未來若要 backup、restore、offsite sync、remote delete、retention / prune、escrow marker、Velero、kubectl、SSH 或 production write,必須另有維護窗口、rollback owner 與人工批准。", ], "summary": summary, } def write_report(report: dict[str, Any], output: Path | None) -> None: text = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n" if output: output.write_text(text, encoding="utf-8") else: sys.stdout.write(text) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--root", default=".", help="Repository root") parser.add_argument("--generated-at", default=None, help="Override generated_at") parser.add_argument("--output", default=None, help="Output snapshot path") args = parser.parse_args() root = Path(args.root).resolve() source = load_json(root / "docs/security/backup-restore-owner-response-acceptance.snapshot.json") report = build_report(root, source, args.generated_at) output = Path(args.output).resolve() if args.output else None write_report(report, output) summary = report["summary"] print( "BACKUP_RESTORE_POST_INCIDENT_READBACK_PLAN_OK " f"candidates={summary['readback_candidate_count']} " f"write_capable={summary['write_capable_readback_candidate_count']} " f"checks={summary['reviewer_check_count']} " f"lanes={summary['outcome_lane_count']} " f"accepted={summary['post_incident_readback_accepted_count']} " f"runtime_gate={summary['runtime_gate_count']}" ) return 0 if __name__ == "__main__": raise SystemExit(main())