日志里的敏感信息:脱敏落地与旧日志清理

一条报错日志把用户手机号写进了文件

去年冬天,我在排查一个订单提交失败的问题,随手 tail -n 200 看了一眼应用日志。看完那一刻整个人是僵的:日志里赫然躺着用户的姓名、手机号、收货地址、身份证后四位,还有完整的请求参数。这些日志在我的服务器上躺了八个月,被 logrotate 转成了十几个 .gz 文件,同步到备份盘,又被 restic 打包传到了对象存储里。

也就是说,我为了排查问题而留下的日志,已经变成了一份脱敏程度为零的用户信息副本,而且它的数量比数据库里的还多——数据库还会删旧订单,日志可不会自己删。更麻烦的是,这份数据在至少三个地方存在:服务器本地、备份盘、对象存储。任何一个环节出问题,我都得写情况说明。

这篇记录我从发现问题到完成整改的全过程。核心结论先摆出来:日志脱敏不是加一个正则替换就完事,它是一个从「哪些字段该写」到「写下来之后怎么留、怎么删」的完整链条,而且任何一步漏掉,前面的努力都会作废。

第一步:先弄清「谁在写敏感数据」,不要直接动手改

第一反应是去 error_log 里跑一遍正则替换手机号。我忍住了,因为改动之前必须先回答一个问题:这些敏感信息到底是通过哪几条路径写进日志的?如果路径没找全,改完一条还有三条在漏。

实际情况是,这些数据来自四个完全不同的入口,处理方式也完全不同:

第一个入口是 PHP 自己的错误日志。代码里有一批老地方还在用 error_log(print_r($data, true)) 调试,$data 就是整个请求体,里面自然带上了用户表单。这类日志是零散的、不可预测的,只能靠改代码。

第二个入口是框架的统一异常处理器。异常发生的时候它会把 context 数组整个序列化进日志,包括入参、会话、数据库绑定参数。这类日志好处是比较集中,一个地方改就能覆盖大量场景。

第三个入口是 Nginx 的访问日志。这里有个很多人没注意到的细节:如果用默认的 combined 格式,query string 会跟在 URI 后面一起被记录,所以任何把手机号、邮箱、token 拼在 URL 上的接口都会被原样写进 access.log。我站上正好有个老接口还在用 ?phone=138xxxx 的形式传参。

第四个入口是数据库的慢查询日志和错误日志。MySQL 会记录完整的 SQL 语句,如果业务代码没走预处理而是直接拼接,那么 INSERT INTO orders VALUES ('张三','138...') 就会原封不动落到 slow.log 和 general_log 里。

把这四条路径列清楚之后我才明白:只改应用层的日志格式,是解决不了这个问题的。 Nginx 的日志里有,MySQL 的日志里也有,而且这两个地方恰恰是运维平时最爱翻的。

第二步:分级处理,而不是「全部打码」

有人可能会说,那就在所有日志输出之前统一做一遍正则替换,手机号、身份证、邮箱全部打码。这个思路听起来很干净,实际做起来有两个坑。

第一个坑是性能。如果每一次日志写入都跑一遍十几个正则,在高频接口上会变成可观的 CPU 开销,尤其 18 位身份证、11 位手机号这种还要处理各种带分隔符的变体,正则一复杂,回溯成本立刻上去。我实测过一个不太严谨的版本,QPS 3000 的时候日志模块占掉近 8% 的 CPU,这个代价在技术上完全没必要。

第二个坑更要命:全量打码会让你失去排查能力。假设用户投诉「我下的是 A 商品,收到的却是 B」,而你日志里所有 ID 都打成了星号,那你根本没法复现。所以脱敏的目的是保护个人身份信息,不是让人看不懂日志。

我的做法是把字段分成三档,每一档的处理方式不一样:

第一档,绝对不写。 密码、验证码、支付 token、银行卡完整号、身份证完整号、会话 ID。这一类不需要「脱敏」,直接在写日志的入口把它剔除掉,源码里出现这些字段被打印的情况,视为 bug 处理。

