aps-agent/server/agent_core/audit_notify.py

109 lines
4.2 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 审计完整性告警通知 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()