本节摘要:沙箱与策略负责「尽量不出事」,审计负责「出了事(或差点出事)之后,真相可查」。本节先定审计事件 schema——从请求、决策、审批到执行结果,每个字段的取证价值逐一说清;再给最小审计器 auditor.py:JSONL 逐行追加、写后即同步、哈希链防篡改(每一行携带前一行的摘要,改任何一行都会让链条断掉);最后是三层回放思路:时间线重建(发生了什么)、重放对照(怎么发生的)、指标统计(会不会再发生),并说明审计如何为第 4 章供应链取证与第 6 章红队演练提供判据。至此,第 0.1 节的三层防线全部落地且互相咬合。
审计日志的原则:机器写、人不改、事后可验证。格式选 JSONL(每行一个独立 JSON 对象):追加即落盘、逐行可流式处理、坏了也只坏一行。事件 schema:
| 字段 | 类型 | 说明 |
|---|---|---|
event_id / ts |
字符串 | 事件唯一标识与时间戳 |
run_id |
字符串 | 一次 Agent 会话 / 一次任务的聚合键(回放的主线索) |
phase |
枚举 | request(请求到达)/ decision(策略判定)/ approval(人批) / result(执行结果) |
decision |
枚举 | allow / deny / needs_approval / approved / timeout |
command |
数组 | 参数数组原样记录,禁止拼接(保留取证原貌) |
reason |
字符串 | 命中的规则或拒绝理由(如「路径越界: /etc/x」) |
reviewer |
字符串 | 审批人标识(仅 approval 事件)——出事时追责到人 |
exit_code / duration_ms |
数值 | 执行结果与耗时(仅 result 事件) |
stdout_digest |
字符串 | 输出摘要(哈希),需要时可再留截断尾部 |
sandbox |
字符串 | 执行环境标识(docker-hardened / runsc / firecracker) |
prev_hash / digest |
字符串 | 哈希链:前一行摘要与本行摘要(防篡改) |
💡
command坚持存参数数组而不存拼接字符串:注入攻击常利用引号、分号、空格歧义伪装,数组形态让「当时真正要执行的东西」无可辩驳。stdout_digest存哈希而非全文:既留了「输出是否与回放一致」的比对锚点,又不让日志库变成泄露面。
# auditor.py —— 最小审计器:JSONL 追加 + 写后即同步 + 哈希链防篡改(示意实现) import hashlib, json, os, time, uuid LOG_PATH = "audit/audit.jsonl" def _last_digest() -> str: try: with open(LOG_PATH, "rb") as f: lines = f.read().strip().splitlines() return hashlib.sha256(lines[-1]).hexdigest() if lines else "GENESIS" except FileNotFoundError: return "GENESIS" def emit(event: dict) -> dict: event = {"event_id": uuid.uuid4().hex, "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **event} event["prev_hash"] = _last_digest() event["digest"] = hashlib.sha256( json.dumps(event, sort_keys=True, ensure_ascii=False).encode()).hexdigest() os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) with open(LOG_PATH, "a", encoding="utf-8") as f: f.write(json.dumps(event, ensure_ascii=False) + "\n") f.flush() os.fsync(f.fileno()) # 写后即同步:崩溃也不丢最后一行 return event def verify() -> bool: prev = "GENESIS" with open(LOG_PATH, encoding="utf-8") as f: for line in f: e = json.loads(line) body = {k: v for k, v in e.items() if k != "digest"} if e["prev_hash"] != prev or hashlib.sha256( json.dumps(body, sort_keys=True, ensure_ascii=False).encode()).hexdigest() != e["digest"]: return False prev = e["digest"] return True if __name__ == "__main__": emit({"phase": "decision", "run_id": "demo", "decision": "deny", "command": ["curl", "http://evil.example"], "reason": "命中拒绝规则: *curl*", "sandbox": "docker-hardened"}) print("链条完整:", verify())
接入方式:把 gateway.py 与 sandbox_run.py 里原来各自写的 JSONL 追加,统一替换为 from auditor import emit——所有事件进同一条链,一个 verify() 覆盖全部。篡改实验:手工改掉日志中间一行的任意字符再跑 verify(),期望返回 False。
⚠️ 边界诚实说明:哈希链防「事后悄悄改历史」,不防「有 root 权限的人整段重写」。生产级还要加远程转发(审计旁路到另一台机器)与定期外部校验——思路一致,搬运工不同。
第一层 · 时间线重建(发生了什么)。 以 run_id 为主键过滤,按 ts 排序重放:
# replay_query.py —— 按 run_id 重建命令时间线(最小示例) import json, sys for line in open("audit/audit.jsonl", encoding="utf-8"): e = json.loads(line) if e.get("run_id") == sys.argv[1]: print(e["ts"], e["phase"], e["decision"], " ".join(e["command"]), e.get("reason", ""))
典型输出:一条 request 对一条 decision;若出现「同一 run 连续多次 deny 后紧跟一次 allow」,就是注入在试探策略边界的经典指纹。
第二层 · 重放对照(怎么发生的)。 把 decision=allow 的命令序列在干净沙箱里重新执行一遍,比对 exit_code 与 stdout_digest:一致,说明行为可复现(脚本化复盘);不一致,说明当时结果依赖外部状态(网络、时间、被投毒的依赖)——差异点就是调查起点。
第三层 · 指标统计(会不会再发生)。 三个日常指标就够起步:deny 率与 top deny 命令(策略与工作流的摩擦点)、审批通过率(通过率长期趋近百分之百,说明审批门形同虚设,回看第 3.1 节「预览」教训)、超时率(沙箱内死循环或资源限额过紧)。
审计日志不是合规摆设,它直接喂给两个下游场景:其一,第 4 章供应链治理——当恶意依赖或恶意 Skill 事发,command 数组与 reason 是「它到底让 Agent 执行了什么」的第一现场;其二,第 6 章红队自测——演练剧本的判据不是「感觉挡住了」,而是「攻击命令在 decision 事件的 deny 记录里」。在没有日志的地方谈安全,等于在没有病历的地方谈疗效。
phase 四段(请求 / 决策 / 审批 / 结果)、command 永远存数组、审批记 reviewer、输出存摘要。verify() 一行校验全链;防篡改不等于防重写,生产加远程转发。规则、沙箱、审批、审计已经咬合成环,但它守的只是「执行侧」。还有一整条战线本书尚未触及:Agent 吃进去的依赖、工具与 Skill 本身就可能是有毒的——恶意 Skill 已有官方 CVE 记录。第 4 章《供应链治理》从你刚落地的审计日志出发,去管住「进料」这一关。