第二档,脱敏后写。 手机号保留前三位和后四位(138****8888),邮箱保留首字符和域名,住址只保留到市级。这一类既保证了人能凭「前三位 + 后四位」对上人,又不会泄漏完整信息。

第三档,原样写。 订单号、商品 ID、用户内部 ID、状态码、耗时、错误码。这些是排查问题的关键,本来就不属于个人敏感信息,碰都不要碰。

有了这个分档,实现上就清晰了:在统一的日志出口做「键名匹配」而不是「值匹配」。也就是不去正则扫描每一段文本,而是看你正在写的是一个带有明确键名的结构体,键名是 phone、id_card、password 就按对应规则处理,其他键名直接放行。

第三步:应用层改造——在出口收口,而不是在调用处打补丁

最不可取的做法是一个个去调用点改 print_r。我站上有两百多处日志调用,改完必然漏,而且以后新写的代码还会漏。

正确的位置是日志出口。无论你用 Monolog 还是自己封装的 Logger,最后都会汇聚到一个 write 方法。在这里插入一层过滤,就同时解决了存量和增量问题。下面是我实际用的 PHP 实现的核心部分:

const SENSITIVE = [
    'password' => 'drop',
    'passwd'   => 'drop',
    'token'    => 'drop',
    'code'     => 'drop',
    'phone'    => 'mask_phone',
    'mobile'   => 'mask_phone',
    'email'    => 'mask_email',
    'id_card'  => 'mask_idcard',
    'address'  => 'mask_address',
];

function sanitize(array $ctx, int $depth = 0): array
{
    if ($depth > 6) {
        return ['__truncated' => 'too deep'];
    }
    foreach ($ctx as $k => $v) {
        $rule = SENSITIVE[strtolower((string)$k)] ?? null;
        if ($rule === 'drop') {
            $ctx[$k] = '[REDACTED]';
        } elseif ($rule === 'mask_phone' && is_string($v)) {
            $ctx[$k] = preg_replace('/^(\d{3})\d{4}(\d{4})$/', '$1****$2', $v);
        } elseif ($rule === 'mask_idcard' && is_string($v)) {
            $ctx[$k] = preg_replace('/^(.{4}).+(.{4})$/', '$1**********$2', $v);
        } elseif (is_array($v)) {
            $ctx[$k] = sanitize($v, $depth + 1);
        }
    }
    return $ctx;
}

几个细节值得说明。

深度限制不能省。有些框架的上下文里会有循环引用或者嵌套极深的对象,递归时不设上限会直接把内存打爆。我在深度超过六层时直接截断,代价是偶尔少记一点信息,但换来了确定性。

键名匹配要大小写不敏感。同一份代码里 phone、Phone、userPhone 三种写法我都见过。实际做法是把键名先转小写再匹配,并且对 userPhone 这类驼峰命名也做一次拆分匹配,否则很容易漏。

键名规则必须允许白名单例外。有一类字段叫 product_code,它包含 code 这个词,如果照搬上面的 code => drop 规则,商品编码就全被打码了。所以我在规则表里另外维护一个 EXCEPT 列表,先过例外再走规则。

还有一个容易被忽略的问题:异常堆栈里也会有敏感信息。 PDO 抛异常的时候,异常消息里可能带着完整的绑定参数;某些框架会把整个 $request 对象塞进异常的 context。上面这层过滤只处理了 context 数组,堆栈里的消息串需要另外读取并做同样的处理,或者更简单的做法——生产环境把 getTraceAsString 替换成帧序号 + 文件行号,不带参数值。

第四步:Nginx 访问日志——query string 是漏点

应用层改完之后,我去查了 access.log,发现敏感信息依然在。原因就是前面提到的 query string。

标准做法是在 Nginx 层就不记录参数。但完全不记参数会让排查困难,所以更好的做法是记录「参数名」而不记录「参数值」,或者对参数串做整体处理。我给日志加了一个自定义变量:

