Files
awoooi/scripts/security/public-gateway-preflight-inventory.py
Your Name 6239712507
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled
Code Review / ai-code-review (push) Has been cancelled
feat(security): 新增 public gateway preflight 只讀清冊
2026-06-12 01:25:04 +08:00

377 lines
14 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 public gateway 變更前置 Gate 只讀清冊。
本工具只讀取已提交的 Nginx drift snapshot 與 DNS / TLS snapshot整理
public gateway reload / route change 前必備的 owner、diff、nginx -t、
route smoke、rollback 與維護窗口欄位。它不 SSH、不讀 live 主機、不執行
nginx -t、不 reload、不做 DNS / TLS probe也不收 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))
REQUIRED_PREFLIGHT_GATES = [
{
"gate_id": "PG1",
"label": "source-of-truth hash",
"required_evidence": "repo_source_raw_and_normalized_sha256",
"owner_acceptance_required": False,
"repo_only_ready": True,
},
{
"gate_id": "PG2",
"label": "affected route list",
"required_evidence": "domain_route_upstream_tls_admin_websocket_acme_inventory",
"owner_acceptance_required": False,
"repo_only_ready": True,
},
{
"gate_id": "PG3",
"label": "owner response",
"required_evidence": "owner role/team, decision, reason, affected scope, redacted refs, followup owner",
"owner_acceptance_required": True,
"repo_only_ready": False,
},
{
"gate_id": "PG4",
"label": "owner-provided live config",
"required_evidence": "redacted live conf export or owner statement",
"owner_acceptance_required": True,
"repo_only_ready": False,
},
{
"gate_id": "PG5",
"label": "rendered diff",
"required_evidence": "source-to-live rendered diff with redaction",
"owner_acceptance_required": True,
"repo_only_ready": False,
},
{
"gate_id": "PG6",
"label": "nginx -t evidence",
"required_evidence": "syntax test output captured by approved maintainer",
"owner_acceptance_required": True,
"repo_only_ready": False,
},
{
"gate_id": "PG7",
"label": "public route smoke",
"required_evidence": "public route smoke plan and result for affected domains",
"owner_acceptance_required": True,
"repo_only_ready": False,
},
{
"gate_id": "PG8",
"label": "admin route smoke",
"required_evidence": "admin/auth route smoke when admin path is affected",
"owner_acceptance_required": True,
"repo_only_ready": False,
},
{
"gate_id": "PG9",
"label": "WebSocket / API smoke",
"required_evidence": "WebSocket or API smoke when upstream or upgrade header is affected",
"owner_acceptance_required": True,
"repo_only_ready": False,
},
{
"gate_id": "PG10",
"label": "ACME / TLS owner check",
"required_evidence": "certificate path, SAN/wildcard/shared-cert statement, ACME impact",
"owner_acceptance_required": True,
"repo_only_ready": False,
},
{
"gate_id": "PG11",
"label": "maintenance window",
"required_evidence": "approved window and notification scope",
"owner_acceptance_required": True,
"repo_only_ready": False,
},
{
"gate_id": "PG12",
"label": "rollback owner and ref",
"required_evidence": "rollback owner, rollback file/ref, post-rollback smoke",
"owner_acceptance_required": True,
"repo_only_ready": False,
},
]
EXECUTION_BOUNDARIES = {
"runtime_execution_authorized": False,
"host_live_conf_read_authorized": False,
"ssh_read_authorized": False,
"ssh_write_authorized": False,
"host_write_authorized": False,
"nginx_test_authorized": False,
"nginx_test_executed": False,
"nginx_reload_authorized": False,
"nginx_reload_executed": False,
"public_gateway_reload_authorized": False,
"public_route_change_authorized": False,
"admin_route_change_authorized": False,
"websocket_route_change_authorized": False,
"acme_challenge_change_authorized": False,
"dns_query_executed": False,
"live_tls_probe_executed": False,
"certbot_renew_authorized": False,
"certbot_renew_executed": False,
"route_smoke_authorized": False,
"route_smoke_executed": False,
"rollback_executed": False,
"secret_value_collection_allowed": False,
"action_buttons_allowed": False,
}
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 unique_upstreams(domains: list[dict[str, Any]]) -> list[str]:
return sorted({upstream for item in domains for upstream in item.get("upstreams", [])})
def build_config_rows(nginx_report: dict[str, Any]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for config in nginx_report.get("configs", []):
parsed = config.get("repo_source", {}).get("parsed", {})
rows.append(
{
"config_id": config["config_id"],
"host": config["host"],
"role": config["role"],
"control_tier": config["control_tier"],
"owner_gate": config["owner_gate"],
"repo_source_path": config["repo_source_path"],
"live_path": config["live_path"],
"server_block_count": parsed.get("server_block_count", 0),
"server_name_count": len(parsed.get("server_names", [])),
"upstream_count": len(parsed.get("proxy_passes", [])),
"tls_certificate_path_count": len(parsed.get("ssl_certificates", [])),
"admin_route_count": len(parsed.get("admin_routes", [])),
"acme_route_count": len(parsed.get("acme_routes", [])),
"websocket_route_count": len(parsed.get("websocket_routes", [])),
"repo_source_hash_ready": True,
"owner_response_received": False,
"owner_response_accepted": False,
"live_conf_evidence_received": False,
"rendered_diff_ready": False,
"nginx_test_evidence_received": False,
"route_smoke_evidence_received": False,
"maintenance_window_accepted": False,
"rollback_owner_accepted": False,
"runtime_gate_open": False,
}
)
return rows
def build_route_impacts(domains: list[dict[str, Any]]) -> list[dict[str, Any]]:
impacts: list[dict[str, Any]] = []
for item in domains:
impacts.append(
{
"domain": item["domain"],
"hosts": item.get("hosts", []),
"config_ids": item.get("config_ids", []),
"control_tier": item.get("control_tier", "C3"),
"upstream_count": len(item.get("upstreams", [])),
"has_tls_certificate_path": item.get("tls_certificate_path_present", False),
"certificate_owner_confirmation_required": item.get(
"certificate_owner_confirmation_required",
False,
),
"acme_challenge_present": item.get("acme_challenge_present", False),
"admin_route_count": item.get("admin_route_count", 0),
"websocket_route_count": item.get("websocket_route_count", 0),
"public_route_smoke_required": True,
"admin_route_smoke_required": item.get("admin_route_count", 0) > 0,
"websocket_or_api_smoke_required": item.get("websocket_route_count", 0) > 0
or len(item.get("upstreams", [])) > 0,
"tls_owner_check_required": item.get("certificate_owner_confirmation_required", False),
"owner_response_accepted": False,
"route_smoke_accepted": False,
}
)
return impacts
def build_report(
root: Path,
nginx_report: dict[str, Any],
domain_tls_report: dict[str, Any],
generated_at: str | None,
) -> dict[str, Any]:
report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
managed_domains = domain_tls_report.get("managed_domains", [])
config_rows = build_config_rows(nginx_report)
route_impacts = build_route_impacts(managed_domains)
upstreams = unique_upstreams(managed_domains)
repo_only_ready_count = sum(1 for gate in REQUIRED_PREFLIGHT_GATES if gate["repo_only_ready"])
owner_required_count = sum(1 for gate in REQUIRED_PREFLIGHT_GATES if gate["owner_acceptance_required"])
summary = {
"source_config_count": len(config_rows),
"c0_source_config_count": sum(1 for item in config_rows if item["control_tier"] == "C0"),
"managed_domain_count": len(managed_domains),
"route_impact_count": len(route_impacts),
"unique_upstream_count": len(upstreams),
"tls_certificate_path_count": domain_tls_report.get("summary", {}).get(
"unique_certificate_path_count",
0,
),
"certificate_owner_confirmation_required_count": domain_tls_report.get("summary", {}).get(
"certificate_owner_confirmation_required_count",
0,
),
"admin_route_domain_count": domain_tls_report.get("summary", {}).get("admin_route_domain_count", 0),
"websocket_route_domain_count": domain_tls_report.get("summary", {}).get(
"websocket_route_domain_count",
0,
),
"acme_challenge_domain_count": domain_tls_report.get("summary", {}).get(
"acme_challenge_domain_count",
0,
),
"preflight_gate_count": len(REQUIRED_PREFLIGHT_GATES),
"repo_only_preflight_ready_count": repo_only_ready_count,
"owner_acceptance_required_gate_count": owner_required_count,
"preflight_gate_accepted_count": 0,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"owner_provided_live_conf_received_count": 0,
"rendered_diff_ready_count": 0,
"nginx_test_evidence_count": 0,
"route_smoke_evidence_count": 0,
"maintenance_window_accepted_count": 0,
"rollback_owner_accepted_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
"coverage_percent_before_preflight": 78,
"coverage_percent_after_preflight": 84,
}
return {
"schema_version": "public_gateway_preflight_inventory_v1",
"generated_at": report_time,
"status": "repo_only_preflight_contract_ready",
"source_scope": "committed_nginx_and_domain_tls_snapshots_only",
"git_commit": git_short_sha(root),
"source_reports": [
"docs/security/nginx-config-drift-repo.snapshot.json",
"docs/security/domain-tls-certbot-inventory.snapshot.json",
],
"summary": summary,
"execution_boundaries": EXECUTION_BOUNDARIES,
"required_preflight_gates": REQUIRED_PREFLIGHT_GATES,
"config_preflight_rows": config_rows,
"route_impacts": route_impacts,
"unique_upstreams": upstreams,
"required_owner_fields": [
"owner_role_or_team",
"decision",
"decision_reason",
"affected_scope",
"redacted_evidence_refs",
"followup_owner",
"rollback_owner",
"maintenance_window",
"validation_plan",
"nginx_test_evidence_ref",
"route_smoke_evidence_ref",
],
"next_collection_order": [
"public_gateway_owner_response",
"owner_provided_live_conf_export",
"source_to_live_rendered_diff",
"nginx_t_evidence",
"affected_public_route_smoke",
"admin_route_smoke_if_affected",
"websocket_or_api_smoke_if_affected",
"tls_certificate_owner_confirmation",
"maintenance_window",
"rollback_owner_and_ref",
],
"operator_interpretation": [
"這是 Nginx public gateway 變更前置控管清冊,不是 reload 授權。",
"repo-only ready 只代表 source hash 與 affected route list 已能重跑產生。",
"owner response、live conf、rendered diff、nginx -t、route smoke、maintenance window 與 rollback owner 全部仍為 0。",
"若未來發現 live drift只能建立 evidence 與 owner decision不得自動覆寫 live。",
],
}
def main() -> int:
parser = argparse.ArgumentParser(description="IwoooS public gateway 變更前置 Gate 只讀清冊")
parser.add_argument("--root", default=".", help="repo root")
parser.add_argument(
"--nginx-report",
default="docs/security/nginx-config-drift-repo.snapshot.json",
help="Nginx repo-only drift detector snapshot",
)
parser.add_argument(
"--domain-tls-report",
default="docs/security/domain-tls-certbot-inventory.snapshot.json",
help="DNS / TLS / certbot repo-only inventory snapshot",
)
parser.add_argument("--output", help="寫出 JSON 報告")
parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用")
args = parser.parse_args()
root = Path(args.root).resolve()
report = build_report(
root,
load_json(root / args.nginx_report),
load_json(root / args.domain_tls_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_PREFLIGHT_INVENTORY_OK "
f"configs={summary['source_config_count']} "
f"routes={summary['route_impact_count']} "
f"gates={summary['preflight_gate_count']} "
f"runtime_gate={summary['runtime_gate_count']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())