两个零依赖校验脚本把我的审计记录拒了 7 次:security-audit-skill 的 schema 机制拆解

3 阅读8分钟

问题定义:agent 的审计产出要能被机器接住

让 coding agent 做安全审查,工程上难的不是"它看不看得出来",是"它看出来的东西怎么进流程"。

散文式报告没法入库、没法比对、没法在两次运行之间合并。每次审计从零开始,结论也只能靠人重读一遍——读的人还得有能力判断哪条是真的。

Cloudflare 开源的 security-audit-skill 走的是另一条路:把审计产出定义成结构化记录,再用两个零依赖校验脚本当门禁。它对自己的定位是一句话,README 逐字:"A coding-agent skill that turns your agent into a security auditor."

它在第三方聚合站 2026-09-21 那期 GitHub 周榜总榜排第三(+9,047 星,聚合口径,非 GitHub 官方榜单)。我把它装在一个模型走 qwen3.8-max 的 coding agent 上跑了一轮受限审计,被校验脚本拒了 7 次。这篇不写跑通过程,拆它三个机制:canonical ID 的合并语义、validator 的调用时机、trace/evidence 的锚定设计。

环境

宿主 Qwen Code,模型 qwen3.8-max,baseUrl 指向百炼兼容端点(dashscope compatible-mode),该模型支持 function calling——满足 README 对底座那条逐字要求:"A coding agent with a model that supports tool use and parallel sub-agents"。Key 在控制台密钥管理页创建,命令文档与模型清单见阿里云百炼 CLI(bl)。

安装命令是 README 原文:

npx skills add https://github.com/cloudflare/security-audit-skill --skill security-audit

我实测时是把 skill 目录落盘到工作区的 agent skills 目录,效果等价。

仓库里与机制相关的部分不多:SKILL.md、13 个攻击面知识文件、report-schema.json、两个校验脚本、测试。校验脚本零依赖,用 Node 直接跑,这点决定了它能当流水线里的门禁步骤,不需要额外装环境。

机制一:canonical ID 让多次运行可以合并

阶段 1 产出 coverage-ledger.json。每个覆盖单元的 coverage_id 不是随手起的名字,而是由 surface、boundary、subsystem、attack_class 四个引用做百分号编码后用 :: 拼出的规范 ID。

我第一版连顶层类型都写错了,规范 ID 这条是第三版才被点出来的,第四版才通过。这个约束看着像洁癖,实际是合并语义的前提:同一个"表面×边界×子系统×攻击类"组合,无论谁跑、什么时候跑、跑第几遍,coverage_id 都相同,两份台账才谈得上对齐。

README 把多次运行定义为 additive:先读旧 ledger 和旧 findings,变更过的源码重新验,没变的证据带着 fingerprint 延续。

再配两条约束一起看,设计意图就完整了:

约束我踩的版本它防的是什么
coverage_id 用规范 IDv3 被拒身份不稳定,跨运行无法对齐
unresolved 必须是数组v3 被拒未解决项要能累积,不是一个布尔
planned 单元 agent_id 必须为 nullv3 被拒"计划覆盖"与"已在跑"混为一谈,进度不可判

规范 ID 解决身份,fingerprint 解决证据延续。两者合起来,审计记录才是可以增量维护的资产,而不是一次性文档。

机制二:validator 的调用时机写在流程里

这套 skill 最值得抄的设计,是它没把校验当"跑完检查一下",而是把时机钉死在流程节点上。README 原文:

"The parent runs validate-coverage-ledger.cjs after creating the ledger and after each later ledger update. It runs validate-findings.cjs in Phase 4 and again after every Phase 5 replacement."

拆成三个位置:

校验时机校验对象不通过的后果
台账创建后、每次台账更新后coverage-ledger.json狩猎阶段不开始
阶段 4findings.json发现进不了报告
阶段 5 每次替换后findings.json复核产生的改动同样要过校验

第一行是前置门禁:台账结构不对,后面所有 hunter 的工作都建立在错误的覆盖划分上,不如直接停。

第三行是我觉得最关键的一条。独立复核会替换发现,如果替换后的记录不再过一遍 schema,复核就成了门禁的旁路——最需要被校验的改动,恰好绕过了校验。把 validator 挂在"每次替换之后",等于承认复核本身也会写错。

我这轮跑的是 quick 档(skill 为小目标定义的受限路径:粗粒度台账单元、单波 hunter、单次 critic pass),阶段 1 到 4 实跑,阶段 5、6 没跑。所以第三行来自 README 的流程定义,不是我的实测结论。

机制三:trace/evidence 把每条发现锚到行号

findings.json 的字段设计是整套东西里最重的部分,也是我被打回最多的地方——四轮。