map $args $args_safe {
    default            "[stripped]";
    ""                 "";
    ~^page=\d+$        $args;
    ~^cid=\d+$         $args;
}

log_format zz '$remote_addr - $remote_user [$time_iso8601] '
              '"$request_method $uri?$args_safe $server_protocol" '
              '$status $body_bytes_sent "$http_referer" '
              '"$http_user_agent" rt=$request_time';

这里的 map 逻辑是「默认把参数串整体替换成一个标记,只有明确白名单里的几个参数才保留」。这是一条很关键的原则:白名单而不是黑名单。 因为参数是开发者随手加的,你今天列全了 phone、email、token,明天上线一个新接口叫 acct,黑名单就漏了。白名单相反,漏掉的代价只是「少记了点排查信息」,方向是安全的。

改 log_format 必须 reload Nginx,而且这里有一个老坑:reload 不会影响已经打开的日志文件句柄,但新旧行会因为格式不同而字段错位。 如果你的日志分析脚本是按位置取第几个字段的,从 reload 那一刻起所有历史数据的解析都会出错。我的做法是先 mv 掉现有日志、reload 让 Nginx 新建文件,然后在分析脚本里按 $time_iso8601 出现的位置判断格式版本,新旧分开解析。分析完新格式稳定了,再把老文件单独跑一遍。

另外,real_ip 相关的检查也要一起做。如果前面有 CDN,$remote_addr 是 CDN 节点 IP 而不是真实用户 IP,这时候日志本身不算泄漏用户 IP,但反过来——如果你在日志里额外记录了 $http_x_forwarded_for,那就是把整条链路都写下来了,而且用户是可以自己伪造这个头的,等于给了别人往你日志里注入任意内容的机会。我的建议是只记录经 real_ip 模块还原后的 $remote_addr,不记录原始 XFF。

第五步:MySQL 侧的三个开关

数据库日志这块,很多人只知道关慢查询,其实要处理的是三个地方。

第一个是 general_log。这是记录所有 SQL 的开关,默认是关闭的,但我见过有人在排查问题时打开忘了关,结果一晚上写出了几十 GB 的日志,里面全是明文业务数据。检查方法很简单:SHOW VARIABLES LIKE 'general_log%',如果 general_log 是 ON,立刻关掉,然后把已生成的日志文件安全删除(不是直接 rm,见下一节)。

第二个是慢查询日志里的 SQL 文本。从 MySQL 8.0 开始有 slow_query_log_use_global_control 之类参数,但真正管用的是应用侧:所有业务 SQL 都走预处理,不用字符串拼接。 走预处理的语句在慢查询日志里记录的是带 ? 占位符的模板,自然就没有具体值。这一条同时也是防 SQL 注入的根本手段,一举两得。

第三个是 performance_schema 里的事件历史表。events_statements_history_long 保留的也是完整 SQL 文本,默认存在内存里,重启即失。但如果有人为了排查把它 dump 到文件,同样会造成泄漏。定期确认这张表的使用情况即可,一般不需要额外处理。

如果实在绕不开拼接(比如存在没法参数化的动态表名),那我建议至少给这类表的关键列做应用层的记录节制:日志里只记 ID,不记内容。

第六步:已经写下的旧日志怎么办

改完代码只是止住了出血,已经躺在磁盘上的旧日志还在那儿。这一步最容易被跳过,因为麻烦。

处理顺序是这样的。

先做一个影响面评估:统计一下现有日志里命中敏感正则的条数。用 zgrep -c 扫一遍压缩文件,得到一个数字。这个数字决定了你后面要不要走「删除」这条路——如果只有几十条,可以手工处理;如果是几十万条,那基本只能整体销毁。

然后是备份链路上的同步清理。这一环经常被忘:日志已经从服务器同步到了备份盘,restic 仓库里也有快照。只删服务器上的文件,备份里的副本还在。restic 的处理方式是先 restic forget 掉包含这些日志的快照,再 restic prune 真正回收数据块。注意 prune 有写放大和请求费用,大仓库要挑低峰期跑,跑之前确认没有别的东西依赖这个快照。

