fix(security): close public metrics exposure
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
---
|
||||
|
||||
@@ -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:
|
||||
|
||||
13
monitoring/edge/ewoooc_metrics_nginx_location.conf
Normal file
13
monitoring/edge/ewoooc_metrics_nginx_location.conf
Normal 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;
|
||||
}
|
||||
10
monitoring/edge/ewoooc_prometheus_scrape.yml
Normal file
10
monitoring/edge/ewoooc_prometheus_scrape.yml
Normal 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'
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
85
tests/test_metrics_edge_security.py
Normal file
85
tests/test_metrics_edge_security.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user