字段约束逐条列出来:

  • trace 是一条链,每个节点带 kind(entrypoint / propagation / sink 三选一)、file、line、scope、description
  • evidence 必须锚到 file+line
  • conditions 是数组
  • execution 要有 attacker_perspective、payloads、instructions、observed_result 四个字段
  • severity 和 confidence 都是带 score+reason 的对象
  • severity 额外要一个字符串枚举的 overall_severity
  • 整个数组按 fingerprint 字典序排

每个字段各自防一种偷懒:

字段防的偷懒写法
trace.kind 三选一只给一段模糊的"相关代码",不说这一跳是入口、传播还是落点
evidence 锚 file+line没有行号的"证据"
execution.observed_result"理论上可以"——这里要写实际观察到什么
severity/confidence 带 reason只给一个 high,不给理由
fingerprint 字典序产物顺序随机,两次运行的 diff 没有意义

我这轮的三个发现都配了 PoC,命令与输出都是本机真跑的:

node -e PoC1   # eval 注入:input "' || '1'=='1" => returned rows: 1(应为 0)
node -e PoC2   # 路径穿越:resolve 后落到 /etc/passwd
grep -n sk-live src/server.js   # 3:const API_KEY = 'sk-live-…';

三条分别对应靶仓里我预埋的三个洞:查询参数拼进 eval()、上传文件名不做净化直接拼路径、源码里硬编码 live key。靶仓是本机自造的演示仓库,两个文件;PoC1、PoC2 指代本次为前两个洞写的验证脚本。

校验为什么放在 agent 外面

有个细节容易被忽略:README 里跑校验脚本的主语是 "The parent",不是 hunter、verifier 这些干活的子角色。写记录的和判记录合不合格的,不是同一个角色。

这条分离比字段设计更根本。让 agent 自己判自己的产出,等于让它给自己的作业打分,它当然会说自己过了。把校验脚本握在父角色手里、并且做成零依赖的独立可执行文件,判定结果就不取决于模型的自我评估,而取决于脚本退出码。

零依赖也服务于同一个目标:不需要装环境、不需要网络、不需要模型,所以它能在 CI 里跑,能在别人机器上跑,也能在你完全不信任 agent 的环境里跑。门禁要有效,前提是它独立于被约束的那一方。

七次拒绝的分布说明了什么

ledger 三轮、findings 四轮,拒因全部是结构,没有一次是"没看出漏洞":

版本结果拒因
ledger v1拒顶层须为数组(ERROR: $: expected a top-level array)
ledger v2拒缺 14 个必填字段(coverage_id、canonical_refs、subsystem、starting_paths、ordinary_attack_class_block 等)
ledger v3拒规范 ID 拼法、unresolved 数组、planned 不得指派 agent
ledger v4PASS: 3 coverage units valid通过
findings v1拒trace / evidence 字段结构
findings v2拒trace.kind 枚举、conditions 数组、execution 四字段、severity 与 confidence 为对象
findings v3拒缺 overall_severity、未按 fingerprint 字典序
findings v4拒overall_severity 须为字符串枚举
findings v5PASS: 3 findings valid通过

这个分布本身是个结论:agent 在这套流程里的主要失败模式,是不会按契约写记录,而不是不会审代码。契约能被脚本强制,"认真"不能。

反过来说,编造一条发现的成本也被同一套契约抬高了——你得先造出锚到真实 file:line 的 trace、可复现的 execution、带 reason 的 severity 对象。这比真去验一遍还费劲。

可迁移到其他 agent 产出的三条

这三个机制跟"安全审计"这个具体领域关系不大,可以直接搬到别的 agent 输出上:

  1. 身份字段由内容确定性生成,不由人起名。 跨运行可合并,增量维护才有基础。
  2. 校验时机写进流程,并且覆盖"修改之后"。 门禁不留旁路,复核产物同样受检。
  3. 结论字段必须锚到可核查位置。 file:line、实际观察结果、带理由的评分——把散文变成契约。

第三条最难实现,也最值钱。前两条属于工程纪律,第三条要求你先想清楚"什么才算证据"。

边界

  • 跑的是 quick 受限档,不是全量审计;结论只覆盖这个靶仓这一次运行
  • 阶段 5、6 未跑:独立复核与中立报告这轮没有产出
  • 三个漏洞是本机自埋靶子,不代表任何真实项目的安全状态
  • 周榜 +9,047 为第三方聚合站口径,非 GitHub 官方榜单
  • skill 要求 tool use 加并行子代理;我这次在单 agent 上串行完成 hunter 与 verifier 角色,并行编排未实测

你们那边 agent 的产出,是靠人重读一遍,还是已经有 schema 卡着?评论区聊聊校验挂在流程的哪一步——我想知道有没有比"每次替换之后再校验"更省事、又不留旁路的位置。