很多系统都做过日志脱敏:
php
logger.info("user login", {
userId,
phone: mask(phone),
token: "***",
});
表面上,敏感字段都被处理了。
但线上排查问题时,仍然可能从日志平台搜到完整手机号、Cookie、身份证号,甚至支付凭证。
原因通常不是脱敏函数写错了,而是敏感数据从没有被识别为"需要脱敏的出口"。
日志并不只来自某个显式的 logger.info。它可能出现在:
- 请求头和响应头;
- URL 查询参数;
- 异常对象和堆栈;
- ORM 参数和 SQL 日志;
- 链路追踪属性;
- 第三方 SDK 的内部日志;
- 数据导出与备份文件。
只要有一个出口绕过了统一的序列化边界,敏感数据就可能被写入长期存储。
下面聊聊日志脱敏里最常见的 5 个出口。
出口一:请求头、Cookie 和鉴权信息
请求头是最容易被完整记录的地方。
常见的敏感头包括:
sql
Authorization
Cookie
Set-Cookie
X-Api-Key
X-Auth-Token
Proxy-Authorization
很多框架的请求日志默认会打印完整 Headers,或者把整个 Request 对象序列化。
一个常见的错误写法是:
php
logger.info({
event: "request_received",
headers: req.headers,
});
如果请求里包含 Authorization 或 Cookie,整个凭证就被写进了日志。
更安全的做法是使用允许清单,而不是只维护一份很长的拒绝清单:
javascript
const allowedHeaders = new Set([
"content-type",
"user-agent",
"x-request-id",
"traceparent",
]);
function redactHeaders(headers) {
return Object.fromEntries(
Object.entries(headers)
.filter(([name]) => allowedHeaders.has(name.toLowerCase()))
.map(([name, value]) => [
name,
name.toLowerCase() === "content-type"
? value
: value.slice(0, 200),
]),
);
}
允许清单的好处是:
- 新出现的敏感头不会被默认记录;
- 日志字段范围稳定;
- 更容易做合规审查。
不要只依赖 password、token 这些字段名匹配。凭证可能叫 session、credential、auth、key,也可能放在自定义头里。
出口二:URL 和查询参数
URL 也经常被当作安全字段。
但下面这些 URL 都可能在日志、Trace、网关和浏览器历史中留下敏感数据:
ini
/reset-password?token=...
/api/export?email=user@example.com
/callback?code=...
/files?path=...
常见的日志写法:
php
logger.info({
event: "request_received",
url: req.originalUrl,
query: req.query,
});
这会把完整的查询参数写入日志。
更安全的策略是:
- 只记录已知安全的查询参数;
- 对 URL 中的参数值做白名单或脱敏;
- 不在 URL 中传输密码、Token、验证码;
- Trace 属性中对 url.query 默认关闭或过滤。
例如:
vbnet
function safeUrl(rawUrl) {
const url = new URL(rawUrl, "http://local");
for (const key of url.searchParams.keys()) {
url.searchParams.set(key, "[REDACTED]");
}
return url.pathname + url.search;
}
更彻底的方式是调整接口设计,避免把敏感数据放进 URL。
URL 会出现在反向代理、浏览器历史、监控和错误上报中,它的传播范围往往比请求体更大。
出口三:异常对象和堆栈里的上下文
很多错误日志会这样写:
go
logger.error("request failed", error);
如果错误对象来自 HTTP Client、数据库驱动或第三方 SDK,它可能包含:
- 请求 URL;
- 请求 Headers;
- 请求体;
- 响应体;
- SQL 和参数;
- 连接字符串;
- 用户身份信息。
不同库的 Error 对象结构不同,直接序列化可能把所有附加字段带进日志。
更安全的做法是先把错误转换成稳定的内部错误结构:
lua
function toSafeError(error) {
if (error instanceof Error) {
return {
name: error.name,
code: getErrorCode(error),
message: sanitizeMessage(error.message),
stack: sanitizeStack(error.stack),
cause: error.cause ? toSafeError(error.cause) : undefined,
};
}
return {
name: "UnknownError",
code: "UNKNOWN",
message: "unknown error",
};
}
message 也需要处理。
例如数据库唯一键冲突的错误信息可能包含真实邮箱:
less
duplicate key value violates unique constraint "users_email_key"
DETAIL: Key (email)=(user@example.com) already exists.
这类错误在业务层应该映射成稳定的错误码,而不是把数据库原始消息直接透传到日志或用户响应中。
出口四:ORM、SQL 和第三方 SDK 的隐式日志
有些日志不是你主动打印的。
例如:
- ORM 的慢查询日志会打印绑定参数;
- 连接池会打印连接串;
- HTTP SDK 会打印请求和响应;
- 缓存客户端会打印 Key 和 Value;
- 消息队列会打印消息体;
- 框架会在异常时打印完整请求上下文。
这些日志默认可能是 Debug 级别,但一旦线上临时打开 Debug,就可能短暂产生大量敏感数据。
因此,日志治理不能只检查业务代码,还要覆盖依赖和框架配置:
arduino
数据库:关闭或重写绑定参数日志
Redis:不打印 Value,Key 使用业务命名但避免 PII
HTTP Client:只记录方法、域名、状态码、耗时
MQ:日志中不输出完整消息体
框架:统一替换请求/响应序列化器
同时要检查临时调试开关:
- 是否可以按租户、接口或采样率开启;
- 是否自动过期;
- 是否会写入持久日志;
- 是否经过审批和审计。
"只开五分钟 Debug"也可能产生远超预期的日志量。
出口五:Trace、指标和错误上报平台
可观测性不只有日志。
Trace Span 属性、指标标签、错误上报事件,都可能是敏感数据出口。
例如:
less
span.setAttribute("user.email", user.email);
span.setAttribute("http.request.body", JSON.stringify(req.body));
高基数且敏感的数据不仅可能泄露,还可能让指标系统膨胀。
更合理的 Trace 属性应该包括:
go
service.name
http.method
http.route
http.status_code
db.system
messaging.system
error.type
tenant.id(仅在允许且低基数时)
不要把完整 URL 查询串、用户输入、Token 或请求体塞进 Trace 属性。
错误上报平台也要单独检查:
- 请求体是否会随异常上传;
- 用户上下文是否包含手机号或邮箱;
- Breadcrumb 是否记录了表单内容;
- Source Map 是否暴露内部路径;
- 日志附件是否被永久保存。
可观测性数据通常会长期留存、跨团队检索,并可能被导出到第三方平台。它的敏感数据风险不低于业务数据库。
脱敏应该放在哪个边界
最可靠的做法不是在每个调用点手动 mask,而是建立统一的序列化边界:
rust
业务代码
-> 结构化日志事件
-> 字段策略 / 脱敏器
-> JSON 序列化
-> 日志采集器
日志事件只包含允许的字段,序列化器统一处理:
csharp
function serializeLog(event) {
return JSON.stringify({
...event,
metadata: redactObject(event.metadata ?? {}),
});
}
redactObject 可以采用三层策略:
- 允许清单:只保留已知安全字段;
- 模式匹配:识别手机号、邮箱、证件号、Token;
- 深度限制:限制嵌套层级、数组长度和字符串长度。
但不要把正则脱敏当成唯一防线。格式变化、编码方式和嵌套结构都可能让正则失效。
如何验证脱敏是否有效
脱敏测试不应该只跑一个手机号样本。
可以准备一组包含敏感信息的测试夹具:
ini
Authorization: Bearer secret-token
Cookie: session=secret-session
/api/reset?token=secret-token
email=user@example.com
phone=13800000000
cardNumber=6222020000000000
让它们通过完整的日志链路,然后检查:
- 日志文件中是否出现原始值;
- Trace 属性是否包含原始值;
- 错误上报平台是否包含原始值;
- 备份和导出文件是否包含原始值;
- 第三方 SDK 是否输出请求体。
还可以在 CI 中加入敏感值扫描,使用专门的测试凭证和测试邮箱,避免误报真实数据。
最后检查这 8 项
- 是否只记录允许的请求头和查询参数
- 错误对象是否经过统一结构化和脱敏
- ORM、HTTP、MQ、Redis 等依赖日志是否配置安全
- Debug 日志是否有临时开关和自动过期
- Trace 和错误上报属性是否避免敏感字段
- 日志序列化是否在统一边界执行
- 是否有覆盖完整日志链路的敏感数据测试
- 日志留存、导出和访问权限是否经过治理
日志脱敏不是"把手机号打码"这么简单。
它真正需要保护的是所有可能被长期保存、被跨团队搜索、被第三方采集的数据出口。
当脱敏从调用点转移到统一序列化边界,再扩展到 Trace、SDK 和导出链路,敏感数据才不会因为某个遗漏的入口重新出现在日志里。
你们团队会在日志进入平台前统一脱敏,还是由每个业务服务自己处理?有没有遇到过第三方 SDK 打印敏感请求体的情况?欢迎在评论区聊聊。
如果你也在设计日志治理、可观测性或数据安全方案,也欢迎在微信公众号搜索「长安米粒贵」继续交流。