aps-agent/server/agent_core/audit_notify.py

109 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 审计完整性告警通知 v1(moduleId: core-audit-notify, 可重生 ✅)
# plan.md §3.6 / 矩阵「全审计」剩余项:告警通知渠道
# build_alerts 聚合出告警后,本模块按配置渠道投递(console/log/jsonl/webhook),
# critical/warning 默认投递、info 可选;同告警码+消息防抖(时间窗内不重复发)。
# ============================================================
from __future__ import annotations
import json
import logging
import os
import time
import urllib.request
from pathlib import Path
from typing import Any
logger = logging.getLogger("aps.audit.notify")
# 防抖窗口(秒):同 code+message 在该窗口内只投递一次,避免 gov/audit 轮询刷屏
_DEDUP_WINDOW_SECONDS = float(os.environ.get("APS_AUDIT_ALERT_DEDUP_SECONDS") or "300")
# 默认投递渠道:console(日志);可配 jsonl:路径 或 webhook:url(多个用逗号分隔)
_DEFAULT_CHANNELS = (os.environ.get("APS_AUDIT_ALERT_CHANNELS") or "console").strip()
# info 级告警是否投递(默认否,仅 critical/warning)
_INCLUDE_INFO = (os.environ.get("APS_AUDIT_ALERT_INCLUDE_INFO") or "0") == "1"
_seen: dict[tuple[str, str], float] = {}
def _channel_list() -> list[str]:
return [c.strip() for c in _DEFAULT_CHANNELS.split(",") if c.strip()]
def _deliver_console(alert: dict[str, Any]) -> bool:
level = logging.CRITICAL if alert.get("severity") == "critical" else logging.WARNING
logger.log(level, "[audit-alert] %s %s: %s", alert.get("code"), alert.get("severity"), alert.get("message"))
return True
def _deliver_jsonl(alert: dict[str, Any], target: str) -> bool:
path = Path(target)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(alert, ensure_ascii=False, sort_keys=True) + "\n")
return True
def _deliver_webhook(alert: dict[str, Any], url: str, timeout: float = 8.0) -> bool:
payload = json.dumps(alert, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310(url 来自显式配置)
return resp.status < 400
except Exception:
logger.exception("audit alert webhook failed: %s", url)
return False
def _should_send(alert: dict[str, Any]) -> bool:
severity = str(alert.get("severity") or "info")
if severity == "info" and not _INCLUDE_INFO:
return False
code = str(alert.get("code") or "?")
message = str(alert.get("message") or "")
now = time.monotonic()
key = (code, message)
last = _seen.get(key, -1e9)
if now - last < _DEDUP_WINDOW_SECONDS:
return False
_seen[key] = now
return True
def notify_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
"""按配置渠道投递审计告警;返回投递统计(尽力而为,不抛异常)。
Returns: {"delivered": int, "skipped": int, "channels": [...]}
"""
channels = _channel_list()
if not channels:
channels = ["console"]
delivered = 0
skipped = 0
for alert in alerts or []:
if not _should_send(alert):
skipped += 1
continue
ok = True
for channel in channels:
try:
if channel == "console":
ok = _deliver_console(alert) and ok
elif channel.startswith("jsonl:"):
ok = _deliver_jsonl(alert, channel[len("jsonl:"):]) and ok
elif channel.startswith("webhook:"):
ok = _deliver_webhook(alert, channel[len("webhook:"):]) and ok
else:
logger.warning("unknown audit alert channel: %s", channel)
except Exception:
logger.exception("audit alert delivery failed on channel %s", channel)
ok = False
if ok:
delivered += 1
return {"delivered": delivered, "skipped": skipped, "channels": channels}
def reset_notify_state() -> None:
"""清空防抖缓存(测试隔离用)。"""
_seen.clear()