feat(security): 建立 Nginx 只讀漂移偵測器 [skip ci]

This commit is contained in:
Your Name
2026-06-11 11:40:37 +08:00
parent eca53646cf
commit e1cacdf39f
5 changed files with 1633 additions and 2 deletions

View File

@@ -1,3 +1,34 @@
## 2026-06-11IwoooS 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-11IwoooS 高價值配置控管清冊與 P0 source-control 止血
**背景**:使用者要求所有重要配置都要納入資安控管,特別指出 Nginx 常被手動變更,必須建立資安機制。同時需完整盤點哪些配置要先納管、哪些既有規範不符合現在要求、哪些需要新增或調整。

View File

@@ -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 影響面。

View File

@@ -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%` | 未授權且未執行 |

File diff suppressed because it is too large Load Diff

View File

@@ -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())