作者将专用安全审查Agent接入PR流程,4个月内审查约300个PR,发现11个linters和SAST工具都漏掉的真实漏洞,经验是需引入第二个Agent专门反驳第一个以降低误报。
我把一个专门的安全审查 Agent 接入到我的 PR 流程中,让它在四个月里跑了约 300 个 PR。它发现了 11 个我的 linter 漏掉的真实漏洞——但在那之前它也产生了很多误报,直到我加入了一个专门用来反驳前一个 Agent 结论的第二个 Agent。以下是这套方案的具体设置、检查清单,以及真实的数据报表。
我的实现系统是全自动的:Agent 自主领取任务、写代码、大量自主开 PR。这套机制在吞吐量上表现优异,但对我的心理状态来说是一场噩梦。
具体的担忧不是"Agent 写了烂代码"。烂代码会在测试中暴露。真正担忧的是 Agent 写的代码运行完美,但同时也是一个安全漏洞。Happy-path 集成测试不会关心你把一个用户可控的字符串插入了 shell 命令。它通过了,然后上线了。
我现有的防御体系存在结构性的盲区:
Linter(ESLint、Ruff)只抓风格,不抓意图。
SAST 工具(Semgrep、CodeQL)非常擅长捕捉已知模式——但对于逻辑缺陷完全束手无策,比如"这个接口检查了你已登录,但从未检查这条记录是否属于你"。
依赖扫描器只抓 package.json 中的 CVE,不抓我刚写的代码里的问题。
而我——在晚上 11 点 Review 我自己的 Agent 开的 PR,其质量可想而知。
最后一个类别才是破坏性访问控制(broken access control)的藏身之处。这东西在 OWASP Top 10 排名第一,对模式匹配工具来说本质上不可见,因为有漏洞的代码和安全的代码看起来一模一样——区别只在一条缺失的 WHERE owner_id = ?。
所以问题变成了:语言模型擅长"读这个 diff 告诉你作者做了什么假设",它能否覆盖模式匹配无法企及的那片空白?
简短结论:可以,但必须搭配一个对抗性的第二轮通过。以下是我如何做到的。
安全审查 Agent 是一个独立的 Agent,有自己的 system prompt,而不是在我主 Agent 的指令里多加一条。这个区别在构建过程中比任何其他设计都重要,我会在后面"教训"部分展开说明。
flowchart TD
A[PR opened] --> B[Collect diff + changed file context]
B --> C[Security reviewer agent<br/>runs threat checklist]
C -->|0 findings| G[Pass ✅]
C -->|N findings| D{Verifier agent<br/>tries to REFUTE each}
D -->|refuted| E[Dropped, logged only]
D -->|survives| F[Blocking PR comment 🚩]
F --> H[I review, then fix or dismiss]
触发机制是一个普通的 Git hook 加上一个 CI step,没有花哨的东西:
#!/usr/bin/env bash
# .git/hooks/pre-push — cheap local gate before CI even sees it
set -euo pipefail
DIFF=$(git diff --stat "origin/main...HEAD" -- \
':!*.lock' ':!*.snap' ':!dist/*' | tail -1)
# Skip the review entirely for docs-only pushes — no point burning tokens
if git diff --name-only "origin/main...HEAD" | grep -qvE '\.(md|txt|svg)$'; then
claude -p "$(cat .github/prompts/security-review.md)" \
--allowedTools "Read,Grep,Glob,Bash(git diff:*)" \
--output-format json > /tmp/secreview.json
else
echo "docs-only push, skipping security review"
fi
注意 --allowedTools 列表:只读。安全审查 Agent 可以读文件、grep、检查 diff。它不能写、不能运行任意 shell、不能 push。一个能编辑代码的审查者不叫审查者,那是第二作者。
检查清单才是整个产品的核心
我的第一个版本的 prompt 大致是"审查这个 diff 的安全问题"。输出毫无用处——一面墙的"建议验证用户输入"套话被贴到了那些本来就验证了用户输入的代码上。
真正有效的是给它一个具体的、枚举好的威胁模型,并强制结构化输出。模糊的 prompt,模糊的发现。现在的 prompt 大约 60 行,承重部分如下:
For EVERY changed file, check these classes explicitly. For each one,
state either a finding or "N/A — <one line reason>". Do not skip a class
silently.
1. AUTHZ: Does every new data access path check *ownership*, not just
authentication? Look for queries missing a tenant/user scope.
2. INJECTION: String interpolation into SQL, shell, HTML, or template
contexts. Trace the value back to its source — is it user-reachable?
3. SECRETS: Hardcoded keys, tokens, or credentials. Also: secrets newly
added to log statements or error messages.
4. SSRF/PATH: User-controlled URLs passed to fetch/request, or user
strings joined into filesystem paths.
5. CRYPTO: Custom crypto, weak hashes for passwords, non-constant-time
comparison of secrets.
6. DESERIALIZATION: pickle/yaml.load/eval on anything user-reachable.
7. RATE/DOS: New unauthenticated endpoints with no limit; unbounded
regex or recursion on user input.
For each finding you MUST provide:
- file:line
- the exact attacker-controlled input
- a concrete exploitation path, step by step
- severity, and why it is that severity and not one lower
最后一条要求——"为什么它是这个严重级别而不是更低"——单枪匹马删掉了大约三分之一的噪音。你很难为一个"高严重级别"写出连贯的论证,而当输入只是你自己配置文件里的一个硬编码枚举时。模型会开始写论证、发现自己圆不回来、然后降级或丢弃这个发现。
验证 Agent 才是让这套系统可用的关键
即使有了一份严格的检查清单,第一轮 Agent 标记的东西里仍有大量是安全的。LLM 很会顺从。问它"这是一个漏洞吗?"它会有一种朝向"是"的拉力,因为"是"听起来是个更有帮助的答案。
所以我不再问那个问题了。第二阶段为每个发现 spawn 一个验证 Agent,而验证 Agent 的工作是反驳:
You are trying to DISPROVE the following security finding.
<finding>{{finding_json}}</finding>
Read the actual code. Look specifically for:
- an upstream guard, middleware, or type constraint the reporter missed
- whether the "attacker-controlled" input is actually attacker-controlled
- whether the exploitation path survives contact with the real call chain
Default to refuted=true. Only return refuted=false if you can write a
working exploitation path yourself, in concrete steps, using real
identifiers from this codebase.
"默认反驳"这个框架起了实质作用。它把模型的顺从性翻转到了一边,而那边的失败成本是廉价的:一个漏报会在下一次 review 或我这里被抓住,但一个阻塞了 PR 的误报会训练我彻底无视这个工具——一个被无视的安全工具价值正好是零。
通过了验证 Agent 的发现会被发布为阻塞性评论。被反驳掉的发现在日志里,不展示。我偶尔看看那个日志,了解什么类型的东西被干掉了,这就是我调整第一阶段 prompt 的方式。
四个月,约 300 个 PR,主要是 TypeScript 和 Python 服务。数字是从我日志里手工四舍五入并统计的:
三个严重的例子,因为具体细节比感觉更有说服力:
一个 GET /api/documents/:id 路由上缺失了所有权检查。认证做得正确,但未做归属校验。任何已登录用户都可以通过猜测 ID 读取任意文档。这一个案例就值回了整个项目的投入——Semgrep 和 CodeQL 都没出声,因为这里根本没有模式可匹配。
一个错误路径里的密钥。Happy path 把 API token 从日志里 redact 了。后来在另一个 PR 里加的 catch 块却把整个 config 对象打日志了。两个 PR 单独看都没问题。
一个导出功能里的路径遍历,把用户提供的文件名拼接到输出目录。../../ 做了它该做的事。
注意 #1 和 #2 的形态:都是跨文件、跨 PR、语义层面的 bug。这就是它的专长领域。Agent 从未在找硬编码 AWS key 这件事上赢过 Semgrep——Semgrep 瞬间免费搞定。它的优势在于需要理解代码含义的地方。
也有必要直说:412 → 47 → 11 意味着原始输出 97% 是噪音,即使经过验证器过滤后精确率也不到 25%。但这是一个可用的工具,因为验证阶段存在,也因为发现是在我已经 review PR 的时候到达的。
给审查者独立的上下文,不是多加一条指令。我第一次尝试是把"也检查安全问题"追加到主编码 Agent 的 prompt 里。它从未发现任何有意义的东西,事后回想原因显而易见:写代码的 Agent 本身就相信代码是对的。它的上下文充满了导致 bug 的那套推理。一个只看到 diff 的全新 Agent 没有这种依恋。独立的上下文就是特性。这就是你不 review 自己 PR 的原因。
枚举威胁类别,否则得到套话。"审查安全问题"产生论文。"检查这七个类别,对每个你清除的类别明确说明 N/A"产生发现。强制明确 N/A 也会暴露模型是否跳过了某个类别,这是静默通过给不了你的信息。
对抗性的第二轮胜过更好的第一轮 prompt。我花了两周调优 finder prompt 减少误报,只取得了约 20% 的改善。我花了一个下午加了一个默认反驳的验证器,误报减少了约 88%。生成和评估是两件事,当你把评估框定为反驳时,模型在第二件事上明显更强。
工具永远是只读的。让审查者直接修它发现的东西很诱人。不要。一个有写权限的审查者开始"修复"它自己的误报,然后你的误报就成了 commits。把人类精确地放在需要判断的那个节点上。
这是 SAST 的补充,不是替代。两个都跑。Semgrep 更快、确定性、免费、在已知模式上无敌——用作第一道门。Agent 慢、不确定、花 token,却是我工具链里唯一发现过破坏性访问控制 bug 的东西。各用各的工具。
当 diff 不可能包含漏洞时跳过 review。纯文档、lockfile-only、snapshot-only 的 push 跳过。这听起来是个成本优化,是,但更大的好处是保持工具输出的信噪比足够高,让我继续读它。
我正在基于这个系统构建两个扩展:
喂给它威胁模型,而不只是 diff。现在审查者从代码里推断什么重要。我希望它读每个服务的 THREAT_MODEL.md,这样它知道哪些接口是公开的、哪些数据是敏感的、哪些边界是真正的信任边界。我的假设是这是精确率下一次真正飞跃的来源。
跨 PR 追踪发现。上面 #2 这个 bug 只存在于两个独立 PR 的交互中。每次只看到一个 diff 的审查者在结构上对这类问题视而不见。我正在尝试给它一个小型的持久化索引,记录之前发现的东西和 redact 点供交叉比对。
等我有足够的运行时数据来说实话了,我会写这两个扩展。
版本号(这些玩意儿老化很快):Claude Code(2026 年 8 月 release 系列),Node.js 22.x,Python 3.13,Semgrep OSS。
如果你在跑会开 PR 的 AI Agent,你已经有了这个问题——只是可能还没有具体数据。这套方案真的是一个周末项目:一个带明确检查清单的 prompt 文件,一个默认反驳的验证 prompt,一个 Git hook。从第一天就用只读工具和反驳阶段,否则你会在两周内构建出一个你学会无视的工具。
轮到你了:如果你用 LLM 做过安全审查,我想知道你的精确率数字——在验证器之前我的数字比预期差得多,我怀疑很多人默默看到了一样的东西但没说。在评论区留下你的数字吧。👇
如果你觉得有用,在 Dev.to 上关注我——我会在构建这个自主系统的过程中持续记录,战争故事和死胡同都会包含在内。🚀