diff --git a/apps/web/.env.example b/apps/web/.env.example index 315423a95..bb6ab801e 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -9,7 +9,7 @@ # ---------------------------------------------------------------------------- # API 後端 URL(Next.js build-time 寫入 JS bundle,禁止使用內網 IP) -NEXT_PUBLIC_API_URL=http://192.168.0.188:32334 +NEXT_PUBLIC_API_URL=https://awoooi.wooo.work # ---------------------------------------------------------------------------- # 可選 (OPTIONAL) @@ -18,18 +18,15 @@ NEXT_PUBLIC_API_URL=http://192.168.0.188:32334 # 是否啟用 Demo 模式(true/false) NEXT_PUBLIC_ENABLE_DEMO=false -# SignOz 可觀測性平台 URL -NEXT_PUBLIC_SIGNOZ_URL=http://192.168.0.110:3301 +# SignOz 可觀測性平台 URL(公開前端只允許公網入口) +NEXT_PUBLIC_SIGNOZ_URL=https://signoz.wooo.work -# 主機 IP 列表(逗號分隔,live-dashboard 用於 fallback 顯示) -NEXT_PUBLIC_HOST_IPS=192.168.0.110,192.168.0.112,192.168.0.120,192.168.0.188 - -# K8s Cluster VIP 資訊字串(host-grid 顯示用) -NEXT_PUBLIC_K8S_VIP_INFO=VIP 192.168.0.125 · kubectl :6443 · Web :32335 · API :32334 +# 主機清單與 K8s VIP 顯示由後端提供遮罩後的資產代號。 +# 不得在公開前端 env 寫入實際內網拓樸。 # ---------------------------------------------------------------------------- # Server-side Only(不含 NEXT_PUBLIC_ 前綴,不會暴露在 JS bundle) # ---------------------------------------------------------------------------- # Sentry 自建主機 URL(sentry-tunnel route handler 使用) -SENTRY_HOST=http://192.168.0.110:9000 +SENTRY_HOST=https://sentry.wooo.work diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 9d853ddd1..91c46b35f 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,34 @@ +## 2026-06-15|公開前端 env 範例內網拓樸防回歸 + +**背景**:production `/api/sentry-tunnel` 已不再回顯實際 upstream target,但 repo 內 `apps/web/.env.example` 仍保留多個 `NEXT_PUBLIC_*` 內網 IP 範例與 server-side `SENTRY_HOST` 內網 URL。這不一定代表目前正式站已外洩,但會讓未來複製範例建置時,把內網拓樸 bake 進 JS bundle 或公開文件,違反前端內網 IP 禁令。 + +**完成項目**: +- `apps/web/.env.example` 改為只使用公網入口:`NEXT_PUBLIC_API_URL=https://awoooi.wooo.work`、`NEXT_PUBLIC_SIGNOZ_URL=https://signoz.wooo.work`、`SENTRY_HOST=https://sentry.wooo.work`。 +- 移除 active `NEXT_PUBLIC_HOST_IPS` 與 `NEXT_PUBLIC_K8S_VIP_INFO` 範例值,改以繁中註記說明主機清單與 K8s VIP 顯示需由後端提供遮罩後的資產代號。 +- 新增 `scripts/security/public-frontend-env-guard.py`,檢查公開前端 env 範例不得包含 RFC1918 內網 IP、不得重新啟用已停用的公開拓樸 env key,且 `NEXT_PUBLIC_*` 範例需有明確安全預設。 +- `scripts/security/security-mirror-progress-guard.py` 已接入 public frontend env guard,讓 IwoooS 主 guard 一併鎖住此類回歸。 + +**Production 只讀驗證**: +- `https://awoooi.wooo.work/api/sentry-tunnel` 回 `200`,body 為 `{"status":"ok","tunnel":"/api/sentry-tunnel","target_configured":true}`;沒有實際 upstream target、內網 IP、`SENTRY_HOST` 或 `NEXT_PUBLIC_SENTRY_DSN`。 +- `https://awoooi.wooo.work/api/v1/health` 回 `200`;未命中內網 IP、`SENTRY_HOST`、`NEXT_PUBLIC_SENTRY_DSN`、工作視窗字串、個人 owner namespace、raw blocker 狀態。 +- `https://awoooi.wooo.work/zh-TW/iwooos?_v=7529232f-public-scan` 與 `https://awoooi.wooo.work/zh-TW/awooop/tenants?_v=7529232f-public-scan` 回 `200`;未命中內網 IP、工作視窗字串、個人 owner namespace、raw blocker 狀態。 + +**本地驗證**: +- `python3 -m py_compile scripts/security/public-frontend-env-guard.py scripts/security/security-mirror-progress-guard.py` 通過。 +- `python3 scripts/security/public-frontend-env-guard.py --root .` → `OK public frontend env guard`。 +- `python3 scripts/security/security-mirror-progress-guard.py --root .` → `SECURITY_MIRROR_PROGRESS_GUARD_OK`。 +- `python3 scripts/security/iwooos-config-control-guard.py --root .` → `IWOOOS_CONFIG_CONTROL_GUARD_OK`。 +- `python3 scripts/security/iwooos-owner-gate-guard.py --root .` → `IWOOOS_OWNER_GATE_GUARD_OK`。 +- `python3 scripts/security/package-supply-chain-owner-policy-guard.py --root .` → `PACKAGE_SUPPLY_CHAIN_OWNER_POLICY_GUARD_OK`。 +- `git diff --check` 通過。 + +**完成度與邊界**: +- Public frontend env 範例防回歸:`0% -> 100%`。 +- Frontend public sensitive redaction defense:`88% -> 89%`,只反映範例與 guard 覆蓋提升,不代表 runtime gate 開啟。 +- IwoooS headline 維持 `64%`;active runtime gate 維持 `0`。 +- S4.9 owner response、source-control owner acceptance、Nginx reload、host restart、firewall change、active scan、secret collection、runtime execution、action button 全部維持 `0 / false`。 +- 本輪未 SSH、未讀 live conf、未改主機、未重啟 Docker / Nginx、未修改 firewall / iptables、未收 secrets 明文、未執行 active scan、未 force push。 + ## 2026-06-15|Monitoring / Alerting / Observability owner response acceptance 只讀帳本 **背景**:Monitoring / Alerting / Observability 已有 repo-only 清冊與 owner request draft,但仍缺少「owner 回覆收件後如何驗收、哪些欄位必填、哪些證據可接受、哪些動作必須阻擋」的固定帳本。這會讓 Prometheus / Alertmanager / Grafana / SigNoz / Sentry / Langfuse / OTEL / Telegram 告警鏈路在後續收件時,可能被誤判成 reload、receiver route change、silence change、Telegram send 或 alert chain smoke 授權。 diff --git a/scripts/security/public-frontend-env-guard.py b/scripts/security/public-frontend-env-guard.py new file mode 100644 index 000000000..aee95e073 --- /dev/null +++ b/scripts/security/public-frontend-env-guard.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""檢查公開前端 env 範例不可洩漏內網拓樸。""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + + +PRIVATE_IP_PATTERN = re.compile( + r"\b(?:10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})\b" +) + +RETIRED_PUBLIC_TOPOLOGY_KEYS = { + "NEXT_PUBLIC_HOST_IPS", + "NEXT_PUBLIC_K8S_VIP_INFO", +} + +ENV_EXAMPLE_PATHS = ( + Path("apps/web/.env.example"), +) + + +def _active_assignment(line: str) -> tuple[str, str] | None: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + return None + key, value = stripped.split("=", 1) + return key.strip(), value.strip() + + +def validate(root: Path) -> None: + errors: list[str] = [] + + for relative_path in ENV_EXAMPLE_PATHS: + path = root / relative_path + if not path.exists(): + continue + + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + assignment = _active_assignment(line) + if assignment is None: + continue + + key, value = assignment + if key in RETIRED_PUBLIC_TOPOLOGY_KEYS: + errors.append(f"{relative_path}:{line_number}: 已停用公開前端拓樸 env key:{key}") + if PRIVATE_IP_PATTERN.search(value): + errors.append(f"{relative_path}:{line_number}: env 範例不得包含內網 IP:{key}") + if key.startswith("NEXT_PUBLIC_") and not value: + errors.append(f"{relative_path}:{line_number}: NEXT_PUBLIC_* 範例需明確使用公網入口或安全預設值:{key}") + + if errors: + raise SystemExit("BLOCKED public frontend env guard:\n" + "\n".join(f"- {error}" for error in errors)) + + +def main() -> None: + parser = argparse.ArgumentParser(description="檢查公開前端 env 範例不可洩漏內網拓樸") + parser.add_argument("--root", default=".", help="repository root") + args = parser.parse_args() + validate(Path(args.root).resolve()) + print("OK public frontend env guard") + + +if __name__ == "__main__": + main() diff --git a/scripts/security/security-mirror-progress-guard.py b/scripts/security/security-mirror-progress-guard.py index 0929b8e36..2d86b4809 100755 --- a/scripts/security/security-mirror-progress-guard.py +++ b/scripts/security/security-mirror-progress-guard.py @@ -83,6 +83,10 @@ def validate(root: Path) -> None: str(root / "scripts" / "security" / "package-supply-chain-owner-policy-guard.py") ) package_policy_guard["validate"](root) + public_frontend_env_guard = runpy.run_path( + str(root / "scripts" / "security" / "public-frontend-env-guard.py") + ) + public_frontend_env_guard["validate"](root) manifest = load_json(security_dir / "security-supply-chain-contract-manifest.snapshot.json") readiness = load_json(security_dir / "security-mirror-readiness.snapshot.json")