feat(security): 新增 DNS TLS 只讀清冊
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

This commit is contained in:
Your Name
2026-06-11 18:40:18 +08:00
parent e8d5eafb9f
commit 32b553ee8f
11 changed files with 1921 additions and 0 deletions

View File

@@ -0,0 +1,290 @@
#!/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())

View File

@@ -87,6 +87,7 @@ def validate(root: Path) -> None:
primary_gate = load_json(security_dir / "source-control-primary-readiness-gate.snapshot.json")
rollout_policy = load_json(security_dir / "security-rollout-policy.snapshot.json")
iwooos_projection = load_json(security_dir / "iwooos-posture-projection.snapshot.json")
domain_tls_inventory = load_json(security_dir / "domain-tls-certbot-inventory.snapshot.json")
s49_request_draft = load_json(security_dir / "gitea-inventory-owner-attestation-request-draft.snapshot.json")
kali_status = load_json(security_dir / "kali-integration-status.snapshot.json")
iwooos_projection_page = (
@@ -11579,6 +11580,150 @@ def validate(root: Path) -> None:
list(web_messages_en["iwooos"]["highValueConfigOwnerPacket"]["items"].keys()),
key,
)
domain_tls_summary = domain_tls_inventory["summary"]
assert_equal("domain_tls_inventory.schema", domain_tls_inventory["schema_version"], "domain_tls_certbot_inventory_v1")
assert_equal("domain_tls_inventory.summary.source_config_count", domain_tls_summary["source_config_count"], 3)
assert_equal("domain_tls_inventory.summary.managed_domain_count", domain_tls_summary["managed_domain_count"], 14)
assert_equal(
"domain_tls_inventory.summary.unique_certificate_path_count",
domain_tls_summary["unique_certificate_path_count"],
10,
)
assert_equal(
"domain_tls_inventory.summary.acme_challenge_domain_count",
domain_tls_summary["acme_challenge_domain_count"],
7,
)
assert_equal(
"domain_tls_inventory.summary.certificate_owner_confirmation_required_count",
domain_tls_summary["certificate_owner_confirmation_required_count"],
4,
)
assert_equal("domain_tls_inventory.summary.runtime_gate_count", domain_tls_summary["runtime_gate_count"], 0)
assert_false("domain_tls_inventory.summary.live_tls_probe_executed", domain_tls_summary["live_tls_probe_executed"])
assert_false("domain_tls_inventory.summary.dns_change_executed", domain_tls_summary["dns_change_executed"])
assert_false("domain_tls_inventory.summary.certbot_renew_executed", domain_tls_summary["certbot_renew_executed"])
assert_false("domain_tls_inventory.summary.nginx_reload_executed", domain_tls_summary["nginx_reload_executed"])
assert_false("domain_tls_inventory.summary.action_buttons_allowed", domain_tls_summary["action_buttons_allowed"])
assert_equal(
"iwooos_projection.summary.domain_tls_certbot_inventory_managed_domain_count",
iwooos_projection["summary"]["domain_tls_certbot_inventory_managed_domain_count"],
14,
)
assert_equal(
"iwooos_projection.summary.domain_tls_certbot_inventory_owner_confirmation_required_count",
iwooos_projection["summary"]["domain_tls_certbot_inventory_owner_confirmation_required_count"],
4,
)
assert_equal(
"iwooos_projection.summary.domain_tls_certbot_inventory_runtime_gate_count",
iwooos_projection["summary"]["domain_tls_certbot_inventory_runtime_gate_count"],
0,
)
for source_path in [
"docs/security/domain-tls-certbot-inventory.snapshot.json",
"docs/security/DOMAIN-TLS-CERTBOT-INVENTORY.md",
"docs/schemas/domain_tls_certbot_inventory_v1.schema.json",
]:
assert_contains("iwooos_projection.source_paths", iwooos_projection["source_paths"], source_path)
assert_text_contains(
"iwooos_page.domain_tls_certbot_inventory_testid",
iwooos_projection_page,
'data-testid="iwooos-domain-tls-certbot-inventory-board"',
)
assert_text_contains(
"iwooos_page.domain_tls_certbot_inventory_boundary_testid",
iwooos_projection_page,
'data-testid="iwooos-domain-tls-certbot-inventory-boundaries"',
)
assert_text_contains(
"iwooos_page.domain_tls_certbot_inventory_component",
iwooos_projection_page,
"IwoooSDomainTlsCertbotInventoryBoard",
)
for text in [
"domain_tls_certbot_inventory_frontstage_summary_count=4",
"domain_tls_certbot_inventory_frontstage_item_count=5",
"domain_tls_certbot_inventory_source_config_count=3",
"domain_tls_certbot_inventory_managed_domain_count=14",
"domain_tls_certbot_inventory_unique_certificate_path_count=10",
"domain_tls_certbot_inventory_acme_challenge_domain_count=7",
"domain_tls_certbot_inventory_owner_confirmation_required_count=4",
"domain_tls_certbot_inventory_admin_route_domain_count=1",
"domain_tls_certbot_inventory_websocket_route_domain_count=6",
"domain_tls_certbot_inventory_request_sent_count=0",
"domain_tls_certbot_inventory_received_response_count=0",
"domain_tls_certbot_inventory_accepted_response_count=0",
"domain_tls_certbot_inventory_runtime_gate_count=0",
"domain_tls_certbot_inventory_live_tls_probe_executed=false",
"domain_tls_certbot_inventory_dns_change_executed=false",
"domain_tls_certbot_inventory_certbot_renew_executed=false",
"domain_tls_certbot_inventory_nginx_reload_executed=false",
"domain_tls_certbot_inventory_action_buttons_allowed=false",
"runtime_execution_authorized=false",
"active_runtime_gate_count=0",
"action_buttons_allowed=false",
"not_authorization=true",
"secret_value_collection_allowed=false",
]:
assert_text_contains(
"iwooos_page.domain_tls_certbot_inventory_boundary",
iwooos_projection_page,
text,
)
assert_contains(
"web_messages.zh-TW.iwooos.domainTlsCertbotInventory",
list(web_messages_zh["iwooos"].keys()),
"domainTlsCertbotInventory",
)
assert_contains(
"web_messages.en.iwooos.domainTlsCertbotInventory",
list(web_messages_en["iwooos"].keys()),
"domainTlsCertbotInventory",
)
for key in [
"eyebrow",
"title",
"subtitle",
"checkLabel",
"stateLabel",
"boundaryTitle",
"boundaryIntro",
"summary",
"items",
]:
assert_contains(
"web_messages.zh-TW.iwooos.domainTlsCertbotInventory.keys",
list(web_messages_zh["iwooos"]["domainTlsCertbotInventory"].keys()),
key,
)
assert_contains(
"web_messages.en.iwooos.domainTlsCertbotInventory.keys",
list(web_messages_en["iwooos"]["domainTlsCertbotInventory"].keys()),
key,
)
for key in ["domains", "certPaths", "ownerConfirm", "runtimeGate"]:
assert_contains(
"web_messages.zh-TW.iwooos.domainTlsCertbotInventory.summary",
list(web_messages_zh["iwooos"]["domainTlsCertbotInventory"]["summary"].keys()),
key,
)
assert_contains(
"web_messages.en.iwooos.domainTlsCertbotInventory.summary",
list(web_messages_en["iwooos"]["domainTlsCertbotInventory"]["summary"].keys()),
key,
)
for key in ["repoSource", "domainMap", "acmeRoutes", "ownerConfirmation", "runtimeBoundary"]:
assert_contains(
"web_messages.zh-TW.iwooos.domainTlsCertbotInventory.items",
list(web_messages_zh["iwooos"]["domainTlsCertbotInventory"]["items"].keys()),
key,
)
assert_contains(
"web_messages.en.iwooos.domainTlsCertbotInventory.items",
list(web_messages_en["iwooos"]["domainTlsCertbotInventory"]["items"].keys()),
key,
)
assert_text_contains(
"iwooos_page.three_axis_product_progress_testid",
iwooos_projection_page,