删除日志文件时不要直接 rm。如果 Nginx 或者 PHP-FPM 还持有这个文件的文件描述符,rm 之后空间不会立刻释放,而且进程会继续往这个已经 unlink 的文件里写。正确做法是 truncate -s 0 把大小清零,或者先 mv 走再让进程重开。我倾向后者配合 nginx -s reopen,因为保留文件名对日志分析脚本更友好。

最后是对象存储上的归档。如果日志备份是周包或月包,需要确认保留策略里有没有一条「敏感数据相关的包不再延长保留」。对象存储的生命周期规则可以按前缀设置,专门给日志规格一个更短的过期时间,比手工删可靠。

第七步:加一条能持续生效的检查

整改完之后,最大的风险是「过几个月又慢慢漏回来」。新来的同事不知道这条规矩,调试时又 print_r 了整个数组。所以我加了两道检查。

第一道是每天跑一次的日志扫描。不是扫描全部,那太重;只扫当天新增的日志文件,用几条固定的正则匹配手机号、身份证、邮箱、银行卡的格式,命中就发邮件提醒。脚本用 grep -c 配合不要写 set -e——因为 grep 没有匹配时返回 1,写了 set -e 会让整个脚本在「一切正常」的时候静默退出,这是我自己踩过的坑,第一次跑完发现脚本没输出,还以为是有问题。

#!/bin/bash
LOG=/var/log/php/app-$(date +%F).log
[ -f "$LOG" ] || exit 0
hits=$(grep -Ec '1[3-9][0-9]{9}|[0-9]{17}[0-9Xx]' "$LOG")
if [ "$hits" -gt 0 ]; then
    echo "$(date '+%F %T') suspicious=$hits file=$LOG" >> /var/log/sensitive-check.log
fi

第二道是代码层的静态检查,放在提交前。用一条 grep 就能覆盖大部分场景:检查新增的代码里有没有 print_r、var_dump、error_log(json_encode($request 这类写法。不追求百分百准确,它的作用是提醒——让写代码的人在提交时就想起这件事,而不是等三个月后被日志扫描发现。

这两道检查的成本都很低,加起来不到二十行代码,但它们把「靠人记住」变成了「靠流程保证」,这是我认为整个整改里最有价值的部分。

几个我认为值得说清楚的边界

第一,脱敏和可用性是有冲突的,不能两头都要。不要试图设计一个「既能精确定位到人,又完全匿名」的方案,那不存在。你只能明确地选择保留哪几个字符,然后接受这个取舍。

第二,日志保留期本身就是一个安全参数。很多人关心脱敏,却不关心「这些日志留多久」。我现在的策略是应用日志保留 14 天,访问日志保留 30 天,超出的一律删。保留期缩短的收益往往比脱敏更大,因为它减少了暴露窗口。前提是你要确保 14 天足够排查问题——这个数值得按自己的业务节奏定,别照抄。

第三,不要指望一键工具。市面上有日志脱敏的中间件,用起来确实方便,但它们通常只处理明文字段,对已经序列化成 JSON 字符串的整体 payload 无能为力,而这个形态恰恰是最常见的。真要落地,还是得在自己的日志出口做,因为只有你知道哪些字段是敏感字段。

第四,脱敏规则本身要进版本库,并且写清楚变更记录。理由很简单:半年后有人来问「为什么某个字段突然看不到了」,如果没有记录,就只能靠翻代码。我在代码里给规则表加了一行注释指向变更日志,每次调整都记一笔日期和原因,后来确实用上了。

整套改完花了我大概两个周末,其中一半时间用在翻旧日志和清理备份上,改代码只占了不到四分之一。这个比例可能对每个站都不一样,但有一点是共通的:发现问题的第一反应不应该是改代码,而应该是先弄清楚数据都流到了哪些地方。 否则你只是堵住了一个口子,剩下的还在往外漏。

Last modification:October 25th, 2026 at 07:26 pm

Leave a Comment