1. 当代码的“作者”不再是人类,审计到底在审什么
先说一个我最近亲身经历的场景。团队里一个后端小伙子用 AI 编程助手生成了一个订单状态机的核心模块,三百多行代码,逻辑跑通、单元测试全绿、上线后一周没出问题。结果做季度代码审计的时候,我们几个老家伙盯着那段代码看了整整一个下午,愣是没人能说清楚“为什么这里要用这个状态转移顺序”。代码能跑,测试能过,但没有人能解释它的设计意图。这就是我今天想聊的核心问题:当代码不再为人类而写,我们的审计体系正在面对一个巨大的黑盒。
这个标题里有个词特别扎眼——“不再为人类而写”。这不是危言耸听。过去的代码,哪怕写得再烂,它至少是写给人看的:变量名是给人理解的,注释是给人读的,函数拆分是给人维护的。而 AI 生成的代码,它的“读者”本质上是编译器和运行时,人类只是顺带看一眼。这就导致一个根本性的转变:代码的可读性从“设计目标”降级成了“副产品”。
我先把这篇要讲的东西交代清楚。这篇内容适合三类人:一是正在团队里推行 AI 编程工具、但还没想清楚审计怎么做的技术负责人;二是每天用 Cursor、Copilot、Windsurf 这类工具写代码、心里隐隐有点不踏实的一线开发者;三是做代码安全审计、静态分析、质量管控的同行。我会从审计对象的变化讲起,拆解 AST 层面的审计逻辑,聊 AI 代码特有的风险模式,最后给一套我自己在用的、能落地的审计方法。全程说人话,给能抄的作业。
在展开之前,先明确一个概念边界。这里说的“审计黑盒”,不是指 AI 模型本身是黑盒——那是另一个话题。我说的是AI 产出的代码在进入生产环境前,其正确性、安全性、可维护性无法被现有审计手段有效覆盖这个状态。审计的对象没变,还是代码;但代码的“生成方式”变了,导致审计的“抓手”变了。这个区别很关键,后面会反复用到。
2. AI 生成代码和人类代码,在审计视角下到底差在哪
2.1 从“意图可追溯”到“结果可运行”的范式转移
人类写代码,哪怕是个新手,脑子里也有一个模糊的意图链条:我要解决什么问题,所以我先做什么,再做什么。这个意图链条会体现在代码结构里——函数怎么拆、变量怎么命名、异常怎么处理,都是意图的外化。审计的时候,我们其实是在逆向重建这个意图链条,然后判断它合不合理。
AI 生成代码完全不是这个逻辑。它是基于上下文概率分布,一个 token 一个 token 地“续写”出来的。它没有意图,只有统计上的“最可能”。这就导致一个很诡异的现象:代码在局部看每一行都合理,但整体看缺乏一个统一的“设计意志”。我见过一段 AI 生成的权限校验代码,每个分支单独看都对,但分支之间的优先级顺序是随机的——它只是“续写”出了几个看起来合理的判断,至于哪个判断应该先执行,它不关心。
这对审计意味着什么?意味着传统的“读代码猜意图”的方法失效了。你没法通过读代码去重建一个根本不存在的意图。你必须换一套方法:从行为反推约束。不是问“作者想干什么”,而是问“这段代码在所有可能输入下会干什么”。
2.2 命名与注释的“伪人类化”陷阱
这是最坑的一点,也是我踩过的最大的坑。AI 生成的代码,命名往往非常“规范”——驼峰命名、语义清晰、甚至还有像模像样的注释。这给人一种错觉:这代码是“可读”的,是“人类友好”的。但恰恰是这种伪人类化,让审计者放松了警惕。
我举个真实的例子。一段 AI 生成的缓存失效逻辑,函数名叫invalidateCacheSafely,注释写着“安全地使缓存失效,避免并发问题”。听起来很靠谱对吧?但实际逻辑是:先删除缓存,再更新数据库。这在并发场景下是经典的错误顺序,会导致脏读。命名和注释都在“表演”安全性,但实现根本不安全。AI 学会了人类代码的“表面特征”,但没有学会背后的“约束知识”。
所以审计 AI 代码的第一条铁律:永远不要相信命名和注释,只相信控制流和数据流。命名和注释在 AI 代码里是装饰品,不是信息源。
2.3 依赖引入的“隐形膨胀”
人类引入一个依赖,通常会权衡:这个库多大、维护活跃度如何、有没有已知漏洞、团队熟不熟。AI 引入依赖的逻辑简单粗暴:训练数据里这个场景经常用这个库,那就用它。它不关心你的项目已经有一个功能重叠的库了,也不关心这个库的版本有没有 CVE。
我做过一个统计,在一个中等规模的项目里,让 AI 自由生成代码两周后,package.json里的直接依赖从 23 个涨到了 41 个,其中 7 个是功能重叠的,3 个是已经两年没更新的。这些依赖不会让测试失败,不会让构建报错,但它们实实在在地扩大了攻击面。审计 AI 代码,依赖审计的权重要比审计人类代码高得多。
3. AST 层面的审计:为什么语法树是拆开黑盒的第一把钥匙
3.1 AST 审计和传统文本审计的本质区别
大部分人做代码审计,习惯是“读文本”——打开文件,从上往下看。这个方法对 AI 代码基本无效,原因前面说了:AI 代码的文本层面是“表演”出来的,读起来通顺不代表逻辑正确。你需要一个能绕过“表演”、直接看“骨架”的工具,这就是 AST(抽象语法树)。
AST 把代码从“文本”变成“结构”。在 AST 层面,invalidateCacheSafely这个漂亮的函数名消失了,剩下的是一棵由节点构成的树:函数声明节点、调用表达式节点、赋值节点、控制流节点。你看到的不再是“作者想表达什么”,而是“代码实际在做什么”。这是拆开黑盒的第一步。
我举个具体的对比。文本层面看这段代码:
def process_order(order): # 验证订单合法性 if validate(order): # 扣减库存 deduct_stock(order) # 创建支付单 create_payment(order)读起来很清晰。但 AST 层面,你会看到validate、deduct_stock、create_payment三个调用节点是顺序兄弟节点,它们之间没有数据依赖边。这意味着什么?意味着如果deduct_stock失败了,create_payment照样会执行——因为 AST 里它们只是顺序排列,没有异常传播的约束。文本层面的“验证-扣减-创建”叙事,在 AST 层面根本不存在。
3.2 用 AST 做“意图-实现”一致性校验
这是我目前最常用的一招。思路很简单:从函数签名和调用上下文里提取“意图”,从 AST 结构里提取“实现”,然后比对两者是否一致。
具体怎么做?分三步。
第一步,提取意图。函数的参数名、返回类型、被调用时的上下文,这些构成了意图的线索。比如一个函数叫check_permission,参数是user和resource,返回bool,那意图就是“判断 user 是否有权限访问 resource”。
第二步,提取实现。用 AST 遍历函数体,找出所有的条件判断节点、返回节点、以及它们依赖的变量。如果函数体里出现了对数据库的写操作、对外部服务的调用、或者对全局状态的修改,那实现就超出了“判断”的范畴。
第三步,比对。如果意图是“判断”,实现里却有“写操作”,这就是不一致。AI 代码里这种不一致极其常见,因为它不理解“check”这个词隐含的“无副作用”约束。
我用 Python 的ast模块写过一个简单的校验脚本,核心逻辑就是遍历函数体,标记出所有Call节点,然后看被调用的函数名里有没有write、update、delete、save、send这类动词。有的话,就标记为“疑似副作用”。这个脚本误报率不低,但漏报率很低,作为第一道筛子够用了。
3.3 控制流图与数据流图:把“能跑”翻译成“为什么能跑”
AST 是静态结构,控制流图(CFG)和数据流图(DFG)是动态行为的静态近似。对 AI 代码来说,这两个图特别重要,因为 AI 代码的 bug 往往不在“某一行写错了”,而在“路径组合错了”。
我举个典型例子。AI 生成的一段重试逻辑,单独看每个分支都对:成功就返回,失败就重试,超过次数就抛异常。但 CFG 画出来之后,你会发现有一条路径是:失败 -> 重试 -> 失败 -> 重试 -> …… 没有终止条件。为什么?因为 AI 在生成“超过次数就抛异常”这个分支时,用的计数器变量和“重试”分支里递增的变量不是同一个。文本层面看不出来,CFG 层面一目了然。
数据流图则用来追踪“污点”。用户输入从哪进来,经过哪些变换,最终流到哪去。AI 代码在污点传播上特别容易出问题,因为它经常“忘记”在某个中间环节做净化。DFG 能帮你把这条链路完整画出来,哪一环漏了净化,一眼就能看到。
提示:AST、CFG、DFG 这三个工具不需要你自己从零实现。Python 有
ast和astroid,JavaScript 有@babel/parser和eslint的 AST 接口,Java 有 JavaParser。关键是你要有“用结构视角看代码”的意识,工具是次要的。
4. AI 代码特有的风险模式:那些人类审计员容易漏掉的东西
4.1 “看起来对”的边界条件处理
人类写边界条件,通常是踩过坑之后补上去的,所以带着“痛感”——你知道为什么要有这个判断。AI 写边界条件,是从训练数据里“学”来的模式,它知道“这里通常要判空”,但不知道“为什么要判空”。
结果就是:AI 会写出形式上正确、但覆盖不全的边界处理。比如判空,它可能只判了null,没判空字符串、空数组、空对象。比如数组越界,它可能只判了上界,没判下界。这些遗漏在正常测试里很难发现,因为测试用例通常也是 AI 生成的,会“默契”地避开这些边界。
我的做法是:对 AI 生成的每一个涉及边界判断的函数,手动构造一组“恶意输入”——空值、极值、类型错误的值、超长字符串、特殊字符。不用多,每个函数五六个就够。这一步能筛出大量“看起来对”的边界 bug。
4.2 并发与状态管理的“想当然”
这是重灾区。AI 对并发的理解基本停留在“加个锁就行”的层面。它会在该加锁的地方加锁,但经常加错粒度、加错顺序、或者在不该加锁的地方加锁导致死锁。
我见过最离谱的一个例子:AI 生成的一段库存扣减逻辑,用了数据库行锁,逻辑本身没问题。但它在锁内部又调用了一个外部服务做风控校验,这个外部服务超时时间设了 30 秒。结果就是:一个请求持有行锁 30 秒,其他请求全部阻塞。文本层面看,锁用得很“规范”;并发层面看,这是个灾难。
审计这类问题,光看代码不够,必须结合调用链分析。你要把锁的持有范围、锁内部的所有调用、每个调用的超时设置,全部拉出来看。我通常会用一张表来梳理:
| 锁对象 | 持有范围 | 内部调用 | 调用超时 | 风险等级 |
|---|---|---|---|---|
| 订单行锁 | 扣减库存函数 | 风控校验 | 30s | 高 |
| 用户行锁 | 更新积分函数 | 无外部调用 | - | 低 |
| 全局配置锁 | 读取配置函数 | 无 | - | 低 |
这张表一拉出来,高风险项一目了然。
4.3 错误处理的“静默吞没”
AI 生成的错误处理,有一个非常一致的模式:try-catch包住,catch 里要么log一下,要么直接pass。它不会区分“可恢复错误”和“不可恢复错误”,也不会在 catch 里做补偿或回滚。
这在审计里是致命的。因为静默吞没的错误会让系统在“看起来正常”的状态下持续运行,直到数据彻底不一致。我审计过一个 AI 生成的支付回调处理,catch 块里只打了日志,没有重试、没有告警、没有人工介入入口。结果就是:支付成功了但订单状态没更新,用户投诉了才发现。
审计这类问题,我的方法是:把每一个 catch 块都当成一个“决策点”来审。问三个问题:这个错误可恢复吗?如果可恢复,恢复逻辑在哪?如果不可恢复,为什么不上抛?三个问题有一个答不上来,就是风险点。
4.4 依赖版本的“训练数据滞后”
AI 的训练数据是有时间截止点的。它推荐的依赖版本,往往是训练数据截止时的“主流版本”。如果你的项目已经用了更新的版本,AI 可能会给你生成不兼容的调用方式;如果你的项目还在用旧版本,AI 可能会给你生成需要新版本才支持的 API。
更隐蔽的是安全漏洞。AI 推荐的某个库的某个版本,可能在训练数据截止时还没爆出 CVE,但现在爆出来了。AI 不知道,它还在推荐。
我的做法是:对 AI 引入的每一个新依赖,强制走一遍依赖审计流程。查最新版本、查已知 CVE、查维护状态、查和现有依赖的兼容性。这一步不能省,省了就是给自己埋雷。
5. 一套能落地的 AI 代码审计流程
5.1 审计前置:在 AI 生成阶段就埋好“审计锚点”
最好的审计是“不需要审计”。我在团队里推行的一个做法是:在给 AI 的提示词里,强制要求它输出结构化的“设计说明”。不是让它写注释,而是让它单独输出一段 JSON,描述这段代码的:输入约束、输出保证、副作用列表、依赖的外部服务、错误处理策略。
这个 JSON 就是“审计锚点”。审计的时候,我不需要去猜代码的意图,我直接拿这个 JSON 和 AST 提取的实现做比对。不一致的地方,就是需要人工介入的地方。
这个做法的好处是:它把“审计”从“事后补救”变成了“事中校验”。AI 生成代码的同时,就产出了一份“自述”。这份自述可能不准确,但至少给审计提供了一个起点。
5.2 三层审计流水线:语法层、语义层、行为层
我把审计分成三层,每层用不同的工具和方法。
语法层:用 linter 和 formatter 做基础检查。这一层主要抓格式问题、明显的语法错误、未使用的变量。AI 代码在这一层通常表现很好,因为它的“表面功夫”做得很足。但不要跳过这一层,因为它是后面两层的基础。
语义层:用 AST 分析做意图-实现一致性校验、控制流分析、数据流分析。这一层是核心,前面讲的 AST 审计方法都用在这一层。这一层的输出是一份“风险点列表”,每个风险点标注类型和严重程度。
行为层:用动态测试和模糊测试做行为验证。这一层主要抓“语义层没抓到但运行时暴露”的问题,比如并发 bug、边界 bug、资源泄漏。AI 代码在这一层往往表现最差,因为它的“表演”在运行时会被戳穿。
三层流水线的关键是:每一层都有明确的通过标准,不通过就不进入下一层。语法层不通过,说明代码质量太差,直接打回;语义层不通过,说明有逻辑风险,需要人工确认;行为层不通过,说明有实际 bug,必须修复。
5.3 人工审计的介入时机:不是所有代码都值得人看
人的注意力是稀缺资源,不能平均分配。我的原则是:只对“高风险变更”做人工审计。什么是高风险变更?我定义了几个信号:
- 涉及资金、权限、数据删除的代码
- 引入了新的外部依赖
- 修改了并发相关的逻辑
- 修改了错误处理策略
- 代码复杂度(圈复杂度)超过阈值
这些信号可以用脚本自动检测。检测到了,就标记为“需要人工审计”,进入人工队列。没检测到的,走自动化流水线就够了。
这个策略的核心逻辑是:AI 代码的量太大了,全量人工审计不现实。必须用风险信号做过滤,把人的精力集中在真正重要的地方。
5.4 审计结果的反馈闭环:让 AI 下次别再犯
审计出来的问题,不能只修代码,还要反馈给 AI。我的做法是:把审计发现的问题模式,整理成“负面提示词”,加到团队的 AI 编程规范里。比如“不要生成先删缓存再更新数据库的逻辑”“不要在锁内部调用外部服务”“catch 块里不允许只有 log”。
这些负面提示词会随着审计的进行不断积累。积累到一定程度,AI 生成代码的“基线质量”就会提升,审计的负担就会下降。这是一个正向循环。
6. 审计黑盒的边界:哪些问题现在解决不了
6.1 语义等价性验证:AI 重构后的代码真的等价吗
这是目前最头疼的问题。AI 经常被用来做代码重构——把一段旧代码“翻译”成新写法。问题是,你很难验证重构后的代码和原代码在语义上是否等价。
文本层面看,重构后的代码更简洁、更“现代”。但语义层面,可能引入了微妙的差异:异常传播路径变了、副作用顺序变了、边界行为变了。这些差异在测试里不一定能暴露,因为测试用例可能覆盖不到。
目前没有好的自动化方案。我的做法是:对 AI 重构的代码,强制要求保留原代码作为“参照实现”,然后做差分测试。同一组输入,分别跑原实现和新实现,比对输出。输出不一致的,人工介入。这个方法笨,但有效。
6.2 训练数据污染:AI 学到的“最佳实践”可能是错的
AI 是从海量代码里学“最佳实践”的。但海量代码里,大部分是平庸代码,甚至是有 bug 的代码。AI 学到的“最佳实践”,可能是“最常见的写法”,而不是“最正确的写法”。
这个问题在审计里表现为:AI 生成的代码符合某种“流行模式”,但这个模式本身是有问题的。比如某个流行的 ORM 用法,实际上有 N+1 查询问题;某个流行的缓存策略,实际上有缓存穿透风险。AI 不知道这些,它只知道“大家都这么写”。
审计这类问题,需要审计者有足够的经验,能识别出“流行但错误”的模式。这对审计者的要求很高,也是目前 AI 代码审计最大的瓶颈。
6.3 审计工具本身的滞后
现有的代码审计工具,大部分是为人类代码设计的。它们的规则库、启发式算法、风险模型,都是基于“人类会怎么写代码”这个假设。面对 AI 代码,这些工具经常“失灵”——要么误报率飙升,要么漏报严重。
我试过用几个主流的 SAST 工具扫 AI 生成的代码,结果很不理想。误报率高到没法用,因为 AI 代码的“表面规范”触发了大量“看起来可疑但实际无害”的规则。漏报也很严重,因为 AI 代码的 bug 模式不在工具的规则库里。
目前的应对方式是:在现有工具的基础上,叠加一层“AI 代码专用规则”。这些规则是我从实际审计中总结出来的,针对 AI 代码的特有模式。比如“检测 catch 块是否只有 log”“检测锁内部是否有外部调用”“检测依赖是否在训练数据截止后爆出 CVE”。这层规则不完美,但比裸用现有工具强。
7. 我踩过的几个坑和一点个人体会
第一个坑:过度信任 AI 的“测试通过”。早期我有个习惯,AI 生成代码后,跑一遍测试,绿了就合并。后来发现,AI 生成的测试和 AI 生成的代码是“一伙的”——测试会默契地避开代码的弱点。现在我强制要求:AI 生成的代码,测试必须由人来写,或者至少由人来审查测试用例的覆盖范围。
第二个坑:用审计人类代码的节奏审计 AI 代码。人类代码的审计,可以“读一遍,凭经验判断”。AI 代码不行,必须“跑一遍,用工具验证”。我一开始没转过这个弯,导致漏掉了好几个并发 bug。后来调整了节奏:AI 代码的审计时间,至少是人类代码的 1.5 倍。
第三个坑:忽略了“代码之外”的东西。AI 生成代码,往往还会生成配套的配置、文档、部署脚本。这些东西的审计同样重要,而且更容易被忽略。我见过 AI 生成的 Dockerfile 里用了latest标签,见过 AI 生成的 CI 配置里把密钥硬编码进去了。这些问题的危害不比代码 bug 小。
一点个人体会:AI 编程时代的审计,核心矛盾不是“AI 会不会写错”,而是“我们能不能有效地验证 AI 写的东西”。前者是 AI 的问题,后者是我们的问题。把精力放在后者上,比抱怨前者更有用。
最后分享一个我最近在用的技巧:让 AI 自己审计自己。具体做法是:把 AI 生成的代码,连同“审计锚点”JSON,一起喂给另一个 AI 实例,让它做“对抗性审查”——专门找代码和锚点不一致的地方。这个方法的准确率不高,但能发现一些人类审计员容易忽略的“模式性”问题。把它作为人工审计的补充,效果还不错。
代码不再为人类而写,但审计必须为人类而做。这个转变,是我们这代技术人躲不掉的功课。