fix(security): close public metrics exposure
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-11 19:36:12 +08:00
parent de851d6780
commit 731ed12164
9 changed files with 220 additions and 27 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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 transportPrometheus 必須以內部 canary 證明 target `up`,且 public/LAN readback 必須拒絕。監控 Compose 不得保存明文 Grafana 管理密碼Prometheus 對外埠預設只綁 loopback。
- V10.777 起 web process 只能註冊 OpenClaw Blueprint不得在 import 或 `record_once` 啟動 APScheduler、timed jobs 或 embedding thread10 個 OpenClaw timed jobs 與唯一 embedding worker runtime owner 必須由 `momo-scheduler` 明確啟動。每日 04:15 的 codebase inventory 只掃描程式與前端資產並寫 artifact receipt不讀 secret、不寫業務 DB。
---

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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'

View File

@@ -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",
]

View File

@@ -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"
),
}

View File

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

View File

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