Cloudflare 六月份开源了一个 security-audit 技能,11 月 4 日官方在 X 上又推了一次,5 日的 GitHub 趋势日榜直接把它顶到第 3(GitHub API 实查 61,535 star,MIT,2026-06-18 创建)。它的自我描述里有一句话很扎眼:finding 和 verifier 必须是两个不同的 Agent,验证者的任务是把候选漏洞证伪。这套「找的人不许自己下结论」的分工,是从 Cloudflare 内部那套跑遍 128 个仓库的漏洞挖掘流水线上抠下来的单仓库起点,官方博文把这个演进过程写得挺细。
我关心的是另一件事:这套流程真装进一台 16GB Mac、用免费模型跑一遍,能看到什么。于是我写了一个 222 行、埋了 6 个真漏洞和 5 个诱饵的多用户笔记服务当靶子,把六阶段流程跑了两轮,又做了 18 组校验器对抗测试。
先说结论里最反直觉的一条:它最大的价值不是「多抓到」,而是「少说错」。而这个「少说错」恰好是最贵的一步,最先被免费额度卡住。
它到底装了什么
仓库极小。skills/security-audit/ 下 17 份 Markdown 共 1778 行(SKILL.md 192 行 + 6 份攻击类伴生文档 + 侦察/狩猎/验证三份流程文档),加一份 report-schema.json、两个零依赖校验器 validate-findings.cjs / validate-coverage-ledger.cjs,以及它们各自的测试。没有二进制,没有模型权重,纯 prompt + 三个可执行的 Node 脚本。
六阶段是这样走的:
| 阶段 | 干什么 | 谁写文件 |
|---|---|---|
| 1 侦察 | 画架构、信任边界、入口面,生成 architecture.md + coverage-ledger.json |
父 Agent |
| 2 覆盖驱动的狩猎 | 按账本单元派隔离的 hunter,另派 critic 找漏掉的面 | hunter 只写自己的 scratch |
| 3 候选验证 | 每个候选交给一个全新的验证者,任务是证伪 | verifier |
| 4 结构化输出 | 写 findings.json,跑 schema 校验 |
父 Agent |
| 5 独立复核 | 再做一轮全新 Agent 核对源码引用 | verifier |
| 6 报告 | 从复核后的记录生成 REPORT / FINDINGS-DETAIL / NEEDS-VALIDATION | 纯脚本 |
官方博文里有一组数字值得记住:内部流水线跑了 20,799 个原始候选,12,057 个通过独立验证,去重掉 5,442 个,1,154 个判为「错仓库/低风险」打回,剩 7,245 个交给人。初始验证拒绝率从 40% 降到 11%。一个三万行的仓库跑一次完整扫描要 3–4 小时。
博文还很老实地写了一句:「无法声称召回率」,因为不存在带标签的完整 bug 集。
这句话正好给了我一个可以做的事:自己造一份带答案的靶子。
靶子:6 个真洞 + 5 个诱饵
靶子是个用标准库写的多用户笔记 API,http.handler + sqlite3,222 行,六个入口:搜索、读单条、写笔记、导入配置快照、导出 CSV、运维探测。
真洞六个,按应有的评级排序:
| 编号 | 漏洞 | 位置 | 为什么越界 |
|---|---|---|---|
| V1 | pickle.loads 直接吃请求体 |
app/server.py POST /api/profile/import |
登录用户即可进程内执行代码 |
| V2 | subprocess 拼 shell=True |
app/server.py GET /admin/ping |
拿到令牌即宿主命令执行 |
| V3 | % 格式化拼 SQL |
app/db.py search_notes |
跨租户读,能读 users.password_hash |
| V4 | get_note 不按 owner 过滤 |
app/db.py + 路由 |
A 读 B 的笔记 |
| V5 | os.path.join 无规范化 |
app/server.py GET /export |
该路由还不校验会话,直接读任意文件 |
| V6 | 管理令牌硬编码且校验处写死同一字面量 | app/config.py + app/server.py |
读过仓库的人直接过鉴权 |
诱饵五个,专门用来测「敢不敢说不」:
- D1
SESSION_SECRET硬编码。看起来是最典型的高危,但它在本项目里不成立:这个密钥只用来算HMAC(secret, user_id),而current_user()是拿 token 去查进程内字典SESSIONS。离线算出来的 token 不在字典里,直接 401。而且整棵树里没有任何路由调用auth.login_response,会话根本没发放入口。 - D2 POST 没有 CSRF token。cookie 是
SameSite=Strict,跨站表单带不上来。这是纵深防御建议。 - D3
random.random()做退避抖动。跟安全无关。 - D4 没有限流,代码里还留着
# TODO: 上线前补限流。 - D5
json.loads没包 try。畸形 JSON 换 500,自伤。
实测一:六阶段跑起来是什么样
环境是 macOS 26.5.2 / M4 / 16GB,opencode 2.1.4,模型用免费的 glm-5.3-flash-free。技能放进项目的 .opencode/skill/security-audit/,按 opencode 的约定直接能被识别(→ Skill "security-audit" 这行就是挂载确认)。
# 环境:macOS + opencode 2.1.4,Node 22 用于跑校验器
# 1) 把技能放进项目(也可以按官方 README 用 npx skills add)
mkdir -p .opencode/skill
cp -R /path/to/security-audit-skill/skills/security-audit .opencode/skill/
# 2) 先跑一遍它自带的测试,确认校验器本身没坏
cd .opencode/skill/security-audit
node validate-findings.test.cjs | tail -3
node validate-coverage-ledger.test.cjs | tail -3
# 3) 在目标仓库根目录发起完整审计(quick profile)
opencode run --auto \
-m opencode/glm-5.3-flash-free \
"对这个代码库做一次完整的安全审计(full audit mode)。\
范围限定 app/ 与 scripts/,profile 用 quick。输出目录 /tmp/audit-out。"两个测试套件分别 34 个和 31 个用例,全过。这一段没问题。
然后实际情况是这样:
- 侦察阶段跑得很好。它先
which sandbox-exec确认 macOS 有 OS 级沙箱可用(技能要求本地执行目标代码必须在这个沙箱里,否则只能报needs_validation),然后写出 37 行的architecture.md。这份文档把 6 个真洞全部点名了,还多写了两件我埋的时候没想到会被看出来的事:make_session生成的 token 是确定性的、无 nonce 无过期;以及login_response从未被任何路由调用。 - 账本过了它自己的校验器。8 个覆盖单元、
PASS: 8 coverage units valid,按「未认证面 / 认证面」分给两个 hunter,连「配置里的明文常量」「wildcard 扫荡」都单独开了单元。 - 中间还踩了一次工具坑:它想改账本里某个单元的
selected_companion_blocks,用 Edit 改 JSON 报了Could not find oldString,最后绕道用 Python 读-改-写才补上。手工维护 JSON 账本这件事,对人和对模型都不友好。 - wave 1 只有一个 hunter 活着回来。hunter-b 花了 9 分钟在 scratch 里造了一套真的本地 harness:临时 SQLite 播种两个用户、把目标
HTTPServer起在 loopback、用make_session(1)在进程内造会话,然后分别打 SQLi、跨租户读、和一个os.system写标记文件的 pickle 载荷。hunter-a 直接报Free usage limit reached。整轮在 18 分 20 秒 处终止。
这一轮的 token 我从 opencode 的 opencode.db 里逐会话加总(session_v2 表):
| 会话 | 输入 | 输出 | 推理 | 缓存读 |
|---|---|---|---|---|
| 父 Agent(协调) | 71,433 | 6,485 | 14,366 | 537,472 |
| hunter-b(认证面) | 39,891 | 4,176 | 13,799 | 365,568 |
| hunter-a(未认证面,失败) | 18,658 | 406 | 568 | 68,544 |
| 合计 | 129,982 | 11,067 | 28,733 | 971,584 |
一个 222 行的仓库,光「重复读那 1778 行技能文档」的缓存命中就 97 万 token。Cloudflare 博文说内部流水线每仓库的 worker 池是 50–200 个,一次扫描要 3–4 小时——现在我知道这个量级是怎么来的了。免费额度撑不到 wave 1 结束,不是模型不行,是这套流程的固定开销本身就大。
额度用完后我又换了个更小的免费模型(flint-v1.0:free,它跑单次通读只用 5 分半)在一份干净副本上重跑。结果更有意思:八分钟里它把同一批 6 份技能文档来回读了 16 次,run-metadata.json 一次都没写出来,连第一阶段的产物都没落地。
同一套 prompt,一个模型能推完侦察、写出 37 行架构文档和 8 单元账本,另一个在同一步空转。所以「技能是纯 prompt,随便什么模型都能跑」这个假设不成立。六阶段流程要的是持续遵守格式约定、在长上下文里不迷路的能力,这跟「能不能读懂一段代码找漏洞」是两种本事。我后来那份单次通读的对照之所以换模型还能做,是因为它只有一句话的约束;六阶段不行。
实测二:同样一份代码,单次通读
换一个角度。我把靶子复制一份(剥掉技能目录,避免自动触发),只给一句最朴素的话:
opencode run --auto -m openrouter/recursive/flint-v1.0:free \
"帮我找出这个代码库里所有的安全漏洞,按文件列出来,说明位置和风险。"结果 5 分 31 秒,一次通读:
- 6 个真洞全部命中,连「
/export既不校验会话又没做路径规范化」这种一处代码两个问题都拆开写了。 - D1 报错了。它的原话是「密钥泄露 → 可伪造任意用户会话」。这个推论在别的项目里成立,在这里不成立,因为会话状态是进程内字典。
- D4 被报成「中危漏洞」。缺限流是加固建议,不是边界失效。
- 还多报了第 10 条:仓库里混着一份跟项目无关的演示日志,里面打了
HF_TOKEN length: 37这类环境信息。这条是对的,但价值最低——单次通读的特点就是「看见什么说什么」。
所以这两条路的差别不在召回率上(都是 6/6),在于输出的信噪比和它愿不愿意为一个结论负责。
needs_validation 这个判决是整套设计里我最喜欢的一处。它不是一个「低置信度的 confirmed」,而是「有一个具体被卡住的事实,所以我不给评级」。schema 层面直接锁死了这件事:needs_validation 记录里不允许出现 severity 字段,出现就是校验失败。同理,confirmed 必须有非空的 observed_result,光靠读代码「我觉得能打」是不行的。
这个设计解决的是一个很具体的问题:模型很容易被自己写出来的漂亮假设说服。换一个没参与发现过程、也没继承发现者上下文的 Agent 去反驳,比让同一个 Agent「再检查一遍」有用得多。
实测三:两个校验器到底能不能兜住
模型的那部分我没跑完,但校验器这部分是完全确定性的,可以往死里测。我写了 18 个用例分别喂给两个校验器。最小的一份是这样,用一条「评级超过实际影响」的假记录试试它拦不拦:
# 环境:Python 3.13 + Node 22;security-audit 技能目录下
import json, subprocess
SKILL = "./security-audit"
fake = [{
"verdict": "confirmed",
"fingerprint": "app/server.py:pickle",
"title": "反序列化导致代码执行",
"description": "请求体直接进 pickle.loads。",
"root_cause": "缺少格式约束",
"intended_behavior": "只接受 JSON",
"trace": [
{"kind": "entrypoint", "file": "app/server.py", "line": 78,
"scope": "do_POST", "description": "读取原始请求体"},
{"kind": "sink", "file": "app/server.py", "line": 82,
"scope": "pickle.loads", "description": "反序列化攻击者字节"},
],
"evidence": [{"file": "app/server.py", "line": 82, "description": "pickle.loads(raw)"}],
"conditions": [{"kind": "authentication_level", "description": "任意登录用户"}],
"execution": {
"attacker_perspective": "登录后投递恶意 pickle",
"payloads": ["cos\\nsystem\\n(S'id'\\ntR."],
"instructions": ["构造 pickle 字节流", "POST 到 /api/profile/import"],
"observed_result": "标记文件被写出",
},
"remediation": {"strategy": "改用 json.loads 并做字段白名单"},
"severity": {
"likelihood": {"score": "high", "reason": "接口对登录用户开放"},
"impact": {"score": "medium", "reason": "仅影响单进程"},
"overall_severity": "critical", # ← 故意抬高
},
"confidence": {"score": "high", "reason": "源码路径完整"},
}]
json.dump(fake, open("/tmp/fake-findings.json", "w"), ensure_ascii=False)
r = subprocess.run(["node", SKILL + "/validate-findings.cjs", "/tmp/fake-findings.json"],
capture_output=True, text=True)
print(r.returncode) # 1
print(r.stderr) # ERROR: $[0].severity.overall_severity: cannot exceed demonstrated impact "medium"把 overall_severity 改回 medium、把 trace 和 field 都对齐,同一份文件就会打印 PASS: 1 findings valid。上面这条规则是我最认可的一处设计:评级的最大值只能等于你能演示出来的影响,不能等于你感觉到的严重程度。
findings 校验器 11 个用例,只有 1 个合法样本通过:
| 用例 | 结果 | 它拦下来的理由 |
|---|---|---|
| 合法 confirmed(trace 是 entrypoint→sink) | PASS | — |
| trace 第一段写成 propagation | FAIL | trace[0].kind: must be "entrypoint" |
| overall 写 critical,impact 写 medium | FAIL | overall_severity: cannot exceed demonstrated impact "medium" |
| confirmed 的 observed_result 是空白字符 | FAIL | requires a visible execution observed_result |
| needs_validation 带了 severity | FAIL | needs_validation finding must not contain "severity" |
| 两条记录指纹倒序 | FAIL | findings must be sorted lexicographically |
| 两条记录指纹重复 | FAIL | duplicate of $[0].fingerprint |
| title 全是零宽字符 | FAIL | title: must contain a visible character |
| title 中间夹零宽字符 | PASS | 只判「整段不可见」,夹带不管 |
evidence.file 写成 ../../etc/passwd |
FAIL | must be a safe repository-relative source path |
多一个 todo 字段 |
FAIL | unexpected field "todo" |
账本校验器 7 个用例:
| 用例 | 结果 | 说明 |
|---|---|---|
| 两个单元,都有 agent_id / reviewed_paths / local_checks | PASS | — |
| status 写成中文「已覆盖」 | FAIL | 枚举外 |
| 两个单元 coverage_id 重复 | FAIL | 拒绝 |
coverage_id 不按 surface::boundary::subsystem::attack_class 生成 |
FAIL | 会把它期望的规范 ID 打出来 |
| 单元顺序不是字典序 | FAIL | 强制排序 |
| 空数组 | PASS | PASS: 0 coverage units valid |
| 声称 covered 但没有 agent_id / reviewed_paths / local_checks | FAIL | 三条一起报 |
这里有两个必须记住的坑。
第一个是空账本漏洞。 一个 agent 如果把 coverage-ledger.json 写成 [],校验器会开心地放行。也就是说「零覆盖」和「全覆盖」在校验器眼里都是合法文档——覆盖声明这件事最终还是靠人看一眼 units 的数量。这和博文自己承认的「无法声称召回率」是同一个洞的两面:格式能锁,语义锁不住。
第二个是排序。 两个校验器都要求记录按指纹 / coverage_id 字典序排列。这不是安全要求,是实现上的确定性要求,但它意味着你不能随手往 findings.json 里 append 一条,得插到正确位置。手工维护或让模型直接 edit,很容易踩。
至于格式之外的东西,比如「这条到底算不算漏洞」,校验器一句都管不了。它只是让你没法用模棱两可的话把不确定包装成确定。
什么时候别用它
- 仓库小、你自己就是写它的人。单次通读 5 分半拿到 6/6,代价是三条误报,你自己一眼就能划掉。为了这 6 个洞花 18 分钟以上(还只是侦察加半个 wave),不值。
- 想接 CI 做每个 PR 都跑一遍。博文写得很清楚:单次扫描是数小时级的周期性清扫,不是 per-PR 检查。技能里的 quick profile 也只是把波次压到一轮,没把固定开销压下来。
- 没有 OS 级沙箱的机器。技能要求本地执行目标代码必须在禁用外网、空环境变量、只给 scratch 写权限的沙箱里跑。macOS 上
sandbox-exec还在,Linux 上得有unshare这一套。没有的话,所有需要「跑一下才能确定」的候选都会掉进NEEDS-VALIDATION.md,报告看起来会很保守——那不是它胆小,是它按规矩不越线。 - 你把「零误报」当唯一目标。它的取舍是宁可漏也不乱报,
needs_validation会堆得很高,你得自己接手那部分。
我最后留下的用法
如果我只从这次实测里带走一条,是这样:把 needs_validation 当成阅读清单,而不是失败清单。
跑一轮,先看 NEEDS-VALIDATION.md,每一条都带着「卡在哪个具体事实上」,比 REPORT.md 更有用——因为那些正是你在自己机器上十分钟能验证、而 Agent 不敢替你签字的判断。至于 REPORT.md 里 confirmed 的那几条,反而不是最需要你花时间的部分。
顺带一提,两个校验器单独拿出来也好用。哪怕你不用它的六阶段流程,把 validate-coverage-ledger.cjs 塞进自己的检查脚本,逼着每次审计都留下「审了哪些面、谁审的、看了哪些文件」的账,这个成本几乎为零,收益是把「我审过了」变成可核对的东西。这和之前那篇 task-view 实测 里「让 Agent 交一份能被 Git 核对的自述」是同一条思路,只不过这次核对的是覆盖面。
想看校验器怎么用的,可以翻一下它跟 Bernstein 编排链 那篇的差别:Bernstein 管的是「这条改动是谁在什么条件下产生的」,这个技能管的是「这块代码有没有洞」,两者都在给 Agent 的输出加一层可验证的壳。
另外补一句实测环境的事。这次跑在 opencode 2.0 之后的服务化架构 上,后台服务能同时挂多个会话,所以父 Agent 和 hunter 是真正的并行会话,不是串行假装并行——这也是为什么 hunter-a 挂了以后 hunter-b 的产物还在。技能类项目(跟 unslop 那种纯 prompt 技能一样)能不能跑,很大程度取决于宿主支不支持子会话隔离,而不是模型本身多强。
未实测,需人工确认
- 完整的六阶段闭环:免费额度在 wave 1 就断了,
findings.json、REPORT.md、NEEDS-VALIDATION.md我没拿到真实产出,所以「验证阶段到底驳回了多少条候选」这件事我给不出数字。换小模型那次连第一阶段都没落地。想复现完整的六阶段,需要付费额度或者一个本地不限量模型。 - Claude Code / Codex / Cursor 上的原生表现:技能声称 agent-neutral,我只在 opencode 上验证了。子代理机制、写隔离的落地方式各家不同,
sandbox-exec那套在别的宿主里可能给不到。 - 目标代码的本地执行:hunter-b 造的 harness 我没看到它跑完(额度断了)。所以「沙箱里执行目标代码」这一环,本机是可用但未完成验证。
- 召回率的绝对值:6/6 只针对我埋的这 6 个。真实仓库里有多少没被发现的,我这份答案卡回答不了——这也是 Cloudflare 自己说「无法声称召回率」的原因。