
做研发安全的人应该都有这种感受静态扫描工具每天给你几千条告警但真正能直接提工单的没几条大部分时间都在噪音里捞针。我最近在整理一个老项目的代码审计流程试着把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 SpringNode ExpressPython 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 / LowHighCWE 编号漏洞分类标准编号方便对接体系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, encodingutf-8) as f: items parse_audit_table(f.read()) with open(audit_result.json, w, encodingutf-8) as f: json.dump(items, f, ensure_asciiFalse, indent2)跑完这一层流转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/DjangoORM 的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 磨到能用再慢慢扩展。这条路走起来不快但每一步的收益都看得见。