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