日志脱敏为什么总是漏?聊聊 5 个容易被忽略的数据出口

很多系统都做过日志脱敏:

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 可以采用三层策略:

  1. 允许清单:只保留已知安全字段;
  2. 模式匹配:识别手机号、邮箱、证件号、Token;
  3. 深度限制:限制嵌套层级、数组长度和字符串长度。

但不要把正则脱敏当成唯一防线。格式变化、编码方式和嵌套结构都可能让正则失效。

如何验证脱敏是否有效

脱敏测试不应该只跑一个手机号样本。

可以准备一组包含敏感信息的测试夹具:

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 打印敏感请求体的情况?欢迎在评论区聊聊。

如果你也在设计日志治理、可观测性或数据安全方案,也欢迎在微信公众号搜索「长安米粒贵」继续交流。

相关推荐
Dumbness1 小时前
后台管理员把自己锁在门外:RBAC 里「自己的权限只能由别人来减」
安全·node.js
manageengine1 小时前
PHP2Shell 事件复盘:企业应该如何自查、修复与规模化处置
运维·安全·php
箓维2 小时前
线程池&&线程安全与可重入
linux·网络·安全
龙孚信息3 小时前
跨境电商独立站的安全盲区:一次 PHP 核心漏洞的影响评估
开发语言·安全·php
志尊宝3 小时前
AI原生前端开发(019):端侧AI安全与隐私:数据保护、权限控制
人工智能·安全·ai-native
德迅云安全-上官3 小时前
新一代智能分布式云接入:应用加速守护 TCP 业务安全与体验
分布式·tcp/ip·安全
桌管小李3 小时前
终端安全一体化实战:AI 审计、行为分析、零信任、防拍照与远程运维全解析
运维·服务器·网络·人工智能·windows·安全·macos
迪康Defender4 小时前
端点安全选型观察:一体化平台与传统 EDR 的场景差异
运维·经验分享·安全·自动化·学习方法·远程工作·安全架构
零域行者4 小时前
Linux入门基础命令
linux·服务器·安全·web安全