From 731ed12164b3a5f0eded75e4e858292b774a9950 Mon Sep 17 00:00:00 2001 From: ogt Date: Sat, 11 Jul 2026 19:36:12 +0800 Subject: [PATCH] fix(security): close public metrics exposure --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 3 +- monitoring/docker-compose.yml | 16 ++-- .../edge/ewoooc_metrics_nginx_location.conf | 13 +++ monitoring/edge/ewoooc_prometheus_scrape.yml | 10 +++ services/http_access_policy_service.py | 54 +++++++++++- .../security_governance_review_service.py | 23 +++-- tests/test_metrics_edge_security.py | 85 +++++++++++++++++++ ...test_security_governance_review_service.py | 41 ++++++++- 9 files changed, 220 insertions(+), 27 deletions(-) create mode 100644 monitoring/edge/ewoooc_metrics_nginx_location.conf create mode 100644 monitoring/edge/ewoooc_prometheus_scrape.yml create mode 100644 tests/test_metrics_edge_security.py diff --git a/config.py b/config.py index bc1d4fb..862311c 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.785" +SYSTEM_VERSION = "V10.786" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 88367c9..c29a28b 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-07-11 (台北時間) > **狀態**: 🟠 Partial。四 Agent、PixelRAG、MCP/RAG registry、PChome controlled-preview families 與多項 smoke/readback 已建立;但 access control、database identity/RBAC、full asset reconciliation、software supply chain、same-run controlled apply、restore drill、internal RAG canary 與 MCP/RAG runtime closure 尚未全部完成。任何局部 receipt 不得代表整體閉環完成。 -> **適用版本**: V10.785 +> **適用版本**: V10.786 --- @@ -15,6 +15,7 @@ - Gitea CD 必須執行 `report_security_governance_review.py --strict`,且不得解析 GitHub action/source/image。 - production 安全與治理完成度必須分開顯示 program、asset coverage、runtime closure;目前結論是 partial,不是 complete。 - 目前 ordered P0 以 `docs/guides/ai_automation_mainline_work_items.md` 為準:先關閉 access exposure,再做 identity/RBAC、webhook trust、supply chain、asset reconciliation、controlled-apply envelope、internal RAG canary 與 MCP/RAG runtime closure。 +- V10.786 起 `/metrics` 不再是 public exception;應用中央政策與 Nginx exact-match edge policy 僅允許 loopback / Docker transport,Prometheus 必須以內部 canary 證明 target `up`,且 public/LAN readback 必須拒絕。監控 Compose 不得保存明文 Grafana 管理密碼,Prometheus 對外埠預設只綁 loopback。 - V10.777 起 web process 只能註冊 OpenClaw Blueprint,不得在 import 或 `record_once` 啟動 APScheduler、timed jobs 或 embedding thread;10 個 OpenClaw timed jobs 與唯一 embedding worker runtime owner 必須由 `momo-scheduler` 明確啟動。每日 04:15 的 codebase inventory 只掃描程式與前端資產並寫 artifact receipt,不讀 secret、不寫業務 DB。 --- diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml index d566791..1bff8ac 100644 --- a/monitoring/docker-compose.yml +++ b/monitoring/docker-compose.yml @@ -1,7 +1,4 @@ -version: '3.8' - services: - # cAdvisor - 容器資源監控 cadvisor: image: gcr.io/cadvisor/cadvisor:latest container_name: cadvisor @@ -24,7 +21,7 @@ services: container_name: prometheus restart: unless-stopped ports: - - "9090:9090" + - "127.0.0.1:9090:9090" volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus_data:/prometheus @@ -38,16 +35,15 @@ services: - monitoring - momo-network - # Grafana - 視覺化儀表板 grafana: image: grafana/grafana:latest container_name: grafana restart: unless-stopped ports: - - "3000:3000" + - "127.0.0.1:3000:3000" environment: - GF_SECURITY_ADMIN_USER=admin - - GF_SECURITY_ADMIN_PASSWORD=WoooTech2026 + - "GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD is required}" - GF_INSTALL_PLUGINS= - GF_SERVER_ROOT_URL=http://192.168.0.110:3000 volumes: @@ -58,13 +54,12 @@ services: depends_on: - prometheus - # Node Exporter - 主機資源監控 node-exporter: image: prom/node-exporter:latest container_name: node-exporter restart: unless-stopped ports: - - "9100:9100" + - "127.0.0.1:9100:9100" command: - '--path.procfs=/host/proc' - '--path.sysfs=/host/sys' @@ -77,13 +72,12 @@ services: networks: - monitoring - # Blackbox Exporter - 網站/域名監控 blackbox-exporter: image: prom/blackbox-exporter:latest container_name: blackbox-exporter restart: unless-stopped ports: - - "9115:9115" + - "127.0.0.1:9115:9115" volumes: - ./blackbox.yml:/etc/blackbox_exporter/config.yml:ro command: diff --git a/monitoring/edge/ewoooc_metrics_nginx_location.conf b/monitoring/edge/ewoooc_metrics_nginx_location.conf new file mode 100644 index 0000000..7883d56 --- /dev/null +++ b/monitoring/edge/ewoooc_metrics_nginx_location.conf @@ -0,0 +1,13 @@ +# Source-owned exact-match edge policy for EwoooC Prometheus metrics. +location = /metrics { + allow 127.0.0.1; + allow ::1; + allow 172.16.0.0/12; + deny all; + + proxy_pass http://127.0.0.1:5003/metrics; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} diff --git a/monitoring/edge/ewoooc_prometheus_scrape.yml b/monitoring/edge/ewoooc_prometheus_scrape.yml new file mode 100644 index 0000000..db1713b --- /dev/null +++ b/monitoring/edge/ewoooc_prometheus_scrape.yml @@ -0,0 +1,10 @@ + - job_name: 'ewoooc-app' + scheme: https + metrics_path: /metrics + scrape_interval: 30s + scrape_timeout: 15s + static_configs: + - targets: ['mo.wooo.work'] + labels: + environment: 'production' + product: 'ewoooc' diff --git a/services/http_access_policy_service.py b/services/http_access_policy_service.py index d306ab1..0b7ecc0 100644 --- a/services/http_access_policy_service.py +++ b/services/http_access_policy_service.py @@ -2,6 +2,8 @@ from __future__ import annotations +import ipaddress + POLICY = "ewoooc_sensitive_http_access_policy_v1" # Entire blueprints that expose business data or operator actions. @@ -38,11 +40,20 @@ INTENTIONALLY_PUBLIC_ENDPOINTS = frozenset({ "misc.brand_assets", "system_public.favicon", "system_public.health_check", - "system_public.prometheus_metrics", "system_public.webcrumbs_asset_proxy", "user_bp.get_password_reqs", }) +# Metrics stay anonymous only on explicitly trusted transport networks. The edge +# proxy applies the same allowlist, while this policy protects direct app access. +INTERNAL_ONLY_ENDPOINTS = frozenset({ + "system_public.prometheus_metrics", +}) +INTERNAL_METRICS_NETWORKS = tuple( + ipaddress.ip_network(cidr) + for cidr in ("127.0.0.0/8", "172.16.0.0/12", "::1/128") +) + def is_session_required(endpoint: str | None, blueprint: str | None) -> bool: return bool( @@ -51,9 +62,45 @@ def is_session_required(endpoint: str | None, blueprint: str | None) -> bool: ) +def is_internal_metrics_request( + remote_addr: str | None, + x_real_ip: str | None = None, + x_forwarded_for: str | None = None, +) -> bool: + """Return whether the effective client belongs to the metrics allowlist.""" + try: + peer_address = ipaddress.ip_address((remote_addr or "").strip()) + except ValueError: + return False + if not any(peer_address in network for network in INTERNAL_METRICS_NETWORKS): + return False + + forwarded_ip = (x_forwarded_for or "").split(",", 1)[0].strip() + candidate = ( + (x_real_ip or "").strip() + or forwarded_ip + or str(peer_address) + ) + try: + address = ipaddress.ip_address(candidate) + except ValueError: + return False + return any(address in network for network in INTERNAL_METRICS_NETWORKS) + + def enforce_http_access_policy(): """Protect sensitive routes before their route function executes.""" - from flask import request + from flask import jsonify, request + + if request.endpoint in INTERNAL_ONLY_ENDPOINTS: + allowed = is_internal_metrics_request( + request.remote_addr, + request.headers.get("X-Real-IP"), + request.headers.get("X-Forwarded-For"), + ) + if not allowed: + return jsonify({"error": "not_found"}), 404 + return None if not is_session_required(request.endpoint, request.blueprint): return None @@ -67,6 +114,9 @@ __all__ = [ "SESSION_REQUIRED_BLUEPRINTS", "SESSION_REQUIRED_ENDPOINTS", "INTENTIONALLY_PUBLIC_ENDPOINTS", + "INTERNAL_ONLY_ENDPOINTS", + "INTERNAL_METRICS_NETWORKS", "enforce_http_access_policy", + "is_internal_metrics_request", "is_session_required", ] diff --git a/services/security_governance_review_service.py b/services/security_governance_review_service.py index fa0f2f2..e7a1530 100644 --- a/services/security_governance_review_service.py +++ b/services/security_governance_review_service.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any from services.http_access_policy_service import ( + INTERNAL_ONLY_ENDPOINTS, INTENTIONALLY_PUBLIC_ENDPOINTS, SESSION_REQUIRED_BLUEPRINTS, SESSION_REQUIRED_ENDPOINTS, @@ -166,6 +167,7 @@ def _scan_routes(root: Path) -> dict[str, Any]: central_guard = ( blueprint in SESSION_REQUIRED_BLUEPRINTS or endpoint in SESSION_REQUIRED_ENDPOINTS + or endpoint in INTERNAL_ONLY_ENDPOINTS ) rows.append({ "file": path.relative_to(root).as_posix(), @@ -320,6 +322,8 @@ def _runtime_receipt(root: Path) -> dict[str, Any]: and access.get("sensitive_routes_denied") is True and access.get("security_headers_passed") is True and access.get("secure_cookie") is True + and access.get("metrics_public") is False + and access.get("metrics_internal_canary") is True and database.get("unchanged") is True and post_verifier.get("health_status") == "healthy" and post_verifier.get("governance_gate") == "pass" @@ -338,6 +342,8 @@ def _runtime_receipt(root: Path) -> dict[str, Any]: "sensitive_route_count": int(access.get("sensitive_route_count") or 0), "denied_route_count": int(access.get("denied_route_count") or 0), "metrics_public": bool(access.get("metrics_public")), + "metrics_internal_canary": bool(access.get("metrics_internal_canary")), + "metrics_target_health": access.get("metrics_target_health"), "database_unchanged": bool(database.get("unchanged")), "health_status": post_verifier.get("health_status"), "governance_gate": post_verifier.get("governance_gate"), @@ -474,18 +480,19 @@ def _work_items( checks: dict[str, dict[str, Any]], runtime_receipt: dict[str, Any], ) -> list[dict[str, Any]]: + access_closed = runtime_receipt["security_readback_passed"] access_next_action = ( - "move_metrics_behind_edge_auth_with_prometheus_canary" - if runtime_receipt["security_readback_passed"] - else "verify_production_anonymous_matrix_and_move_metrics_behind_edge_auth" + "inventory_active_users_then_shadow_database_auth_before_cutover" + if access_closed + else "deny_public_metrics_then_verify_internal_prometheus_canary" ) release_next_action = ( "restore_dedicated_ewoooc_runner_then_replay_waiting_cd" if runtime_receipt["security_readback_passed"] - else "deploy_v10_773_then_read_back_security_matrix_and_container_identity" + else "deploy_current_source_then_read_back_security_matrix_and_container_identity" ) return [ - {"order": 1, "id": "SEC-P0-001", "priority": "P0", "status": "in_progress", "lane": "deny_by_default_access", "objective": "Close all sensitive and mutating routes behind central auth or signed internal transport.", "next_machine_action": access_next_action}, + {"order": 1, "id": "SEC-P0-001", "priority": "P0", "status": "completed" if access_closed else "in_progress", "lane": "deny_by_default_access", "objective": "Close all sensitive and mutating routes behind central auth or signed internal transport.", "next_machine_action": access_next_action}, {"order": 2, "id": "SEC-P0-002", "priority": "P0", "status": "not_started", "lane": "identity_rbac", "objective": "Replace the shared admin password with database-backed identities, least-privilege RBAC, durable lockout and auditable sessions.", "next_machine_action": "inventory_active_users_then_shadow_database_auth_before_cutover"}, {"order": 3, "id": "SEC-P0-003", "priority": "P0", "status": "in_progress", "lane": "webhook_trust", "objective": "Require Telegram and internal webhook transport secrets with replay protection.", "next_machine_action": "provision_telegram_webhook_secret_then_enable_required_mode_and_canary"}, {"order": 4, "id": "SUPPLY-P0-001", "priority": "P0", "status": "in_progress", "lane": "software_supply_chain", "objective": "Use Gitea-only checkout, exact locks, SAST/SCA/secret scan, SBOM, image digest and provenance gates.", "next_machine_action": "add_internal_scanner_artifacts_and_exact_dependency_lock"}, @@ -663,7 +670,7 @@ def build_security_governance_review( ), runtime_receipt, ( - "move_metrics_behind_edge_auth_with_prometheus_canary" + "inventory_active_users_then_shadow_database_auth_before_cutover" if runtime_receipt["security_readback_passed"] else "write_commit_bound_production_security_runtime_receipt" ), @@ -852,9 +859,9 @@ def build_security_governance_review( "external_side_effects": False, }, "next_machine_action": ( - "move_metrics_behind_edge_auth_with_prometheus_canary" + "inventory_active_users_then_shadow_database_auth_before_cutover" if runtime_receipt["security_readback_passed"] - else "verify_production_anonymous_matrix_and_move_metrics_behind_edge_auth" + else "deny_public_metrics_then_verify_internal_prometheus_canary" ), } diff --git a/tests/test_metrics_edge_security.py b/tests/test_metrics_edge_security.py new file mode 100644 index 0000000..e9aa91c --- /dev/null +++ b/tests/test_metrics_edge_security.py @@ -0,0 +1,85 @@ +from pathlib import Path + +from flask import Blueprint, Flask, jsonify + + +ROOT = Path(__file__).resolve().parents[1] + + +def _metrics_app() -> Flask: + from services.http_access_policy_service import enforce_http_access_policy + + app = Flask(__name__) + system_public = Blueprint("system_public", __name__) + + @system_public.route("/metrics") + def prometheus_metrics(): + return jsonify({"status": "up"}) + + app.register_blueprint(system_public) + app.before_request(enforce_http_access_policy) + return app + + +def test_metrics_network_classifier_is_deny_by_default(): + from services.http_access_policy_service import is_internal_metrics_request + + assert is_internal_metrics_request("127.0.0.1") is True + assert is_internal_metrics_request("172.20.0.4") is True + assert is_internal_metrics_request("127.0.0.1", "172.20.0.4") is True + assert is_internal_metrics_request("127.0.0.1", "203.0.113.10") is False + assert is_internal_metrics_request("127.0.0.1", "192.168.0.110") is False + assert is_internal_metrics_request("203.0.113.10", "172.20.0.4") is False + assert is_internal_metrics_request("127.0.0.1", "not-an-ip") is False + + +def test_metrics_route_denies_public_and_lan_but_allows_internal_transport(): + client = _metrics_app().test_client() + + public = client.get( + "/metrics", + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + headers={"X-Real-IP": "203.0.113.10"}, + ) + lan = client.get( + "/metrics", + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + headers={"X-Real-IP": "192.168.0.110"}, + ) + internal = client.get( + "/metrics", + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + headers={"X-Real-IP": "172.20.0.4"}, + ) + direct = client.get("/metrics", environ_base={"REMOTE_ADDR": "172.18.0.5"}) + + assert public.status_code == 404 + assert public.get_json() == {"error": "not_found"} + assert lan.status_code == 404 + assert internal.status_code == 200 + assert direct.status_code == 200 + + +def test_source_owned_edge_and_prometheus_templates_are_closed_by_default(): + nginx = (ROOT / "monitoring/edge/ewoooc_metrics_nginx_location.conf").read_text( + encoding="utf-8" + ) + prometheus = (ROOT / "monitoring/edge/ewoooc_prometheus_scrape.yml").read_text( + encoding="utf-8" + ) + + assert "location = /metrics" in nginx + assert "allow 172.16.0.0/12;" in nginx + assert "deny all;" in nginx + assert "proxy_pass http://127.0.0.1:5003/metrics;" in nginx + assert "job_name: 'ewoooc-app'" in prometheus + assert "metrics_path: /metrics" in prometheus + assert "targets: ['mo.wooo.work']" in prometheus + + +def test_monitoring_compose_has_no_plaintext_admin_password_or_public_prometheus_port(): + compose = (ROOT / "monitoring/docker-compose.yml").read_text(encoding="utf-8") + + assert "GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?" in compose + assert "127.0.0.1:9090:9090" in compose + assert "\n - \"9090:9090\"" not in compose diff --git a/tests/test_security_governance_review_service.py b/tests/test_security_governance_review_service.py index a238624..96db362 100644 --- a/tests/test_security_governance_review_service.py +++ b/tests/test_security_governance_review_service.py @@ -32,7 +32,7 @@ def test_security_governance_review_is_honest_and_release_gate_passes(): access = checks["PR.AA-route-access-control"]["evidence"] assert access["mutating_unguarded_count"] == 0 assert access["unclassified_unguarded_count"] == 0 - assert access["unguarded_count"] == 8 + assert access["unguarded_count"] == 7 dependencies = checks["ID.RA-dependency-vulnerability-management"]["evidence"] assert dependencies["sbom_files"] == [] supply = checks["GV.SC-gitea-only-supply-chain"] @@ -149,7 +149,9 @@ def test_runtime_receipt_advances_access_readback_without_hiding_remaining_debt( "secure_cookie": True, "sensitive_route_count": 8, "denied_route_count": 8, - "metrics_public": True, + "metrics_public": False, + "metrics_internal_canary": True, + "metrics_target_health": "up", }, "database": {"unchanged": True}, "post_verifier": { @@ -162,7 +164,7 @@ def test_runtime_receipt_advances_access_readback_without_hiding_remaining_debt( "blocker": "no_ewoooc_host_runner", }, "learning_recorded": False, - "next_machine_action": "move_metrics_behind_edge_auth_with_prometheus_canary", + "next_machine_action": "inventory_active_users_then_shadow_database_auth_before_cutover", } (governance / "security_governance_runtime_receipt.json").write_text( json.dumps(payload, ensure_ascii=False), @@ -173,11 +175,42 @@ def test_runtime_receipt_advances_access_readback_without_hiding_remaining_debt( assert receipt["security_readback_passed"] is True assert receipt["database_unchanged"] is True - assert receipt["metrics_public"] is True + assert receipt["metrics_public"] is False + assert receipt["metrics_internal_canary"] is True + assert receipt["metrics_target_health"] == "up" assert receipt["cd_status"] == "waiting" assert receipt["learning_recorded"] is False +def test_runtime_receipt_rejects_public_metrics_even_when_other_checks_pass(tmp_path): + from services.security_governance_review_service import _runtime_receipt + + governance = tmp_path / "governance" + governance.mkdir() + payload = { + "receipt_kind": "security_governance_runtime_receipt", + "source_commit": "d" * 40, + "access_control": { + "status": "pass", + "sensitive_routes_denied": True, + "security_headers_passed": True, + "secure_cookie": True, + "metrics_public": True, + "metrics_internal_canary": True, + }, + "database": {"unchanged": True}, + "post_verifier": {"health_status": "healthy", "governance_gate": "pass"}, + } + (governance / "security_governance_runtime_receipt.json").write_text( + json.dumps(payload), + encoding="utf-8", + ) + + receipt = _runtime_receipt(tmp_path) + + assert receipt["security_readback_passed"] is False + + def test_sensitive_blueprint_policy_denies_anonymous_api(monkeypatch): import auth from services.http_access_policy_service import enforce_http_access_policy