问题定义: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 用规范 ID | v3 被拒 | 身份不稳定,跨运行无法对齐 |
| unresolved 必须是数组 | v3 被拒 | 未解决项要能累积,不是一个布尔 |
| planned 单元 agent_id 必须为 null | v3 被拒 | "计划覆盖"与"已在跑"混为一谈,进度不可判 |
规范 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 | 狩猎阶段不开始 |
| 阶段 4 | findings.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 v4 | PASS: 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 v5 | PASS: 3 findings valid | 通过 |
这个分布本身是个结论:agent 在这套流程里的主要失败模式,是不会按契约写记录,而不是不会审代码。契约能被脚本强制,"认真"不能。
反过来说,编造一条发现的成本也被同一套契约抬高了——你得先造出锚到真实 file:line 的 trace、可复现的 execution、带 reason 的 severity 对象。这比真去验一遍还费劲。
可迁移到其他 agent 产出的三条
这三个机制跟"安全审计"这个具体领域关系不大,可以直接搬到别的 agent 输出上:
- 身份字段由内容确定性生成,不由人起名。 跨运行可合并,增量维护才有基础。
- 校验时机写进流程,并且覆盖"修改之后"。 门禁不留旁路,复核产物同样受检。
- 结论字段必须锚到可核查位置。 file:line、实际观察结果、带理由的评分——把散文变成契约。
第三条最难实现,也最值钱。前两条属于工程纪律,第三条要求你先想清楚"什么才算证据"。
边界
- 跑的是 quick 受限档,不是全量审计;结论只覆盖这个靶仓这一次运行
- 阶段 5、6 未跑:独立复核与中立报告这轮没有产出
- 三个漏洞是本机自埋靶子,不代表任何真实项目的安全状态
- 周榜 +9,047 为第三方聚合站口径,非 GitHub 官方榜单
- skill 要求 tool use 加并行子代理;我这次在单 agent 上串行完成 hunter 与 verifier 角色,并行编排未实测
你们那边 agent 的产出,是靠人重读一遍,还是已经有 schema 卡着?评论区聊聊校验挂在流程的哪一步——我想知道有没有比"每次替换之后再校验"更省事、又不留旁路的位置。