审计日志

排水器与完整性

记录合规
auditEnricher 用于自动填充请求上下文,auditOnly 用于将审计路由到专用 sink,而 signed 用于提供可检测篡改的 HMAC 或哈希链完整性。

三个构建模块:auditEnricher 填充上下文,auditOnly 将审计路由到专用 drain,而 signed 添加可检测篡改的完整性。每个都是可选启用且可替换的。

auditEnricher()

auditEnricher() 会填充 event.audit.context.{requestId, traceId, ip, userAgent, tenantId}。如果你的策略不同,可以跳过它并提供自定义 enricher。

server/plugins/evlog.ts
import { auditEnricher } from 'evlog'

nitro.hooks.hook('evlog:enrich', auditEnricher())

对于多租户应用和自定义会话桥接,请传入选项:

nitro.hooks.hook('evlog:enrich', auditEnricher({
  tenantId: ctx => ctx.event.tenant as string | undefined,
  bridge: { getSession: async ctx => readSessionActor(ctx.headers) },
}))

如果没有 auditEnricheraudit.context 将保持为空——审计员和事件响应人员至少需要 requestIdip 来定位一次已记录的操作。

auditOnly()

为什么要把审计过滤到单独的 sink? 有三个原因:成本(审计量相对于产品遥测来说很小——把它们分开可以避免保留成本失控)、权限(审计数据集应当对工程师只读、对应用只写)、以及保留(审计通常保留 7 年以上;产品日志很少保留超过 90 天)。

auditOnly(drain) 只转发带有 audit 字段的事件。可与任何 drain 组合:

import { auditOnly } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'

// 将审计发送到专用的 Axiom 数据集:
nitro.hooks.hook('evlog:drain', auditOnly(
  createAxiomDrain({ dataset: 'audit', apiKey: process.env.AXIOM_AUDIT_API_KEY }),
))

设置 await: true 以使审计写入同步化(审计不使用 fire-and-forget——默认具备崩溃安全):

auditOnly(createFsDrain({ dir: '.audit' }), { await: true })

await: true 标志会为每个记录审计的请求带来一点点延迟(一次同步 drain 调用),但能保证在响应发送之前审计已经落盘。对于合规级审计来说,这个权衡总是值得的。

signed()

signed(drain, opts) 会添加可检测篡改的完整性。它有两种策略:

策略它增加的内容使用场景
'hmac'event.audit.signature(规范化事件的 HMAC)单事件完整性检查(任何后续修改都会验证失败)。
'hash-chain'event.audit.prevHashevent.audit.hash可验证的链——删除和重排也会变得可检测。
signed() 实际带来的好处。 是检测,不是预防。任何对底层 sink 有写入权限的人仍然可以直接删掉文件或表——但这条链能在事后证明哪些事件被删除或修改了。如果你已经写入的是仅追加 / WORM 存储(S3 Object Lock、带行级不可变性的 Postgres、BigQuery 仅追加表),就跳过 signed();叠加两层完整性只会增加延迟,却不会提高门槛。

HMAC

每个事件都会获得一个签名。篡改某一行会破坏该行的验证,但不会破坏后续行。

import { signed } from 'evlog'

signed(drain, { strategy: 'hmac', secret: process.env.AUDIT_SECRET! })

哈希链

每个事件都引用前一个事件的哈希。删除任何一行都会破坏该点之后的链,因此验证器可以精确定位被篡改的那一行。

signed(drain, {
  strategy: 'hash-chain',
  state: {
    load: () => fs.readFile('.audit/head', 'utf8').catch(() => null),
    save: (h) => fs.writeFile('.audit/head', h),
  },
})

对于跨进程或持久化链路,state 配置是必需的:在每个事件之前,从你自己的存储(Redis、Postgres、文件)中加载上一个 head hash,并在之后保存新的 head。

audit chain·idle
  1. #1invoice.refund·success
    prev:hash:3f2c8e1a
  2. #2user.update·denied
    prev:3f2c8e1ahash:9a1b4d7c
  3. #3apiKey.revoke·success
    prev:9a1b4d7chash:c4e7f2b9
link ok tamper detected chain verified · 3 events intact
用于遍历并验证链的 CLI(evlog audit verify)已列入路线图。在此之前,请通过重新计算已存储事件的哈希,并将每个 prevHash 与前一个事件的 hash 进行比较来验证。