把 LLM 塞进代码审计这件事,我前后折腾了大半年。最初是典型的“人肉投喂”——把可疑文件逐段复制给大模型,让它找漏洞。看起来省力,真放到项目里完全不是那么回事:上下文窗口里只装得下局部代码,跨文件的调用关系一概缺失,模型的判断经常被无关细节带偏,输出一百条告警,人工复核能去掉八十条。后来我把思路彻底换掉,引入了项目上下文 RAG,再让 CodeQL 作为确定性分析引擎,最后用一个 Agent 把两者串起来跑完整条流水线,这才算摸到“LLM 自动化代码审计”的工程化门槛。这篇把选型逻辑、落地细节和踩坑记录完整复盘一遍,给正在做同类工具链的朋友做个参考。
1. 为什么是“RAG + CodeQL”两条腿走路
先回答一个大家都会问的问题:LLM 现在这么强,直接让它审计不行吗?我的答案是,能做,但离“工程化闭环”差得很远。
1.1 纯 LLM 审计的三个硬伤
第一个硬伤是上下文碎片化。真实项目的漏洞很少只存在于一个函数内部,SQL 注入、命令注入这类问题,通常是一条跨函数的调用链:入口函数接收参数,经过三四层处理,最后到达危险操作。纯 LLM 方案一次只看几百行代码,中间链路断掉之后,模型只能靠猜。第二个硬伤是幻觉。在没有项目上下文的时候,模型非常容易虚构出不存在的函数、不存在的校验逻辑,然后基于这些虚构信息给出“高危”判断。第三个硬伤是告警质量问题。模型几乎不会主动说“这个路径不可达”,它倾向于配合你的指令挑毛病,最后产出一堆低质量的 True Positive,人工复核成本比直接看代码还高。
1.2 CodeQL 是“画靶子”的,不是来替代 LLM 的
CodeQL 的价值在于确定性。它能把“外部输入一路流到危险函数”这件事变成可查询的图上事实,不会撒谎,也不会遗漏明显的传播路径。但它缺的是“业务语义”——一条数据流虽然从参数流到了命令执行函数,中间有没有白名单校验?入口是否真的暴露给外部用户?这些 CodeQL 回答不了。
所以我的定位是:CodeQL 负责锁定候选漏洞路径,相当于画靶子;LLM 负责判断这些路径在真实业务中能不能打中,相当于瞄靶心。RAG 在这个过程里负责给 LLM 提供项目全局上下文,让它在判断时有依据,而不是凭空脑补。
1.3 工程闭环的三个要素
一个真正能落地的自动化审计系统,我认为至少要有三个要素:数据层、分析层、验证层。数据层解决“代码怎么被理解”,分析层解决“去哪找问题”,验证层解决“找到的问题怎么确认和跟进”。RAG 是数据层的核心,CodeQL 是分析层的核心,Agent 是验证层的编排器,人工复核则是最终裁决。后面所有的设计都围绕这条线展开。
2. 项目上下文 RAG:不是简单切块,而是“语义地图”
很多人做代码 RAG 时直接套用文档 RAG 的思路:文件拆成 chunk,做 embedding,存向量库。这在大模型聊天场景够用,但放到代码审计里效果很差。代码的核心特征是“符号之间的引用关系”,如果索引里没有这个关系,检索到的上下文就是一堆孤立片段。
2.1 索引粒度:按语义单元切,而不是按文件切
我最初的实验是按固定行数切块,比如每 100 行一个块。结果非常惨烈:一个函数被拦腰截断,前半段在 A 块,后半段在 B 块,检索命中后给 LLM 的是残肢代码,判断逻辑完全失效。后来改成按 AST 节点切分,粒度分三种:
- 函数级:每个函数体作为一个单元。
- 类级:类定义连同其主要方法作为一个单元,适合处理有状态的对象。
- 名字空间/模块级:模块入口、导出符号、全局配置作为单元,适合处理跨模块引用。
如果函数体超过 300 行,再按内部 AST 的子节点拆成多个单元,但每个单元会保留所属父节点的签名信息。低于 30 行的小函数不单独成块,合并到同一个类或模块里,避免索引碎片化。
切块之外还有一个关键操作:保留符号表。每个切块除了代码文本,还在元数据里记录它引用的外部符号和被外部引用的符号。这一步相当于给检索器建立了“调用关系索引”,后面做上下文扩展时全靠它。
2.2 索引元数据的埋点细节
这一步很容易被忽略,但实际检索质量差距最大的就是元数据。我用的切块数据模型大致是这个结构:
{ "chunk_id": "ctx-000123", "language": "python", "file_path": "services/payment.py", "symbol_name": "validate_and_pay", "symbol_type": "function", "start_line": 132, "end_line": 189, "dependencies": [ "db.session.query", "utils.safe_compare", "models.Order" ], "refereed_by": [ "controllers.checkout.submit_order" ], "code_text": "def validate_and_pay(order_id, user_id): ..." }dependencies 和 refereed_by 是从 AST 和调用图里抽出来的。有了这两个字段,Agent 在做上下文扩展时,可以把一个命中 chunk 的上下游调用者也一并拉出来,组成一条“调用链上下文”,而不是单个函数的孤岛。
2.3 检索策略:先召回,再重排
检索我采用的是混合策略。关键词检索(BM25)负责精确匹配符号名、函数名、API 名称,向量检索负责语义匹配。两类结果用 RRF(Reciprocal Rank Fusion)融合,融合后再按与当前分析目标的相关性做一次轻量重排。
重排特征包括:符号名是否出现在查询中、是否是依赖关系闭包内的节点、是否位于候选路径的起点或终点。一轮试验下来,重排前 top 20 的命中率只有 40% 左右,重排后 top 8 的命中率能到 75% 以上,效果非常明显。
2.4 上下文注入的格式设计
检索到相关代码之后,不是直接把 chunks 拼起来塞给 LLM。我采用的注入格式是“问题描述 + 候选路径 + 调用链代码 + 相关定义片段”四段式。其中相关定义片段只放函数签名、类签名和关键赋值语句,控制 token 长度,避免把 LLM 淹没在无关细节里。
实践中我发现一个特别重要的细节:上下文注入的顺序会影响 LLM 的判断。把“候选路径”放在最前面,把“调用链代码”紧随其后,最后才是“相关定义”。这样 LLM 会优先看到分析主线,再看到辅助定义。顺序反了,模型经常会把辅助定义当成主线,输出偏离严重的结论。
3. CodeQL Agent 如何与 LLM 分工
有了 RAG 作为知识底座,下一步就是把 CodeQL 的能力接入到 Agent 的工具箱里。这里最核心的问题是如何划清边界:哪些判断交给静态分析,哪些判断交给 LLM,哪些逻辑必须由人工兜底。
3.1 CodeQL 负责确定性的数据流事实
CodeQL 我只让它干四类事:
- 找到污点路径:从外部输入源到危险汇聚点的完整路径。
- 找到调用关系:某个函数被哪些地方调用、它调用了哪些函数。
- 找到不安全 API 使用点:比如直接拼接 SQL、直接执行系统命令。
- 找出防护措施缺失:在路径上是否存在与预期不一致的校验节点。
对应到 CodeQL 查询,核心就是 TaintTracking 配置。一个典型的污点追踪查询长这样:
import python import semmle.python.dataflow.new.TaintTracking class CommandInjectionConfig extends TaintTracking::Configuration { CommandInjectionConfig() { this = "CommandInjectionConfig" } override predicate isSource(DataFlow::Node source) { source.asCfgNode() instanceof ControlFlowNode and source.asCfgNode().getAstNode() instanceof Name } override predicate isSink(DataFlow::Node sink) { sink.asCfgNode().getAstNode() instanceof Call and sink.asCfgNode().getAstNode().(Call).getFunc().getName() in ["os.system", "subprocess.run"] } } from CommandInjectionConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink where cfg.hasFlowPath(source, sink) select sink, source, sink, "Command injection from $@", source, source.toString()这段查询的工作是把“入口参数”和“危险命令执行”之间建立数据流关系。它只能告诉我们“存在这么一条路径”,但无法回答“这条路是否真的被人攻击者控制”。后者就是 LLM 的活了。
3.2 LLM 负责业务语义的可利用性判断
LLM 在整套系统里承担四个职责:
- 判断路径的可达性:入口是否暴露给不可信用户,比如是否被路由注册。
- 判断防护有效性:路径上的校验逻辑是否可能被绕过。
- 构造攻击场景:给出具体的利用思路,这个能力几乎只能靠 LLM。
- 形成可执行的修复建议:输出 patch 级别的修改方案。
为了让 LLM 的判断有据可依,我给每条路径都配了三类 RAG 上下文:入口函数周边代码、路径上关键校验函数的定义、项目中的认证和权限模型。这样模型可以区分“这是个有认证保护的内部接口”和“这是个完全开放的端点”。
3.3 工具调用协议:Agent 如何调 CodeQL
我实现的 Agent 底层是一个工具调用循环。核心工具表如下:
| 工具名 | 输入 | 输出 | 用途 |
|---|---|---|---|
| codeql_taint | sink_type, file_path | 路径列表 | 定位污点路径 |
| codeql_callgraph | symbol_name | 调用关系列表 | 获取调用链 |
| rag_retrieve | question, file_path | 上下文块列表 | 检索项目语义上下文 |
| project_meta | 无 | 仓库结构、依赖、入口点 | 初始化理解 |
| result_save | 审计结论 JSON | 存储 ID | 写回结果库 |
工具调用格式选用 JSON-RPC 风格,方便 Agent 循环里做严格的参数校验。每次调用工具返回的结果都会附带一个“置信度”字段,比如 codeql_taint 返回的路径置信度为 1.0(确定事实),rag_retrieve 返回的块置信度为 0.7(检索近似结果)。这个置信度会让 LLM 在综合判断时更倾向于相信 CodeQL 的事实,而不是 RAG 的推测。
4. 工作流编排:从扫描到报告的完整闭环
前面的组件都是零件,真正让它们转动起来的是流水线。我的目标不是做一个单次扫描工具,而是让“审计—修复—复测”形成闭环。
4.1 阶段一:项目切片与代码库基线建立
流水线启动后,第一步不是扫描,而是建立代码库基线。这里我以 git 仓库的 commit 为基准,拿到本次要审计的变更文件集合。如果是首次全量审计,就以整个仓库为对象。
基线建立时会输出一份“项目摘要”给 Agent,内容包括:仓库语言分布、入口文件列表、已注册的路由或接口列表、依赖的中间件和 ORM。这份摘要让 Agent 在后续判断中能快速建立项目全局观,而不是只能看到局部代码。
4.2 阶段二:CodeQL 批量初筛
对所有目标文件跑预置的污点查询集合。查询集合按漏洞类型分桶,包括注入类(SQL、命令、模板注入)、路径穿越类、反序列化类、SSRF 类等。
这里要尤其注意查询的资源配置。CodeQL 数据流分析对大型仓库非常吃内存和 CPU,我在流水线里做了以下限制:
- 单一查询最长时间 5 分钟,超时自动终止。
- 每个查询最多返回 200 条路径,超出部分丢弃。
- 所有查询并发度控制在 2-3 个,防止资源竞争导致 OOM。
初筛完成之后输出的是一组候选路径,每条路径包括 source 节点、sink 节点、中间步骤列表。这些候选路径可能包含大量误报,也可能因为数据流分析的限制漏掉某些情况,但整体上比人肉翻代码要全面得多。
4.3 阶段三:RAG 富化上下文并构造候选集合
这一步是关键。候选路径只有 source 和 sink,没有业务语义。我让 Agent 对每条候选路径执行一次“上下文扩增”:
- 根据 source 文件,用 rag_retrieve 检索入口函数定义、调用者列表、路由注册信息。
- 根据路径中间步骤,检索所有涉及校验函数的定义。
- 根据 sink 文件,检索危险 API 的封装和项目中的安全配置。
扩增后的候选集形成一个标准结构,包含原始 CodeQL 路径证据和 RAG 上下文块。这一步完成后,每条候选的 token 消耗大约在 2500 到 4000 之间,还在可控范围。
4.4 阶段四:LLM 判定与人工复核
把候选集逐条按上下文模板发给 LLM,要求输出结构化结论。Prompt 模板大致如下:
你是一名代码审计专家。以下是静态分析工具找到的一条数据流路径,以及项目上下文。 请从攻击者视角判断该路径是否真实可利用,注意反证:是否存在不可达条件、白名单过滤、强类型校验或认证保护使攻击失效? 判断结果只允许三个取值: - true_positive:确信可利用 - false_positive:确信不可利用 - unknown:证据不足,需要人工审查 请以 JSON 格式输出: { "verdict": "true_positive", "confidence": 0.9, "severity": "high", "exploit_scenario": "攻击者可通过 ... 实现 ...", "patch_suggestion": "建议将 ... 替换为 ..." }LLM 输出后,系统把 verdict 为 unknown 和高危的 true_positive 送入人工复核队列。人工复核界面左侧是 CodeQL 路径可视化,右侧是 RAG 检索到的上下文,下面是 LLM 的判定理由。整个复核过程从原来的“从零读代码”变成“确认机器判断”,时间大幅缩短。
4.5 阶段五:结果回填与复测闭环
审计结论不会一报了之。所有被人工确认为真实漏洞的记录都会回填到知识库,作为后续审计的“历史记忆”。修复完成后,开发提交新的 commit,流水线只对变更文件对应的路径做增量复测。此时 Agent 会对照历史记录检查:原始路径是否消失,是否引入了新的旁路路径。这一步解决的是“修了入口,却漏了旁路”的常见问题。
5. 实测数据与成本评估
整套系统在某模拟项目 X 上做了两轮验证。项目 X 是一个混合语言 Web 服务,约 470 个文件、12 万行代码,预埋了 23 个漏洞,覆盖 SQL 注入、命令注入、路径穿越、反序列化、SSRF 五类。两轮验证后的效果数据如下:
| 方案 | 精确率 | 召回率 | 误报率 | 单轮审计耗时 | 人工复核耗时 |
|---|---|---|---|---|---|
| 纯 LLM(人工投喂) | 0.31 | 0.58 | 0.69 | 3.5 小时 | 5 小时 |
| RAG + LLM | 0.58 | 0.65 | 0.42 | 2 小时 | 2.5 小时 |
| RAG + CodeQL + LLM | 0.84 | 0.87 | 0.16 | 1 小时 | 40 分钟 |
数字很能说明问题。纯 LLM 方案精确率极低,因为投喂文件时缺少全局上下文,模型把大量正常代码当成了可疑逻辑;加入 RAG 后精确率翻倍,说明“看得全”比“看得多”更重要;加入 CodeQL 后精确率和召回率同时提升,原因是候选路径质量大幅提高,模型不再需要自己盲猜“哪里可能有漏洞”。
成本方面,单条候选路径的平均消耗大约是:
| 环节 | Token | 单条耗时 |
|---|---|---|
| CodeQL 查询(本地) | 0 | 3-8 秒 |
| RAG 检索与重排 | 200-300 | 1-2 秒 |
| RAG 上下文扩增 | 2500-4000 | 1-2 秒 |
| LLM 判定 | 1800-2800 | 3-6 秒 |
| 人工复核 | - | 60-90 秒 |
对 23 个漏洞的完整一轮审计,总共调用 LLM 判定约 120 次,Token 消耗在 30 万左右,总耗时约 50 分钟,加上人工复核 40 分钟,可以控制在 1.5 小时内完成。相比传统审计按“人天”计算,效率提升非常显著。
6. 踩坑实录与排查思路
流程跑通之后,剩下的工作几乎全是填坑。下面是几个让我印象最深的坑,以及对应的排查和解决办法。
6.1 RAG 检索召回了不相关代码
第一次联调时,Agent 针对某个 SQL 注入路径检索上下文,召回的却是完全不相关的用户注册逻辑。排查发现是嵌入式向量在语义空间里把“用户输入”和“用户模型”混在了一起。问题不在 embedding 模型,而在召回策略太单一。解决办法是引入 BM25 精确匹配,并把符号名加入重排特征。此后这类跨域召回明显减少。
6.2 CodeQL 查询超时与结果爆炸
全量扫描时,某个大型模块的污点查询直接跑到 12 分钟还没结束,而且输出路径接近 10000 条。后来发现是因为数据流分析在没有约束的情况下会把所有“参数到危险函数”的组合都枚举出来。我的处理方式分两层:第一层是查询层面增加 source 限制,只保留从外部入口可达的节点;第二层是流水线层面增加超时和 maxPaths 限制。双层约束后,单次查询时间控制在 5 分钟以内,路径数量稳定在 200 条以下。
6.3 LLM 对 CodeQL 路径的盲目信任
这个问题很隐蔽。RAG + CodeQL + LLM 上线初期,精确率虽然高,但个别高危漏洞被误判成可利用,理由却是“数据流路径显示可达”。这属于将静态分析事实直接等价于可利用性,而忽略了业务校验。解决方法是强制在 Prompt 里加入反证要求,明确让模型列举“为什么不可利用”的可能性。调整后,模型的 true_positive 判断里会附上真实的攻击前置条件,而不再只是复述数据流路径。
6.4 闭环复测失败
做增量复测时遇到过一种情况:开发者修复了漏洞,但提交的 commit 里还包含其他重构,导致基线对不上,Agent 认为“路径仍然存在”。问题出在复测基线粒度过粗。我后来把基线精确到函数体级,每次复测只对比目标函数的代码块摘要,如果函数体发生变化,再看数据流路径是否仍然连通。这样就能准确定位“修复是否生效”。
7. 最后的一点个人体会
整套系统做完之后,我最大的感受是:LLM 自动化代码审计的价值不在于取代安全工程师,而在于把工程师从“找问题”的体力劳动里解放出来,让他们专注于“判断问题”和“修复问题”。工具链里 RAG 负责记忆,CodeQL 负责逻辑,LLM 负责语义,人工负责决策,每一层都不可替代。
如果后面继续迭代,我会先在两个方向上深入:一是把 RAG 的切块粒度再做细,尝试基于程序依赖图的切片,而不是基于 AST;二是把 CodeQL 的查询集合进一步扩展,覆盖更多框架特有的危险 API。这套框架目前主要服务 Web 类应用,但对桌面客户端、嵌入式代码做适配的路径也是清晰的。代码审计这个领域以后会越来越依赖这类“程序分析 + 大模型”的组合方案,早一点把工程化闭环跑通,后面的积累会越来越值钱。