Files
awoooi/scripts/security/domain-tls-certbot-inventory.py
Your Name 32b553ee8f
All checks were successful
CD Pipeline / tests (push) Successful in 1m26s
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / build-and-deploy (push) Successful in 4m23s
CD Pipeline / post-deploy-checks (push) Successful in 1m54s
feat(security): 新增 DNS TLS 只讀清冊
2026-06-11 18:40:54 +08:00

291 lines
11 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 DNS / TLS / certbot 只讀清冊。
本工具只讀取已提交的 Nginx repo-only drift snapshot整理 domain、TLS
certificate path、ACME challenge、upstream、admin route 與 WebSocket 影響面。
它不做 DNS 查詢、不連線 TLS、不執行 certbot、不 SSH、不 reload Nginx。
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
TAIPEI = timezone(timedelta(hours=8))
TIER_ORDER = {"C0": 0, "C1": 1, "C2": 2, "C3": 3}
CERT_DOMAIN_RE = re.compile(r"/etc/letsencrypt/live/([^/]+)/")
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 cert_domain(path: str) -> str | None:
match = CERT_DOMAIN_RE.search(path)
if not match:
return None
return match.group(1)
def cert_domain_matches(domain: str, candidate: str) -> bool:
if candidate == domain:
return True
if candidate.startswith("*.") and domain.endswith(candidate[1:]):
return True
return False
def strongest_tier(tiers: set[str]) -> str:
if not tiers:
return "C3"
return sorted(tiers, key=lambda tier: TIER_ORDER.get(tier, 99))[0]
def ensure_domain(domains: dict[str, dict[str, Any]], domain: str) -> dict[str, Any]:
if domain not in domains:
domains[domain] = {
"domain": domain,
"hosts": set(),
"config_ids": set(),
"source_paths": set(),
"live_paths": set(),
"control_tiers": set(),
"server_block_indexes": set(),
"listens": set(),
"certificate_paths": set(),
"certificate_key_paths": set(),
"certificate_path_domains": set(),
"acme_challenge_roots": set(),
"upstreams": set(),
"admin_route_count": 0,
"websocket_route_count": 0,
}
return domains[domain]
def add_server_to_domain(
domain_item: dict[str, Any],
config: dict[str, Any],
server: dict[str, Any],
) -> None:
domain_item["hosts"].add(config["host"])
domain_item["config_ids"].add(config["config_id"])
domain_item["source_paths"].add(config["repo_source_path"])
domain_item["live_paths"].add(config["live_path"])
domain_item["control_tiers"].add(config["control_tier"])
domain_item["server_block_indexes"].add(f"{config['config_id']}#{server['index']}")
domain_item["listens"].update(server.get("listens", []))
domain_item["certificate_paths"].update(server.get("ssl_certificates", []))
domain_item["certificate_key_paths"].update(server.get("ssl_certificate_keys", []))
domain_item["upstreams"].update(server.get("proxy_passes", []))
for path in server.get("ssl_certificates", []):
candidate = cert_domain(path)
if candidate:
domain_item["certificate_path_domains"].add(candidate)
for location in server.get("locations", []):
path = str(location.get("path", ""))
if ".well-known/acme-challenge" in path:
domain_item["acme_challenge_roots"].update(location.get("roots", []))
if "/admin" in path:
domain_item["admin_route_count"] += 1
if location.get("websocket_upgrade"):
domain_item["websocket_route_count"] += 1
def normalize_domain_item(item: dict[str, Any]) -> dict[str, Any]:
domain = item["domain"]
cert_domains = sorted(item["certificate_path_domains"])
certificate_owner_confirmation_required = bool(cert_domains) and not any(
cert_domain_matches(domain, candidate) for candidate in cert_domains
)
tls_certificate_path_present = bool(item["certificate_paths"])
if not tls_certificate_path_present:
status = "repo_only_tls_path_missing"
elif certificate_owner_confirmation_required:
status = "repo_only_owner_confirmation_required"
else:
status = "repo_only_ready_for_owner_review"
return {
"domain": domain,
"hosts": sorted(item["hosts"]),
"config_ids": sorted(item["config_ids"]),
"source_paths": sorted(item["source_paths"]),
"live_paths": sorted(item["live_paths"]),
"control_tier": strongest_tier(item["control_tiers"]),
"server_block_refs": sorted(item["server_block_indexes"]),
"listens": sorted(item["listens"]),
"tls_certificate_path_present": tls_certificate_path_present,
"certificate_paths": sorted(item["certificate_paths"]),
"certificate_key_paths": sorted(item["certificate_key_paths"]),
"certificate_path_domains": cert_domains,
"certificate_owner_confirmation_required": certificate_owner_confirmation_required,
"acme_challenge_present": bool(item["acme_challenge_roots"]),
"acme_challenge_roots": sorted(item["acme_challenge_roots"]),
"upstreams": sorted(item["upstreams"]),
"admin_route_count": item["admin_route_count"],
"websocket_route_count": item["websocket_route_count"],
"live_tls_probe_status": "not_executed",
"dns_resolution_status": "not_executed",
"certbot_renewal_status": "not_executed",
"owner_review_status": status,
}
def build_report(root: Path, nginx_report: dict[str, Any], generated_at: str | None) -> dict[str, Any]:
domains: dict[str, dict[str, Any]] = {}
for config in nginx_report.get("configs", []):
parsed = config.get("repo_source", {}).get("parsed", {})
for server in parsed.get("servers", []):
for domain in server.get("server_names", []):
if not domain or domain == "_":
continue
item = ensure_domain(domains, domain)
add_server_to_domain(item, config, server)
managed_domains = [normalize_domain_item(item) for item in domains.values()]
managed_domains.sort(key=lambda item: item["domain"])
certificate_paths = sorted(
{
path
for item in managed_domains
for path in item.get("certificate_paths", [])
}
)
owner_confirmation_required = [
item
for item in managed_domains
if item["certificate_owner_confirmation_required"] or not item["tls_certificate_path_present"]
]
acme_domains = [item for item in managed_domains if item["acme_challenge_present"]]
admin_domains = [item for item in managed_domains if item["admin_route_count"] > 0]
websocket_domains = [item for item in managed_domains if item["websocket_route_count"] > 0]
return {
"schema_version": "domain_tls_certbot_inventory_v1",
"generated_at": generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds"),
"mode": "repo_only_from_nginx_source_of_truth",
"git_commit": git_short_sha(root),
"source_nginx_report": "docs/security/nginx-config-drift-repo.snapshot.json",
"execution_boundaries": {
"dns_query_executed": False,
"live_tls_probe_executed": False,
"certbot_renew_executed": False,
"nginx_reload_executed": False,
"host_write_executed": False,
"runtime_gate_opened": False,
"secret_value_collected": False,
"action_buttons_allowed": False,
},
"summary": {
"source_config_count": nginx_report.get("summary", {}).get("source_config_count", 0),
"managed_domain_count": len(managed_domains),
"unique_certificate_path_count": len(certificate_paths),
"acme_challenge_domain_count": len(acme_domains),
"certificate_owner_confirmation_required_count": len(owner_confirmation_required),
"admin_route_domain_count": len(admin_domains),
"websocket_route_domain_count": len(websocket_domains),
"owner_response_request_sent_count": 0,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"runtime_gate_count": 0,
"live_tls_probe_executed": False,
"dns_change_executed": False,
"certbot_renew_executed": False,
"nginx_reload_executed": False,
"action_buttons_allowed": False,
},
"certificate_paths": certificate_paths,
"managed_domains": managed_domains,
"owner_confirmation_required_domains": [
{
"domain": item["domain"],
"certificate_path_domains": item["certificate_path_domains"],
"tls_certificate_path_present": item["tls_certificate_path_present"],
"owner_review_status": item["owner_review_status"],
}
for item in owner_confirmation_required
],
"required_owner_fields": [
"owner_role_or_team",
"decision",
"decision_reason",
"affected_scope",
"redacted_evidence_refs",
"followup_owner",
"rollback_owner",
"maintenance_window",
"validation_plan",
],
"next_steps": [
"請 owner 確認 certificate path 是否由 SAN 或 wildcard 合法覆蓋;未確認前不得 renew 或 reload。",
"未來若要做 live TLS / DNS probe需另行 scope approval本清冊只保留 repo-only 證據。",
"任何 certbot renew、Nginx reload 或 DNS 變更都必須另開維護窗口、rollback owner 與 post-check。",
],
}
def main() -> int:
parser = argparse.ArgumentParser(description="IwoooS DNS / TLS / certbot 只讀清冊")
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("--output", help="寫出 JSON 報告")
parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用")
args = parser.parse_args()
root = Path(args.root).resolve()
nginx_report_path = root / args.nginx_report
nginx_report = load_json(nginx_report_path)
report = build_report(root, nginx_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_INVENTORY_OK "
f"domains={summary['managed_domain_count']} "
f"cert_paths={summary['unique_certificate_path_count']} "
f"owner_confirm={summary['certificate_owner_confirmation_required_count']} "
f"runtime_gate={summary['runtime_gate_count']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())