Files
awoooi/scripts/security/port-firewall-change-evidence-acceptance.py
Your Name b9b61e5001
All checks were successful
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 1m33s
CD Pipeline / build-and-deploy (push) Successful in 4m4s
CD Pipeline / post-deploy-checks (push) Successful in 1m56s
feat(iwooos): 強化端口防火牆事故證據驗收
2026-06-15 13:30:23 +08:00

454 lines
20 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 / Firewall / Network Access owner response acceptance
snapshot建立未來端口關閉、端口開放、防火牆規則、NodePort、
NetworkPolicy 或 WireGuard 變更的 evidence acceptance ledger。它不 SSH、
不讀 live firewall、不改端口、不套用規則、不做 route smoke、不修復主機
也不把 incident evidence 誤判成 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))
SOURCE_CONFIG_KINDS = {
"ssh_target_inventory",
"ci_deploy_ssh",
"ssh_discovery_script",
"monitoring_ssh_deploy_script",
"ssh_backup_capture",
"sudoers_policy",
"k8s_network_policy",
"k8s_nodeport_service",
"wireguard_runbook",
"alert_ssh_action_rules",
}
CHANGE_EVIDENCE_FIELDS = [
"change_evidence_candidate_id",
"source_acceptance_candidate_id",
"surface_id",
"config_kind",
"control_tier",
"write_capable_surface",
"change_or_incident_ref",
"actor_role_or_team",
"decision",
"decision_reason",
"affected_scope",
"affected_host_or_route_aliases",
"affected_ports_or_paths_ref",
"before_state_ref",
"after_state_ref",
"firewall_rule_diff_ref",
"allowlist_or_denylist_ref",
"service_dependency_ref",
"customer_impact_ref",
"monitoring_alert_refs",
"cross_project_sync_ref",
"incident_severity",
"incident_detected_at_ref",
"restoration_time_ref",
"service_health_impact_refs",
"external_route_impact_refs",
"emergency_change_classification",
"operator_notification_refs",
"incident_commander_or_owner",
"maintenance_window",
"rollback_owner",
"rollback_plan_ref",
"validation_plan",
"postcheck_evidence_refs",
"communication_owner",
"break_glass_owner",
"change_freeze_rule",
"reviewer_outcome",
"followup_owner",
"not_approval",
]
REQUIRED_EVIDENCE_FIELDS = [
"change_or_incident_ref",
"actor_role_or_team",
"decision",
"decision_reason",
"affected_scope",
"affected_ports_or_paths_ref",
"before_state_ref",
"after_state_ref",
"service_dependency_ref",
"customer_impact_ref",
"incident_severity",
"restoration_time_ref",
"service_health_impact_refs",
"operator_notification_refs",
"incident_commander_or_owner",
"maintenance_window",
"rollback_owner",
"rollback_plan_ref",
"validation_plan",
"postcheck_evidence_refs",
"cross_project_sync_ref",
]
REVIEWER_CHECKS = [
{"check_id": "change_ref_present", "instruction": "必須有可追溯的 change / incident ref。"},
{"check_id": "actor_role_traceable", "instruction": "必須標示 actor role / team不接受匿名端口變更。"},
{"check_id": "decision_reason_present", "instruction": "decision 與 decision reason 必須同時存在。"},
{"check_id": "affected_scope_matches_surface", "instruction": "affected scope 必須能對回既有 SSH / network surface。"},
{"check_id": "ports_or_paths_redacted", "instruction": "端口、路徑、host alias 只能收脫敏摘要或 owner-provided ref。"},
{"check_id": "before_after_state_refs", "instruction": "需有變更前後狀態 ref不能只有口頭說明。"},
{"check_id": "firewall_diff_metadata_only", "instruction": "firewall diff 僅收 metadata ref不保存 raw firewall dump。"},
{"check_id": "dependency_impact_present", "instruction": "必須列出受影響服務、agent、監控、公開入口或後台路徑。"},
{"check_id": "customer_impact_review", "instruction": "若造成服務異常,需有 customer / product impact ref。"},
{"check_id": "cross_project_sync_present", "instruction": "需標示已同步受影響產品 / Session / owner不得單點改動。"},
{"check_id": "incident_severity_present", "instruction": "事故型端口變更需標示嚴重度與判定依據。"},
{"check_id": "service_health_impact_present", "instruction": "需列出受影響健康檢查、agent provider、public route 或 monitoring evidence ref。"},
{"check_id": "restoration_time_present", "instruction": "已恢復事故需提供恢復時間或 still-degraded ref不可只寫已處理。"},
{"check_id": "operator_notification_present", "instruction": "需提供已通知受影響產品 / owner / Session 的脫敏 ref。"},
{"check_id": "break_glass_classification_present", "instruction": "若為緊急變更,必須標示 break-glass 分類與回補責任。"},
{"check_id": "maintenance_window_present", "instruction": "未來變更需有維護窗口;事故回補也需標記 break-glass。"},
{"check_id": "rollback_owner_present", "instruction": "rollback owner 與 rollback plan 必須同時存在。"},
{"check_id": "postcheck_evidence_present", "instruction": "post-check evidence 必須覆蓋 API / route / agent / monitoring。"},
{"check_id": "secret_or_key_value_absent", "instruction": "不得包含 secret、SSH key、token、cookie 或私鑰內容。"},
{"check_id": "no_runtime_authorization", "instruction": "驗收證據不等於允許 firewall / port / route 變更。"},
{"check_id": "counts_transition_safe", "instruction": "只有 reviewer record 能更新 accepted count且不得同時開 runtime gate。"},
]
OUTCOME_LANES = [
{"lane_id": "waiting_change_evidence", "meaning": "尚未收到端口 / 防火牆變更證據;所有 accepted / runtime count 維持 0。"},
{"lane_id": "quarantine_raw_firewall_dump", "meaning": "收到 raw firewall dump、secret 或 key material 時只能隔離。"},
{"lane_id": "reject_unattributed_change", "meaning": "無 actor、無 owner、無 affected scope 或無 rollback 的變更不得驗收。"},
{"lane_id": "request_impact_supplement", "meaning": "缺 before/after、impact、dependency、post-check 或 cross-project sync 時要求補件。"},
{"lane_id": "ready_for_network_review", "meaning": "metadata 合格後,只能進 network / firewall reviewer review。"},
{"lane_id": "incident_backfill_only", "meaning": "事故回補只能更新只讀 ledger不得反向視為已批准操作。"},
{"lane_id": "emergency_change_backfill_required", "meaning": "緊急端口 / 防火牆變更需補 actor、severity、health impact、通知、恢復與 rollback evidence。"},
{"lane_id": "owner_review_only_update", "meaning": "只允許更新 reviewer ledger不改 firewall、port、route 或 host。"},
{"lane_id": "waiting_runtime_gate", "meaning": "即使證據 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",
"secret_value_collection",
"ssh_key_collection",
"raw_firewall_dump_storage",
"raw_key_material_storage",
"mark_change_evidence_accepted_without_reviewer_record",
"mark_incident_resolved_without_health_evidence",
"hide_cross_project_impact",
"close_management_port_without_owner",
"treat_break_glass_as_approval",
"open_runtime_gate",
"add_action_button",
"production_write",
]
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]:
surface_id = source["surface_id"]
return {
"change_evidence_candidate_id": f"port_firewall_change_evidence:{surface_id}",
"status": "waiting_change_evidence",
"source_acceptance_candidate_id": source["acceptance_candidate_id"],
"surface_id": surface_id,
"config_kind": source["config_kind"],
"control_tier": source["control_tier"],
"write_capable_surface": source["write_capable_surface"],
"expected_scope": source["expected_scope"],
"change_or_incident_ref": None,
"actor_role_or_team": "pending_change_evidence",
"decision": "pending_change_evidence",
"decision_reason": "pending_change_evidence",
"affected_scope": "pending_change_evidence",
"affected_host_or_route_aliases": [],
"affected_ports_or_paths_ref": None,
"before_state_ref": None,
"after_state_ref": None,
"firewall_rule_diff_ref": None,
"allowlist_or_denylist_ref": None,
"service_dependency_ref": None,
"customer_impact_ref": None,
"monitoring_alert_refs": [],
"cross_project_sync_ref": None,
"incident_severity": "pending_change_evidence",
"incident_detected_at_ref": None,
"restoration_time_ref": None,
"service_health_impact_refs": [],
"external_route_impact_refs": [],
"emergency_change_classification": "pending_change_evidence",
"operator_notification_refs": [],
"incident_commander_or_owner": "pending_change_evidence",
"maintenance_window": "pending_change_evidence",
"rollback_owner": "pending_change_evidence",
"rollback_plan_ref": None,
"validation_plan": "pending_change_evidence",
"postcheck_evidence_refs": [],
"communication_owner": "pending_change_evidence",
"break_glass_owner": "pending_change_evidence",
"change_freeze_rule": "pending_change_evidence",
"reviewer_outcome": "waiting_change_evidence",
"followup_owner": "pending_change_evidence",
"change_evidence_fields": CHANGE_EVIDENCE_FIELDS,
"required_evidence_fields": REQUIRED_EVIDENCE_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,
"change_evidence_received": False,
"change_evidence_accepted": False,
"change_evidence_rejected": False,
"change_evidence_quarantined": False,
"impact_supplement_requested": False,
"incident_backfill_only": True,
"actor_identified": False,
"affected_scope_accepted": False,
"before_state_accepted": False,
"after_state_accepted": False,
"firewall_rule_diff_accepted": False,
"port_policy_accepted": False,
"service_dependency_accepted": False,
"customer_impact_accepted": False,
"cross_project_sync_accepted": False,
"incident_severity_accepted": False,
"restoration_time_accepted": False,
"service_health_impact_accepted": False,
"operator_notification_accepted": False,
"incident_commander_accepted": False,
"emergency_change_classification_accepted": False,
"maintenance_window_accepted": False,
"rollback_owner_accepted": False,
"rollback_plan_accepted": False,
"validation_plan_accepted": False,
"postcheck_evidence_accepted": False,
"host_write_authorized": 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,
"sudo_action_authorized": False,
"deploy_ssh_action_authorized": False,
"route_smoke_authorized": False,
"public_gateway_reload_authorized": False,
"nginx_reload_authorized": False,
"host_restart_authorized": False,
"secret_value_collection_allowed": False,
"ssh_key_collection_allowed": False,
"runtime_gate": False,
"runtime_execution_authorized": False,
"production_write_authorized": 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")
source_candidates = [
item
for item in source_report.get("acceptance_candidates", [])
if item.get("config_kind") in SOURCE_CONFIG_KINDS
]
change_candidates = [build_candidate(item) for item in source_candidates]
write_capable = [item for item in change_candidates if item["write_capable_surface"]]
policy_or_exposure = [
item
for item in change_candidates
if item["config_kind"] in {"k8s_network_policy", "k8s_nodeport_service", "wireguard_runbook"}
]
return {
"schema_version": "port_firewall_change_evidence_acceptance_v1",
"generated_at": report_time,
"git_commit": git_short_sha(root),
"source_acceptance_schema_version": source_report.get("schema_version"),
"source_acceptance_status": source_report.get("status"),
"status": "change_evidence_acceptance_ready_no_runtime_action",
"summary": {
"source_acceptance_candidate_count": source_report.get("summary", {}).get("acceptance_candidate_count", 0),
"change_evidence_candidate_count": len(change_candidates),
"write_capable_change_evidence_candidate_count": len(write_capable),
"policy_or_exposure_candidate_count": len(policy_or_exposure),
"change_evidence_field_count": len(CHANGE_EVIDENCE_FIELDS),
"required_evidence_field_count": len(REQUIRED_EVIDENCE_FIELDS),
"reviewer_check_count": len(REVIEWER_CHECKS),
"outcome_lane_count": len(OUTCOME_LANES),
"blocked_action_count": len(BLOCKED_ACTIONS),
"change_evidence_received_count": 0,
"change_evidence_accepted_count": 0,
"change_evidence_rejected_count": 0,
"change_evidence_quarantined_count": 0,
"impact_supplement_requested_count": 0,
"actor_identified_count": 0,
"affected_scope_accepted_count": 0,
"before_state_accepted_count": 0,
"after_state_accepted_count": 0,
"firewall_rule_diff_accepted_count": 0,
"port_policy_accepted_count": 0,
"service_dependency_accepted_count": 0,
"customer_impact_accepted_count": 0,
"cross_project_sync_accepted_count": 0,
"incident_severity_accepted_count": 0,
"restoration_time_accepted_count": 0,
"service_health_impact_accepted_count": 0,
"operator_notification_accepted_count": 0,
"incident_commander_accepted_count": 0,
"emergency_change_classification_accepted_count": 0,
"maintenance_window_accepted_count": 0,
"rollback_owner_accepted_count": 0,
"rollback_plan_accepted_count": 0,
"validation_plan_accepted_count": 0,
"postcheck_evidence_accepted_count": 0,
"host_write_authorized_count": 0,
"ssh_read_authorized_count": 0,
"ssh_write_authorized_count": 0,
"live_firewall_read_authorized_count": 0,
"firewall_change_authorized_count": 0,
"port_change_authorized_count": 0,
"port_close_authorized_count": 0,
"port_open_authorized_count": 0,
"network_policy_apply_authorized_count": 0,
"nodeport_change_authorized_count": 0,
"wireguard_change_authorized_count": 0,
"sudo_action_authorized_count": 0,
"deploy_ssh_action_authorized_count": 0,
"route_smoke_authorized_count": 0,
"public_gateway_reload_authorized_count": 0,
"nginx_reload_authorized_count": 0,
"host_restart_authorized_count": 0,
"secret_value_collection_allowed_count": 0,
"ssh_key_collection_allowed_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
},
"execution_boundaries": {
"change_evidence_acceptance_authorized": False,
"runtime_execution_authorized": False,
"production_write_authorized": False,
"live_host_read_authorized": False,
"host_write_authorized": 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,
"sudo_action_authorized": False,
"deploy_ssh_action_authorized": False,
"route_smoke_authorized": False,
"public_gateway_reload_authorized": False,
"nginx_reload_authorized": False,
"host_restart_authorized": False,
"secret_value_collection_allowed": False,
"ssh_key_collection_allowed": False,
"action_buttons_allowed": False,
"not_authorization": True,
},
"change_evidence_fields": CHANGE_EVIDENCE_FIELDS,
"required_evidence_fields": REQUIRED_EVIDENCE_FIELDS,
"reviewer_checks": REVIEWER_CHECKS,
"outcome_lanes": OUTCOME_LANES,
"blocked_actions": BLOCKED_ACTIONS,
"change_evidence_candidates": change_candidates,
"next_steps": [
"等待 owner-provided change / incident evidence未收到前不得更新 accepted count。",
"收到證據後先檢查 actor、affected scope、before / after state、impact、rollback、post-check 與 cross-project sync。",
"metadata 合格也只能進 network / firewall reviewer review端口、防火牆、NetworkPolicy、NodePort、WireGuard 與 route smoke 仍需獨立人工批准。",
],
}
def main() -> int:
parser = argparse.ArgumentParser(description="IwoooS 端口 / 防火牆變更證據驗收只讀帳本產生器")
parser.add_argument("--root", default=".", help="repo root")
parser.add_argument(
"--source-acceptance-report",
default="docs/security/ssh-network-owner-response-acceptance.snapshot.json",
help="ssh-network-owner-response-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_acceptance_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(
"PORT_FIREWALL_CHANGE_EVIDENCE_ACCEPTANCE_OK "
f"candidates={summary['change_evidence_candidate_count']} "
f"write_capable={summary['write_capable_change_evidence_candidate_count']} "
f"policy_or_exposure={summary['policy_or_exposure_candidate_count']} "
f"checks={summary['reviewer_check_count']} "
f"lanes={summary['outcome_lane_count']} "
f"accepted={summary['change_evidence_accepted_count']} "
f"runtime_gate={summary['runtime_gate_count']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())