审计这件事,最容易掉进去的坑是「什么都记」。字段越堆越多,日志越写越大,最后没人读,出事的时候还是翻不出来。狸花云的审计层只有一个文件 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 前先转义。这条永远记不住,永远会再犯一次。
审计的价值不在记录了多少,在于出事那天,你能不能在一分钟里翻到那一行。