From e1cacdf39f0c3297ded7199b79e6a9113a2b8579 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 11:40:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E5=BB=BA=E7=AB=8B=20Nginx=20?= =?UTF-8?q?=E5=8F=AA=E8=AE=80=E6=BC=82=E7=A7=BB=E5=81=B5=E6=B8=AC=E5=99=A8?= =?UTF-8?q?=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/LOGBOOK.md | 31 + .../IWOOOS-CONFIG-CONTROL-INVENTORY.md | 6 +- docs/security/NGINX-CONFIG-DRIFT-DETECTOR.md | 81 ++ .../nginx-config-drift-repo.snapshot.json | 1106 +++++++++++++++++ .../security/nginx-config-drift-detector.py | 411 ++++++ 5 files changed, 1633 insertions(+), 2 deletions(-) create mode 100644 docs/security/NGINX-CONFIG-DRIFT-DETECTOR.md create mode 100644 docs/security/nginx-config-drift-repo.snapshot.json create mode 100644 scripts/security/nginx-config-drift-detector.py diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index ddd03f2b..eb183872 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,34 @@ +## 2026-06-11|IwoooS Nginx 只讀漂移偵測器 repo-only 第一波 + +**背景**:接續高價值配置控管清冊,P0 下一步是先把 Nginx 這個最容易被手動改動的公開入口配置做成可重跑的只讀漂移偵測框架。使用者已要求 Nginx 必須有資安機制控管;本階段仍不 SSH、不讀 live、不 reload、不修改主機。 + +**完成內容:** +- 新增 `scripts/security/nginx-config-drift-detector.py`,只讀解析 repo 內 Nginx source-of-truth,輸出 raw / normalized SHA-256、`server_name`、`listen`、`proxy_pass`、TLS certificate path、admin route、ACME route 與 WebSocket route。 +- 新增 `docs/security/NGINX-CONFIG-DRIFT-DETECTOR.md`,記錄 detector 用法、判讀規則、owner-provided live file compare 模式與禁止事項。 +- 新增 `docs/security/nginx-config-drift-repo.snapshot.json`,固定 repo-only snapshot;目前覆蓋 `host188_all_sites`、`host188_internal_tools_https`、`host110_ollama_proxy` 三份 source template。 +- 更新 `docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md`:repo-only Nginx detector 完成度 `100%`,owner-provided live file compare 格式 `70%`,live evidence collection 仍 `0%`。 + +**本地驗證:** +- `python3 scripts/security/nginx-config-drift-detector.py --root . --generated-at 2026-06-11T12:00:00+08:00 --output docs/security/nginx-config-drift-repo.snapshot.json` 通過。 +- `python3 -m json.tool docs/security/nginx-config-drift-repo.snapshot.json` 通過。 +- `python3 -m py_compile scripts/security/nginx-config-drift-detector.py` 通過。 +- detector compare smoke 通過:使用 `host110_ollama_proxy=infra/ansible/roles/nginx/templates/110-ollama-proxy.conf.j2` 作為 owner-provided live file,回傳 `live_input_count=1`、`drift_detected_count=0`。 +- repo-only snapshot 摘要:`source_config_count=3`、`live_input_count=0`、`drift_detected_count=0`、`live_evidence_collected=false`。 +- `python3 scripts/security/security-mirror-progress-guard.py --root .` 通過。 +- `python3 scripts/security/source-control-owner-response-guard.py --root .` 通過。 +- `node scripts/ci/check-gitea-step-env-secrets.js` 通過。 +- `python3 scripts/ops/doc-secrets-sanity-check.py docs .gitea` 通過,`scanned_files=640`。 +- `git diff --check` 通過。 +- P0 高風險字串掃描通過:關閉 SSH host key 驗證的逐字參數、舊 Gitea token、Grafana 密碼常值、舊 MinIO credential、舊 MinIO token、Prometheus inline bearer token 均未命中。 + +**完成度與邊界:** +- Nginx repo source-of-truth 指紋:`100%`。 +- domain / upstream / TLS / admin / ACME / WebSocket 摘要:`100%`。 +- owner-provided live file compare 格式:`70%`,已支援手動提供 live conf 檔,不主動取得 live。 +- live Nginx evidence collection:`0%`;本階段未 SSH、未 Ansible check-mode、未讀 live hash。 +- Nginx `nginx -t`、reload、restart、DNS 修改、TLS renew、主機寫入、runtime gate:全部未執行。 +- IwoooS 整體仍維持 `64%`;active runtime gate 仍為 `0`;owner response received / accepted 仍為 `0 / false`。 + ## 2026-06-11|IwoooS 高價值配置控管清冊與 P0 source-control 止血 **背景**:使用者要求所有重要配置都要納入資安控管,特別指出 Nginx 常被手動變更,必須建立資安機制。同時需完整盤點哪些配置要先納管、哪些既有規範不符合現在要求、哪些需要新增或調整。 diff --git a/docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md b/docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md index cb097bb3..3394bad8 100644 --- a/docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md +++ b/docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md @@ -126,14 +126,16 @@ Nginx 是目前必須最先資安控管的配置,原因是它同時控制公 | 重要配置範圍盤點 | `100%` | 已建立 C0-C3 分級與總清單 | | Nginx 控管機制定義 | `100%` | 已定義 source-of-truth、live path、gate、drift 原則 | | source-control P0 止血 | `100%` | 已清掉本波掃到的 token 範例、Grafana 密碼常值與 SSH host key 關閉 | -| live Nginx drift detector | `0%` | 尚未 SSH / Ansible check-mode / live hash;需 owner 與維護窗口規則 | +| repo-only Nginx drift detector | `100%` | 已新增 `scripts/security/nginx-config-drift-detector.py` 與 repo source-of-truth snapshot | +| owner-provided live Nginx file compare | `70%` | 工具可吃 owner 匯出的 live conf 檔比較;本階段不主動 SSH 取得 | +| live Nginx evidence collection | `0%` | 尚未 SSH / Ansible check-mode / live hash;需 owner 與維護窗口規則 | | live Nginx reload / restart | `0%` | 未授權,未執行 | | DNS / TLS live validation | `0%` | 本階段未跑 live probe;若下一階段改前端或 route,需 desktop / mobile / route smoke | | cross-product owner response | `0%` | 尚未收到 VibeWork、agent-bounty-protocol、StockPlatform 等 owner acceptance | ## 7. 下一階段優先順序 -1. P0:建立 Nginx 只讀 drift detector 草案,輸出 repo-rendered hash、live hash、affected domain / upstream / TLS / admin route,不自動覆寫。 +1. P0:由 owner 提供脫敏 live Nginx conf 匯出檔,重跑 compare mode;不自動覆寫、不 reload。 2. P0:補 DNS / TLS / certbot domain inventory,先只讀,不 renew、不 reload。 3. P0:把 workflow / runner / secret name owner response 與高價值配置 C0 gate 串成同一個 IwoooS 狀態。 4. P1:盤點 110 / 188 Docker Compose 與 systemd live config,標記 Harbor、Sentry、Langfuse、Gitea、agent-bounty-protocol 影響面。 diff --git a/docs/security/NGINX-CONFIG-DRIFT-DETECTOR.md b/docs/security/NGINX-CONFIG-DRIFT-DETECTOR.md new file mode 100644 index 00000000..9bb443b1 --- /dev/null +++ b/docs/security/NGINX-CONFIG-DRIFT-DETECTOR.md @@ -0,0 +1,81 @@ +# Nginx 配置只讀漂移偵測器 + +| 項目 | 內容 | +|------|------| +| 日期 | 2026-06-11 | +| 狀態 | `repo_only_detector_ready` | +| 工具 | `scripts/security/nginx-config-drift-detector.py` | +| Snapshot | `docs/security/nginx-config-drift-repo.snapshot.json` | +| runtime gate | `0` | + +## 1. 目的 + +本工具把 Nginx 從「靠人記得不要手改」推進到「有可重跑的 source-of-truth 指紋與後續 live 比對格式」。目前只讀 repo 內配置,不 SSH、不 reload、不修改主機。 + +## 2. 已納管 source-of-truth + +| config id | 主機 | repo source | live path | 等級 | +|-----------|------|-------------|-----------|------| +| `host188_all_sites` | `192.168.0.188` | `infra/ansible/roles/nginx/templates/188-all-sites.conf.j2` | `/etc/nginx/sites-enabled/all-sites.conf` | C0 | +| `host188_internal_tools_https` | `192.168.0.188` | `infra/ansible/roles/nginx/templates/188-internal-tools-https.conf.j2` | 待 owner 確認 | C0 | +| `host110_ollama_proxy` | `192.168.0.110` | `infra/ansible/roles/nginx/templates/110-ollama-proxy.conf.j2` | `/etc/nginx/sites-enabled/110-ollama-proxy.conf` | C1 | + +## 3. 可產出的證據 + +工具會輸出: + +1. repo raw / normalized SHA-256。 +2. `server_name` 清單。 +3. `listen` 清單。 +4. `proxy_pass` upstream 清單。 +5. TLS certificate / key path 清單。 +6. `/admin` route、ACME challenge route、WebSocket route。 +7. live conf 若由 owner 提供,會比較 normalized hash、server name、upstream 與 TLS path 差異。 + +## 4. 指令 + +repo-only snapshot: + +```bash +python3 scripts/security/nginx-config-drift-detector.py \ + --root . \ + --generated-at 2026-06-11T12:00:00+08:00 \ + --output docs/security/nginx-config-drift-repo.snapshot.json +``` + +未來 owner 提供 live conf 匯出檔後,可用比較模式: + +```bash +python3 scripts/security/nginx-config-drift-detector.py \ + --root . \ + --live-file host188_all_sites=/path/to/redacted-live-188-all-sites.conf \ + --live-file host110_ollama_proxy=/path/to/redacted-live-110-ollama-proxy.conf +``` + +## 5. 判讀規則 + +| 狀態 | 意義 | 可做事項 | +|------|------|----------| +| `repo_only_no_live_evidence` | 只有 repo source-of-truth 指紋,尚未比較 live | 可作為 owner request packet,不能宣稱 live 無漂移 | +| `matched` | owner 提供的 live 檔與 repo normalized hash / 語意比對一致 | 可記 evidence,仍不代表 reload 已授權 | +| `drift_detected` | live 與 repo 出現 hash 或語意差異 | 建立 P0 drift evidence 與 owner decision,不自動覆寫 live | +| `live_file_missing` | 指定的 live 匯出檔不存在 | 要求補件,不做判斷 | + +## 6. 邊界 + +1. 本工具不執行 SSH。 +2. 本工具不執行 `nginx -t`。 +3. 本工具不 reload / restart Nginx。 +4. 本工具不讀 TLS private key 內容。 +5. 本工具不收 secret value。 +6. 本工具不開 runtime gate。 + +## 7. 完成度 + +| 工作 | 完成度 | 說明 | +|------|--------|------| +| repo source-of-truth 指紋 | `100%` | 已覆蓋三份 Nginx source templates | +| domain / upstream / TLS / admin / ACME 摘要 | `100%` | 已由 parser 產出 | +| owner-provided live file 比對格式 | `70%` | 支援手動提供 live conf 檔,不主動取得 live | +| live Nginx 證據收集 | `0%` | 本階段不 SSH、不讀主機 | +| Nginx reload / restart | `0%` | 未授權且未執行 | diff --git a/docs/security/nginx-config-drift-repo.snapshot.json b/docs/security/nginx-config-drift-repo.snapshot.json new file mode 100644 index 00000000..447a7ab7 --- /dev/null +++ b/docs/security/nginx-config-drift-repo.snapshot.json @@ -0,0 +1,1106 @@ +{ + "configs": [ + { + "comparison": { + "drift_detected": null, + "note": "尚未提供 live conf 匯出檔;本階段不 SSH、不讀 live、不 reload。", + "status": "repo_only_no_live_evidence" + }, + "config_id": "host188_all_sites", + "control_tier": "C0", + "host": "192.168.0.188", + "live_input": { + "path": null, + "provided": false, + "summary": null + }, + "live_path": "/etc/nginx/sites-enabled/all-sites.conf", + "owner_gate": "public_gateway_owner_response_required", + "repo_source": { + "line_count": 268, + "normalized_sha256": "175e13f1a66b349d188f054a5232b9dc3ebf801e25f4a2d3879e34248a787e2a", + "parsed": { + "acme_routes": [ + { + "auth_basic": [], + "path": "/.well-known/acme-challenge/", + "proxy_passes": [], + "roots": [ + "/var/www/html" + ], + "server_names": [ + "stock.wooo.work" + ] + }, + { + "auth_basic": [], + "path": "/.well-known/acme-challenge/", + "proxy_passes": [], + "roots": [ + "/var/www/html" + ], + "server_names": [ + "vtuber.wooo.work" + ] + } + ], + "admin_routes": [ + { + "auth_basic": [], + "path": "= /admin", + "proxy_passes": [], + "roots": [], + "server_names": [ + "stock.wooo.work" + ] + }, + { + "auth_basic": [ + "off" + ], + "path": "/admin/", + "proxy_passes": [ + "http://192.168.0.110:31235" + ], + "roots": [], + "server_names": [ + "stock.wooo.work" + ] + } + ], + "listens": [ + "443 ssl", + "443 ssl http2", + "80" + ], + "proxy_passes": [ + "http://127.0.0.1:3000", + "http://127.0.0.1:3301", + "http://127.0.0.1:5003", + "http://192.168.0.110:3003", + "http://192.168.0.110:31235", + "http://192.168.0.110:8929", + "http://192.168.0.125:32334/api/", + "http://192.168.0.125:32334/api/v1/ws", + "http://192.168.0.125:32335", + "https://192.168.0.110" + ], + "server_block_count": 15, + "server_names": [ + "aiops.wooo.work", + "bitan.wooo.work", + "gitlab.wooo.work", + "mo.wooo.work", + "signoz.wooo.work", + "stock.wooo.work", + "tsenyang.com", + "vtuber.wooo.work", + "www.tsenyang.com" + ], + "servers": [ + { + "has_tls": false, + "index": 1, + "listens": [ + "80" + ], + "locations": [], + "proxy_passes": [], + "server_names": [ + "aiops.wooo.work" + ], + "ssl_certificate_keys": [], + "ssl_certificates": [] + }, + { + "has_tls": true, + "index": 2, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/api/", + "proxy_passes": [ + "http://192.168.0.125:32334/api/" + ], + "roots": [], + "websocket_upgrade": false + }, + { + "auth_basic": [], + "path": "/api/v1/ws", + "proxy_passes": [ + "http://192.168.0.125:32334/api/v1/ws" + ], + "roots": [], + "websocket_upgrade": true + }, + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.125:32335" + ], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://192.168.0.125:32334/api/", + "http://192.168.0.125:32334/api/v1/ws", + "http://192.168.0.125:32335" + ], + "server_names": [ + "aiops.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/aiops.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/aiops.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": false, + "index": 3, + "listens": [ + "80" + ], + "locations": [], + "proxy_passes": [], + "server_names": [ + "gitlab.wooo.work" + ], + "ssl_certificate_keys": [], + "ssl_certificates": [] + }, + { + "has_tls": true, + "index": 4, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:8929" + ], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://192.168.0.110:8929" + ], + "server_names": [ + "gitlab.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/gitlab.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/gitlab.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": false, + "index": 5, + "listens": [ + "80" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://127.0.0.1:3301" + ], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://127.0.0.1:3301" + ], + "server_names": [ + "signoz.wooo.work" + ], + "ssl_certificate_keys": [], + "ssl_certificates": [] + }, + { + "has_tls": false, + "index": 6, + "listens": [ + "80" + ], + "locations": [], + "proxy_passes": [], + "server_names": [ + "www.tsenyang.com", + "tsenyang.com" + ], + "ssl_certificate_keys": [], + "ssl_certificates": [] + }, + { + "has_tls": true, + "index": 7, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://127.0.0.1:3000" + ], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://127.0.0.1:3000" + ], + "server_names": [ + "www.tsenyang.com", + "tsenyang.com" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/www.tsenyang.com/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/www.tsenyang.com/fullchain.pem" + ] + }, + { + "has_tls": false, + "index": 8, + "listens": [ + "80" + ], + "locations": [ + { + "auth_basic": [], + "path": "/.well-known/acme-challenge/", + "proxy_passes": [], + "roots": [ + "/var/www/html" + ], + "websocket_upgrade": false + }, + { + "auth_basic": [], + "path": "/", + "proxy_passes": [], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [], + "server_names": [ + "stock.wooo.work" + ], + "ssl_certificate_keys": [], + "ssl_certificates": [] + }, + { + "has_tls": true, + "index": 9, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "= /admin", + "proxy_passes": [], + "roots": [], + "websocket_upgrade": false + }, + { + "auth_basic": [ + "off" + ], + "path": "/admin/", + "proxy_passes": [ + "http://192.168.0.110:31235" + ], + "roots": [], + "websocket_upgrade": true + }, + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:31235" + ], + "roots": [], + "websocket_upgrade": true + } + ], + "proxy_passes": [ + "http://192.168.0.110:31235", + "http://192.168.0.110:31235" + ], + "server_names": [ + "stock.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/stock.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/stock.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": false, + "index": 10, + "listens": [ + "80" + ], + "locations": [], + "proxy_passes": [], + "server_names": [ + "mo.wooo.work" + ], + "ssl_certificate_keys": [], + "ssl_certificates": [] + }, + { + "has_tls": true, + "index": 11, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://127.0.0.1:5003" + ], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://127.0.0.1:5003" + ], + "server_names": [ + "mo.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/mo.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/mo.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": false, + "index": 12, + "listens": [ + "80" + ], + "locations": [], + "proxy_passes": [], + "server_names": [ + "bitan.wooo.work" + ], + "ssl_certificate_keys": [], + "ssl_certificates": [] + }, + { + "has_tls": true, + "index": 13, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:3003" + ], + "roots": [], + "websocket_upgrade": true + } + ], + "proxy_passes": [ + "http://192.168.0.110:3003" + ], + "server_names": [ + "bitan.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/bitan.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/bitan.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": true, + "index": 14, + "listens": [ + "443 ssl" + ], + "locations": [ + { + "auth_basic": [], + "path": "/.well-known/acme-challenge/", + "proxy_passes": [], + "roots": [ + "/var/www/html" + ], + "websocket_upgrade": false + }, + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "https://192.168.0.110" + ], + "roots": [], + "websocket_upgrade": true + } + ], + "proxy_passes": [ + "https://192.168.0.110" + ], + "server_names": [ + "vtuber.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/vtuber.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/vtuber.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": false, + "index": 15, + "listens": [ + "80" + ], + "locations": [], + "proxy_passes": [], + "server_names": [ + "vtuber.wooo.work" + ], + "ssl_certificate_keys": [], + "ssl_certificates": [] + } + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/aiops.wooo.work/privkey.pem", + "/etc/letsencrypt/live/bitan.wooo.work/privkey.pem", + "/etc/letsencrypt/live/gitlab.wooo.work/privkey.pem", + "/etc/letsencrypt/live/mo.wooo.work/privkey.pem", + "/etc/letsencrypt/live/stock.wooo.work/privkey.pem", + "/etc/letsencrypt/live/vtuber.wooo.work/privkey.pem", + "/etc/letsencrypt/live/www.tsenyang.com/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/aiops.wooo.work/fullchain.pem", + "/etc/letsencrypt/live/bitan.wooo.work/fullchain.pem", + "/etc/letsencrypt/live/gitlab.wooo.work/fullchain.pem", + "/etc/letsencrypt/live/mo.wooo.work/fullchain.pem", + "/etc/letsencrypt/live/stock.wooo.work/fullchain.pem", + "/etc/letsencrypt/live/vtuber.wooo.work/fullchain.pem", + "/etc/letsencrypt/live/www.tsenyang.com/fullchain.pem" + ], + "websocket_routes": [ + { + "auth_basic": [], + "path": "/api/v1/ws", + "proxy_passes": [ + "http://192.168.0.125:32334/api/v1/ws" + ], + "roots": [], + "server_names": [ + "aiops.wooo.work" + ] + }, + { + "auth_basic": [ + "off" + ], + "path": "/admin/", + "proxy_passes": [ + "http://192.168.0.110:31235" + ], + "roots": [], + "server_names": [ + "stock.wooo.work" + ] + }, + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:31235" + ], + "roots": [], + "server_names": [ + "stock.wooo.work" + ] + }, + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:3003" + ], + "roots": [], + "server_names": [ + "bitan.wooo.work" + ] + }, + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "https://192.168.0.110" + ], + "roots": [], + "server_names": [ + "vtuber.wooo.work" + ] + } + ] + }, + "raw_sha256": "6fec2bde00cc0935296738ff6bc4564528fe53e7794d518d1a5dbc57feb5c498" + }, + "repo_source_path": "infra/ansible/roles/nginx/templates/188-all-sites.conf.j2", + "role": "public_gateway_all_sites" + }, + { + "comparison": { + "drift_detected": null, + "note": "尚未提供 live conf 匯出檔;本階段不 SSH、不讀 live、不 reload。", + "status": "repo_only_no_live_evidence" + }, + "config_id": "host188_internal_tools_https", + "control_tier": "C0", + "host": "192.168.0.188", + "live_input": { + "path": null, + "provided": false, + "summary": null + }, + "live_path": "owner_confirmation_required", + "owner_gate": "public_tools_owner_response_required", + "repo_source": { + "line_count": 149, + "normalized_sha256": "0b67241536252c0da30198be88ccb9a2f283073ad5e1e3ff2cbc4ebea3ff30d4", + "parsed": { + "acme_routes": [ + { + "auth_basic": [], + "path": "/.well-known/acme-challenge/", + "proxy_passes": [], + "roots": [ + "/var/www/certbot" + ], + "server_names": [ + "gitea.wooo.work", + "sentry.wooo.work", + "langfuse.wooo.work", + "harbor.wooo.work", + "registry.wooo.work", + "stock.wooo.work" + ] + } + ], + "admin_routes": [], + "listens": [ + "443 ssl http2", + "80" + ], + "proxy_passes": [ + "http://127.0.0.1:3301", + "http://192.168.0.110:3001", + "http://192.168.0.110:3100", + "http://192.168.0.110:31235", + "http://192.168.0.110:5000", + "http://192.168.0.110:9000" + ], + "server_block_count": 8, + "server_names": [ + "gitea.wooo.work", + "harbor.wooo.work", + "langfuse.wooo.work", + "registry.wooo.work", + "sentry.wooo.work", + "signoz.wooo.work", + "stock.wooo.work" + ], + "servers": [ + { + "has_tls": false, + "index": 1, + "listens": [ + "80" + ], + "locations": [ + { + "auth_basic": [], + "path": "/.well-known/acme-challenge/", + "proxy_passes": [], + "roots": [ + "/var/www/certbot" + ], + "websocket_upgrade": false + }, + { + "auth_basic": [], + "path": "/", + "proxy_passes": [], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [], + "server_names": [ + "gitea.wooo.work", + "sentry.wooo.work", + "langfuse.wooo.work", + "harbor.wooo.work", + "registry.wooo.work", + "stock.wooo.work" + ], + "ssl_certificate_keys": [], + "ssl_certificates": [] + }, + { + "has_tls": true, + "index": 2, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://127.0.0.1:3301" + ], + "roots": [], + "websocket_upgrade": true + } + ], + "proxy_passes": [ + "http://127.0.0.1:3301" + ], + "server_names": [ + "signoz.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/sentry.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/sentry.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": true, + "index": 3, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:31235" + ], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://192.168.0.110:31235" + ], + "server_names": [ + "stock.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/stock.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/stock.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": true, + "index": 4, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:9000" + ], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://192.168.0.110:9000" + ], + "server_names": [ + "sentry.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/sentry.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/sentry.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": true, + "index": 5, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:3001" + ], + "roots": [], + "websocket_upgrade": true + } + ], + "proxy_passes": [ + "http://192.168.0.110:3001" + ], + "server_names": [ + "gitea.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/sentry.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/sentry.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": true, + "index": 6, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:3100" + ], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://192.168.0.110:3100" + ], + "server_names": [ + "langfuse.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/sentry.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/sentry.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": true, + "index": 7, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:5000" + ], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://192.168.0.110:5000" + ], + "server_names": [ + "harbor.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/harbor.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/harbor.wooo.work/fullchain.pem" + ] + }, + { + "has_tls": true, + "index": 8, + "listens": [ + "443 ssl http2" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:5000" + ], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://192.168.0.110:5000" + ], + "server_names": [ + "registry.wooo.work" + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/registry.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/registry.wooo.work/fullchain.pem" + ] + } + ], + "ssl_certificate_keys": [ + "/etc/letsencrypt/live/harbor.wooo.work/privkey.pem", + "/etc/letsencrypt/live/registry.wooo.work/privkey.pem", + "/etc/letsencrypt/live/sentry.wooo.work/privkey.pem", + "/etc/letsencrypt/live/stock.wooo.work/privkey.pem" + ], + "ssl_certificates": [ + "/etc/letsencrypt/live/harbor.wooo.work/fullchain.pem", + "/etc/letsencrypt/live/registry.wooo.work/fullchain.pem", + "/etc/letsencrypt/live/sentry.wooo.work/fullchain.pem", + "/etc/letsencrypt/live/stock.wooo.work/fullchain.pem" + ], + "websocket_routes": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://127.0.0.1:3301" + ], + "roots": [], + "server_names": [ + "signoz.wooo.work" + ] + }, + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.110:3001" + ], + "roots": [], + "server_names": [ + "gitea.wooo.work" + ] + } + ] + }, + "raw_sha256": "791ac846869e03d3ad3052f1068fc30905f00e7a4975228e055b42827b1956b8" + }, + "repo_source_path": "infra/ansible/roles/nginx/templates/188-internal-tools-https.conf.j2", + "role": "public_internal_tools_https" + }, + { + "comparison": { + "drift_detected": null, + "note": "尚未提供 live conf 匯出檔;本階段不 SSH、不讀 live、不 reload。", + "status": "repo_only_no_live_evidence" + }, + "config_id": "host110_ollama_proxy", + "control_tier": "C1", + "host": "192.168.0.110", + "live_input": { + "path": null, + "provided": false, + "summary": null + }, + "live_path": "/etc/nginx/sites-enabled/110-ollama-proxy.conf", + "owner_gate": "ai_provider_proxy_owner_response_required", + "repo_source": { + "line_count": 104, + "normalized_sha256": "8f065c79659c76cbfc1dff497546de30977f07dd32751376504a73efd5f48277", + "parsed": { + "acme_routes": [], + "admin_routes": [], + "listens": [ + "11435", + "11436", + "11437", + "[::]:11435", + "[::]:11436", + "[::]:11437" + ], + "proxy_passes": [ + "http://192.168.0.111:11434", + "http://34.143.170.20:11434", + "http://34.21.145.224:11434" + ], + "server_block_count": 3, + "server_names": [], + "servers": [ + { + "has_tls": false, + "index": 1, + "listens": [ + "11435", + "[::]:11435" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://34.143.170.20:11434" + ], + "roots": [], + "websocket_upgrade": false + }, + { + "auth_basic": [], + "path": "/nginx-health", + "proxy_passes": [], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://34.143.170.20:11434" + ], + "server_names": [], + "ssl_certificate_keys": [], + "ssl_certificates": [] + }, + { + "has_tls": false, + "index": 2, + "listens": [ + "11436", + "[::]:11436" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://34.21.145.224:11434" + ], + "roots": [], + "websocket_upgrade": false + }, + { + "auth_basic": [], + "path": "/nginx-health", + "proxy_passes": [], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://34.21.145.224:11434" + ], + "server_names": [], + "ssl_certificate_keys": [], + "ssl_certificates": [] + }, + { + "has_tls": false, + "index": 3, + "listens": [ + "11437", + "[::]:11437" + ], + "locations": [ + { + "auth_basic": [], + "path": "/", + "proxy_passes": [ + "http://192.168.0.111:11434" + ], + "roots": [], + "websocket_upgrade": false + }, + { + "auth_basic": [], + "path": "/nginx-health", + "proxy_passes": [], + "roots": [], + "websocket_upgrade": false + } + ], + "proxy_passes": [ + "http://192.168.0.111:11434" + ], + "server_names": [], + "ssl_certificate_keys": [], + "ssl_certificates": [] + } + ], + "ssl_certificate_keys": [], + "ssl_certificates": [], + "websocket_routes": [] + }, + "raw_sha256": "46332f8e3172027c98e5dbe804af5cffe12b4b0b256ceb24b5c5ec31129a42d3" + }, + "repo_source_path": "infra/ansible/roles/nginx/templates/110-ollama-proxy.conf.j2", + "role": "ollama_proxy_gateway" + } + ], + "execution_boundaries": { + "host_write_executed": false, + "nginx_reload_executed": false, + "nginx_test_executed": false, + "runtime_gate_opened": false, + "secret_value_collected": false, + "ssh_executed": false + }, + "generated_at": "2026-06-11T12:00:00+08:00", + "git_commit": "eca53646", + "mode": "repo_only", + "next_steps": [ + "由 owner 提供脫敏 live conf 匯出檔後重跑比較模式。", + "若偵測 drift,只建立 evidence 與 owner decision,不自動覆寫 live。", + "任何 Nginx reload 仍需 maintenance window、rollback owner、nginx -t 與 route smoke。" + ], + "schema_version": "nginx_config_drift_detector_v1", + "summary": { + "drift_detected_count": 0, + "live_evidence_collected": false, + "live_input_count": 0, + "repo_source_inventory_complete": true, + "source_config_count": 3 + } +} diff --git a/scripts/security/nginx-config-drift-detector.py b/scripts/security/nginx-config-drift-detector.py new file mode 100644 index 00000000..e5e7f044 --- /dev/null +++ b/scripts/security/nginx-config-drift-detector.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python3 +""" +IwoooS Nginx 只讀配置漂移偵測器。 + +本工具只讀取 repo 內的 Nginx source-of-truth,或由 owner 另行提供的 +live conf 匯出檔;它不 SSH、不 reload、不寫入主機、不觸發部署。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + + +TAIPEI = timezone(timedelta(hours=8)) + + +@dataclass(frozen=True) +class NginxSource: + config_id: str + host: str + role: str + source_path: str + live_path: str + control_tier: str + owner_gate: str + + +SOURCES = [ + NginxSource( + config_id="host188_all_sites", + host="192.168.0.188", + role="public_gateway_all_sites", + source_path="infra/ansible/roles/nginx/templates/188-all-sites.conf.j2", + live_path="/etc/nginx/sites-enabled/all-sites.conf", + control_tier="C0", + owner_gate="public_gateway_owner_response_required", + ), + NginxSource( + config_id="host188_internal_tools_https", + host="192.168.0.188", + role="public_internal_tools_https", + source_path="infra/ansible/roles/nginx/templates/188-internal-tools-https.conf.j2", + live_path="owner_confirmation_required", + control_tier="C0", + owner_gate="public_tools_owner_response_required", + ), + NginxSource( + config_id="host110_ollama_proxy", + host="192.168.0.110", + role="ollama_proxy_gateway", + source_path="infra/ansible/roles/nginx/templates/110-ollama-proxy.conf.j2", + live_path="/etc/nginx/sites-enabled/110-ollama-proxy.conf", + control_tier="C1", + owner_gate="ai_provider_proxy_owner_response_required", + ), +] + + +def strip_comments(text: str) -> str: + lines: list[str] = [] + for line in text.splitlines(): + if "#" in line: + line = line.split("#", 1)[0] + lines.append(line) + return "\n".join(lines) + + +def normalized_text(text: str) -> str: + clean = strip_comments(text) + return "\n".join(line.strip() for line in clean.splitlines() if line.strip()) + + +def sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def match_closing_brace(text: str, open_brace: int) -> int: + depth = 0 + for index in range(open_brace, len(text)): + char = text[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + return -1 + + +def named_blocks(text: str, name: str) -> list[tuple[str, str]]: + clean = strip_comments(text) + if name == "server": + pattern = re.compile(r"\bserver\s*\{") + else: + pattern = re.compile(rf"\b{name}\s+([^{{]+)\{{") + + blocks: list[tuple[str, str]] = [] + for match in pattern.finditer(clean): + open_brace = clean.find("{", match.start()) + close_brace = match_closing_brace(clean, open_brace) + if close_brace == -1: + continue + args = "" + if name != "server": + args = (match.group(1) or "").strip() + blocks.append((args, clean[match.start() : close_brace + 1])) + return blocks + + +def directive_values(block: str, directive: str) -> list[str]: + pattern = re.compile(rf"(?ms)^\s*{re.escape(directive)}\s+(.*?);") + return [" ".join(match.group(1).split()) for match in pattern.finditer(block)] + + +def split_words(value: str) -> list[str]: + return [part for part in re.split(r"\s+", value.strip()) if part] + + +def location_entries(block: str) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for args, body in named_blocks(block, "location"): + proxy_passes = directive_values(body, "proxy_pass") + roots = directive_values(body, "root") + auth_basic = directive_values(body, "auth_basic") + entries.append( + { + "path": args, + "proxy_passes": proxy_passes, + "roots": roots, + "auth_basic": auth_basic, + "websocket_upgrade": "Upgrade $http_upgrade" in body + or "Connection \"upgrade\"" in body + or "Connection $connection_upgrade" in body, + } + ) + return entries + + +def parse_nginx(text: str) -> dict[str, Any]: + servers: list[dict[str, Any]] = [] + all_server_names: set[str] = set() + all_listens: set[str] = set() + all_proxy_passes: set[str] = set() + all_ssl_certificates: set[str] = set() + all_ssl_certificate_keys: set[str] = set() + admin_routes: list[dict[str, Any]] = [] + acme_routes: list[dict[str, Any]] = [] + websocket_routes: list[dict[str, Any]] = [] + + for index, (_, block) in enumerate(named_blocks(text, "server"), start=1): + names = [ + word + for value in directive_values(block, "server_name") + for word in split_words(value) + if word and word != "_" + ] + listens = directive_values(block, "listen") + ssl_certs = directive_values(block, "ssl_certificate") + ssl_keys = directive_values(block, "ssl_certificate_key") + locations = location_entries(block) + proxy_passes = [ + proxy + for location in locations + for proxy in location.get("proxy_passes", []) + ] + + all_server_names.update(names) + all_listens.update(listens) + all_proxy_passes.update(proxy_passes) + all_ssl_certificates.update(ssl_certs) + all_ssl_certificate_keys.update(ssl_keys) + + for location in locations: + path = str(location["path"]) + entry = { + "server_names": names, + "path": path, + "proxy_passes": location.get("proxy_passes", []), + "roots": location.get("roots", []), + "auth_basic": location.get("auth_basic", []), + } + if "/admin" in path: + admin_routes.append(entry) + if ".well-known/acme-challenge" in path: + acme_routes.append(entry) + if location.get("websocket_upgrade"): + websocket_routes.append(entry) + + servers.append( + { + "index": index, + "server_names": names, + "listens": listens, + "ssl_certificates": ssl_certs, + "ssl_certificate_keys": ssl_keys, + "proxy_passes": proxy_passes, + "locations": locations, + "has_tls": bool(ssl_certs or any("443" in item for item in listens)), + } + ) + + return { + "server_block_count": len(servers), + "server_names": sorted(all_server_names), + "listens": sorted(all_listens), + "proxy_passes": sorted(all_proxy_passes), + "ssl_certificates": sorted(all_ssl_certificates), + "ssl_certificate_keys": sorted(all_ssl_certificate_keys), + "admin_routes": admin_routes, + "acme_routes": acme_routes, + "websocket_routes": websocket_routes, + "servers": servers, + } + + +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 read_source(path: Path) -> dict[str, Any]: + raw = path.read_text(encoding="utf-8") + normalized = normalized_text(raw) + parsed = parse_nginx(raw) + return { + "raw_sha256": sha256_text(raw), + "normalized_sha256": sha256_text(normalized), + "line_count": len(raw.splitlines()), + "parsed": parsed, + } + + +def compare_sets(source_values: list[str], live_values: list[str]) -> dict[str, list[str]]: + source_set = set(source_values) + live_set = set(live_values) + return { + "missing_in_live": sorted(source_set - live_set), + "extra_in_live": sorted(live_set - source_set), + } + + +def compare_config(source: dict[str, Any], live: dict[str, Any]) -> dict[str, Any]: + source_parsed = source["parsed"] + live_parsed = live["parsed"] + normalized_matches = source["normalized_sha256"] == live["normalized_sha256"] + semantic_diff = { + "server_names": compare_sets(source_parsed["server_names"], live_parsed["server_names"]), + "proxy_passes": compare_sets(source_parsed["proxy_passes"], live_parsed["proxy_passes"]), + "ssl_certificates": compare_sets( + source_parsed["ssl_certificates"], + live_parsed["ssl_certificates"], + ), + } + has_semantic_diff = any( + diff["missing_in_live"] or diff["extra_in_live"] + for diff in semantic_diff.values() + ) + return { + "normalized_hash_matches": normalized_matches, + "semantic_diff": semantic_diff, + "drift_detected": (not normalized_matches) or has_semantic_diff, + } + + +def parse_live_files(items: list[str]) -> dict[str, Path]: + live_files: dict[str, Path] = {} + for item in items: + if "=" not in item: + raise ValueError(f"--live-file 必須使用 config_id=/path 格式:{item}") + config_id, raw_path = item.split("=", 1) + live_files[config_id.strip()] = Path(raw_path.strip()) + return live_files + + +def build_report( + root: Path, + live_files: dict[str, Path], + generated_at: str | None, +) -> dict[str, Any]: + report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds") + configs: list[dict[str, Any]] = [] + drift_count = 0 + live_input_count = 0 + + for source in SOURCES: + source_path = root / source.source_path + source_report = read_source(source_path) + live_path = live_files.get(source.config_id) + live_report: dict[str, Any] | None = None + comparison: dict[str, Any] = { + "status": "repo_only_no_live_evidence", + "drift_detected": None, + "note": "尚未提供 live conf 匯出檔;本階段不 SSH、不讀 live、不 reload。", + } + + if live_path is not None: + live_input_count += 1 + if not live_path.exists(): + comparison = { + "status": "live_file_missing", + "drift_detected": None, + "note": f"找不到 owner 提供的 live conf 匯出檔:{live_path}", + } + else: + live_report = read_source(live_path) + comparison = compare_config(source_report, live_report) + comparison["status"] = ( + "drift_detected" if comparison["drift_detected"] else "matched" + ) + if comparison["drift_detected"]: + drift_count += 1 + + configs.append( + { + "config_id": source.config_id, + "host": source.host, + "role": source.role, + "control_tier": source.control_tier, + "owner_gate": source.owner_gate, + "repo_source_path": source.source_path, + "live_path": source.live_path, + "repo_source": source_report, + "live_input": { + "provided": live_path is not None, + "path": str(live_path) if live_path else None, + "summary": live_report, + }, + "comparison": comparison, + } + ) + + return { + "schema_version": "nginx_config_drift_detector_v1", + "generated_at": report_time, + "mode": "repo_only" if live_input_count == 0 else "compare_owner_provided_live_files", + "git_commit": git_short_sha(root), + "execution_boundaries": { + "ssh_executed": False, + "nginx_test_executed": False, + "nginx_reload_executed": False, + "host_write_executed": False, + "runtime_gate_opened": False, + "secret_value_collected": False, + }, + "summary": { + "source_config_count": len(configs), + "live_input_count": live_input_count, + "drift_detected_count": drift_count, + "repo_source_inventory_complete": True, + "live_evidence_collected": live_input_count > 0, + }, + "configs": configs, + "next_steps": [ + "由 owner 提供脫敏 live conf 匯出檔後重跑比較模式。", + "若偵測 drift,只建立 evidence 與 owner decision,不自動覆寫 live。", + "任何 Nginx reload 仍需 maintenance window、rollback owner、nginx -t 與 route smoke。", + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="IwoooS Nginx 只讀配置漂移偵測器") + parser.add_argument("--root", default=".", help="repo root") + parser.add_argument("--output", help="寫出 JSON 報告") + parser.add_argument( + "--live-file", + action="append", + default=[], + help="owner 提供的 live conf 匯出檔,格式:config_id=/path/to/file", + ) + parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用") + parser.add_argument("--fail-on-drift", action="store_true", help="偵測到 drift 時回傳 1") + args = parser.parse_args() + + root = Path(args.root).resolve() + live_files = parse_live_files(args.live_file) + report = build_report(root, live_files, 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) + + if args.fail_on_drift and report["summary"]["drift_detected_count"] > 0: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())