Cloudflare 安全审计技能实测:222 行的靶子埋 6 个洞,单次通读全抓到,六阶段先撞了额度墙

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" 这行就是挂载确认)。

Bash
# 环境: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,随便什么模型都能跑」这个假设不成立。六阶段流程要的是持续遵守格式约定、在长上下文里不迷路的能力,这跟「能不能读懂一段代码找漏洞」是两种本事。我后来那份单次通读的对照之所以换模型还能做,是因为它只有一句话的约束;六阶段不行。

实测二:同样一份代码,单次通读

换一个角度。我把靶子复制一份(剥掉技能目录,避免自动触发),只给一句最朴素的话:

Bash
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
# 环境: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 自己说「无法声称召回率」的原因。

推荐阅读

  • VoiceStudio 实测:10 万星的本地语音克隆,我在 16GB M4 上从源码跑通了引擎,也踩了三个坑

    VoiceStudio 是 10 万星的本地语音克隆工具,我用默认的 OmniVoice 引擎在 16GB M4 上从源码编译跑通:945MB 权重、Metal 加速,加载 1.6 秒、RTF 约 0…

  • floci 实测:LocalStack 停摆后的免费 AWS 模拟器,我无 Docker 从源码跑通了 Agent 本地云链路

    LocalStack 社区版强制登录后,MIT 开源的 AWS 模拟器 floci 成了热门替代品。我在 16GB MacBook 上从源码编译 2.4.0 实测:无 Docker 跑通 S3、Dyn…

  • colibri 实测:大模型不装内存、改「摆放」,我在 16GB M4 上摸清了它的真实边界

    GitHub 46,000 star 的纯 C 推理引擎 colibri,把 744B 大模型的专家权重留在磁盘、按 token 流式读取。我在 16GB M4 Mac 上实测:规划器裁决显示内存下限…

  • Lightpanda 实测:给 AI Agent 换个不吃内存的无头浏览器,我在 M4 上和 Chrome 真刀真枪比了一轮

    GitHub 4 万 star 的 Zig 无头浏览器 Lightpanda,专为 AI Agent 抓网页而生。我在 16GB M4 Mac 上实测:与 Chrome headless 同页对比,峰…