跳转至

告警地址与计数增强(alert-enrich.py)

仓库路径:deploy/nfos/monitoring/alert-enrich.py | 线上路径:/opt/Cy/services/system.d/nfos/monitoring/alert-enrich.py 语言:python3 | 定时器:无(被 security-guard.sh 以管道调用,不挂 timer)


用途

把安全告警的「源 / 目的地址、同类次数 / 同 IP 次数、是否公网」补齐。守卫每轮调用一次:把 plan JSON 从 stdin 读进来,输出增强后的 plan JSON。

源码 docstring 说明了为什么不把它塞进 security-guard.sh 的内嵌 heredoc:

  1. 在线防线的爆炸半径:security-guard.sh 每 60 秒被拉一次,是实时检测链路的全部;把「纯字符串/统计」逻辑放进它的内嵌 heredoc,会让这类逻辑的每次改动都直接改动防线本体(且 bash -n 查不出 python 语法错误)。
  2. 可离线自测:本模块的函数是纯函数(输入字符串/JSON,输出字符串/JSON),.scratch/alert-enrich-test.py 直接 import 就能覆盖公网/私网边界、次数统计、异常输入。
  3. 与既有模式一致:守卫已经在用 monitoring/sec-notify.py 这条腿(同目录、同「best-effort、失败不影响检测」的约定)。
  4. 可复用:log-audit.py 等其它上报方也能直接吃同一套口径。

「公网」的口径

