#!/usr/bin/env python3 """ IwoooS Public Gateway live conf 匯出請求包產生器。 本工具讀取 public gateway preflight inventory,產生 owner-provided live conf 匯出請求草稿。它不 SSH、不讀 live conf、不保存 raw conf、不執行 nginx -t、不 reload、不做 route smoke。 """ 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)) EXPORT_REQUEST_FIELDS = [ "export_request_id", "config_id", "host", "live_path", "export_owner_role_or_team", "export_method", "redaction_policy_ref", "redacted_live_conf_ref", "source_snapshot_ref", "intended_use", "followup_owner", "not_approval", ] REDACTION_RULES = [ "只收 owner 提供的脫敏 live conf export ref,不收 raw live conf。", "不得包含 TLS private key、token、secret、cookie、session、authorization header 或 Basic Auth credential。", "若 upstream URL 含 credential,必須整段遮罩為 redacted_upstream_credential。", "若路徑含 private credential、query token 或 webhook secret,必須整段遮罩。", "允許保留 server_name、listen、location、proxy_pass host / port、ACME path、TLS certificate path metadata。", "不得貼主機 shell history、完整環境變數、私鑰內容、DB URL 或未脫敏 log。", "疑似敏感 payload 只能記 quarantine metadata,不得寫入 repo、LOGBOOK 或前端。", "匯出請求不等於 nginx -t、reload、route smoke、DNS / TLS probe 或 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 export_request_for(row: dict[str, Any]) -> dict[str, Any]: config_id = row["config_id"] return { "export_request_id": f"public_gateway_live_conf_export:{config_id}", "status": "draft_not_dispatched", "config_id": config_id, "host": row["host"], "role": row["role"], "control_tier": row["control_tier"], "owner_gate": row["owner_gate"], "repo_source_path": row["repo_source_path"], "live_path": row["live_path"], "export_owner_role_or_team": "pending_owner_role_or_team", "export_method": "owner_provided_redacted_export_only", "redaction_policy_ref": "docs/security/PUBLIC-GATEWAY-LIVE-CONF-EXPORT-REQUEST.md#3-redaction-policy", "redacted_live_conf_ref": None, "source_snapshot_ref": "docs/security/public-gateway-preflight-inventory.snapshot.json", "intended_use": "rendered_diff_and_route_change_preflight_only", "followup_owner": "pending_followup_owner", "not_approval": True, "export_request_fields": EXPORT_REQUEST_FIELDS, "redaction_rules": REDACTION_RULES, "route_impact_summary": { "server_name_count": row.get("server_name_count", 0), "upstream_count": row.get("upstream_count", 0), "tls_certificate_path_count": row.get("tls_certificate_path_count", 0), "admin_route_count": row.get("admin_route_count", 0), "websocket_route_count": row.get("websocket_route_count", 0), "acme_route_count": row.get("acme_route_count", 0), }, "request_sent": False, "recipient_confirmed": False, "redacted_export_received": False, "raw_live_conf_stored": False, "rendered_diff_ready": False, "nginx_test_authorized": False, "nginx_test_executed": False, "nginx_reload_authorized": False, "route_smoke_authorized": False, "route_smoke_executed": False, "runtime_gate": False, "action_buttons_allowed": False, "secret_value_collection_allowed": False, "production_write_authorized": False, } def build_report(root: Path, preflight_report: dict[str, Any], generated_at: str | None) -> dict[str, Any]: report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds") rows = preflight_report.get("config_preflight_rows", []) requests = [export_request_for(row) for row in rows] c0_requests = [request for request in requests if request.get("control_tier") == "C0"] c1_requests = [request for request in requests if request.get("control_tier") == "C1"] return { "schema_version": "public_gateway_live_conf_export_request_v1", "generated_at": report_time, "git_commit": git_short_sha(root), "source_preflight_schema_version": preflight_report.get("schema_version"), "source_preflight_status": preflight_report.get("status"), "status": "live_conf_export_request_ready_not_dispatched", "summary": { "export_request_count": len(requests), "c0_export_request_count": len(c0_requests), "c1_export_request_count": len(c1_requests), "export_request_field_count": len(EXPORT_REQUEST_FIELDS), "redaction_rule_count": len(REDACTION_RULES), "request_sent_count": 0, "recipient_confirmed_count": 0, "redacted_export_received_count": 0, "raw_live_conf_stored_count": 0, "rendered_diff_ready_count": 0, "nginx_test_authorized_count": 0, "nginx_test_executed_count": 0, "nginx_reload_authorized_count": 0, "route_smoke_authorized_count": 0, "route_smoke_executed_count": 0, "runtime_gate_count": 0, "action_button_count": 0, }, "execution_boundaries": { "ssh_read_authorized": False, "host_live_conf_read_authorized": False, "raw_live_conf_storage_allowed": False, "nginx_test_authorized": False, "nginx_test_executed": False, "nginx_reload_authorized": False, "nginx_reload_executed": False, "route_smoke_authorized": False, "route_smoke_executed": False, "dns_query_executed": False, "live_tls_probe_executed": False, "certbot_renew_authorized": False, "runtime_execution_authorized": False, "action_buttons_allowed": False, "secret_value_collection_allowed": False, "production_write_authorized": False, "not_authorization": True, }, "export_request_fields": EXPORT_REQUEST_FIELDS, "redaction_rules": REDACTION_RULES, "export_requests": requests, "next_steps": [ "若 owner 願意提供,只能提供脫敏 live conf export ref,不得提供 raw conf。", "收到 export ref 後先做敏感 payload 隔離檢查,再進 rendered diff。", "rendered diff 成立仍不代表 nginx -t、reload 或 route smoke 已授權。", ], } def main() -> int: parser = argparse.ArgumentParser(description="IwoooS Public Gateway live conf 匯出請求包產生器") parser.add_argument("--root", default=".", help="repo root") parser.add_argument( "--preflight-report", default="docs/security/public-gateway-preflight-inventory.snapshot.json", help="public-gateway-preflight-inventory.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() preflight_report = load_json(root / args.preflight_report) report = build_report(root, preflight_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_LIVE_CONF_EXPORT_REQUEST_OK " f"requests={summary['export_request_count']} " f"c0={summary['c0_export_request_count']} " f"redaction_rules={summary['redaction_rule_count']} " f"received={summary['redacted_export_received_count']} " f"runtime_gate={summary['runtime_gate_count']}", file=sys.stderr, ) return 0 if __name__ == "__main__": sys.exit(main())