跳转至

出站文本统一脱敏(sanitize.py)

仓库路径:deploy/nfos/monitoring/sanitize.py | 线上路径:/opt/Cy/services/system.d/nfos/monitoring/sanitize.py 语言:python3 | 定时器:无(被调用,不挂 timer)


用途

所有离开本机的告警文本(飞书机器人、安全邮箱、每小时 / 每 12 小时汇总通报)在发送前必须经过这里。它是纯函数:同样输入 → 同样输出,不读环境、不写文件、不联网、不依赖第三方库(只用标准库 re),因此可以单独 --selftest 逐条核对。

它要解决的问题是:告警正文天然带着大量内部信息(绝对路径暴露目录结构、登录用户名、邮件地址与手机号、飞书 webhook 与各类 token、内网地址),这些对值班判断毫无帮助,却会随告警进入 IM 群、邮件归档、截图与转发链。目标是「去掉可被利用的内部信息,但绝不吞掉判据」。

铁律:判据必须保留

脱敏只允许动「身份信息」,不允许动下面这些(它们正是研判依据):

  • HTTP 响应码(403 被拦 / 200 打穿)—— 级别语义的判据;
  • 特征号(WA-002、SL-005、nfos.sec:WA-002:ip:...);
  • 命中次数、阈值、扫描行数、窗口;
  • 时间戳;
  • 公网来源 IP——完整保留(攻击溯源必需;掩码会让研判失效)。

另外两点是刻意为之:不追求可逆(只面向展示,不做会引入新泄漏面的可逆编码);保留末两级路径 / 服务账号名,让值班还能对上「是哪台机器上的哪个脚本」。

规则一览(源码 docstring 的 R1–R8)

规则 内容
R1 内部绝对路径 → 保留末两级:/opt/Cy/services/system.d/nfos/security-guard.sh → …/nfos/security-guard.sh
R2 用户名 → 首字符 + ***(ecs-user → e***);服务账号(root/nginx/nobody/nfztna/systemd-* 等)原样保留
R3 邮箱 → a***@domain.tld;手机号 → 138****1234
R4 凭据类 KV → 值替换为 ***(保留键名);值为 URL 时走 R5
R5 URL 里的 webhook / uuid → 保留末 8 位
R6 IP → 公网完整保留;RFC1918 私网掩码后两段;回环 / 链路本地保留
R7 判据保护:响应码 / 特征号 / 计数 / 时间戳永不被改写
R8 单测:--selftest 覆盖 R1..R7,含「不该被动」的反例

唯一明文豁免是 PRESERVE_PATHS:digest.py 里那条自证链路命令 sudo bash /opt/Cy/services/system.d/nfos/check-pipeline.sh 整条豁免路径脱敏—— 那是给值班照着敲的操作指引,掩码后不可执行。

何时用

  • 不需要定时跑:它是库,被 notify.py / sec-notify.py / digest.py / alert-adapter.py 在发送路径上调用。
  • 改了规则之后:先 --selftest(离线、逐条对照),这是唯一必要的验收动作。
  • 排障「某条正文被过度掩码 / 漏掩码」:用 --text / --stdin / --file 单条复现,加 --json 拿到命中清单 hits,定位是哪条规则干的。
  • 接新调用方之前:把它接到出站出口的一处,不要在多个地方各写一份脱敏。

命令与参数

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

参数 类型 默认值 说明
--selftest 开关 False 逐条对照自检
--text 字符串 None 就地脱敏一段文本
--stdin 开关 False 从标准输入读
--file 路径 None 从文件读(UTF-8)
--json 开关 False 输出 {text,hits} JSON

共 5 个参数。

不带参数时的默认行为

按 --text → --file → --stdin 的优先级取输入;三者都没有时,再看 sys.stdin.isatty():

  • 若不是 TTY(例如被管道接上但没写 --stdin),把 stdin 全部读完当作输入(elif args.stdin or not sys.stdin.isatty(): src = sys.stdin.read());
  • 若是交互式终端(真的什么都没给)→ 打印帮助并退出码 2。

即:python3 sanitize.py 直接回车运行不会「卡住等输入」以外的歧义——在终端里裸跑 = 打印帮助 + 退出码 2。

示例

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

# 1) 离线自检:逐条对照 R1..R7 与反例(唯一必要的验收动作)
python3 "$D/monitoring/sanitize.py" --selftest

# 2) 单条就地脱敏(注意 403 与路径末两级必须保留)
python3 "$D/monitoring/sanitize.py" \
  --text 'user=ecs-user 172.27.142.182 /opt/Cy/services/logs/nginx/waf-blocked.log 403'

# 3) 从管道读(脚本内部一律走这条路径)
printf '%s' 'source=/opt/Cy/services/system.d/nfos/security-guard.sh hit=25' \
  | python3 "$D/monitoring/sanitize.py" --stdin

# 4) 带命中清单的机器可读输出(排障用:看是哪条规则改的)
printf '%s' 'user=ecs-user' | python3 "$D/monitoring/sanitize.py" --stdin --json

# 5) 从文件读(UTF-8)
python3 "$D/monitoring/sanitize.py" --file /var/tmp/report-raw.txt --json

