Files
awoooi/scripts/security/ssh-network-post-incident-readback-plan.py
Your Name 09aeebb767
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / tests (push) Successful in 1m36s
CD Pipeline / build-and-deploy (push) Successful in 3m54s
CD Pipeline / post-deploy-checks (push) Successful in 1m45s
feat(iwooos): 新增 SSH network 事故回讀 gate
2026-06-15 19:26:24 +08:00

383 lines
17 KiB
Python
Raw 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 SSH / network / firewall post-incident readback 只讀計畫產生器。
本工具讀取端口 / 防火牆變更證據驗收 snapshot建立事故後回讀計畫
誰改、何時改、改前改後、影響哪些服務 / AI provider / public route /
monitoring、是否同步相關產品、如何恢復、如何防再發。它不 SSH、不讀 live
firewall、不改 port、不做 route smoke、不重啟服務、不打 provider endpoint
也不把事故恢復誤判成 runtime authorization。
"""
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 = [
"readback_candidate_id",
"source_change_evidence_candidate_id",
"surface_id",
"config_kind",
"control_tier",
"expected_scope",
"write_capable_surface",
"policy_or_exposure_surface",
"change_or_incident_ref",
"actor_attribution_ref",
"incident_detected_at_ref",
"change_window_ref",
"affected_port_or_policy_ref",
"before_state_ref",
"after_state_ref",
"service_dependency_ref",
"public_route_impact_ref",
"ai_provider_impact_ref",
"monitoring_alert_impact_ref",
"customer_or_product_impact_ref",
"operator_notification_ref",
"cross_project_sync_ref",
"restoration_evidence_ref",
"postcheck_readback_ref",
"recurrence_guard_ref",
"maintenance_window",
"rollback_owner",
"reviewer_outcome",
"followup_owner",
"not_approval",
]
REQUIRED_READBACK_FIELDS = [
"change_or_incident_ref",
"actor_attribution_ref",
"incident_detected_at_ref",
"change_window_ref",
"affected_port_or_policy_ref",
"before_state_ref",
"after_state_ref",
"service_dependency_ref",
"public_route_impact_ref",
"ai_provider_impact_ref",
"monitoring_alert_impact_ref",
"customer_or_product_impact_ref",
"operator_notification_ref",
"cross_project_sync_ref",
"restoration_evidence_ref",
"postcheck_readback_ref",
"recurrence_guard_ref",
"maintenance_window",
"rollback_owner",
"followup_owner",
"redacted_evidence_refs",
"no_secret_value_attestation",
"no_raw_firewall_dump_attestation",
"no_false_green_attestation",
]
REVIEWER_CHECKS = [
{"check_id": "source_change_evidence_current", "instruction": "來源 change evidence snapshot 必須是目前版本。"},
{"check_id": "incident_ref_present", "instruction": "必須有可追溯 incident / change ref。"},
{"check_id": "actor_not_anonymous", "instruction": "必須標示 actor role / team不接受匿名端口關閉。"},
{"check_id": "before_after_state_present", "instruction": "必須有變更前與恢復後狀態 ref。"},
{"check_id": "port_policy_redacted", "instruction": "端口、policy、host 只收脫敏 ref 或 alias不保存 raw dump。"},
{"check_id": "service_dependency_present", "instruction": "必須列出受影響服務、agent、public route、monitoring 或 deploy path。"},
{"check_id": "public_route_impact_present", "instruction": "必須列出 public route / admin route / callback 影響 ref。"},
{"check_id": "ai_provider_impact_present", "instruction": "若影響 Ollama / provider health需列出脫敏 impact ref。"},
{"check_id": "monitoring_alert_impact_present", "instruction": "必須列出 alert / SRE / dashboard 影響與 false-green 風險。"},
{"check_id": "customer_product_impact_present", "instruction": "需標示產品或使用者影響,不得只寫已恢復。"},
{"check_id": "operator_notification_present", "instruction": "必須有受影響 owner / Session / product 的通知 ref。"},
{"check_id": "cross_project_sync_present", "instruction": "跨專案同步 ref 必須存在,避免單點修改。"},
{"check_id": "restoration_evidence_present", "instruction": "必須有恢復時間與恢復證據 ref。"},
{"check_id": "postcheck_independent", "instruction": "post-check 需獨立於原操作人與 UI 卡片。"},
{"check_id": "recurrence_guard_present", "instruction": "必須提出防再發 guard、change freeze 或 owner review。"},
{"check_id": "emergency_classification_present", "instruction": "緊急破窗需標示分類與事後補件責任。"},
{"check_id": "maintenance_window_present", "instruction": "後續任何 port / firewall 操作都需維護窗口。"},
{"check_id": "rollback_owner_present", "instruction": "rollback owner 與回復 plan 必須同時存在。"},
{"check_id": "no_false_green_route_200", "instruction": "不得只用 route 200 / service up 當成事故已驗收。"},
{"check_id": "raw_firewall_dump_absent", "instruction": "不得保存 raw firewall dump、raw iptables、raw nftables 或 raw ACL。"},
{"check_id": "secret_or_key_value_absent", "instruction": "不得包含 secret、SSH key、token、cookie、私鑰或 partial secret。"},
{"check_id": "hidden_impact_absent", "instruction": "不得隱藏 AI provider、registry、monitoring、deploy 或 product route 影響。"},
{"check_id": "counts_transition_safe", "instruction": "只有 reviewer record 能更新 accepted count且不得同時開 runtime gate。"},
{"check_id": "runtime_stays_zero", "instruction": "readback plan 不得觸發任何 SSH、firewall、route smoke、restart 或 production write。"},
]
OUTCOME_LANES = [
{"lane_id": "waiting_post_incident_readback", "meaning": "尚未收到事故回讀包;所有 accepted / runtime count 維持 0。"},
{"lane_id": "request_actor_supplement", "meaning": "缺 actor / owner / decision 時要求補件。"},
{"lane_id": "request_before_after_supplement", "meaning": "缺 before / after 或 restoration evidence 時要求補件。"},
{"lane_id": "request_health_impact_supplement", "meaning": "缺 service / AI provider / monitoring / product impact 時要求補件。"},
{"lane_id": "quarantine_raw_payload", "meaning": "收到 raw firewall dump、secret 或 key material 時只能隔離。"},
{"lane_id": "reject_unattributed_incident", "meaning": "無 actor、無 affected scope、無 rollback 或無 notification 的事故回讀不得驗收。"},
{"lane_id": "ready_for_post_incident_review", "meaning": "metadata 合格後,只能進 reviewer review。"},
{"lane_id": "incident_readback_only_update", "meaning": "只允許更新只讀 ledger不得反向視為已批准操作。"},
{"lane_id": "recurrence_guard_backfill_required", "meaning": "需補防再發 guard、owner review 與 change freeze。"},
{"lane_id": "waiting_runtime_gate", "meaning": "即使 readback acceptedruntime gate 仍需獨立人工批准。"},
]
BLOCKED_ACTIONS = [
"ssh_read",
"ssh_write",
"live_firewall_read",
"firewall_change",
"port_change",
"port_close",
"port_open",
"network_policy_apply",
"nodeport_change",
"wireguard_change",
"sudo_action",
"deploy_ssh_action",
"route_smoke",
"public_gateway_reload",
"nginx_reload",
"host_restart",
"docker_restart",
"systemd_restart",
"secret_value_collection",
"ssh_key_collection",
"raw_firewall_dump_storage",
"raw_key_material_storage",
"mark_readback_accepted_without_reviewer_record",
"mark_incident_resolved_without_postcheck",
"hide_cross_project_impact",
"treat_route_200_as_all_green",
"treat_break_glass_as_approval",
"close_management_port_without_owner",
"open_runtime_gate",
"add_action_button",
"production_write",
"active_scan",
"provider_switch",
"prompt_send",
]
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 is_policy_or_exposure(source: dict[str, Any]) -> bool:
return source.get("config_kind") in {
"k8s_network_policy",
"k8s_nodeport_service",
"wireguard_runbook",
}
def build_candidate(source: dict[str, Any]) -> dict[str, Any]:
surface_id = source["surface_id"]
return {
"readback_candidate_id": f"ssh_network_post_incident_readback:{surface_id}",
"status": "waiting_post_incident_readback",
"source_change_evidence_candidate_id": source["change_evidence_candidate_id"],
"surface_id": surface_id,
"config_kind": source["config_kind"],
"control_tier": source["control_tier"],
"expected_scope": source.get("expected_scope"),
"write_capable_surface": source["write_capable_surface"],
"policy_or_exposure_surface": is_policy_or_exposure(source),
"change_or_incident_ref": None,
"actor_attribution_ref": None,
"incident_detected_at_ref": None,
"change_window_ref": None,
"affected_port_or_policy_ref": None,
"before_state_ref": None,
"after_state_ref": None,
"service_dependency_ref": None,
"public_route_impact_ref": None,
"ai_provider_impact_ref": None,
"monitoring_alert_impact_ref": None,
"customer_or_product_impact_ref": None,
"operator_notification_ref": None,
"cross_project_sync_ref": None,
"restoration_evidence_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,
"before_after_state_accepted": False,
"service_dependency_accepted": False,
"public_route_impact_accepted": False,
"ai_provider_impact_accepted": False,
"monitoring_alert_impact_accepted": False,
"operator_notification_accepted": False,
"cross_project_sync_accepted": False,
"restoration_evidence_accepted": False,
"postcheck_readback_accepted": False,
"recurrence_guard_accepted": False,
"maintenance_window_accepted": False,
"rollback_owner_accepted": False,
"no_false_green_accepted": False,
"ssh_read_authorized": False,
"ssh_write_authorized": False,
"live_firewall_read_authorized": False,
"firewall_change_authorized": False,
"port_change_authorized": False,
"port_close_authorized": False,
"port_open_authorized": False,
"network_policy_apply_authorized": False,
"nodeport_change_authorized": False,
"wireguard_change_authorized": False,
"route_smoke_authorized": False,
"host_restart_authorized": False,
"secret_value_collection_allowed": False,
"active_scan_authorized": False,
"provider_switch_authorized": False,
"prompt_send_authorized": False,
"runtime_gate": False,
"action_buttons_allowed": False,
"production_write_authorized": 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")
source_candidates = source_report.get("change_evidence_candidates", [])
readback_candidates = [build_candidate(item) for item in source_candidates]
write_capable = [item for item in readback_candidates if item["write_capable_surface"]]
policy_or_exposure = [item for item in readback_candidates if item["policy_or_exposure_surface"]]
return {
"schema_version": "ssh_network_post_incident_readback_plan_v1",
"generated_at": report_time,
"git_commit": git_short_sha(root),
"status": "post_incident_readback_plan_ready_no_runtime_action",
"source_schema_version": source_report.get("schema_version"),
"source_status": source_report.get("status"),
"source_paths": [
"docs/security/PORT-FIREWALL-CHANGE-EVIDENCE-ACCEPTANCE.md",
"docs/security/port-firewall-change-evidence-acceptance.snapshot.json",
"docs/security/SSH-NETWORK-OWNER-RESPONSE-ACCEPTANCE.md",
"docs/security/ssh-network-owner-response-acceptance.snapshot.json",
],
"summary": {
"readback_candidate_count": len(readback_candidates),
"write_capable_readback_candidate_count": len(write_capable),
"policy_or_exposure_readback_candidate_count": len(policy_or_exposure),
"health_impact_review_required_candidate_count": len(readback_candidates),
"cross_project_sync_required_candidate_count": len(readback_candidates),
"recurrence_guard_required_candidate_count": len(readback_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_state_accepted_count": 0,
"service_dependency_accepted_count": 0,
"public_route_impact_accepted_count": 0,
"ai_provider_impact_accepted_count": 0,
"monitoring_alert_impact_accepted_count": 0,
"operator_notification_accepted_count": 0,
"cross_project_sync_accepted_count": 0,
"restoration_evidence_accepted_count": 0,
"postcheck_readback_accepted_count": 0,
"recurrence_guard_accepted_count": 0,
"no_false_green_accepted_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
"coverage_percent_after_readback_plan": 64,
},
"required_readback_fields": REQUIRED_READBACK_FIELDS,
"reviewer_checks": REVIEWER_CHECKS,
"outcome_lanes": OUTCOME_LANES,
"blocked_actions": BLOCKED_ACTIONS,
"readback_candidates": readback_candidates,
"boundaries": {
"not_authorization": True,
"ssh_read_authorized": False,
"ssh_write_authorized": False,
"live_firewall_read_authorized": False,
"firewall_change_authorized": False,
"port_change_authorized": False,
"port_close_authorized": False,
"port_open_authorized": False,
"network_policy_apply_authorized": False,
"nodeport_change_authorized": False,
"wireguard_change_authorized": False,
"route_smoke_authorized": False,
"host_restart_authorized": False,
"secret_value_collection_allowed": False,
"active_scan_authorized": False,
"provider_switch_authorized": False,
"prompt_send_authorized": False,
"runtime_execution_authorized": False,
"production_write_authorized": False,
"action_buttons_allowed": False,
},
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", default=".")
parser.add_argument(
"--source-change-evidence-report",
default="docs/security/port-firewall-change-evidence-acceptance.snapshot.json",
)
parser.add_argument(
"--output",
default="docs/security/ssh-network-post-incident-readback-plan.snapshot.json",
)
parser.add_argument("--generated-at")
args = parser.parse_args()
root = Path(args.root).resolve()
source_report = load_json(root / args.source_change_evidence_report)
report = build_report(root, source_report, args.generated_at)
payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
output_path = root / args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(payload + "\n", encoding="utf-8")
summary = report["summary"]
print(
"SSH_NETWORK_POST_INCIDENT_READBACK_PLAN_OK "
f"candidates={summary['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__":
sys.exit(main())