#!/usr/bin/env python3 """ IwoooS DNS / TLS / certbot owner confirmation request 產生器。 本工具讀取 DNS / TLS / certbot repo-only 清冊,將 certificate path 需 owner 確認的 domain 轉成送件前草稿。它不做 DNS query、不做 live TLS probe、不執行 certbot renew、不讀憑證或 private key 內容、不 reload Nginx、不修改主機。 """ 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_OWNER_FIELDS = [ "owner_role_or_team", "decision", "decision_reason", "affected_scope", "redacted_evidence_refs", "followup_owner", "rollback_owner", "maintenance_window", "validation_plan", ] REQUEST_FIELDS = [ "request_id", "domain", "control_tier", "hosts", "certificate_path_domains", "certificate_paths", "owner_role_or_team", "decision", "decision_reason", "affected_scope", "redacted_evidence_refs", "followup_owner", "rollback_owner", "maintenance_window", "validation_plan", "not_approval", ] CONFIRMATION_QUESTIONS = [ ( "certificate_coverage_basis", "請 owner 說明 certificate path domain 與 service domain 不同時,是否由 SAN、wildcard 或共用憑證合法覆蓋。", ), ( "certificate_expiry_metadata_ref", "若需提供憑證狀態,只能提供脫敏 metadata ref;不得貼 raw certificate、private key 或 certbot account 內容。", ), ( "renewal_owner_and_method", "請確認未來 renewal owner、工具路徑與責任邊界;不得在本 request 夾帶 certbot renew 要求。", ), ( "acme_challenge_route_owner", "若 domain 依賴 HTTP-01 ACME route,請確認 challenge path owner 與 route smoke 負責人。", ), ( "postcheck_and_rollback_owner", "請提供後續若要 probe、renew 或 reload 時的 validation plan、rollback owner 與維護窗口。", ), ] REJECTION_GUARDS = [ "tls_private_key_or_raw_cert_payload", "certbot_account_key_or_credentials", "dns_provider_or_registrar_credential", "token_secret_cookie_session", "authorization_header_or_basic_auth", "unredacted_certbot_log_or_env_dump", "shell_history_or_private_key_path_dump", "dns_query_or_tls_probe_request", "certbot_renew_execution_request", "nginx_reload_or_route_change_request", "ssh_host_write_or_runtime_request", "production_write_or_action_button_request", ] 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 confirmation_questions() -> list[dict[str, Any]]: return [ { "question_id": question_id, "required": True, "instruction": instruction, "gate_effect": "不得增加 request sent / received / accepted / runtime gate。", } for question_id, instruction in CONFIRMATION_QUESTIONS ] def request_for(domain_item: dict[str, Any]) -> dict[str, Any]: domain = domain_item["domain"] return { "request_id": f"domain_tls_certbot_owner_confirmation:{domain}", "status": "draft_not_dispatched", "domain": domain, "control_tier": domain_item["control_tier"], "hosts": domain_item["hosts"], "config_ids": domain_item["config_ids"], "source_paths": domain_item["source_paths"], "live_paths": domain_item["live_paths"], "certificate_path_domains": domain_item["certificate_path_domains"], "certificate_paths": domain_item["certificate_paths"], "tls_certificate_path_present": domain_item["tls_certificate_path_present"], "owner_review_status": domain_item["owner_review_status"], "source_snapshot_ref": "docs/security/domain-tls-certbot-inventory.snapshot.json", "request_fields": REQUEST_FIELDS, "required_owner_fields": REQUIRED_OWNER_FIELDS, "confirmation_questions": [question_id for question_id, _instruction in CONFIRMATION_QUESTIONS], "rejection_guards": REJECTION_GUARDS, "owner_role_or_team": "pending_owner_role_or_team", "decision": "pending_owner_decision", "decision_reason": "pending_decision_reason", "affected_scope": "pending_affected_scope", "redacted_evidence_refs": [], "followup_owner": "pending_followup_owner", "rollback_owner": "pending_rollback_owner", "maintenance_window": "pending_maintenance_window", "validation_plan": "pending_validation_plan", "not_approval": True, "request_sent": False, "recipient_confirmed": False, "owner_response_received": False, "owner_response_accepted": False, "owner_response_rejected": False, "quarantine_written": False, "dns_query_authorized": False, "dns_query_executed": False, "live_tls_probe_authorized": False, "live_tls_probe_executed": False, "certbot_renew_authorized": False, "certbot_renew_executed": False, "nginx_reload_authorized": False, "nginx_reload_executed": False, "host_write_authorized": False, "runtime_gate": False, "action_buttons_allowed": False, "secret_value_collection_allowed": False, "production_write_authorized": False, } def build_report(root: Path, inventory_report: dict[str, Any], generated_at: str | None) -> dict[str, Any]: report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds") request_domains = [ item for item in inventory_report.get("managed_domains", []) if item.get("certificate_owner_confirmation_required") or not item.get("tls_certificate_path_present") ] request_domains.sort(key=lambda item: item["domain"]) requests = [request_for(item) for item in request_domains] c0_requests = [item for item in requests if item.get("control_tier") == "C0"] c1_requests = [item for item in requests if item.get("control_tier") == "C1"] return { "schema_version": "domain_tls_certbot_owner_confirmation_request_v1", "generated_at": report_time, "git_commit": git_short_sha(root), "source_inventory_schema_version": inventory_report.get("schema_version"), "source_inventory_status": inventory_report.get("mode"), "status": "owner_confirmation_request_ready_not_dispatched", "summary": { "owner_confirmation_request_count": len(requests), "c0_owner_confirmation_request_count": len(c0_requests), "c1_owner_confirmation_request_count": len(c1_requests), "required_owner_field_count": len(REQUIRED_OWNER_FIELDS), "request_field_count": len(REQUEST_FIELDS), "confirmation_question_count": len(CONFIRMATION_QUESTIONS), "rejection_guard_count": len(REJECTION_GUARDS), "request_sent_count": 0, "recipient_confirmed_count": 0, "owner_response_received_count": 0, "owner_response_accepted_count": 0, "owner_response_rejected_count": 0, "quarantined_payload_count": 0, "dns_query_authorized_count": 0, "dns_query_executed_count": 0, "live_tls_probe_authorized_count": 0, "live_tls_probe_executed_count": 0, "certbot_renew_authorized_count": 0, "certbot_renew_executed_count": 0, "nginx_reload_authorized_count": 0, "nginx_reload_executed_count": 0, "host_write_authorized_count": 0, "runtime_gate_count": 0, "action_button_count": 0, }, "execution_boundaries": { "request_sent": False, "recipient_confirmed": False, "owner_response_received": False, "owner_response_accepted": False, "dns_query_authorized": False, "dns_query_executed": False, "live_tls_probe_authorized": False, "live_tls_probe_executed": False, "certbot_renew_authorized": False, "certbot_renew_executed": False, "nginx_reload_authorized": False, "nginx_reload_executed": False, "host_write_authorized": False, "host_write_executed": False, "runtime_execution_authorized": False, "action_buttons_allowed": False, "secret_value_collection_allowed": False, "production_write_authorized": False, "not_authorization": True, }, "required_owner_fields": REQUIRED_OWNER_FIELDS, "request_fields": REQUEST_FIELDS, "confirmation_questions": confirmation_questions(), "rejection_guards": REJECTION_GUARDS, "owner_confirmation_requests": requests, "next_steps": [ "人工送件前先確認 recipient role / team 與本 snapshot 版本,送件後也只可更新 request metadata。", "收到 owner 回覆後先做敏感 payload 隔離與欄位完整性檢查,不可直接開 DNS / TLS probe。", "若未來要 certbot renew、Nginx reload 或 route smoke,必須另開 maintenance window、rollback owner 與 post-check gate。", ], } def main() -> int: parser = argparse.ArgumentParser(description="IwoooS DNS / TLS / certbot owner confirmation request 產生器") parser.add_argument("--root", default=".", help="repo root") parser.add_argument( "--inventory-report", default="docs/security/domain-tls-certbot-inventory.snapshot.json", help="domain-tls-certbot-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() inventory_report = load_json(root / args.inventory_report) report = build_report(root, inventory_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( "DOMAIN_TLS_CERTBOT_OWNER_CONFIRMATION_REQUEST_OK " f"requests={summary['owner_confirmation_request_count']} " f"c0={summary['c0_owner_confirmation_request_count']} " f"fields={summary['required_owner_field_count']} " f"received={summary['owner_response_received_count']} " f"runtime_gate={summary['runtime_gate_count']}", file=sys.stderr, ) return 0 if __name__ == "__main__": sys.exit(main())