# 6) 在调用方里核对「脱敏确实生效」(替代真发一条消息)
sudo python3 -c 'import sys; sys.path.insert(0, "/opt/Cy/services/system.d/nfos/monitoring"); \
import sanitize; print(sanitize.sanitize_with_report("path=/opt/Cy/x/y/z.sh user=ecs-user 403"))'

依赖与权限

项 内容
解释器 任意 python3(零第三方依赖,只用标准库 argparse / json / re / sys)
外部命令 无(不调用 journalctl、不调用 curl、不调 sudo)
root 不需要。纯函数、只读输入、只写 stdout。线上之所以在 root 下跑,是因为调用方(digest / ticket-notify 的 timer 单元)本身就是 User=root
sudo -n 不需要
读取 只有 --file 指定的路径(UTF-8)
写入 不写任何文件。唯一输出是 stdout(--json 时是 JSON,否则是脱敏后的纯文本)
网络 不联网
公开符号 sanitize、sanitize_with_report、RULES、PRESERVE_PATHS、SERVICE_ACCOUNTS(__all__)

幂等与失败语义

  • 纯函数,天然幂等:同样输入 → 同样输出;重复执行无副作用。对已经脱敏过的文本再跑一次不会继续变形(判据与已掩码形态都不在规则命中面上)——但不要把它当「可逆编码」用。
  • 不抛异常:设计上任何异常都原样返回(notify._sanitize_out() 里明确写了「脱敏是展示层加固,绝不能反过来阻断告警」)。
  • 状态文件:没有。无任何落盘。
  • 一半状态的风险:没有。「全部改动都在内存里,输出一次成型」,不会留下半处理的中间文件。
  • 退出码:
退出码 含义
0 脱敏成功输出;或 --selftest 全部通过
1 --selftest 有失败项
2 没有输入且 stdin 是交互式终端 → 打印帮助

常见错误与排查

现象 代码里的对应分支 排查步骤
裸跑脚本只打印帮助、退出码 2 else: ap.print_help(); return 2 这是设计行为;给 --text / --file / --stdin 之一,或用管道喂输入
--selftest 返回 1 --selftest 分支逐条对照失败 看它逐条打印的对照行,定位是 R1..R7 哪一条;先确认改动前是否就失败(用 .bak 版本对照)
正文里判据(403 / 特征号 / 次数)被掩掉了 R7 判据保护分支(不该发生) 用示例 4 的 --json 看 hits 命中清单,确认是哪条规则误伤;在 SELFTEST_CASES 里补一条反例再改规则
出站正文里 webhook / token 仍是明文 notify.py 的 _sanitize_out() 静默 except 分支 说明 import sanitize 失败(sanitize.py 不在 notify.py 同目录,或不可读)。第一现场是「同目录能 import 到吗」:ls -l "$D/monitoring/sanitize.py"、python3 -c "import sys;sys.path.insert(0,'$D/monitoring');import sanitize"
路径只剩 …/x.sh,看不出是哪台机器的哪个脚本 R1 保留末两级是刻意的 对照 R1 说明;需要更精确的定位信息应放在「主机 / 资源」字段而不是正文路径里
用户名 root 没被掩码 R2 的 SERVICE_ACCOUNTS 白名单 这是设计行为:服务账号原样保留,方便值班定位
公网 IP 没被掩码 R6 的「公网完整保留」 这是设计行为:攻击溯源必需

回滚方式

本脚本不修改任何文件或系统状态,是最容易回滚的一类。回滚 = 把脚本本身换回去:

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/sanitize.py" "$BK/sanitize.py.bak-$TS"

# 2) 出问题时换回去
sudo cp -a "$BK/sanitize.py.bak-<时间戳>" "$D/monitoring/sanitize.py"
sudo chown root:root "$D/monitoring/sanitize.py"
sudo chmod 644       "$D/monitoring/sanitize.py"

# 3) 自检(唯一必需的验收动作)
python3 "$D/monitoring/sanitize.py" --selftest

# 4) 端到端复核:看渲染结果,而不是真发消息
sudo python3 "$D/monitoring/digest.py" --window 1h --dry-run

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

特别注意:sanitize.py 是 notify.py 的运行时依赖。回滚时不要简单删文件 (docs/CHANGELOG-2026-09-26.md 的「§8.2 告警治理:备节点回滚」里对备节点用过 sudo rm -f "$D/monitoring/sanitize.py",那只适用于「本轮新增」的场景); 在主节点上删掉它会让脱敏静默失效且不报错。

相关 CHANGELOG

CHANGELOG 章节标题
docs/CHANGELOG-2026-09-26.md §1.7 脱敏:出口统一收口在 notify.py 的 render()
docs/CHANGELOG-2026-09-26.md §6.2 离线自检 1:sanitize.py --selftest
docs/CHANGELOG-2026-09-26.md §0.1 行号口径(读本文档前请先看这一条)(含 sanitize.py 模块 docstring 铁律的行号校准)
docs/CHANGELOG-2026-09-26.md §9. 通知腿 NameError 缺陷(P0,已修复)(notify.py 顶部缺 import sys 导致脱敏静默失效的同类缺陷)

汇总 / 通报侧的调用位置见 digest.md;通知渠道与限速见 notify.md。