Files
awoooi/scripts/security/domain-tls-certbot-owner-confirmation-request.py

293 lines
11 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 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())