Files
awoooi/scripts/security/public-gateway-live-conf-export-request.py

217 lines
8.7 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 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())