做研发安全的人应该都有这种感受:静态扫描工具每天给你几千条告警,但真正能直接提工单的没几条,大部分时间都在噪音里捞针。我最近在整理一个老项目的代码审计流程,试着把AI 代码审计这件事做细——不是简单地把代码丢给大模型问"有没有漏洞",而是给它一套专用于审计的skill(可复用的技能包/提示词工作流),让 AI 像一个有经验的安全工程师那样有顺序地排查。这篇文章就是我这段实践的记录,讲清楚我为什么最终选择这条路、审计 skill 该怎么搭、在真实项目上跑起来是什么效果,以及踩过的坑和调优方向。如果你是做安全开发、SDL、代码评审,或者手里有一堆历史项目等着做漏洞自查,这篇应该能给你一套可以照着用的方法。
1. 为什么通用AI问答做不了代码审计:三个现实矛盾
先说结论:现在的大模型本身并不缺安全知识,缺的是"按审计员的思路干活"的能力。我一开始也犯过懒,把整个项目目录拖进对话框,问一句"帮我找找漏洞",结果得来的是一堆教科书式答案——SQL 注入、XSS、SSRF 挨个列一遍,每个都像是从 OWASP 指南里抄的,跟我的代码没多大关系。后来我复盘了一下,通用 AI 问答在代码审计这件事上至少有三个绕不开的矛盾,也正是这三个矛盾逼着我去做 skill。
1.1 矛盾一:静态扫描能触发规则,却读不懂业务语义
很多团队现在还在依赖 SAST 工具做第一道筛子,工具确实快,但它本质上是在做模式匹配。比如扫描器看到 JDBC 里有字符串拼接,立刻报一条 SQL 注入高危;看到${}出现在 MyBatis 的 XML 里,又来一条。可问题在于,它根本不知道这个方法到底是外部接口触发的,还是某个定时任务内部调度的,也不知道参数在到达这里之前,是否已经经过了一层白名单过滤。
我手头有个系统就是这样:SAST 报了 300 多条告警,我抽了 20 条人工看,真正能利用的不到 3 条。大部分告警属于"看起来危险但实际不可达"——要么方法只在内部服务间调用,要么入口处有严格枚举校验。AI 读代码的能力恰恰能补上这块短板,它看得懂调用关系,能判断参数源头是否用户可控。但前提是你得给它一套引导,让它按"外部入口 → 内部调用 → 数据库操作"的顺序去读,而不是把一堆文件丢过去之后让它自由发挥。
1.2 矛盾二:大模型擅长回答,但不擅长自主排查
这其实是 agent 领域一直在说的老问题:大模型在"单轮问答"和"多步任务执行"之间的表现差距极大。你问它"这段代码有没有问题",它能答得头头是道;但你要它"遍历整个模块,把所有外部输入流向 SQL 拼装的链路都找出来",它就容易漏,甚至中途跑偏。
我最早把项目压缩包喂给 AI 的时候,它给我产出了一份很像样的报告,连风险等级都有。可我一对照代码,发现它审到的几个"高危点"里,有两个根本不在我想要的范围里,真正一个问题多发的工具类,它反而没提。原因很简单:代码审计是个典型的"多步任务"——先枚举入口,再追踪数据流,再核对过滤逻辑,最后判断可利用性。每一步的中间结果如果没人管,模型就会顺着自己的偏好走。skill 要做的就是把这些步骤固化下来,强制模型按流程输出中间产物,而不是让它一篇小作文写到底。
1.3 矛盾三:聊天式回复没法直接写进工单系统
就算 AI 答对了,聊天式输出也很难用。研发同事看一段"这里可能存在 SQL 注入风险,建议修复"只会一脸茫然:具体是哪个接口?参数从哪来?修复后怎么验证?没有这些信息,审计结论就只是个提醒,不是可执行的工单。
我在 skill 里强制规定了输出模板之后,情况立刻不一样了。每一份发现的漏洞都带编号、风险等级、CWE 类别、触发链路、精确到行的位置、修复建议和验证步骤,研发拿到手就能直接开工。这个体验上的差别非常明显——之前 AI 审计完我还要人肉整理一遍,现在整理这步基本省了。
所以,AI 代码审计的真正瓶颈不是模型不够聪明,而是没有一套适用于审计任务的规范流程。所谓 skill,就是把一个成熟审计员脑子里的"流程纪律"翻译成模型能读懂的指令。接下来我详细说说这套东西怎么搭。
2. 审计Skill的骨架:输入、输出与思考路径
我把 skill 看成一个有固定接口的"函数":给它定义好的输入边界,它按固定的思考路径处理,最后按固定格式输出报告。这一节讲的就是这三个部分的落地细节,也是整个实践里最花心思的地方。
2.1 输入约定:先让AI知道自己在审什么
好的 skill 不会一上来就埋头查代码,它首先要做的是向使用者确认审计边界。我自己习惯在 skill 开头就要求 AI 先收集这几类信息:
- 项目语言和框架(Java Spring?Node Express?Python Django?),这决定了很多分析逻辑的侧重点
- 代码路径和审计范围,精确到目录或模块
- 本次审计关注的漏洞类型,是 OWASP Top 10 全量,还是只想看注入类、文件上传类
- 威胁模型,比如是外部用户可见的系统,还是内部系统,是否对接支付、权限等敏感模块
- 是否包含第三方依赖(如果包含,通常得单独去查已知 CVE,而不是逐行看源码)
你可能觉得这些信息用户不说也能猜,但实际效果差很多。有一次我直接让 skill 审计整个src/main/java,它跑到一半就有点"糊",后面输出明显开始偷懒。后来我改成只审com.example.module下的用户模块,并明确告诉它"入口是 Controller 层的/api/classify接口",一轮下来质量立刻上来了。输入边界越清楚,模型对"哪些代码要细看、哪些代码只是上下文"的判断就越准。
这里我把"输入收集"做成了一个固定开场白,类似这样:
## 审计开始前必读 在你开始分析任何代码之前,必须确认以下输入信息: 1. 项目语言与框架 2. 审计目标目录(绝对路径或相对路径) 2. 审计范围(全部模块 / 指定模块) 4. 关注问题类型(注入 / XSS / 文件上传 / SSRF / 反序列化 / 全部) 5. 威胁模型(外部暴露 / 内部系统 / 第三方对接) 若用户已提供完整信息,直接进入审计流程;若缺少关键项,先用不超过5个问题询问确认,不要擅自猜测。这段指令千万不能省略。少了它,AI 会默认按它自己的想法设定范围,而审计最忌讳的就是审了半天,发现审错模块了。
2.2 输出结构:审计结论要像工单一样清晰
我最终用的输出模板是一张表格,加一段数据流描述。表格里的字段是反复删改后定下来的,每个字段都有实际用途:
| 字段 | 说明 | 示例 |
|---|---|---|
| 编号 | 每次审计的漏洞唯一标识 | AUDIT-001 |
| 风险等级 | Critical / High / Medium / Low | High |
| CWE 编号 | 漏洞分类标准编号,方便对接体系 | CWE-89 |
| 漏洞标题 | 一句话说清问题 | 外部参数直接拼接 SQL 查询 |
| 触发链路 | 从入口到 sink 的完整调用链 | GET /api/classify → ModuleService.detail → ModuleRepository.queryByType |
| 受影响文件:行号 | 精确定位 | ModuleRepository.java:34 |
| 利用条件 | 需要什么前提才能触发 | 需要登录用户可访问该接口,且参数可控 |
| 修复建议 | 具体可落地的改法 | 改用参数化查询?占位 |
| 验证步骤 | 能复现的判断手段 | 调用接口并传入含 SQL 特殊字符的入参,观察响应与正常入参是否出现差异 |
为什么一定要 CWE 编号?因为研发团队不一定懂安全,但 CWE-79、CWE-89 这种编号在漏洞管理平台和后续合规扫描里都通用,可以直接映射。触发链路是最关键的字段,一份报告里如果写不出链路,那基本可以判断是误报——这是我在 skill 里反复训练出来的判断标准。
2.3 思考路径:从入口到出口是核心审计心法
这一小节是整个 skill 的灵魂。我参考了自己做人工审计时的习惯,把流程拆成固定的六步,写进 skill 里让模型按序执行:
## 审计思考路径(必须按序执行) 1. 枚举入口:找出所有接收外部输入的代码位置(Controller 方法、定时任务入口、MQ 消费者、WebSocket 处理函数、RPC 提供方)。 2. 列出每个入口的用户可控参数,标注参数类型、来源(Query/Path/Body/Header)。 3. 追踪参数流向:从入口方法出发,沿着调用链跟踪参数如何传递,直到 Service、DAO、Repository 或第三方库调用。 4. 识别 sink 点:SQL 查询、命令执行、文件路径拼接、XML 解析、模板渲染、反序列化等危险操作。 5. 检查过滤逻辑:在当前处理函数前,是否已有白名单校验、参数化查询、编码函数、类型转换等安全措施。 6. 确认绕过可能性:若发现过滤函数,不要立即判定安全,需确认过滤点的输入是否已历经解码/编码顺序导致的二次变化;搜索是否存在 URLDecoder.decode、new String(bytes, charset)、JSON 序列化反复解析等操作。 7. 输出结构化报告,每条结论必须包含完整数据流链路。 最后一步特别重要。人做审计时很容易先入为主,看到某个地方调用了 `EscapeUtil.htmlEscape()` 就觉得安全,结果上游其实做了两次 URL 解码,原本的过滤直接被绕过去了。AI 在这一点上和人类审计员犯的错误一模一样,必须先逼它查完编码顺序,再让它下结论。为了说清楚这个思考路径的重要性,我拿一个典型的例子展开讲。一个接口接收type参数,从 Request 里拿到后,先经过一个工具类SecurityUtil.filter(type),看起来做了防护,然后才拼接进 SQL。模型如果只看到filter就写"已过滤,未发现漏洞",就会漏报。实际上filter里的实现是HTML实体编码,根本不是针对 SQL 的过滤,而且在下游还有一次URLDecoder.decode,把编码后的内容又还原了一次。skill 里的第 6 步就是在强制模型别停在第 5 步,而是继续想"这个过滤能挡住什么、不能挡住什么、有没有二次还原"。这一条的收益立竿见影,后面第五节我会给出具体数据。
3. 真机实验:一个Spring Boot模拟项目的SQL注入排查
理论讲再多,不如真跑一次。我特意搭了一个模拟项目来验证 skill 的实际效果。为了避免误解,先说明:这是一个仅用于技术验证的教学用例,不包含任何真实业务数据,我会刻意在代码里埋入几处经典漏洞点,用来观察 skill 的排查能力。
3.1 实验环境怎么搭
我用 Docker 本地起了一个 Spring Boot 2.7 项目,内存数据库用 H2,尽量保持轻量。项目结构故意做得像一个典型的电商后台:有 Controller 层、Service 层、Repository 层,还有一个SecurityUtil公共工具类,里面放着几个看起来像防护但实际并不可靠的函数。
核心接口就一个:
@RestController @RequestMapping("/api") public class ClassifyController { @Autowired private ModuleService moduleService; @GetMapping("/classify") public String classify(@RequestParam String type) { return moduleService.detail(type); } }Service 层几乎没有处理,直接把参数传递下去:
@Service public class ModuleService { @Autowired private ModuleRepository moduleRepository; public String detail(String type) { return moduleRepository.queryByType(type); } }重点在 Repository 层,这里就是教科书式的危险写法:
@Repository public class ModuleRepository { @Autowired private JdbcTemplate jdbcTemplate; public String queryByType(String type) { String sql = "select title from module where type = '" + type + "'"; return jdbcTemplate.queryForObject(sql, String.class); } }同时我在SecurityUtil里放了一个htmlEscape方法,并且让它真的被 Controller 调用了,营造"看起来过滤过"的假象。这样就模拟了我在 2.3 节说的那种最容易漏报的场景。
搭好环境后,我用 agent 工具挂载写好的 skill 跑审计任务,命令大概是这样(具体命令取决于你用的 agent 工具,Claude Code、Codex 都支持类似能力):
claude --skill security-audit "审计 src/main/java/com/example/module 目录,入口为 /api/classify,关注注入类风险"3.2 skill 的第一轮输出:候选风险点清单
技能包跑完一轮,大概几十秒的思考过程后,输出的报告比我想象的干净。核心部分长这样:
| 编号 | 风险等级 | CWE | 漏洞标题 | 触发链路 | 位置 | 修复建议 | |---|---|---|---|---|---|---| | AUDIT-001 | High | CWE-89 | 外部参数直接拼接 SQL 查询 | GET /api/classify → ModuleService.detail → ModuleRepository.queryByType → JdbcTemplate 字符串拼接 | ModuleRepository.java:34 | 使用 JdbcTemplate 参数化查询:`select title from module where type = ?` |这里最让我满意的是触发链路那一栏,基本一次到位。而且它没有停在 Controller 表面,而是顺着 Service 一路追到了 Repository,判断依据也写清楚了:type参数来自外部请求,且未被任何有效 SQL 过滤拦截,SecurityUtil.htmlEscape对 SQL 注入无效。
3.3 边界情况:带过滤函数时的漏报陷阱
我加这个过滤函数,其实是想看 skill 会不会踩常见的坑。第一版 skill 还真踩了——它会看到SecurityUtil被调用就标记为"已过滤",然后给个 Low 或者直接跳过。后来我改了思考路径第 6 步,明确要求"发现过滤函数时,先确认过滤点的输入在更上游是否已被解码/编码,再确认过滤函数是否与 sink 类型匹配",情况才改善。
具体到这个实验里,Controller 里调用的htmlEscape会把'编码成实体,但如果我在下游又加了URLDecoder.decode,就会把编码还原回来。skill 在 v2 版本发现了这一点,并且在报告里额外输出了一个"编码顺序说明":
> 审计提醒:SecurityUtil.htmlEscape 作用于输入字符串,但下游 ModuleService.detail 中存在 URLDecoder.decode 调用, > 会把 HTML 实体还原为可控特殊字符,导致过滤失效。建议以参数化查询为准,不应依赖 htmlEscape。这段提示说明思考路径起了作用。整个过程从下命令到拿到报告,大概 40 秒到 2 分钟不等(取决于模型负载),换成人工审这个模块,正常需要十分钟到半小时,还要依赖审计员当天的状态。
我也踩过反面教材:通用对话模式查同一个项目,AI 给的报告里有三条,其中一条压根不存在——它把H2数据库的关键字拼写错误当成漏洞分析了,让人哭笑不得。
4. 让Skill从Demo走向流水线:与Codex/Claude Code的协作方式
能在本地跑通一次实验,和能在日常开发流程里稳定使用,中间还隔着不少工程问题。这一节讲我怎么让 skill 从"偶尔玩玩"变成"日常干活"的工具。
4.1 从"粘贴代码"到"让Agent自主执行审计"
一开始我是手动把文件路径喂给 agent,后来发现可以直接把 skill 定义成一个可复用的指令文件,在终端里通过 hook 或命令参数让 agent 自动加载。我通常把所有 skill 文件放在版本控制目录下统一管理,比如这样:
skills/ └── security-audit/ ├── main.md # 主指令:流程、思考路径、输出模板 ├── java-spring.md # 针对 Java Spring 的分析规则 ├── nodejs.md # 针对 Node/Express 的分析规则 ├── python-django.md # 针对 Python/Django 的分析规则 └── CHANGELOG.md # 每次调优的记录在 CI 或本地跑增量审计时,我写了个简单的脚本,先拿到变更文件列表,再交给挂载了 skill 的 agent:
#!/usr/bin/env bash # 简易增量审计:只审最近一次提交涉及的代码 CHANGED_FILES=$(git diff HEAD~1 --name-only -- '*.java' '*.js' '*.py') if [ -n "$CHANGED_FILES" ]; then claude --skill security-audit \ --additional-context "本次需审计的变更文件: $CHANGED_FILES" \ --include "$(echo "$CHANGED_FILES" | tr '\n' ',')" fi注意,不要让 agent 一次性把整个工程目录读进上下文,否则它很快就会被非目标代码干扰。最好的做法是先确定了一个入口目录,再把 skill 传给它,让它自主规划读取顺序。这也是 agent skill 与普通 prompt 的最大区别:skill 定义了它该读哪些、按什么顺序读、读到什么程度可以停。
4.2 审计报告自动化流转
skill 输出是 Markdown 表格,但研发的工单系统不认识 Markdown。我写了一个小的 Python 脚本,把表格解析成结构化 JSON,再对接飞书多维表格或 Jira。这里贴一段核心解析逻辑:
import json import re def parse_audit_table(md_text: str) -> list[dict]: """把 skill 输出的 Markdown 表格转成 JSON 列表""" lines = md_text.strip().splitlines() in_table = False header, rows = [], [] for line in lines: if line.startswith("|") and not line.replace("|", "").startswith("-"): cols = [c.strip() for c in line.strip("|").split("|")] if not in_table: header = cols in_table = True else: rows.append(dict(zip(header, cols))) else: in_table = False return rows # 示例:读取 skill 输出文件 with open("audit_result.md", encoding="utf-8") as f: items = parse_audit_table(f.read()) with open("audit_result.json", "w", encoding="utf-8") as f: json.dump(items, f, ensure_ascii=False, indent=2)跑完这一层流转,skill 的产出基本就是工单系统的数据源了。但我必须强调一条铁律:自动创建工单可以,自动指派给研发修改绝对不行。AI 审计结果必须有一位安全负责人复核,因为模型在极端情况下会漏报,也会误报,没人守门的话,轻则研发白忙,重则真的漏洞被淹没在误报里。
4.3 CI/CD 增量审计的尝试与取舍
把 skill 接进 CI,我最开始的设想是每个 MR 都跑一遍,但实践下来发现 token 成本和时效成本都太高。一个中型项目全量审计一次,按我的配置大概要消耗 80 万 token 左右,如果改成整个 diff 一次全量审,成本会随着代码量线性增长,且模型上下文窗口扛不住。
所以我的折中方案是:
- 每次 MR 只做增量审计:让 agent 只看新增和变更的代码,配合
git diff提取上下文,重点关注变更是否引入了新的可控参数、是否改变了原有数据流。 - 每个版本发布前做一次全量审计:范围覆盖所有外部接口和 MQ 消费入口,这是兜底。
- 每次全量审计之后,把历史结果存下来作为基线,下次增量审计时自动跳过已知问题,减少重复劳动。
这个节奏跑下来,增量审计单次成本大约能压到全量的十分之一,而且反馈及时,研发在提 MR 的时候就能看到新问题,不用等月底统一大扫除。
5. 调优日志:误报率、上下文窗口和Skill版本管理的坑
最后这节聊的全是实操中踩过的泥巴。很多写 skill 的文章只告诉你该写什么,但真正决定这东西能不能用的,往往是版本迭代里那些"看起来很小"的坑。
5.1 误报率从 40% 降到 15% 的三次迭代
我对自己做的 skill 做过最朴素的验证:拿十个已知有漏洞的模块跑一遍,统计正确找到的漏洞数和误报数。最初版(v1)的误报率高达四成,报告里充斥着"疑似风险",后来每次迭代改进一个核心点,效果才稳定下来。
| 版本 | 核心改动 | 准确率(找出的真漏洞/总漏洞) | 误报率(报告中的假漏洞占比) | 平均耗时 |
|---|---|---|---|---|
| v1 | 通用 prompt:"请审计以下代码" | 约 55% | 约 40% | 3-5 分钟 |
| v2 | 要求必须给出从入口到 sink 的完整调用链,否则降低风险等级 | 约 70% | 约 25% | 1-2 分钟 |
| v3 | 强制先列入口清单,再逐个追踪;发现过滤函数必须检查解码顺序 | 约 85% | 约 15% | 30-90 秒 |
v1 到 v2 的变化最大,因为"必须给完整调用链"这个要求逼着模型去真正读代码调用关系,而不是凭语义猜。v3 的改动则主要影响漏报率,牺牲了一点速度,换来对"看似有过滤实则被绕过"场景的识别能力。这个对比也告诉我一个经验:在 skill 里拿不准的时候,宁可让它多追问一轮,也不要让它直接跳到结论。
5.2 上下文窗口不够时,按模块拆分审计
模型上下文是有限的,一个几十万行级别的工程塞进去,经常读到后面忘了前面。我试过让 agent 一次审计整个服务,结果第二个模块过半之后,它开始把第一模块的结论重复输出。
解决方式是把审计拆成"模块级"任务。先让 skill 生成一个模块清单(哪些包、哪些入口),然后按模块逐个审计。公共模块单独处理:把common/filter、common/security这类被所有模块共享的代码,先让 skill 单独精读一遍,产出一份"公共上下文摘要",之后每个模块审计时都把它作为附加上下文传入。这样既控制了 token 消耗,又避免了模型丢失关键过滤逻辑的记忆。
具体到 skill 指令里,我会加这么一段:
当审计模块数量超过 5 个时,不允许直接全量审计。必须先输出模块清单,再逐个进行审计。 公共工具类、全局过滤器、统一异常处理、认证授权框架应单独提取并总结为公共上下文, 后续模块审计需引用该上下文,避免重复分析。这个改动看似简单,实际效果很明显,尤其是 Spring 这类大量依赖拦截器和注解的项目,公共上下文能帮模型少走很多弯路。
5.3 Skill 本身也要做版本管理
skill 本质是一堆 Markdown 文件,完全可以放进 git 和测试用例一起管理。我会给每个 skill 文件写一个 CHANGELOG,记录每一版改了哪些规则、为什么要改。这里有一个很典型的调优案例:审计 MyBatis 项目时,模型分不清${}和#{},把参数占位符当成注入点,报了一大堆误报。后来我在java-spring.md里加了一条硬规则:#{}是预编译占位符,不在注入风险范围;${}才是拼接点,需要人工复核。误报率立刻又降了一截。
不同语言要维护不同的子规则文件,我给它们做了一张简要对照表:
| 语言/框架 | skill 里强调的分析侧重点 |
|---|---|
| Java Spring + MyBatis | 区分${}与#{};关注拦截器、注解路由、DTO 校验是否生效 |
| Node.js/Express | 中间件顺序是关键;关注res.render的模板注入、child_process的命令拼接 |
| Python/Django | ORM 的extra()/raw()与原生 SQL 区别;关注pickle、yaml.load等反序列化点 |
| 前端 | 关注 DOM 操作、innerHTML、URL 跳转、postMessage 监听 |
这份表说明同一个 skill 的骨架可以复用,但真正出效果的往往是最细节的那几条领域规则。你一次性堆五十条规则,模型反而记不住核心路径;不如先保骨架,再按项目语言逐步加薄薄的一层"针对于该框架的关键规则"。
5.4 最重要的兜底:AI 可以加速,但不能替代人
我知道网上很多文章把 AI 代码审计吹得很神,但我的实际感受是:它目前更适合作为"第一遍粗筛 + 人工复核"里的粗筛,而不是最后的裁决者。有一次它在某个老项目里报告了一条分页查询的 SQL 注入,我看触发链路写得头头是道,差点直接提工单了,结果一查,发现那是 MyBatis 的#{}预编译,是我当时还没加规则时发生的误报。后来规则补上,这个场景才绝迹。
所以我的流程永远是:skill 全量跑一遍 → 自动过滤掉没有完整链路或者风险等级为 Low 的条目 → 安全负责人对 High 以上的逐条复核 → 确认后再提工单。这样既不浪费 AI 的速度,也不让它带偏整个团队的判断。
回归到这次实践的整体感受。我最深的体会是:代码审计的 skill 不是一次写成的,它是在一次次误报、漏报和版本迭代里"养"出来的。从最初那个只会输出套话的 v1,到后来能识别解码顺序陷阱的 v3,中间改的全是些不起眼的小规则,但它们比任何花哨的功能都更管用。如果你也想试,我建议别一开始就追求覆盖所有漏洞类型,先挑一个你最常遇到的场景,用几个已知漏洞的模块把 skill 磨到"能用",再慢慢扩展。这条路走起来不快,但每一步的收益都看得见。