审计日志的八条字段:一个星号、一个上限,和邮件里没转义的那行 detail

审计这件事,最容易掉进去的坑是「什么都记」。字段越堆越多,日志越写越大,最后没人读,出事的时候还是翻不出来。狸花云的审计层只有一个文件 server/audit.mjs,一百来行,说到底就干三件事:写一条、倒着读、汇个总。这次把它的设计决策拆开讲。

一、一条记录该有哪几个字段

我们最终定下的结构是这样的,不多加一项:

字段 含义 例子
ts ISO 时间戳,写入时自动补 2026-11-06T04:00:12.388Z
type 事件大类,用来筛选和统计 auth / user / code / shop / settings
action 具体动作,动词 login / issue / switch-db
user 谁做的;系统自动产生的事件写来源 admin / gateway / anonymous
target 操作对象 用户 ID、订单号、设备名
detail 人话补充,只在排障时有用 ¥29.90 wxpay 4200...
ok 成功还是失败 true / false
ip 来源地址(取 x-forwarded-for 首段) 1.2.3.4

ok 单独拿出来是刻意的。它是唯一一个布尔型字段,因为它决定了这条记录会不会进日报的「失败明细」。剩下全是字符串,字符串就不用纠结类型转换。

二、为什么是 JSONL,不是数据库表

三个理由,一条一条说。

追加写不会坏。 fs.appendFileSync(filePath, JSON.stringify(row) + '\n') 是原子的行写。进程被 kill、机器掉电,最多丢最后一行,前面全部完好。数据库表遇上崩溃恢复、锁等待、连接池耗尽,审计这种旁路日志反而会拖累主流程。

可以 grep。 这是运维里最被低估的能力。用户报「我订单没开通」,直接 grep 'O20261106' data/audit.jsonl,一秒出结果,不用连库、不用写 SQL、不用记得表名。

写入失败不影响业务。 整个 append 被 try/catch 包住,出错只 console.error,绝不把异常抛回请求链路。审计是证人,不是门卫——证人失忆不能让当事人被拒之门外。

代价当然有:没有索引,查历史要全量扫。所以读取端做了两件事:


const MAX_READ = 2000   // 单次最多读回的条数

// 从尾部往回扫,凑够 limit 就停
for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
  let row
  try { row = JSON.parse(lines[i]) } catch (e) { continue }   // 半行就跳过
  if (type && row.type !== type) continue
  if (usr && String(row.user || '') !== usr) continue
  out.push(row)
}

一是 倒序扫描,最新的先出,不用把整年日志读进内存排序;二是 坏行直接跳过,JSON.parse 失败就 continue——上面说过掉电可能留半行,一行坏数据不该让整个日志打不开。上限 2000 条同时也是防呆:接口参数 limit 再大也钳到 2000,避免有人 ?limit=999999 把内存打满。

三、那个星号

改用户密码的时候,请求体里带着明文。要是原样写进 detail,等于在服务器上又存了一份明文口令,还贴在日志里能 grep 到。


auditLog(req, 'user', 'update', {
  target: req.params.id,
  detail: JSON.stringify({ role, status, password: password ? '***' : undefined })
})

password 有值就写成三个星号,没值就写 undefined——而 JSON.stringify 会把这个键整个丢掉,于是「没改密码」和「改了密码」在日志里长得完全不一样,排障时一眼能看出这次动没动口令。

顺带一个原则:审计日志记的是「发生了什么」,不是「内容是什么」。密钥、口令、验证码、完整邮箱,一律不落盘。真要追,追的是「谁在什么时候改了它」,不是「它原来是多少」。

四、失败要记,但别把失败原因说给外人

登录失败我们记两条不同的 detail:


if (!user)  audit.append({ type:'auth', action:'login', ok:false, detail:'用户名不存在', ... })
if (!verify) audit.append({ type:'auth', action:'login', ok:false, detail:'密码错误', ... })

写日志时区分是对的——管理员看日报,能立刻分辨「有人在撞库」还是「某个同事忘了密码」。但这两条信息不能出现在给客户端的响应里,否则这个接口就成了用户枚举器:攻击者批量试用户名,返回不同的提示,一分钟就能把账号列表摸出来。

同样的道理,recordLoginFail 按用户名计数并临时锁定,也是在给撞库加成本。审计和防护是两件事,同一个事件写两遍、用途不同,别偷懒合并。

五、日报里那行没转义的 detail

审计日报是拼 HTML 发邮件的,其中失败明细这一段:


failHtml = fails.slice(0, 30).map((f) =>
  `<tr><td>${f.detail || ''}</td></tr>`
).join('')

detail 里可能有用户可控的字符串——比如 target 存的是设备名,而设备名是用户自己填的。只要有人在设备名里塞一个 ,这封邮件的表格结构就被打乱了,运气不好就是个 HTML 注入。

这是老毛病了:只要把数据拼进 HTML,就必须转义。之前踩过一次类似的——JSON.stringify 出来的双引号把 onclick 属性截断,页面上按钮全点不动。两条教训是同一个:拼接字符串构造标记语言,永远假设数据是脏的。

写这篇文章的时候,这处还没改。已经记在待办上——escapeHtml() 三个字符的事,但得先想清楚它该放在拼装函数里还是每一处调用点。放在拼装函数里更省心,也更容易漏。

六、四条能照着抄的准则

1. 记录能追责的最小集合。八条字段够了,多一个都嫌吵。

2. 旁路日志不能阻塞主流程。写失败打日志,不抛异常。

3. 脱敏在写入端做,不在展示端做。落盘那一刻就得是干净的。

4. 拼 HTML 前先转义。这条永远记不住,永远会再犯一次。

审计的价值不在记录了多少,在于出事那天,你能不能在一分钟里翻到那一行。

💬 留下你的爪印