AI代码审计实战:用coding agent构建security-audit-skill工作流
1. 从一次代码审计的翻车现场说起去年秋天我接手了一个内部工具链的代码审计任务。项目不大大概两万多行 Python 代码涉及配置解析、文件操作、网络请求和几个自定义的加解密模块。当时我信心满满觉得这种体量的代码人工过一遍加上几款静态扫描工具两三天就能收工。结果第一轮扫下来工具报了四百多条告警其中真正有安全价值的不到二十条。剩下的全是误报、重复告警和上下文缺失导致的假阳性。更麻烦的是有些真正危险的模式——比如用户输入直接拼接进文件路径、异常处理里吞掉了权限校验失败——工具压根没报出来。那次经历让我意识到一个问题通用静态扫描工具解决的是“已知模式匹配”而代码审计真正需要的是“结合业务上下文的推理判断”。这两者之间的鸿沟不是靠调几个规则阈值就能填平的。后来我开始尝试把大语言模型引入审计流程用 coding agent 的方式来做辅助分析逐渐摸索出一套相对稳定的工作流。这套流程我给它起了个名字叫security-audit-skill——不是什么正式框架就是一套让 AI 编程助手在代码审计场景下真正能干活的方法论和提示词工程实践。这篇文章适合两类人看一类是日常需要做代码安全审查的工程师另一类是想把 AI 编程助手用到安全场景但不知道怎么下手的开发者。我会把整个流程拆开讲清楚包括为什么这样设计、每一步的具体操作、我踩过的坑以及怎么判断 AI 给出的审计结论靠不靠谱。全文没有高深的理论都是能直接上手抄作业的东西。2. 为什么通用扫描工具在真实项目里总是不够用2.1 静态分析工具的“模式匹配”天花板静态应用安全测试工具的工作原理本质上是在抽象语法树或者中间表示上跑规则。规则写的是“如果出现 A 模式就报 B 漏洞”。比如检测 SQL 注入规则可能是“用户输入变量直接拼接进 SQL 字符串”。这个思路在教科书级别的例子上很有效但真实项目里的代码往往长这样def build_query(table_name, filters): base fSELECT * FROM {table_name} if filters: conditions AND .join( f{k} {v} for k, v in filters.items() ) base f WHERE {conditions} return base这段代码里table_name和filters都可能来自外部输入但静态工具很难判断它们是否经过了上游的校验或白名单过滤。它只能看到字符串拼接于是报一条 SQL 注入告警。如果上游确实做了严格的枚举校验这就是误报如果没做这就是真漏洞。工具不知道因为它看不到调用链的上下文。更棘手的是那些“合法但危险”的模式。比如下面这段def read_user_file(user_id, filename): base_dir /data/users path os.path.join(base_dir, str(user_id), filename) with open(path, r) as f: return f.read()如果filename包含../就可能穿越到其他用户的目录。静态工具可能会报路径穿越也可能不报取决于规则库的覆盖程度。但真正的问题是这个函数在业务上是否允许filename包含路径分隔符如果上游已经做了os.path.basename处理这里就是安全的如果没有就是漏洞。工具无法回答这个问题因为它不理解业务约束。2.2 代码审计真正需要的是“上下文推理”我在实际审计中发现一个漏洞的成立通常需要三个条件同时满足可控的输入源、危险的传播路径、缺失的防护措施。静态工具擅长检测第二个条件但对第一个和第三个条件的判断往往很粗糙。举个例子一个 Web 应用里有个接口接收用户上传的压缩包解压后读取里面的文件。静态工具会报“Zip Slip”路径穿越漏洞。但如果你去看代码发现解压前已经校验了压缩包内所有文件的路径确保它们都在目标目录下那这个告警就是误报。反过来如果校验逻辑写错了——比如只检查了文件名不包含..但没考虑符号链接——工具可能根本不会报因为它不理解符号链接在解压场景下的特殊风险。这就是为什么我后来转向用 coding agent 来做辅助审计。大语言模型在理解代码意图、追踪数据流、结合业务上下文做推理方面有天然的优势。它可以把一个函数放在整个调用链里看可以理解变量命名的语义可以推断开发者的意图。当然它也会犯错会幻觉会漏报所以需要一套工程化的方法来约束和验证它的输出。2.3 coding agent 在审计场景下的独特价值把 coding agent 引入代码审计最大的价值不是“自动化”而是“对话式推理”。你可以把它当成一个随时在线的安全工程师搭档你问它“这个函数有没有问题”它会给你分析你质疑它的结论它会重新审视你给它补充业务背景它会调整判断。我常用的交互模式是这样的先让 agent 通读一个模块生成一份初步的风险清单然后我针对每一条风险追问它的判断依据如果我觉得某条判断有问题就把相关的调用链和业务约束贴给它让它重新评估。这个过程有点像代码 review但 agent 不会累不会因为看了几千行代码就注意力下降而且它可以同时记住多个模块之间的关联。当然这套方法也有明显的局限。agent 的上下文窗口有限不可能一次性吞下整个大型项目它的判断受训练数据影响对某些冷门框架或自研协议的理解可能不到位它还可能被代码里的注释误导——如果注释写的是“这里已经做了安全校验”但实际代码没有agent 可能会轻信注释。这些坑我在后面会详细讲怎么规避。3. 搭建 security-audit-skill 工作流的四个核心组件3.1 项目上下文注入让 agent 先“读懂”项目直接让 agent 看代码它只能看到孤立的文件。要让它做出靠谱的判断必须先给它注入项目级的上下文。我通常会在审计开始前准备一份“项目背景包”内容包括技术栈说明语言、框架、主要依赖库及其版本。比如“Python 3.10 Flask 2.3 SQLAlchemy 2.0”这能帮助 agent 判断某些模式是否危险。比如在 SQLAlchemy 里用text()拼接原生 SQL 是危险的但用 ORM 的查询构造器通常是安全的。架构概览哪些模块处理外部输入哪些模块做核心业务逻辑哪些模块负责持久化。我一般会画一个简单的数据流图用文字描述清楚“用户请求从哪进来经过哪些处理最后到哪去”。信任边界明确哪些数据是可信的哪些是不可信的。比如“来自内部配置中心的数据视为可信来自 HTTP 请求体的数据视为不可信”。已知的安全机制项目里已经有哪些防护措施比如统一的输入校验中间件、参数化查询封装、权限校验注解等。这些信息能大幅减少 agent 的误报。这份背景包不需要很正式几百字到一千字就够了。我通常直接写在对话的开头或者存成一个AUDIT_CONTEXT.md文件每次审计时让 agent 先读这个文件。提示背景包里的信息必须准确。如果你告诉 agent “所有输入都经过了校验”但实际有遗漏agent 会基于错误前提做出错误判断。宁可写得保守一点也不要过度承诺。3.2 分块审计策略怎么切代码才合理大型项目不可能一次性塞给 agent。我的做法是按“功能模块 数据流”来切块而不是按文件目录切。比如一个电商系统我会切成“用户认证模块”“商品查询模块”“订单处理模块”“支付回调模块”等。每个模块内部再按“入口层 → 业务层 → 数据层”的顺序组织代码给 agent 看。切块的时候有几个原则每个块不超过 2000 行代码。超过这个量agent 的注意力会分散漏报率明显上升。保持调用链完整。如果一个函数在 A 文件定义在 B 文件调用在 C 文件做校验那这三个文件的相关部分要放在同一个块里。标注跨块依赖。如果当前块依赖另一个块的某个函数把这个函数的签名和关键逻辑摘录过来让 agent 知道“这个输入在上游已经被处理过了”。我一般会写一个简单的脚本用正则或 AST 解析来提取函数定义和调用关系辅助切块。但最终的分块决策还是人工来做因为只有人知道业务上的模块边界在哪里。3.3 审计提示词的设计问对问题比什么都重要提示词的质量直接决定审计结果的质量。我试过很多版本最后稳定下来的提示词结构是这样的你是一名资深应用安全工程师正在对以下代码进行安全审计。 项目背景 [粘贴项目背景包] 当前模块 [模块名称和功能描述] 信任边界 [明确哪些输入可信哪些不可信] 请按以下步骤分析 1. 列出当前模块的所有外部输入源函数参数、全局变量、配置读取、网络请求等。 2. 对每个输入源追踪它的传播路径直到它被使用在危险操作中文件操作、数据库查询、命令执行、反序列化、模板渲染等。 3. 对每条传播路径判断是否存在有效的防护措施参数化查询、白名单校验、路径规范化、权限检查等。 4. 只报告你确信存在安全风险的路径并给出具体的代码行号和利用条件。 5. 对于你不确定的情况单独列出来并说明需要什么额外信息才能判断。 输出格式 - 风险清单按严重程度排序 - 每条风险的详细分析输入源、传播路径、缺失的防护、利用条件 - 不确定项列表这个提示词的关键在于强制 agent 先追踪数据流再下结论。很多误报是因为 agent 看到危险函数就报警没有追溯输入是否可控。通过要求它显式列出输入源和传播路径可以大幅降低这类误报。另外我特意加了“不确定项列表”这一条。agent 在遇到模糊情况时与其强行给一个结论不如老实说“我不确定”。这些不确定项往往是最值得人工深入看的地方。3.4 结果验证闭环怎么判断 agent 说得对不对Agent 给出的每一条风险我都会用一套固定的流程来验证复现输入源找到 agent 说的输入源确认它是否真的可控。有时候 agent 会把内部函数的参数当成外部输入实际上这个参数只由内部代码传入且值来自常量。检查防护措施在调用链的每一层查找是否有校验、过滤、转义。特别注意那些“看起来像校验但实际无效”的代码比如用黑名单过滤../但没考虑 URL 编码。构造 PoC对于确认的风险尝试构造一个最小的利用载荷。如果构造不出来要么是风险不成立要么是还有隐藏的防护没被发现。交叉验证用另一款静态工具或另一个 agent 实例重新分析同一段代码看结论是否一致。不一致的地方重点排查。这套流程走下来误报率能压到很低。代价是时间但比起漏掉一个真实漏洞的后果这个时间花得值。4. 实战拆解一次完整的模块审计过程4.1 目标模块与背景准备我拿一个真实的内部项目来演示。这是一个文件管理服务提供上传、下载、删除、列表四个接口。技术栈是 Python 3.10 FastAPI SQLite。用户通过 JWT 认证每个用户有自己的目录。我准备的项目背景包如下项目内部文件管理服务 技术栈Python 3.10, FastAPI 0.100, SQLite 3 认证JWT用户 ID 从 token 中解析 信任边界 - JWT token 中的 user_id 视为可信由认证中间件校验签名 - HTTP 请求中的路径参数、查询参数、请求体视为不可信 - 数据库中的数据视为可信仅由本服务写入 已知防护 - 所有接口都有认证依赖注入 - 数据库操作使用 SQLAlchemy ORM无原生 SQL 拼接 - 文件操作使用 pathlib但未统一做路径规范化这个背景包很简短但把关键信息都覆盖了。特别是最后一条“未统一做路径规范化”直接提示 agent 重点关注路径穿越问题。4.2 第一轮扫描agent 发现了什么我把文件操作相关的三个文件routes.py、services.py、storage.py一起发给 agent附上上面的提示词。Agent 返回了一份风险清单我摘录其中三条风险一下载接口的路径穿越# routes.py router.get(/download/{filename}) async def download_file(filename: str, user_id: str Depends(get_current_user)): return services.get_file(user_id, filename) # services.py def get_file(user_id: str, filename: str): path storage.get_user_path(user_id) / filename if not path.exists(): raise HTTPException(404) return FileResponse(path)Agent 的分析是filename来自路径参数完全可控。storage.get_user_path(user_id)返回用户目录然后直接拼接filename。如果filename是../../etc/passwd就能读取任意文件。没有看到任何路径规范化或白名单校验。风险二上传接口的文件名处理# routes.py router.post(/upload) async def upload_file(file: UploadFile, user_id: str Depends(get_current_user)): content await file.read() services.save_file(user_id, file.filename, content) # services.py def save_file(user_id: str, filename: str, content: bytes): path storage.get_user_path(user_id) / filename path.write_bytes(content)Agent 指出file.filename来自客户端虽然 FastAPI 的UploadFile会做一些处理但文件名本身仍然可能包含路径分隔符。如果客户端发送的 filename 是../../../tmp/evil.sh就可能写到用户目录之外。风险三列表接口的符号链接问题# services.py def list_files(user_id: str): user_dir storage.get_user_path(user_id) return [f.name for f in user_dir.iterdir() if f.is_file()]Agent 认为这条风险较低但提了一个点如果用户目录里存在指向外部的符号链接iterdir()会遍历到链接目标is_file()也会返回 True导致列表接口泄露外部文件的存在性。不过由于这个服务不允许用户创建符号链接上传的是普通文件实际风险有限。4.3 人工复核与误报剔除我逐条复核了 agent 的发现。风险一和风险二确认成立。我实际构造了 PoC用curl发送GET /download/..%2F..%2Fetc%2Fpasswd成功读取到了系统文件。上传接口也类似可以写到任意路径。这两个是真实的高危漏洞。风险三我判断为低风险因为服务本身不提供创建符号链接的功能用户无法通过正常接口在目录里放置符号链接。但如果服务器上其他进程有权限往用户目录写文件这个风险就会升级。我把它标记为“环境依赖型风险”在报告里单独说明。Agent 还漏了一个点删除接口没有检查文件是否存在直接调用unlink()如果文件不存在会抛异常虽然不构成安全漏洞但会导致错误信息泄露路径信息。这个我在人工复核时补上了。4.4 修复方案与二次验证针对确认的漏洞我写了修复代码# storage.py from pathlib import Path def get_safe_path(user_id: str, filename: str) - Path: base get_user_path(user_id).resolve() # 只取文件名部分丢弃任何路径分隔符 safe_name Path(filename).name target (base / safe_name).resolve() # 双重检查确保解析后的路径仍在用户目录下 if not str(target).startswith(str(base)): raise HTTPException(400, Invalid filename) return target修复的关键点有两个一是用Path(filename).name剥离路径信息二是用resolve()解析后做前缀检查防止符号链接绕过。修复后我把代码重新发给 agent让它验证是否还有绕过方式。Agent 确认了修复的有效性但提醒我注意resolve()在文件不存在时的行为——如果目标文件不存在resolve()仍然会返回规范化后的路径前缀检查依然有效。这个提醒很有价值因为我原本担心文件不存在时检查会失效。5. 那些让我踩坑的细节和反直觉经验5.1 Agent 会被注释和命名误导有一次审计一个权限校验模块代码里有个函数叫check_admin_permission注释写着“校验当前用户是否为管理员”。Agent 看到这个函数名和注释直接判定“权限校验已实现”。但我实际看代码发现这个函数只是检查了用户角色字段是否等于admin而角色字段是从 JWT token 里直接取的token 的签名校验中间件在另一个文件里而且那个中间件有个配置项可以关闭签名校验——生产环境恰好没关但测试环境关了。如果只看这个函数会误以为权限控制很完善。这个坑的教训是不要让 agent 依赖命名和注释做判断要让它看实际逻辑。我在提示词里加了一条“忽略所有注释和函数命名只根据实际代码逻辑判断。”这条改动之后agent 的误判率明显下降。5.2 上下文窗口的“中间遗忘”现象Agent 的上下文窗口虽然大但存在“中间遗忘”现象放在对话开头和结尾的信息它记得比较牢放在中间的大段代码它容易忽略细节。我试过把 3000 行代码一次性发给 agent结果它只分析了前 500 行和后 500 行中间的部分基本没看。解决办法是分块而且每块不要太大。我现在的做法是每块控制在 800 到 1200 行并且在每块的开头和结尾都重复一遍关键背景信息。比如在代码块前面写“以下代码来自文件上传模块输入源是 HTTP 请求体中的 file 参数”在代码块后面再写一遍“再次确认file 参数不可信需要检查路径穿越和文件类型校验”。这种重复看起来啰嗦但实测能显著提升 agent 的注意力。5.3 不同语言和框架的“危险模式”差异很大Agent 对主流语言和框架的理解比较好但对一些冷门组合容易出错。比如我审计过一个用 Go 的text/template做 HTML 渲染的项目agent 没有意识到text/template不会自动转义 HTML而html/template会。它把两者混为一谈导致漏报了一个 XSS。后来我在提示词里加了一段“框架特定注意事项”针对当前项目的技术栈手动列出常见的危险 API 和安全 API。比如Go 模板注意事项 - text/template不自动转义输出到 HTML 时需手动转义 - html/template自动转义但使用 template.HTML 类型会绕过转义 - 危险函数template.HTML(), template.JS(), template.URL()这段内容是我根据经验手写的不是让 agent 自己生成。虽然麻烦但能有效弥补 agent 在冷门知识上的不足。5.4 审计报告的可读性比技术深度更重要一开始我让 agent 输出详细的技术分析结果报告几十页开发团队根本看不完。后来我改成“分层输出”第一层是给开发看的修复清单每条风险一句话描述加修复建议第二层是给安全团队看的详细分析包含数据流追踪和 PoC第三层是给管理层看的风险概览按严重程度统计。这个分层结构是我从实际协作中总结出来的。开发人员最关心“哪里有问题、怎么改”安全人员关心“为什么有问题、怎么验证”管理层关心“有多少问题、优先级怎么排”。一份报告同时满足三类人比写三份报告效率高得多。6. 把 security-audit-skill 变成可复用的能力6.1 沉淀自己的审计提示词库经过多个项目的积累我现在有一套按场景分类的提示词模板。比如“Web 接口审计”“文件操作审计”“认证授权审计”“序列化审计”等。每个模板里预置了该场景常见的危险模式、检查清单和输出格式。用的时候只需要把项目背景和代码贴进去微调一下就能用。这套模板库的价值在于它把我个人的审计经验固化了。即使换一个完全不熟悉的项目只要场景匹配提示词就能引导 agent 关注正确的方向。我建议每个做代码审计的人都花时间整理自己的提示词库哪怕一开始只有三五个模板用起来之后会越来越顺手。6.2 和 CI 流程的结合方式我把 security-audit-skill 接入了 CI但不是让它做全量审计——那样太慢而且误报会阻塞构建。我的做法是在 PR 阶段只对变更的文件做增量审计。Agent 分析 diff 里的新增代码如果发现高危模式就在 PR 里留评论。这样既不会拖慢开发节奏又能拦住大部分“新引入的漏洞”。增量审计的提示词和全量审计不同重点是“只看新增代码但要考虑它和现有代码的交互”。比如新增了一个函数要检查它的输入是否来自已有的不可信源它的输出是否流入了已有的危险操作。这种跨变更的上下文需要在提示词里显式提供。6.3 人工审计师的角色变化用了这套方法之后我的角色从“逐行看代码”变成了“设计审计策略、验证 agent 结论、处理模糊情况”。工作量没有减少但产出质量提高了。以前一天只能仔细看两三千行代码现在一天能覆盖上万行而且漏报率更低。但我也清楚agent 不能替代人的判断。它擅长的是模式识别和数据流追踪不擅长的是理解业务意图和权衡风险。比如一个接口返回了详细的错误信息从安全角度这是信息泄露但如果这个接口是内部调试用的且只对管理员开放那风险就可以接受。这种判断需要人来做。6.4 持续更新的必要性安全审计不是一次性的工作。新的漏洞模式、新的框架特性、新的攻击手法不断出现agent 的知识库和我的提示词库都需要持续更新。我现在的做法是每完成一个项目把新发现的误报模式和漏报模式记录下来更新到提示词里每季度回顾一次把过时的内容清理掉。这个习惯看起来不起眼但坚持下来审计效率的提升是复利式的。第一年可能只是省了一些重复劳动第三年你会发现新项目的审计速度比老项目快得多因为大部分常见问题已经被提示词覆盖了agent 一上来就能抓住重点。最后分享一个我最近在用的技巧让 agent 在审计结束后自己生成一份“本次审计的局限性说明”列出它不确定的地方、可能遗漏的场景、需要人工确认的点。这份说明往往比审计报告本身更有价值因为它诚实地告诉你“哪里可能有问题但没查出来”。我每次都会认真看这份说明然后针对性地做补充审计。这个习惯帮我发现了好几个 agent 漏掉但确实存在的漏洞。