出站文本统一脱敏(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 导致脱敏静默失效的同类缺陷) |