与前端 apps/web/*/js/core/config.js 的 NF.util.isPublicIp 完全一致——即「除下列非公网网段之外的地址」:

族 非公网网段
IPv4 0.0.0.0/8(未指定/本网络)、10.0.0.0/8、127.0.0.0/8、169.254.0.0/16、172.16.0.0/12、192.168.0.0/16、224.0.0.0/4(组播)、240.0.0.0/4(保留)
IPv6 ::/128、::1/128、fc00::/7(ULA)、fe80::/10(链路本地)、ff00::/8(组播)

IPv4-mapped IPv6(::ffff:1.2.3.4)按内嵌 IPv4 判定;非 IP 字符串一律不是公网(classify → "unknown")。

何时用

  • 不用手工跑:由 security-guard.sh 每轮以管道调用(printf '%s' "$PLAN" | alert-enrich.py --alerts-file /tmp/alerts.json)。
  • 单条命中调试:--hit '{"sig":"WA-002","subject":"ip:8.8.8.8","detail":"…","evidence":"…"}' —— 读一行 JSON,打印增强结果。
  • 改了口径之后:--selftest(内置最小自检;完整用例在 .scratch/alert-enrich-test.py)。
  • 接新的上报方:log-audit.py 等也能直接复用同一套口径。

命令与参数

以下参数逐字取自 main() 的 argparse 定义。

参数 类型 默认值 说明
--alerts-file 路径 ""(空) /alerts?limit=500 的原始响应(- 表示 stdin 冲突,勿用)
--hit 字符串(JSON) ""(空) 单条命中 JSON(调试用),输出增强记录
--host 字符串 ""(空) 本机名(主机类特征的目的地址用)
--window int 500 计数窗口(条数,默认 500)
--selftest 开关 False 无 help

共 5 个参数。

不带参数时的默认行为

不给 --alerts-file 时 _read_alerts("") 直接返回 None(不会去猜默认路径、不联网);--host 为空时回落到 socket.gethostname();然后从 stdin 读 plan JSON:

  • stdin 为空 → 打印 {"ok": false, "error": "stdin 为空"},退出码 1;
  • stdin 不是合法 JSON → 打印 {"ok": false, "error": "plan JSON 不可解析:…"},退出码 1;
  • 解析成功 → 把增强后的 plan JSON 打到 stdout,退出码 0。

即在终端里裸跑(没有管道喂输入)会立刻返回 stdin 为空 并退出 1,不会卡住。

示例

D=/opt/Cy/services/system.d/nfos

# 1) 自检(公网/私网边界 + 次数统计 + 异常输入)
python3 "$D/monitoring/alert-enrich.py" --selftest

# 2) 守卫的线上调用形式:plan 从 stdin 进,增强结果从 stdout 出
curl -s http://127.0.0.1:9990/alerts?limit=500 > /var/tmp/alerts.json
printf '%s' "$PLAN" | python3 "$D/monitoring/alert-enrich.py" --alerts-file /var/tmp/alerts.json

# 3) 单条命中调试(对照公网/私网标记与同类计数)
python3 "$D/monitoring/alert-enrich.py" \
  --hit '{"sig":"WA-002","subject":"ip:8.8.8.8","detail":"Web 攻击特征命中"}' \
  --alerts-file /var/tmp/alerts.json --window 500

# 4) 指定本机名(主机类特征的目的地址用)
python3 "$D/monitoring/alert-enrich.py" --host Cy.AliCOS-BJ \
  --hit '{"sig":"AU-001","subject":"ssh-failed","detail":"SSH 爆破"}'

# 5) 缩小计数窗口(只看最近 50 条)
python3 "$D/monitoring/alert-enrich.py" --window 50 \
  --hit '{"sig":"WA-001","subject":"ip:203.0.113.7"}' --alerts-file /var/tmp/alerts.json

# 6) 走 stdin 的完整 plan(与守卫一致)
printf '%s' '{"items":[{"sig":"WA-002","subject":"ip:8.8.8.8"}]}' \
  | python3 "$D/monitoring/alert-enrich.py" --alerts-file /var/tmp/alerts.json

依赖与权限

项 内容
解释器 python3
第三方模块 无。ipaddress 也是标准库(源码写成 try: import ipaddress / except ImportError: ipaddress = None,缺它时一切判为 unknown,属降级不属崩溃)
外部命令 无
root 不需要。纯字符串/JSON 变换;--alerts-file 需可读
sudo -n 不需要
读取 --alerts-file 指定的文件(UTF-8,errors="replace");读取失败或内容为空时静默返回 None(不报错)
写入 不写任何文件。唯一输出是 stdout 的一行 JSON
网络 不联网(调用方自己用 curl 取 /alerts)
公开符号 classify_ip、enrich_hit、enrich_plan、parse_alerts 等(.scratch/alert-enrich-test.py 直接 import 使用)

幂等与失败语义

  • 纯函数,天然幂等:同样输入 → 同样输出;重复执行无副作用。
  • 状态文件:没有。无落盘、无缓存(_NETS 只是进程内惰性编译的网表)。
  • 一半状态的风险:没有。所有工作都在内存里完成,一次性输出。
  • 失败不阻断调用方:源码 docstring 明确——「任何情况都不要让调用方因本脚本失败而中断检测/上报」;调用方(守卫)对空输出 / 非零退出的处理是「退化为次数未知」。
  • 退出码:
退出码 含义
0 成功(含 --hit 单条输出、--selftest 全部通过)
1 输入不可解析:--hit 的 JSON 非法、stdin 为空、stdin 的 plan JSON 非法、--selftest 有反例失败
2 参数错误(argparse 自身行为;例如 --window 给了非整数)

常见错误与排查

现象 代码里的对应分支 排查步骤
{"ok": false, "error": "stdin 为空"} if not raw.strip(): 忘了用管道喂 plan,或上游写的是空串。守卫侧检查是 printf '%s' "$PLAN" \| … 还是 echo 了空变量
{"ok": false, "error": "plan JSON 不可解析:…"} json.loads(raw) 的 except 上游传的不是 JSON。先用 printf '%s' "$PLAN" \| python3 -m json.tool 验一遍
{"ok": false, "error": "hit JSON 不可解析:…"} --hit 的 json.loads 的 except 引号 / 转义问题;改用文件 + stdin 的方式,避免命令行转义
计数全是 0 / 次数未知 _read_alerts() 返回 None(文件不存在、空、或 JSON 非法 → 静默) 这是最隐蔽的一条:--alerts-file 读不到不报错。ls -l <file>、python3 -m json.tool < <file> \| head 确认内容;再确认 /alerts?limit=500 本身返回了什么
私网 IP 被判成公网(或反之) classify_ip() / NON_PUBLIC_V4 / NON_PUBLIC_V6 --selftest 里有 12 条边界用例(172.16.0.1 private / 172.32.0.1 public、2001:db8::1 public、fe80::1 private 等)。任何口径改动必须与前端 NF.util.isPublicIp 逐条对应
主机类特征的目的地址是空的 _dst_for_host() 按 sig 分支解析 进程= / 对端= / 本地= / 监听= 说明证据串里没有对应字段;核对 security-guard.sh 传的 evidence 格式
--selftest 返回 1 _selftest() 的 bad 列表非空或计数断言失败 它会打印 {"ok": false, "bad": […], "sample": …};bad 里就是失败用例
缺少 ipaddress 导致一切判为 unknown except ImportError: ipaddress = None → _nets() 返回 None 正常 CPython 不会缺;出现说明解释器环境异常(python3 -c "import ipaddress")。注意这是降级不报错

回滚方式

本脚本不修改任何文件或系统状态(连状态文件都没有)。回滚 = 换回脚本本体:

D=/opt/Cy/services/system.d/nfos
BK=$D/state/unit-backups

# 1) 改之前先备份
TS=$(date +%Y%m%d-%H%M%S)
sudo install -d -m 755 "$BK"
sudo cp -a "$D/monitoring/alert-enrich.py" "$BK/alert-enrich.py.bak-$TS"

# 2) 回滚
sudo cp -a "$BK/alert-enrich.py.bak-<时间戳>" "$D/monitoring/alert-enrich.py"
sudo chown root:root "$D/monitoring/alert-enrich.py"
sudo chmod 644       "$D/monitoring/alert-enrich.py"

# 3) 验收(离线,零副作用)
python3 "$D/monitoring/alert-enrich.py" --selftest
python3 "$D/monitoring/alert-enrich.py" --hit '{"sig":"WA-002","subject":"ip:8.8.8.8"}'

# 4) 若要「临时摘掉这一段」:让守卫不再调用它(改守卫前先备份守卫本体)

备份落点铁律:备份放 /opt/Cy/services/system.d/nfos/state/unit-backups/(命名 <文件名>.bak-YYYYMMDD-HHMMSS)或仓库侧 /opt/Cy/nfarms/.backup/。 绝不能放在 /etc/systemd/system/ —— 会触发 PS-002 完整性告警。见 timers.md 第 11 节。

⚠️ 摘掉它的代价是降级而非失效:守卫对空输出/非零退出的处理是「退化为次数未知」, 检测与上报照常——所以静默的降级不会自己报错,改动后务必用示例 2 的形式真跑一次。

相关 CHANGELOG

CHANGELOG 章节标题
docs/CHANGELOG-2026-09-23.md §11. 安全告警内容增强:源/目的地址、同类与同 IP 次数、公网标记
docs/CHANGELOG-2026-09-23.md §12. 修两个真缺口:主节点邮件腿 + 备节点审计跑不了
docs/CHANGELOG-2026-09-26.md §1.1 级别语义:按「打没打穿」而不是按「看着吓不吓人」(同一套「公网/私网 + 响应码」判据口径)
docs/CHANGELOG-2026-09-26.md §5.1 事故:库哨兵误判导致安全巡检停摆约 45 秒(守卫侧改动的爆炸半径,正是本脚本独立成文件的原因)

定时器全景见 timers.md;auto-respond.py 消费本脚本追加的 addr_block/extra,见 auto-respond.md。