feat(监控): 完善系统监控、登录状态与访问审计能力
This commit is contained in:
@@ -2,14 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import uuid
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestAuditContext:
|
||||
request_id: str | None = None
|
||||
client_ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
client_type: str | None = None
|
||||
@@ -17,6 +22,8 @@ class RequestAuditContext:
|
||||
client_platform: str | None = None
|
||||
build_channel: str | None = None
|
||||
build_commit: str | None = None
|
||||
request_headers: dict[str, str] | None = None
|
||||
request_snapshot: dict[str, Any] | None = None
|
||||
|
||||
|
||||
_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
|
||||
@@ -25,11 +32,37 @@ _request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
|
||||
)
|
||||
|
||||
_SENSITIVE_TEXT_PATTERN = re.compile(
|
||||
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization)=([^&\s]+)"
|
||||
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization|password|passwd|secret|credential|api[-_]?key|session)=([^&\s]+)"
|
||||
)
|
||||
_SENSITIVE_HEADER_NAME_PATTERN = re.compile(
|
||||
r"(?i)(authorization|cookie|set-cookie|token|password|passwd|secret|credential|api[-_]?key|session)"
|
||||
)
|
||||
_SAFE_REQUEST_HEADER_NAMES = frozenset(
|
||||
{
|
||||
"accept",
|
||||
"accept-language",
|
||||
"content-type",
|
||||
"host",
|
||||
"origin",
|
||||
"user-agent",
|
||||
"x-request-id",
|
||||
"x-correlation-id",
|
||||
}
|
||||
)
|
||||
_SAFE_REQUEST_HEADER_PREFIXES = ("x-ctms-",)
|
||||
_SENSITIVE_PARAMETER_NAME_PATTERN = re.compile(
|
||||
r"(?i)(token|authorization|password|passwd|secret|credential|api[-_]?key|session|"
|
||||
r"subject|participant|patient|user_?name|full_?name|email|phone|mobile|id_?card|"
|
||||
r"identity|certificate|contact|address)"
|
||||
)
|
||||
_KNOWN_CLIENT_TYPES = frozenset({"web", "desktop"})
|
||||
_CTMS_CLIENT_SOURCE_TO_TYPE = {
|
||||
"ctms-web": "web",
|
||||
"ctms-desktop": "desktop",
|
||||
}
|
||||
|
||||
|
||||
def _clean_header_value(value: str | None, max_length: int) -> str | None:
|
||||
def _clean_audit_value(value: str | None, max_length: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip())
|
||||
@@ -38,26 +71,174 @@ def _clean_header_value(value: str | None, max_length: int) -> str | None:
|
||||
return cleaned[:max_length]
|
||||
|
||||
|
||||
def _clean_header_value(value: str | None, max_length: int) -> str | None:
|
||||
return _clean_audit_value(value, max_length)
|
||||
|
||||
|
||||
def resolve_ctms_client_type(headers: Any) -> str | None:
|
||||
source = _clean_header_value(headers.get("x-ctms-client-source"), 32)
|
||||
if source:
|
||||
mapped_type = _CTMS_CLIENT_SOURCE_TO_TYPE.get(source.strip().lower())
|
||||
if mapped_type:
|
||||
return mapped_type
|
||||
|
||||
client_type = _clean_header_value(headers.get("x-ctms-client-type"), 16)
|
||||
if not client_type:
|
||||
return None
|
||||
normalized = client_type.strip().lower()
|
||||
return normalized if normalized in _KNOWN_CLIENT_TYPES else normalized[:16]
|
||||
|
||||
|
||||
def build_sanitized_request_headers(request: Any) -> dict[str, str] | None:
|
||||
captured: dict[str, str] = {}
|
||||
for raw_name, raw_value in request.headers.items():
|
||||
name = str(raw_name).strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
if _SENSITIVE_HEADER_NAME_PATTERN.search(name):
|
||||
captured[name] = "[redacted]"
|
||||
continue
|
||||
if name not in _SAFE_REQUEST_HEADER_NAMES and not name.startswith(_SAFE_REQUEST_HEADER_PREFIXES):
|
||||
continue
|
||||
value = _clean_header_value(str(raw_value), 500)
|
||||
if value:
|
||||
captured[name] = value
|
||||
return captured or None
|
||||
|
||||
|
||||
def _sanitize_named_value(raw_name: Any, raw_value: Any, max_length: int = 500) -> dict[str, str] | None:
|
||||
name = _clean_audit_value(str(raw_name), 120)
|
||||
if not name:
|
||||
return None
|
||||
if _SENSITIVE_PARAMETER_NAME_PATTERN.search(name):
|
||||
return {"name": name, "value": "[redacted]"}
|
||||
value = _clean_audit_value(str(raw_value), max_length)
|
||||
if value is None:
|
||||
return {"name": name, "value": ""}
|
||||
return {"name": name, "value": value}
|
||||
|
||||
|
||||
def _query_param_items(request: Any) -> list[dict[str, str]] | None:
|
||||
query_params = getattr(request, "query_params", None)
|
||||
if not query_params:
|
||||
return None
|
||||
if hasattr(query_params, "multi_items"):
|
||||
raw_items = query_params.multi_items()
|
||||
else:
|
||||
raw_items = query_params.items()
|
||||
items = [
|
||||
item
|
||||
for item in (_sanitize_named_value(name, value) for name, value in raw_items)
|
||||
if item is not None
|
||||
][:50]
|
||||
return items or None
|
||||
|
||||
|
||||
def _sanitized_query_string(items: list[dict[str, str]] | None) -> str | None:
|
||||
if not items:
|
||||
return None
|
||||
value = "&".join(f"{item['name']}={item['value']}" for item in items)
|
||||
return value[:2000] or None
|
||||
|
||||
|
||||
def _request_client_snapshot(request: Any) -> dict[str, str | int | None] | None:
|
||||
client = getattr(request, "client", None)
|
||||
if not client:
|
||||
return None
|
||||
host = _clean_audit_value(getattr(client, "host", None), 120)
|
||||
port = getattr(client, "port", None)
|
||||
if host is None and port is None:
|
||||
return None
|
||||
return {"host": host, "port": port}
|
||||
|
||||
|
||||
def build_request_snapshot(request: Any, *, request_id: str | None = None) -> dict[str, Any]:
|
||||
headers = getattr(request, "headers", {})
|
||||
scope = getattr(request, "scope", {}) or {}
|
||||
url = getattr(request, "url", None)
|
||||
query_params = _query_param_items(request)
|
||||
path = getattr(url, "path", None) or scope.get("path")
|
||||
method = getattr(request, "method", None) or scope.get("method")
|
||||
snapshot: dict[str, Any] = {
|
||||
"request_id": request_id,
|
||||
"method": _clean_audit_value(str(method).upper() if method else None, 12),
|
||||
"path": _clean_audit_value(path, 500),
|
||||
"query_string": _sanitized_query_string(query_params),
|
||||
"query_params": query_params,
|
||||
"headers": build_sanitized_request_headers(request),
|
||||
"http_version": _clean_audit_value(scope.get("http_version"), 16),
|
||||
"scheme": _clean_audit_value(getattr(url, "scheme", None) or scope.get("scheme"), 16),
|
||||
"client": _request_client_snapshot(request),
|
||||
"content": {
|
||||
"type": _clean_audit_value(headers.get("content-type"), 200),
|
||||
"length": _clean_audit_value(headers.get("content-length"), 32),
|
||||
},
|
||||
"body": {
|
||||
"captured": False,
|
||||
"reason": "body_not_captured_by_audit_policy",
|
||||
},
|
||||
}
|
||||
return {key: value for key, value in snapshot.items() if value not in (None, {}, [])}
|
||||
|
||||
|
||||
def _parse_ip(value: str | None):
|
||||
try:
|
||||
return ipaddress.ip_address((value or "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _trusted_proxy_networks():
|
||||
networks = []
|
||||
for value in settings.TRUSTED_PROXY_CIDRS.split(","):
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(candidate, strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
return tuple(networks)
|
||||
|
||||
|
||||
def _is_trusted_proxy(value: str | None) -> bool:
|
||||
address = _parse_ip(value)
|
||||
return bool(address and any(address in network for network in _trusted_proxy_networks()))
|
||||
|
||||
|
||||
def resolve_client_ip(request: Any) -> str | None:
|
||||
peer_ip = request.client.host if request.client else None
|
||||
if not _is_trusted_proxy(peer_ip):
|
||||
return _clean_header_value(peer_ip, 45)
|
||||
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return _clean_header_value(forwarded.split(",")[0].strip(), 45)
|
||||
chain = [part.strip() for part in forwarded.split(",") if _parse_ip(part)]
|
||||
chain.append(str(peer_ip))
|
||||
for candidate in reversed(chain):
|
||||
if not _is_trusted_proxy(candidate):
|
||||
return _clean_header_value(candidate, 45)
|
||||
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
if _parse_ip(real_ip):
|
||||
return _clean_header_value(real_ip.strip(), 45)
|
||||
return _clean_header_value(request.client.host if request.client else None, 45)
|
||||
return _clean_header_value(peer_ip, 45)
|
||||
|
||||
|
||||
def build_request_audit_context(request: Any) -> RequestAuditContext:
|
||||
headers = request.headers
|
||||
request_id = str(uuid.uuid4())
|
||||
return RequestAuditContext(
|
||||
request_id=request_id,
|
||||
client_ip=resolve_client_ip(request),
|
||||
user_agent=_clean_header_value(headers.get("user-agent"), 500),
|
||||
client_type=_clean_header_value(headers.get("x-ctms-client-type"), 16),
|
||||
client_type=resolve_ctms_client_type(headers),
|
||||
client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32),
|
||||
client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16),
|
||||
build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16),
|
||||
build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64),
|
||||
request_headers=build_sanitized_request_headers(request),
|
||||
request_snapshot=build_request_snapshot(request, request_id=request_id),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user