15.3 审计与合规:不可篡改审计日志
15.1 拦住了越权,15.2 拦住了脏输出。但安全系统还有一条底线:当事情真的发生时,你必须能还原出「谁、在什么时候、做了什么」。这不是给运维看的调试信息,而是给审计和合规看的证据。一条删不掉的记录,比一百句「我们的 Agent 很安全」都有说服力。
本节把 ResearchCopilot 推进到「可审计」:实现一份追加式 + 哈希链的审计日志,每条记录带上一条的哈希,任何一处改动都会让链断掉。我们真跑一遍——写 5 条记录、校验链完整、篡改中间一条、再校验,看它怎么把「第 N 条被改了」指出来。最后给出审计字段表与保留策略。
15.3.1 审计日志不是普通日志
很多人把审计日志当成「日志的一种」,随手 logging.info 记一条就算数。这是个误区。审计日志与普通日志的目标完全不同:
| 维度 | 普通日志 | 审计日志 |
|---|---|---|
| 目标 | 排障、观测 | 追责、合规、取证 |
| 完整性 | 可丢、可采样 | 必须完整,一条不能少 |
| 可篡改 | 无所谓 | 必须能证明没被改过 |
| 保留期 | 几天到几周 | 数月到数年(按法规) |
| 格式 | 人读优先 | 机器可校验 + 人可读 |
| 写入时机 | 异步、可丢 | 同步、先落盘再继续 |
一句话:普通日志丢了可以重跑,审计日志丢了就是证据链断裂。所以审计日志的写入必须比普通日志更「重」——同步写、落盘后才继续、写失败要阻断业务。这与 3.3 的幂等、4.3 的审批记录是一脉相承的:关键状态先持久化,再继续。
15.3.2 追加式 + 哈希链
「不可篡改」听起来很难,其实一个经典结构就能做到大部分:追加式(append-only)+ 哈希链(hash chain)。
思路很简单:每条记录都存下上一条记录的哈希,再把自己(含前哈希)的哈希也算出来存进去。这样:
- 想改第 3 条 → 它的内容变了 → 它的
record_hash对不上 → 校验时立刻发现。 - 想连哈希一起改 → 第 4 条的
prev_hash指向的是第 3 条原来的哈希 → 第 4 条又对不上了。 - 想改一整条链 → 得把后面每一条的哈希都重算,还得让链的「锚」(比如定期外发的根哈希)也一致。
用一个最小实现说明。先定义记录结构和哈希函数:
import hashlib, json
from dataclasses import dataclass, asdict
GENESIS = "0" * 64 # 创世哈希:链的起点
@dataclass
class AuditRecord:
seq: int
ts: str
actor: str
workspace: str
run_id: str
tool: str
args_digest: str
status: str
approver: str | None
prev_hash: str
record_hash: str = ""
def _body(rec: AuditRecord) -> dict:
d = asdict(rec)
d.pop("record_hash") # 哈希本身不参与计算
return d
def _hash(obj: dict) -> str:
blob = json.dumps(obj, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
def digest_args(args: dict) -> str:
return _hash(args)[:16] # 只留摘要,不落原始参数
两个细节。① sort_keys=True:字典的键序要稳定,否则同样的内容会算出不同的哈希。② args_digest 只存摘要:工具的原始参数可能含敏感信息(15.2),审计只存一个哈希,既能证明「当时是这个参数」,又不把明文留在日志里。
追加和校验:
def seal(rec: AuditRecord) -> AuditRecord:
rec.record_hash = _hash(_body(rec))
return rec
def append(records: list[AuditRecord], **kw) -> AuditRecord:
prev = records[-1].record_hash if records else GENESIS
rec = AuditRecord(seq=len(records) + 1, prev_hash=prev, **kw)
records.append(seal(rec))
return rec
def verify(records: list[AuditRecord]) -> str:
prev = GENESIS
for i, r in enumerate(records):
if r.prev_hash != prev:
return f"第 {i + 1} 条校验失败:prev_hash 与上一条 record_hash 不符"
if _hash(_body(r)) != r.record_hash:
return f"第 {i + 1} 条校验失败:record_hash 与内容不符(内容被改动)"
prev = r.record_hash
return f"链完整:{len(records)} 条记录全部校验通过"
verify 从头到尾走一遍:先比 prev_hash 是否等于上一条的 record_hash,再重算本条哈希看是否对得上。任何一处不一致,都会定位到具体是第几条。
15.3.3 记什么:审计字段
字段设计要回答「谁、何时、何地、对什么、做了什么、结果如何、谁批的」:
| 字段 | 含义 | 例子 |
|---|---|---|
seq | 单调序号 | 3 |
ts | 服务端时间戳(ISO 8601) | 2026-10-07T14:02:11+08:00 |
actor | 发起者 | u_bob |
workspace | 租户 | ws_globex |
run_id | 哪次运行 | run_42 |
tool | 调了哪个工具 | delete_run |
args_digest | 参数摘要(非明文) | a1b2c3… |
status | 结果状态 | approved / ok / denied |
approver | 审批人(危险动作) | u_bob |
prev_hash / record_hash | 链指针 | … |
注意几个口径:时间戳用服务端时钟,不信客户端(4.3 讲过审批的 expires_at 同理);status 区分「审批」和「执行」两条——delete_run 会先落一条 approved(审批通过),执行后再落一条 ok,这样审计能还原「批了但没执行成功」这种中间态;approver 只对危险动作有值,只读操作留空。
15.3.4 真跑:写入、校验、篡改、再校验
写入 5 条记录(覆盖只读、写入、危险三种),校验链,然后篡改第 3 条再校验。真跑输出(Python 3.14.6):
>>> 1) 写入 5 条审计记录并校验链
#1 search ok prev=00000000 hash=7fa8934e
#2 sql_exec ok prev=7fa8934e hash=be0dc0c8
#3 delete_run approved prev=be0dc0c8 hash=b7ddec6a
#4 delete_run ok prev=b7ddec6a hash=228096cc
#5 digest ok prev=228096cc hash=20bdb41e
校验: 链完整:5 条记录全部校验通过
链是完整的:每条 prev 都等于上一条的 hash,从创世哈希 00000000 起头,一路扣到第 5 条。
现在模拟一次篡改——把第 3 条的 status 从 approved 偷偷改成 ok(假装这次删除没经过审批),再校验:
>>> 2) 篡改第 3 条(把 status 从 approved 改成 ok),再校验
校验: 第 3 条校验失败:record_hash 与内容不符(内容被改动)
篡改被精确定位:第 3 条的 record_hash 是按原始内容算的,内容一改,重算的哈希就对不上,校验立刻报「第 3 条」。
如果攻击者更聪明——改完内容顺手把第 3 条的 record_hash 也重算一遍,想掩盖呢?再校验:
>>> 3) 攻击者重算第 3 条的 record_hash 以掩盖改动,再校验
校验: 第 4 条校验失败:prev_hash 与上一条 record_hash 不符
攻击者补上了第 3 条的哈希,但第 4 条里存的是第 3 条原来的哈希——第 4 条的 prev_hash 现在指向一个已经不存在的老哈希,于是第 4 条暴露了。这就是哈希链的威力:改一条要连着改后面所有条,而后面每一条的哈希又都被下一条锚住。要彻底掩盖,只能重算整条链——那就必须连链的「外部锚」也一起改,而外部锚(比如定期把当前根哈希发到别处、写进只读存储)正是防这一手的。
这两次篡改演示了哈希链的两层防御:朴素改动被本条的 record_hash 抓住(第 3 条);补算哈希的改动被下一条的 prev_hash 抓住(第 4 条)。两次报的「第 N 条」不同,但都精确定位了断链的位置。实际取证时,只要 verify 返回的不是「链完整」,就说明这份日志已经不能作为完整证据使用——哪怕只差一条,整条链的可信度都要打问号。
15.3.5 保留期与「按用户删除」的张力
审计日志有个绕不开的矛盾:合规要求「长期保留」,隐私法规要求「用户可删除」。11.3 讲过按用户删除记忆数据,现在审计日志要保留,两者怎么共存?
答案是把**「事实」和「内容」分开**:
| 保留什么 | 删除什么 |
|---|---|
| 谁、何时、哪个 run、调了哪个工具 | 工具参数里的原文(用户消息、检索内容) |
| 结果状态、审批人 | 答案全文、引用块的原文 |
| 参数摘要(哈希) | 参数明文 |
也就是说:审计日志记「发生了这件事」,不记「这件事的原文」。用户要求删除时,把消息表、记忆表、答案表里属于他的内容删掉(11.3),但审计记录留下——它只含 actor / run_id / tool / 状态这些事实,不含内容,删不删都不涉及隐私。这样既满足「可追责」,又不违背「可删除」。
一条底线:审计日志本身绝不能提供「修改」接口。任何「更正一条审计记录」的需求,正确做法都是再追加一条更正记录,而不是改原来那条——append-only 是它的生命线,一旦开了口子,哈希链就形同虚设。
把「删除请求」和「审计记录」两条线并排看,边界很清楚:
- 用户要求删除 → 删内容(消息、记忆、答案),保留事实(谁、何时、哪个 run、调了什么工具)。
- 合规要求留存 → 保留事实,但事实里不含用户内容,所以不冲突。
- 数据主体查询(谁看过我的数据)→ 用
workspace+actor反查审计记录即可回答,无需读内容。
15.3.6 保留策略与真实环境
不同类别的记录,保留期可以不同:
| 记录类别 | 保留期 | 依据 |
|---|---|---|
只读操作(search / get_run) | 90 天 | 排障为主,合规压力小 |
写入操作(create_run / update_note) | 1 年 | 变更追溯 |
危险操作(delete_run / send_email) | 3–7 年 | 合规、取证 |
审批记录(approved / denied) | 与危险操作同 | 与动作绑定 |
15.3.7 落在哪里:与可观测性的分工
审计日志不能和普通日志混在一个地方,因为两者的生命周期和访问权限完全不同。第 13 章用 OTel 把 trace / metric / log 送进可观测性系统,那是排障视角;审计日志是合规视角,应当单独落一处:
| 普通日志 / trace | 审计日志 | |
|---|---|---|
| 落点 | OTel collector → 日志系统 | 专用 audit_records 表 / WORM 存储 |
| 访问 | 运维可读可删 | 只读角色可查,无 UPDATE / DELETE 权限 |
| 采样 | 可采样 | 全量 |
| 关联 | trace_id 串联 | run_id / workspace 串联 |
落进 Postgres(第 5 章)时,一个关键做法是权限层面禁掉修改:建表后只给应用一个「只允许 INSERT」的数据库角色,UPDATE 和 DELETE 权限一律不授予。这样即使应用代码有漏洞,数据库这一层也改不动已落盘的记录——把不可篡改的约束下沉到存储层,而不是只靠应用自觉。第 5 章的迁移里加一张 audit_records 表,record_hash 建唯一索引,写入用 INSERT(不是 UPSERT),就已经把结构立住了:
CREATE TABLE audit_records (
seq BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL,
actor TEXT NOT NULL,
workspace TEXT NOT NULL,
run_id TEXT NOT NULL,
tool TEXT NOT NULL,
args_digest TEXT NOT NULL,
status TEXT NOT NULL,
approver TEXT,
prev_hash TEXT NOT NULL,
record_hash TEXT NOT NULL UNIQUE
);
REVOKE UPDATE, DELETE ON audit_records FROM app_role; -- 应用角色只能 INSERT
record_hash 的 UNIQUE 约束顺带防了「重复插入同一条」;REVOKE 则从数据库权限层堵死了改写。这两条加起来,让「不可篡改」不只写在代码注释里,而是由数据库强制。
再往上,可以用数据库触发器或外部锚定期把当前链的根哈希外发,让「重算整条链」这条路也走不通。本节的纯 Python 实现是它的最小内核,落库只是把同一个结构搬进持久存储。
真实环境里还有几件事本节的简化实现没做,但要提一句:
- 外部锚:定期把当前链的根哈希写进一个只读存储(对象存储的 WORM、另一套系统),让「重算整条链」也掩盖不了。
- 签名而非纯哈希:哈希链防的是「悄悄改」,如果连写入者都不可信,需要给每条记录加数字签名,用私钥签、公钥验。
- 异地多副本:单机 append-only 文件仍可能被物理覆盖,生产上要同步到多副本。
真实模型下会怎样:审计日志与模型无关——不管背后是假模型还是真实 Claude,记录的内容和结构一样。但 Agent 场景让审计多了一层价值:模型的决策链本身也是证据。一次越权删除,审计要能还原「模型被哪篇文档注入、在第几步发出调用、护栏为什么没拦住」。所以本节的结构里,tool + args_digest 之外,真实系统还会追加一条决策摘要(当时上下文的哈希),把「模型看到什么」也锚进链里。这样,一次事故的完整复盘——从注入来源到最终动作——全都可被独立校验,而不是靠当事人口述。
到此第 15 章闭环:15.1 守入口、15.2 守出口、15.3 留下不可篡改的凭证。ResearchCopilot 现在不只是一个「能用」的 Agent,而是一个出了事说得清、追得回、赖不掉的 Agent——这是它能进入合规场景的前提。下一章离开安全,回到工程:把 Python 与 Node 双栈服务装进精简镜像,用 compose 编排起来。
阅读导航:上一节:15.2 输出侧:敏感信息、越狱与幻觉引用校验 · 下一节:16.1 TS 落点三:Python + Node 双栈镜像瘦身 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。