正文
日志能帮你定位线上问题,也能把线上问题扩大成数据泄露。接口报错时多打一行 request body,支付回调失败时把完整订单对象丢进日志,AI 应用调试时保存完整 prompt,看起来都方便排查;一旦日志进入云日志平台、CI 构建输出、客服工单、异常上报系统或第三方 APM,敏感数据就多了一条复制链路。
真实事故已经说明,问题不一定从数据库开始。
2018-05-03,Twitter 披露一个密码处理缺陷。Wired 报道称,Twitter 本来会用 bcrypt 处理密码,但缺陷导致密码在完成哈希前被记录到内部日志中,平台建议用户修改密码。这个案例提醒开发者:敏感数据只要在某个中间环节被日志捕获,后面的存储加密和权限控制都只能减轻影响,不能抹掉已经写出的明文。
2019-03-21,Wired 报道 Facebook 承认数亿用户密码曾以可读格式存在内部系统中。报道还提到,部分明文密码是通过 crash logs 等内部机制被意外捕获。对开发者来说,这比“某个地方忘了哈希”更值得警惕:异常日志、调试日志、网络日志和 crash dump 都可能无意中带上原始输入。
2023-03-24,OpenAI 公布 ChatGPT 3 月 20 日事故复盘:Redis 客户端 bug 让部分用户看到其他用户的对话标题,并可能让 1.2% 活跃 ChatGPT Plus 用户的部分支付相关信息短暂可见。OpenAI 同时说明,他们通过日志检查消息是否只对正确用户可见,并改进了日志以确认问题停止。这个案例不是“日志泄露”的简单版本,但它说明了同一件事:缓存、日志和调试数据都处在用户数据流上,记录什么、保留多久、谁能读,必须提前设计。
MITRE 把“敏感信息写入日志文件”归为 CWE-532。OWASP Logging Cheat Sheet 也明确建议不要直接记录 access token、session 标识、密码、数据库连接串、密钥、支付卡数据和敏感个人信息。日志不是越详细越好,日志应该足够排查问题,但不能成为第二份用户数据库。
上线前先查这五类风险:
| 风险 | 常见写法 | 更稳的记录方式 |
|---|---|---|
| token / cookie | 打印完整 headers | 只记录 authType、token 指纹、校验结果 |
| 手机号 / 邮箱 | 打印完整用户对象 | 只记录脱敏值或 hash |
| 订单 / 支付 | 打印完整订单和回调 payload | 只记录订单号、金额、状态、渠道流水号 |
| prompt / 对话 | 保存完整用户输入和 system prompt | 只记录任务类型、模型、token 数、敏感分类 |
| 异常对象 | console.error(error) 带出请求上下文 | 只记录错误类型、requestId、堆栈和脱敏字段 |
先把日志分成三种用途
不同用途的日志,能记录的字段不一样。
| 日志类型 | 用来解决什么 | 可以记录什么 |
|---|---|---|
| 诊断日志 | 排查接口报错、性能慢、三方调用失败 | requestId、接口、状态码、耗时、错误类型 |
| 审计日志 | 追踪谁在什么时候改了什么 | 操作人 ID、动作、资源 ID、结果、IP 段 |
| 安全日志 | 发现爆破、越权、异常访问 | 登录失败、鉴权失败、限流命中、权限拒绝 |
不要用同一份“超详细业务日志”解决所有问题。诊断日志通常保留更短,访问人更少;审计日志要能证明事件发生过,但不应该保存完整请求体;安全日志要能触发告警,但不能把攻击 payload 原样无限期保存。
哪些字段不能直接进日志
上线前把这些字段当成禁止明文记录:
| 类型 | 字段例子 | 可替代记录 |
|---|---|---|
| 认证凭据 | Authorization、Cookie、refresh_token、api_key、session_id | auth_present=true、token_hash=sha256前8位 |
| 密钥配置 | DATABASE_URL、PRIVATE_KEY、AWS_SECRET_ACCESS_KEY、NPM_TOKEN | 配置项名称、是否存在、来源环境 |
| 个人信息 | 手机号、身份证、邮箱、地址、姓名 | 脱敏手机号、用户 ID hash |
| 支付信息 | 银行卡、支付账号、完整回调、账单地址 | 支付渠道、金额、状态、后四位按合规策略处理 |
| 订单详情 | 商品明细、收货地址、优惠券、备注 | orderId、状态、金额区间、失败原因枚举 |
| AI 输入 | 完整 prompt、system prompt、上传文件正文、聊天记录 | 任务类型、模型、输入长度、敏感分类结果 |
订单号本身不一定是秘密,但它可以成为查询入口。能不能记录完整订单号,要看系统里订单号是否可被外部枚举、客服系统是否能直接按订单号查详情、日志平台是否开放给外包或多团队。
先查有没有整包输出
最危险的日志通常不是手写 password=,而是整包输出。
先扫这些模式:
rg -n --hidden -g "!node_modules" -g "!dist" -g "!.output" "console\\.(log|info|warn|error)|logger\\.(debug|info|warn|error)|print\\(|System\\.out|log\\." .再重点查 request、headers、body、user、order、prompt 这些对象名:
rg -n --hidden -g "!node_modules" -g "!dist" -g "!.output" "(req|request|headers|body|payload|user|order|payment|prompt|messages|token|cookie|authorization)" .看到下面这类写法,先改掉:
logger.info({ headers: req.headers, body: req.body }, "request failed");
logger.error({ error, user, order }, "create order failed");
console.log("llm prompt", systemPrompt, userPrompt);更稳的写法是只记录定位问题需要的字段:
logger.warn(
{
requestId,
route: req.route?.path,
method: req.method,
statusCode: 400,
userIdHash: hashUserId(user.id),
orderId: order.id,
errorCode: "ORDER_PRICE_CHANGED"
},
"create order rejected"
);通过标准:日志能让你按 requestId、userIdHash、orderId、errorCode 找到问题,但看不到完整 token、cookie、手机号、地址、prompt 和支付回调原文。
headers 只保留允许清单
很多框架和网关会把请求头打进访问日志。请求头里最容易混进敏感数据:
AuthorizationCookieSet-CookieX-Api-KeyX-Access-TokenX-Refresh-Token- 内部网关签名头
不要做“黑名单删除几个字段”后就放心。更稳的方式是允许清单:
const safeHeaders = {
"user-agent": req.headers["user-agent"],
"content-type": req.headers["content-type"],
"x-request-id": req.headers["x-request-id"],
"x-forwarded-for": maskIp(req.headers["x-forwarded-for"])
};
logger.info({ requestId, headers: safeHeaders }, "request metadata");如果必须确认 token 变化,只记录不可逆指纹:
logger.info(
{
requestId,
authType: "bearer",
tokenFingerprint: fingerprintToken(req.headers.authorization)
},
"auth checked"
);指纹只用于关联同一个 token 是否反复失败,不用于恢复 token。不要记录 token 前缀,因为很多平台的 token 前缀本身包含供应商、权限或账号信息。
手机号和邮箱要统一脱敏
不要在每个接口里临时写一套脱敏逻辑。日志脱敏应该走统一函数,并且有测试。
export function maskPhone(value: string) {
return value.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2");
}
export function maskEmail(value: string) {
const [name, domain] = value.split("@");
if (!name || !domain) return "***";
return `${name.slice(0, 2)}***@${domain}`;
}日志里只使用脱敏后的值:
logger.info(
{
requestId,
userIdHash: hashUserId(user.id),
phone: maskPhone(user.phone),
email: maskEmail(user.email)
},
"profile update requested"
);通过标准:开发者想打印手机号或邮箱时,只能调用统一脱敏函数;代码评审里看到完整 user.phone、user.email 进入日志,要退回修改。
订单日志只记录排查所需字段
订单日志最容易被写得过细,因为订单问题需要排查库存、价格、优惠、支付、地址和配送。但完整订单对象通常包含:
- 用户姓名、手机号、收货地址。
- 商品明细、优惠券、备注。
- 支付渠道返回的原始 payload。
- 内部成本价、结算规则、风控结果。
订单失败日志可以这样记:
logger.warn(
{
requestId,
orderId,
userIdHash,
amount,
currency,
status: "PAYMENT_FAILED",
paymentProvider: "stripe",
providerCode: "card_declined"
},
"payment failed"
);不要这样记:
logger.warn({ order, paymentCallback, user }, "payment failed");如果客服或运营需要看完整订单详情,让他们走受控后台和权限审计,不要让日志平台承担业务查询功能。
AI prompt 默认按敏感输入处理
AI 应用常见的调试日志会记录完整 prompt:
logger.debug({ systemPrompt, userPrompt, messages }, "llm request");这类日志可能包含用户上传的合同、简历、源代码、数据库字段、客户名单、内部规则、system prompt 和工具调用结果。即使没有传统意义上的手机号和 token,prompt 也可能是敏感数据。
更稳的日志结构:
logger.info(
{
requestId,
model,
taskType: "support_summary",
inputTokens,
outputTokens,
promptTemplateVersion,
sensitiveClass: "customer_support",
toolCallCount
},
"llm request completed"
);确实需要保存 prompt 做质量复盘时,先满足这些条件:
| 条件 | 要求 |
|---|---|
| 用户授权 | 隐私政策或企业协议允许保存 |
| 数据脱敏 | token、手机号、邮箱、地址、订单和文件正文已处理 |
| 存储隔离 | 不和普通应用日志混在一起 |
| 访问控制 | 只有需要复盘的人能读 |
| 保留期限 | 到期自动删除 |
没有这些条件,就不要把 prompt 当普通 debug 字符串打印。
异常日志要去掉原始上下文
很多异常对象会带上请求配置、响应体、headers 和重试上下文。例如 HTTP 客户端、数据库 SDK、支付 SDK、AI SDK 报错时,error 里可能包含完整请求。
先把异常转成安全结构:
function toSafeError(error: unknown) {
if (!(error instanceof Error)) {
return { name: "UnknownError", message: "Unknown error" };
}
return {
name: error.name,
message: error.message,
stack: error.stack?.split("\n").slice(0, 8).join("\n")
};
}
logger.error(
{
requestId,
error: toSafeError(error)
},
"third party request failed"
);如果错误消息本身可能包含 SQL、URL、token 或请求体,要在日志库的 formatter 里做二次清洗。不要只相信业务代码每次都会手动脱敏。
日志库要配置全局 redaction
只靠开发者自觉不够。日志库、网关、APM 和异常上报都要有统一脱敏层。
以常见 Node.js 日志结构为例,可以把敏感路径放到全局 redaction 里:
const redactPaths = [
"req.headers.authorization",
"req.headers.cookie",
"headers.authorization",
"headers.cookie",
"body.password",
"body.token",
"body.refresh_token",
"body.api_key",
"user.phone",
"user.email",
"payment.card",
"messages",
"prompt",
"systemPrompt"
];生产环境的目标不是“开发者不犯错”,而是开发者偶尔打错日志时,敏感字段仍然被统一拦住。
日志平台权限要比业务后台更严格
日志平台通常聚合了多个系统的数据,权限不能按“开发都能看”处理。
上线前检查这些点:
| 检查项 | 通过标准 |
|---|---|
| 访问权限 | 只有排障、SRE、安全和值班人员能读生产日志 |
| 查询审计 | 谁查了哪个时间段、哪个用户、哪个字段可追踪 |
| 导出控制 | 大批量导出需要审批或禁止 |
| 保留期限 | debug 日志短保留,审计日志按合规要求保留 |
| 环境隔离 | 测试、预发、生产日志不混放 |
| 第三方同步 | APM、错误上报、客服系统只接收脱敏字段 |
如果日志平台能按手机号、邮箱、订单号直接查完整用户行为,它已经接近业务数据库,权限和审计也要按业务数据库处理。
上线前跑一张日志检查表
发布前至少确认这些项:
- [ ] No full Authorization, Cookie, Set-Cookie, API key, refresh token in logs
- [ ] No full phone, email, ID number, address, payment account in logs
- [ ] No full request body or response body in normal logs
- [ ] No full order object, payment callback payload, shipping address in logs
- [ ] No full AI prompt, system prompt, messages, uploaded file text in logs
- [ ] Logger redaction is configured globally
- [ ] Error objects are converted to safe fields before logging
- [ ] Debug logging is disabled or tightly scoped in production
- [ ] Production log access is limited and audited
- [ ] Log retention matches data sensitivity
- [ ] CI and deployment logs do not print secrets or environment variables
- [ ] Security events still keep requestId, actor, action, target and result再用几条搜索命令检查构建产物、配置和脚本:
rg -n --hidden -g "!node_modules" -g "!dist" -g "!.output" "(Authorization|Set-Cookie|refresh_token|api_key|secret|password|private_key|DATABASE_URL)" .rg -n --hidden -g "!node_modules" -g "!dist" -g "!.output" "(systemPrompt|userPrompt|messages|prompt|paymentCallback|request\\.body|req\\.body|req\\.headers)" .这些命令不会证明日志绝对安全,但能抓出最常见的危险写法。真正上线时,还要结合代码评审、日志库 redaction、APM 配置、网关访问日志和生产权限一起看。
已经打进日志怎么办
发现敏感数据已经进入日志后,不要只删一行代码。
按泄露处理:
- 先停掉继续写入的日志语句或调低生产日志级别。
- 确认数据进入了哪些位置:应用日志、网关日志、APM、错误上报、CI 输出、客服工单、对象存储。
- 删除或隔离包含敏感数据的日志副本,并记录处理范围。
- 如果包含 token、cookie、API key、refresh token、数据库密码或私钥,立即轮换。
- 如果包含手机号、地址、支付信息、聊天内容或 prompt,按隐私和合规流程评估通知、留痕和后续处理。
- 给日志库补全局 redaction,并补测试,避免同类字段再次进入日志。
日志的价值是让你知道系统发生了什么,不是保存系统看到过的一切。上线前把“能不能排查问题”和“会不会泄露数据”同时校验,日志才是工程资产,而不是下一次事故的入口。