Files
awoooi/scripts/security/public-gateway-post-incident-readback-plan.py
Your Name 5254a0c88b
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m36s
CD Pipeline / build-and-deploy (push) Successful in 4m49s
CD Pipeline / post-deploy-checks (push) Successful in 1m48s
feat(iwooos): 新增 Nginx 事故回讀 gate
2026-06-16 10:31:13 +08:00

464 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
IwoooS Public Gateway / Nginx post-incident readback 只讀計畫產生器。
本工具讀取 Public Gateway rendered diff acceptance snapshot建立事故後回讀
計畫:誰改了 Nginx / public gateway、變更或事故時間窗、route / upstream /
WebSocket / ACME / AI provider / monitoring 影響、回滾與防再發。它不讀 live
conf、不執行 nginx -t、不 reload、不做 route smoke、不連 DNS / TLS、不
renew cert、不 SSH、不保存 raw conf / full diff / secret value。
"""
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_diff_acceptance_id",
"owner_response_acceptance_id",
"diff_gate_id",
"config_id",
"control_tier",
"host",
"live_path",
"gateway_incident_or_change_ref",
"actor_attribution_ref",
"change_time_window_ref",
"change_intent_or_break_glass_ref",
"before_route_state_ref",
"after_route_state_ref",
"source_live_diff_state_ref",
"nginx_test_readback_ref",
"nginx_reload_or_no_reload_ref",
"route_smoke_readback_ref",
"tls_acme_readback_ref",
"websocket_readback_ref",
"upstream_health_ref",
"public_admin_api_route_impact_ref",
"ai_provider_impact_ref",
"monitoring_alert_ref",
"operator_notification_ref",
"cross_project_sync_ref",
"rollback_validation_ref",
"post_change_monitoring_ref",
"recovery_or_still_degraded_ref",
"postcheck_readback_ref",
"recurrence_guard_ref",
"maintenance_window",
"rollback_owner",
"reviewer_outcome",
"followup_owner",
"not_approval",
]
REQUIRED_READBACK_FIELDS = [
"gateway_incident_or_change_ref",
"actor_attribution_ref",
"change_time_window_ref",
"change_intent_or_break_glass_ref",
"before_route_state_ref",
"after_route_state_ref",
"source_live_diff_state_ref",
"nginx_test_readback_ref",
"nginx_reload_or_no_reload_ref",
"route_smoke_readback_ref",
"tls_acme_readback_ref",
"websocket_readback_ref",
"upstream_health_ref",
"public_admin_api_route_impact_ref",
"ai_provider_impact_ref",
"monitoring_alert_ref",
"operator_notification_ref",
"cross_project_sync_ref",
"rollback_validation_ref",
"post_change_monitoring_ref",
"recovery_or_still_degraded_ref",
"postcheck_readback_ref",
"recurrence_guard_ref",
"maintenance_window",
"rollback_owner",
"followup_owner",
"redacted_evidence_refs",
"no_secret_value_attestation",
"no_raw_live_conf_or_diff_attestation",
"no_false_green_attestation",
]
REVIEWER_CHECKS = [
{"check_id": "source_diff_acceptance_current", "instruction": "來源 rendered diff acceptance snapshot 必須是目前版本。"},
{"check_id": "incident_or_change_ref_present", "instruction": "必須有 incident、change、ticket 或 maintenance ref不能只寫路由恢復。"},
{"check_id": "actor_attribution_present", "instruction": "必須標示 actor role / team不接受匿名 Nginx reload、shell 操作或未追蹤自動化。"},
{"check_id": "change_time_window_present", "instruction": "必須有變更 / 事故時間窗,供 route、alert 與跨專案回讀對齊。"},
{"check_id": "intent_or_break_glass_present", "instruction": "正常變更需有 change intent緊急變更需有 break-glass reason但 break-glass 不等於事前批准。"},
{"check_id": "before_after_route_state_present", "instruction": "必須有 before / after route state ref不得只看現在 200。"},
{"check_id": "source_live_diff_state_present", "instruction": "必須說明 source-to-live diff 狀態;不得保存 raw live conf 或完整 diff payload。"},
{"check_id": "nginx_test_readback_is_owner_provided", "instruction": "`nginx -t` 只能是 owner-provided readback ref本工具不得執行。"},
{"check_id": "reload_or_no_reload_called_out", "instruction": "需明確標示是否曾 reload若未 reload 也需列原因與風險。"},
{"check_id": "route_smoke_readback_present", "instruction": "route smoke readback 必須列 affected routes、status、TLS、WebSocket、ACME 或不適用原因。"},
{"check_id": "tls_acme_readback_present", "instruction": "TLS / ACME impact 不可被 route 200 取代。"},
{"check_id": "websocket_readback_present", "instruction": "WebSocket / streaming route 需獨立回讀,不得只用首頁 200。"},
{"check_id": "upstream_health_present", "instruction": "upstream health / port / dependency 影響必須列 ref。"},
{"check_id": "public_admin_api_route_impact_present", "instruction": "public、admin、API、callback 或 webhook route 受影響時需列 impact ref。"},
{"check_id": "ai_provider_impact_present", "instruction": "Ollama、AI provider、model route 或 proxy 受影響時需列 impact ref。"},
{"check_id": "monitoring_alert_present", "instruction": "需有 monitoring / alert / incident ref不能只靠人工看頁面。"},
{"check_id": "operator_notification_present", "instruction": "需提供已通知受影響產品、owner 或 Session 的脫敏 ref。"},
{"check_id": "cross_project_sync_present", "instruction": "若影響 AwoooP、IwoooS、agent-bounty、StockPlatform、公開網站或監控需有跨專案同步 ref。"},
{"check_id": "rollback_validation_present", "instruction": "必須提供 rollback validation ref包含 rollback owner 與回滾後驗證方式。"},
{"check_id": "post_change_monitoring_present", "instruction": "必須有 post-change monitoring window觀察 route、error、alert 與 upstream。"},
{"check_id": "recovery_or_still_degraded_present", "instruction": "已恢復需提供恢復時間與證據;未恢復需提供 still-degraded ref。"},
{"check_id": "postcheck_independent", "instruction": "post-check 必須獨立於原操作人與 UI 卡片。"},
{"check_id": "recurrence_guard_present", "instruction": "必須提出防再發 guard、change freeze、owner review 或 automation block。"},
{"check_id": "maintenance_window_present", "instruction": "後續任何 nginx -t、reload、route smoke 或 DNS / TLS 動作都需維護窗口。"},
{"check_id": "no_false_green", "instruction": "不得只用 route 200、Nginx active、dashboard up、CD success 或 UI 可見當成事故已驗收。"},
{"check_id": "raw_payload_absent", "instruction": "不得保存 raw live conf、完整 diff payload、private key、憑證內容、cookie、token 或未脫敏 screenshot。"},
{"check_id": "runtime_stays_zero", "instruction": "readback plan 不得觸發 nginx -t、reload、route smoke、DNS / TLS probe、certbot renew 或 host write。"},
{"check_id": "counts_transition_safe", "instruction": "只有 reviewer record 能更新 accepted count且不得同時開 runtime gate。"},
]
OUTCOME_LANES = [
{"lane_id": "waiting_post_incident_readback", "meaning": "尚未收到 Public Gateway / Nginx 事故回讀包;所有 accepted / runtime count 維持 0。"},
{"lane_id": "request_actor_or_time_supplement", "meaning": "缺 actor、change time window、intent 或 break-glass reason 時要求補件。"},
{"lane_id": "request_route_state_supplement", "meaning": "缺 before / after route、upstream、WebSocket、TLS / ACME 或 route smoke readback 時要求補件。"},
{"lane_id": "request_diff_test_supplement", "meaning": "缺 source-live diff、nginx test readback、reload / no-reload 判定或 rollback validation 時要求補件。"},
{"lane_id": "request_dependency_supplement", "meaning": "缺 AI provider、monitoring、public/admin/API route 或跨專案影響時要求補件。"},
{"lane_id": "quarantine_raw_payload", "meaning": "收到 raw conf、完整 diff、secret、憑證、cookie、token 或未脫敏截圖時只能隔離。"},
{"lane_id": "reject_false_green_claim", "meaning": "把 route 200、Nginx active、dashboard up、CD success 或 UI 可見當驗收時拒收。"},
{"lane_id": "ready_for_gateway_post_incident_review", "meaning": "metadata 合格後,只能進 reviewer review。"},
{"lane_id": "recurrence_guard_backfill_required", "meaning": "需補防再發 guard、owner review、change freeze 或 automation block。"},
{"lane_id": "waiting_runtime_gate", "meaning": "即使 readback acceptedruntime gate 仍需獨立人工批准。"},
]
BLOCKED_ACTIONS = [
"ssh_read_live_nginx_conf",
"store_raw_live_conf",
"store_full_rendered_diff_payload",
"collect_secret_value",
"collect_private_key",
"collect_certificate_body",
"collect_cookie_or_token",
"run_nginx_test",
"reload_nginx",
"restart_nginx",
"change_nginx_site_enabled",
"change_public_route",
"change_admin_route",
"change_api_route",
"change_websocket_route",
"change_acme_challenge_route",
"change_upstream",
"change_dns_record",
"tls_probe",
"dns_probe",
"certbot_renew",
"certbot_reconfigure",
"route_smoke",
"websocket_smoke",
"host_write",
"firewall_change",
"docker_restart",
"systemd_restart",
"argocd_sync",
"kubectl_apply",
"accept_route_200_as_all_green",
"accept_nginx_active_as_all_green",
"accept_dashboard_up_as_security_acceptance",
"accept_cd_success_as_security_acceptance",
"skip_cross_project_sync",
"skip_operator_notification",
"skip_rollback_validation",
"skip_post_change_monitoring",
"mark_readback_accepted_without_reviewer_record",
"open_runtime_gate",
"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 build_candidate(source: dict[str, Any]) -> dict[str, Any]:
config_id = source["config_id"]
return {
"post_incident_readback_candidate_id": f"public_gateway_post_incident_readback:{config_id}",
"status": "waiting_post_incident_readback",
"source_diff_acceptance_id": source["diff_acceptance_id"],
"owner_response_acceptance_id": source["owner_response_acceptance_id"],
"diff_gate_id": source["diff_gate_id"],
"config_id": config_id,
"control_tier": source["control_tier"],
"host": source["host"],
"live_path": source["live_path"],
"write_capable": True,
"gateway_incident_or_change_ref": None,
"actor_attribution_ref": None,
"change_time_window_ref": None,
"change_intent_or_break_glass_ref": None,
"before_route_state_ref": None,
"after_route_state_ref": None,
"source_live_diff_state_ref": None,
"nginx_test_readback_ref": None,
"nginx_reload_or_no_reload_ref": None,
"route_smoke_readback_ref": None,
"tls_acme_readback_ref": None,
"websocket_readback_ref": None,
"upstream_health_ref": None,
"public_admin_api_route_impact_ref": None,
"ai_provider_impact_ref": None,
"monitoring_alert_ref": None,
"operator_notification_ref": None,
"cross_project_sync_ref": None,
"rollback_validation_ref": None,
"post_change_monitoring_ref": None,
"recovery_or_still_degraded_ref": None,
"postcheck_readback_ref": None,
"recurrence_guard_ref": None,
"maintenance_window": "pending_post_incident_readback",
"rollback_owner": "pending_post_incident_readback",
"reviewer_outcome": "waiting_post_incident_readback",
"followup_owner": "pending_post_incident_readback",
"readback_fields": READBACK_FIELDS,
"required_readback_fields": REQUIRED_READBACK_FIELDS,
"reviewer_checks": [item["check_id"] for item in REVIEWER_CHECKS],
"outcome_lanes": [item["lane_id"] for item in OUTCOME_LANES],
"blocked_actions": BLOCKED_ACTIONS,
"not_approval": True,
"post_incident_readback_received": False,
"post_incident_readback_accepted": False,
"actor_attribution_accepted": False,
"change_time_window_accepted": False,
"intent_or_break_glass_accepted": False,
"before_after_route_state_accepted": False,
"source_live_diff_state_accepted": False,
"nginx_test_readback_accepted": False,
"nginx_reload_or_no_reload_accepted": False,
"route_smoke_readback_accepted": False,
"tls_acme_readback_accepted": False,
"websocket_readback_accepted": False,
"upstream_health_accepted": False,
"public_admin_api_route_impact_accepted": False,
"ai_provider_impact_accepted": False,
"monitoring_alert_accepted": False,
"operator_notification_accepted": False,
"cross_project_sync_accepted": False,
"rollback_validation_accepted": False,
"post_change_monitoring_accepted": False,
"recovery_or_still_degraded_accepted": False,
"postcheck_readback_accepted": False,
"recurrence_guard_accepted": False,
"no_false_green_accepted": False,
"host_live_conf_read_authorized": False,
"nginx_test_authorized": False,
"nginx_test_executed": False,
"nginx_reload_authorized": False,
"nginx_reload_executed": False,
"public_gateway_reload_authorized": False,
"route_smoke_authorized": False,
"route_smoke_executed": False,
"dns_tls_probe_authorized": False,
"certbot_renew_authorized": False,
"public_route_change_authorized": False,
"admin_route_change_authorized": False,
"websocket_route_change_authorized": False,
"acme_challenge_change_authorized": False,
"production_write_authorized": False,
"runtime_gate": False,
"action_buttons_allowed": False,
}
def build_report(root: Path, source_report: dict[str, Any], generated_at: str | None) -> dict[str, Any]:
report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
candidates = [build_candidate(item) for item in source_report.get("diff_acceptance_candidates", [])]
c0_candidates = [item for item in candidates if item["control_tier"] == "C0"]
c1_candidates = [item for item in candidates if item["control_tier"] == "C1"]
source_summary = source_report.get("summary", {})
return {
"schema_version": "public_gateway_post_incident_readback_plan_v1",
"generated_at": report_time,
"git_commit": git_short_sha(root),
"source_schema_version": source_report.get("schema_version"),
"source_status": source_report.get("status"),
"status": "post_incident_readback_plan_ready_no_runtime_action",
"summary": {
"source_diff_acceptance_candidate_count": source_summary.get("diff_acceptance_candidate_count", 0),
"source_c0_diff_acceptance_candidate_count": source_summary.get(
"c0_diff_acceptance_candidate_count", 0
),
"source_c1_diff_acceptance_candidate_count": source_summary.get(
"c1_diff_acceptance_candidate_count", 0
),
"source_required_evidence_field_count": source_summary.get("required_evidence_field_count", 0),
"source_reviewer_check_count": source_summary.get("reviewer_check_count", 0),
"source_blocked_action_count": source_summary.get("blocked_action_count", 0),
"source_rendered_diff_accepted_count": source_summary.get("rendered_diff_accepted_count", 0),
"source_nginx_test_evidence_accepted_count": source_summary.get(
"nginx_test_evidence_accepted_count", 0
),
"source_route_smoke_result_accepted_count": source_summary.get(
"route_smoke_result_accepted_count", 0
),
"source_runtime_gate_count": source_summary.get("runtime_gate_count", 0),
"readback_candidate_count": len(candidates),
"c0_readback_candidate_count": len(c0_candidates),
"c1_readback_candidate_count": len(c1_candidates),
"write_capable_readback_candidate_count": len(candidates),
"route_health_review_required_candidate_count": len(candidates),
"upstream_websocket_tls_review_required_candidate_count": len(candidates),
"ai_monitoring_cross_project_review_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,
"change_time_window_accepted_count": 0,
"intent_or_break_glass_accepted_count": 0,
"before_after_route_state_accepted_count": 0,
"source_live_diff_state_accepted_count": 0,
"nginx_test_readback_accepted_count": 0,
"nginx_reload_or_no_reload_accepted_count": 0,
"route_smoke_readback_accepted_count": 0,
"tls_acme_readback_accepted_count": 0,
"websocket_readback_accepted_count": 0,
"upstream_health_accepted_count": 0,
"public_admin_api_route_impact_accepted_count": 0,
"ai_provider_impact_accepted_count": 0,
"monitoring_alert_accepted_count": 0,
"operator_notification_accepted_count": 0,
"cross_project_sync_accepted_count": 0,
"rollback_validation_accepted_count": 0,
"post_change_monitoring_accepted_count": 0,
"recovery_or_still_degraded_accepted_count": 0,
"postcheck_readback_accepted_count": 0,
"recurrence_guard_accepted_count": 0,
"no_false_green_accepted_count": 0,
"host_live_conf_read_authorized_count": 0,
"nginx_test_authorized_count": 0,
"nginx_test_executed_count": 0,
"nginx_reload_authorized_count": 0,
"nginx_reload_executed_count": 0,
"public_gateway_reload_authorized_count": 0,
"route_smoke_authorized_count": 0,
"route_smoke_executed_count": 0,
"dns_tls_probe_authorized_count": 0,
"certbot_renew_authorized_count": 0,
"public_route_change_authorized_count": 0,
"admin_route_change_authorized_count": 0,
"websocket_route_change_authorized_count": 0,
"acme_challenge_change_authorized_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
"coverage_percent_after_readback_plan": 92,
},
"readback_candidates": candidates,
"required_readback_fields": REQUIRED_READBACK_FIELDS,
"reviewer_checks": REVIEWER_CHECKS,
"outcome_lanes": OUTCOME_LANES,
"blocked_actions": BLOCKED_ACTIONS,
"execution_boundaries": {
"not_authorization": True,
"host_live_conf_read_authorized": False,
"nginx_test_authorized": False,
"nginx_test_executed": False,
"nginx_reload_authorized": False,
"nginx_reload_executed": False,
"public_gateway_reload_authorized": False,
"route_smoke_authorized": False,
"route_smoke_executed": False,
"dns_tls_probe_authorized": False,
"certbot_renew_authorized": False,
"public_route_change_authorized": False,
"admin_route_change_authorized": False,
"websocket_route_change_authorized": False,
"acme_challenge_change_authorized": False,
"production_write_authorized": False,
"runtime_gate": False,
"action_buttons_allowed": False,
},
"source_paths": [
"docs/security/PUBLIC-GATEWAY-RENDERED-DIFF-ACCEPTANCE.md",
"docs/security/public-gateway-rendered-diff-acceptance.snapshot.json",
"docs/security/PUBLIC-GATEWAY-OWNER-RESPONSE-ACCEPTANCE.md",
"docs/security/public-gateway-owner-response-acceptance.snapshot.json",
"docs/security/PUBLIC-GATEWAY-PREFLIGHT-INVENTORY.md",
"docs/security/public-gateway-preflight-inventory.snapshot.json",
],
"mode": "metadata_only_no_live_conf_no_nginx_reload",
"operator_interpretation": [
"此計畫只定義 Public Gateway / Nginx 事故後回讀欄位,不代表 live conf 已讀取或可讀取。",
"route 200、Nginx active、dashboard up、CD success、UI 可見都不能單獨當成 gateway 事故驗收。",
"未來若要 nginx -t、reload、route smoke、DNS / TLS probe、certbot renew 或 host write必須另有維護窗口、rollback owner 與人工批准。",
],
}
def main() -> int:
parser = argparse.ArgumentParser(description="IwoooS Public Gateway / Nginx 事故後回讀只讀計畫產生器")
parser.add_argument("--root", default=".", help="repo root")
parser.add_argument(
"--source-report",
default="docs/security/public-gateway-rendered-diff-acceptance.snapshot.json",
help="public-gateway-rendered-diff-acceptance.py 輸出的 JSON",
)
parser.add_argument("--output", help="寫出 JSON 報告")
parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用")
args = parser.parse_args()
root = Path(args.root).resolve()
source_report = load_json(root / args.source_report)
report = build_report(root, source_report, args.generated_at)
payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
if args.output:
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(payload + "\n", encoding="utf-8")
else:
print(payload)
summary = report["summary"]
print(
"PUBLIC_GATEWAY_POST_INCIDENT_READBACK_PLAN_OK "
f"candidates={summary['readback_candidate_count']} "
f"c0={summary['c0_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']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())