1. 先说说为什么 AI 写的代码上线前必须单独过一遍安全
过去一年我用 AI 辅助写了大概六七个中小型项目,有内部工具、有对外接口服务,也接过几个帮朋友救火的活。这些项目里,AI 生成的代码占比从三成到八成不等。跑通业务逻辑这件事,AI 现在确实很强,一个 CRUD 接口、一套数据清洗脚本、一份定时任务,描述清楚需求,几分钟就能给你一版能跑的。但问题恰恰出在"能跑"这两个字上——能跑不等于能上线,更不等于能扛得住真实流量和真实的人。AI 的产出有一个非常稳定的特征:功能路径写得又快又完整,非功能路径几乎全靠你补。异常分支、边界值、权限校验、密钥管理、日志脱敏、依赖版本,这些地方它默认是"不写就不写",而这些东西恰好是安全工程师最关心的部分。
更麻烦的是,AI 生成的代码读起来特别有说服力。变量命名规整、注释写得漂亮、结构层次分明,评审的时候人很容易被这种"整洁感"带着走,扫两眼觉得没问题就合并了。我自己就吃过这个亏:一段处理优惠券核销的逻辑,AI 写得漂漂亮亮,还贴心地加了注释说明"校验用户是否已使用该券",结果它校验的是"这个优惠券 ID 是否被任何人用过",而不是"是否被当前用户用过"。上线两天,有人拿别人用过的券反复试,直接把库存刷穿了。这个 bug 代码规范、单元测试、代码评审全都没拦住,因为测试用例也是 AI 写的,它照着错误的假设写了对的断言。
所以我现在形成了一个固定习惯:任何 AI 参与生成的代码,在提测之前必须单独跑一轮安全自查,不管这个项目多小、不管是不是内部用。内部工具被当成跳板、测试环境连着生产库、临时接口忘了加鉴权,这些事在真实项目里出现的频率高得吓人。这篇就按我自己实际执行的流程,把"检查什么、怎么查、查完怎么改"完整讲一遍,不讲空泛的原则,只讲能直接抄作业的东西。前端、后端、脚本、数据管道都适用,你不需要是安全专家,但需要有一套固定的动作清单。
2. AI 生成代码的高频雷区,先搞清楚敌人在哪
2.1 为什么 AI 特别容易在这几个地方翻车
理解成因比记住结论重要,因为成因决定了你该把检查重心放在哪。AI 的代码是在海量公开仓库上训练出来的,而这些仓库里本身就充斥着教学示例、Quick Start 片段和十年前的技术博客。教学示例的特点是"省略一切与主题无关的东西"——官方文档里那段连接数据库的代码,密钥永远是明文的,鉴权永远是省略的,错误处理永远是print(e)。AI 学到的就是这套"最小可运行"的表达习惯,它在生成时默认你在写 demo,而不是在写生产系统。
第二个成因是它只对"你说了什么"负责,不对"你没说什么"负责。你让它写一个导出报表的接口,它会写参数解析、写数据库查询、写文件生成、写返回下载链接,但它不会主动问"这个接口谁能调""导出的数据范围要不要按当前用户过滤""文件名会不会被用户控制"。你明确要求它加权限控制,它能加得很好,甚至能识别出越权风险;你不提,它就当不存在。这个特性意味着,检查清单的价值远大于单次对话的技巧——你要靠流程兜底,而不是靠每次都想起来提醒它。
第三个成因是依赖幻觉。AI 会推荐已经停止维护的库、会给出不存在的版本号、会混用两个库的 API,也会习惯性选择老版本写法(比如 Python 里requests关闭证书校验、Node 里用已经不推荐的加密调用方式)。这类问题扫描器不一定报,但一旦上线就是供应链层面的定时炸弹。
2.2 按危害排个序:我实际遇到过的六类问题
下面这张表是我这两年做代码审查时按真实发生频次和危害程度整理出来的,不含理论推演,都是真见过的。
| 问题类型 | 典型表现 | 危害等级 | 自动化发现难度 |
|---|---|---|---|
| 硬编码凭据 | 密钥、Token、数据库密码直接写在源码或配置文件里 | 极高 | 低,工具一扫就出 |
| 认证授权缺失 | 接口无鉴权、只校验登录不校验归属、管理员接口无角色判断 | 极高 | 高,需人工梳理 |
| 注入类问题 | SQL 字符串拼接、命令拼接、模板拼接 | 高 | 中,规则能覆盖大部分 |
| 依赖与供应链 | 引入废弃库、锁文件缺失、镜像基础层老旧 | 高 | 低 |
| 信息泄露 | 报错直接回显堆栈、日志打印敏感字段、接口返回全量字段 | 中 | 中 |
| 配置类问题 | 调试开关未关、跨域放开、内网地址暴露、目录列表开启 | 中 | 低 |
排序的依据很简单:前两类一旦出问题,攻击者不需要任何技巧就能拿到数据;后面几类通常需要组合利用,但也不能放过。特别说明一下第二类,AI 代码在这方面的问题最隐蔽,因为代码看起来"有鉴权"——它确实调了登录校验中间件,只是没做资源归属判断。登录校验解决的是"你是谁",归属校验解决的是"这东西是不是你的",两者差一个量级。
2.3 一句提醒:不要指望 AI 自查 AI
我试过让同一个模型去审查自己刚写的代码,效果有限。它的自查倾向于检查语法、逻辑连贯性、命名规范这些"显性质量",对安全边界的敏感度明显不足,而且它对自己写过的结构有路径依赖,容易得出"看起来没问题"的结论。让另一个模型交叉审查会好一些,但也只能当辅助。真正有效的是确定性的工具加上人的清单式核对。工具负责找模式化的东西,人负责判断业务上下文,这个分工不能颠倒。
3. 上线前的安全体检清单,按优先级一步步来
3.1 第一优先级:把凭据从代码里彻底清出去
这件事必须排第一位,因为它的修复成本最低、收益最高,而且一旦泄露就没法挽回——代码进了仓库,历史记录里就永远有。AI 生成代码时最喜欢干的事就是在配置里写一句API_KEY = "sk-xxxxxxxx",或者给你一段能跑的连接串,密码明文。我的做法是三层防护。
第一层是提交前拦截。在项目根目录放一个.pre-commit-config.yaml,让密钥扫描在git commit阶段就跑起来,不合格直接拒绝提交。
# .pre-commit-config.yaml repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.18.4 # 版本号建议按当前最新稳定版填写 hooks: - id: gitleaks第二层是全量扫描,包括历史提交。改掉当前文件里的密钥没用,历史提交里还在。
# 扫描工作区和全部提交历史,--redact 让输出里的密钥打码,避免二次泄露 gitleaks detect --source . -v --redact # 只关心确认有效的凭据,噪音更小 trufflehog git file://. --only-verified注意:扫描报告本身可能包含明文凭据,别把报告文件直接丢进群里或者工单系统。用
--redact,或者扫描完立刻处理掉输出文件。
第三层是改造代码,把凭据全部外置。判断标准是:源码和镜像里不允许出现任何能直接用于认证的字符串。改造方式按运行环境选,容器化项目用环境变量注入,本地开发用.env文件加.gitignore,云上用密钥管理服务。.env文件一定要确认在.gitignore里,我见过.env被提交上去、扫描器报了、开发者回复"这是本地文件没事"的情况——版本库里没有"本地文件"这回事。
import os # 反例:AI 最常给出的写法 # DB_PASSWORD = "Prod@2024!" # JWT_SECRET = "secret" # 正例:启动时读取,缺失直接崩,避免带着空配置跑起来 DB_PASSWORD = os.environ["DB_PASSWORD"] JWT_SECRET = os.environ["JWT_SECRET"] if len(JWT_SECRET) < 32: raise RuntimeError("JWT_SECRET 长度不足,拒绝启动")这里加一个长度校验是有意为之。很多团队把密钥外置了,但值用的是123456或者项目名,外置了等于没外置。启动即校验、不满足就崩溃,比上线后被人爆破要好得多。
3.2 第二优先级:输入校验和注入类问题
注入类问题是 AI 代码的重灾区,原因前面说过——教程示例为了简洁,大量使用字符串拼接。SQL 注入只是其中一种,还有命令注入、模板注入、路径穿越、XXE、反序列化,本质都是"用户输入被当成了代码或指令执行"。
检查方法上,自动化规则能覆盖大部分常见模式。Semgrep 用社区规则集就能扫出很多,bandit专门针对 Python,gosec针对 Go,前端用eslint-plugin-security。跑一遍基础扫描:
# 通用规则 + 自定义规则,--error 让发现高危问题时返回非零退出码 semgrep --config=auto --error . # Python 专项,-ll 只报中危以上 bandit -r . -ll # Node 项目 npm audit --production但自动化只能解决一半,剩下那一半得靠人看。我的做法是搜关键词,把可疑调用点全部列出来,逐个确认。这些关键词包括:execute、query、raw、system、popen、subprocess、eval、exec、render_template_string、pickle.loads、yaml.load、open(。搜出来之后看两件事:参数是不是用户可控的,拼接方式是不是安全的。
# 反例:AI 生成的"看起来很正常"的查询 def get_order(order_id): sql = f"SELECT * FROM orders WHERE id = '{order_id}'" return db.execute(sql).fetchone() # 正例:参数化查询,同时限定返回字段 def get_order(order_id): sql = "SELECT id, amount, status, created_at FROM orders WHERE id = %s" return db.execute(sql, (order_id,)).fetchone()正例里我顺手把SELECT *改成了列名。原因是很多表里躺着手机号、身份证号、内部备注这些不该给前端的字段,SELECT *一写,序列化的时候全出去了,这就是典型的信息泄露。AI 生成查询时默认用*,这个习惯要改。
命令执行同样,AI 经常给出subprocess.run(cmd, shell=True)这种写法,只要参数里有用户输入就是命令注入。
# 反例 subprocess.run(f"ffmpeg -i {user_path} out.mp4", shell=True) # 正例:列表传参,不走 shell,加超时 subprocess.run( ["ffmpeg", "-i", user_path, "out.mp4"], shell=False, check=True, timeout=60, )反序列化这块要单独点一下。AI 在处理缓存、消息队列、配置文件时,经常给你pickle.loads或者yaml.load(data)。前者在 Python 里等于把执行权交出去,后者不加Loader参数同样危险。看到这两个调用,直接改成json.loads或yaml.safe_load,没有例外。
3.3 第三优先级:认证、授权和越权,AI 最容易"忘"的一环
这类问题自动化工具基本查不出来,因为它需要理解业务语义。我用的方法是"接口清单法":把所有路由导出来,逐个问三个问题——谁能访问、能看到哪些数据、能改哪些数据。三个问题里任何一个答不上来,就是风险点。
具体到这个环节,最有效的技术手段是强制在查询层加归属条件,而不是在业务逻辑里判断。因为业务逻辑判断容易漏分支,数据库条件不会。
# 反例:先查再判断,中间有无数种绕过方式,也容易漏 order = db.query("SELECT * FROM orders WHERE id = %s", (order_id,)) if order["user_id"] != current_user_id: raise PermissionError # 正例:把归属条件写进查询本身,查不到就是查不到 row = db.query( "SELECT id, status FROM orders WHERE id = %s AND user_id = %s", (order_id, current_user_id), ) if row is None: return {"code": 404, "msg": "not found"}正例里还包含一个小技巧:越权访问返回 404 而不是 403。403 等于告诉对方"这个资源存在但不属于你",这在批量探测时是有效信息泄露。404 什么都不透露。
另外几个必须人工确认的点:批量接口有没有做数量限制(AI 生成的接口经常允许一次传几百个 ID,直接变成数据遍历工具);分页接口的页码和页大小有没有上限;导出和下载接口用的是不是自增 ID 作为文件名(改成随机 UUID 或者带签名的短时效链接);管理后台的接口是不是只在前端做了角色隐藏(前端隐藏等于没做)。
3.4 第四优先级:依赖、配置和部署环境
依赖这块,AI 的坑主要集中在两个地方:一是给你一个不存在的包名或版本号,二是推荐一个已经很久没更新的库。前者构建时会报错反而安全,后者能装上、能跑,但躺在那里带漏洞。
# Python pip-audit -r requirements.txt # 通用,支持多语言锁文件 osv-scanner -r . # 文件系统层面,同时查依赖漏洞、密钥、配置错误 trivy fs --scanners vuln,secret,misconfig --exit-code 1 . # 镜像层面,别忘了基础镜像自己也有漏洞 trivy image your-registry/app:1.0.0这里有个实际经验:扫描出来的漏洞不用全部修,按"可被远程利用 + 无需认证 + 有公开利用方式"三个条件筛,把这类清掉,剩下的排期处理。全部清干净在现实项目里不现实,但优先级必须清楚。
配置类问题同样是 AI 和人工都容易忽略的,因为它们在开发环境里都是对的。重点检查这么几项:
- 调试开关。Flask 的
debug=True、Django 的DEBUG=True、Node 的NODE_ENV=development,上线必须是关闭状态。调试模式开着等于把源码和堆栈直接送给访问者。 - 跨域配置。AI 生成的代码里
Access-Control-Allow-Origin: *出现频率极高,尤其是前后端分离项目。如果接口需要携带凭据,通配符本身就是禁止的,必须换成白名单。 - 报错处理。全局异常处理器有没有兜底,会不会把堆栈、SQL 语句、文件路径回显给前端。
- 日志。日志里有没有打印手机号、身份证、Token、完整请求体。日志系统的访问权限往往比数据库松得多。
- 目录列表和静态服务。有没有把整个项目目录或者上传目录直接暴露成静态资源。
- 容器与端口。数据库、缓存、管理端口有没有意外映射到宿主机或者公网。
4. 实操:一次完整的上线前安全检查怎么跑
4.1 工具准备和统一入口
工具散着用很容易漏,我习惯在项目里放一个Makefile,把所有检查串成一条命令,任何时候都能一键跑完,也方便接进流水线。
.PHONY: sec sec-fast sec: @echo "== 1/5 密钥扫描 ==" gitleaks detect --source . -v --redact @echo "== 2/5 依赖漏洞 ==" pip-audit -r requirements.txt @echo "== 3/5 静态规则 ==" semgrep --config=auto --error --quiet . @echo "== 4/5 Python 专项 ==" bandit -r . -ll -q @echo "== 5/5 配置与容器 ==" trivy fs --scanners vuln,secret,misconfig --exit-code 1 . # 提交前跑的轻量版,只查最关键的两项 sec-fast: gitleaks protect --staged -v semgrep --config=auto --error --quiet .两个入口的区别要讲清楚:sec是发版前跑的全量检查,可能要几分钟;sec-fast是每次提交前跑的,只查密钥和规则命中,控制在十秒内,否则没人愿意用。工具的第一原则是快,慢一点的检查一定会被人用--no-verify绕过。
4.2 自定义规则:把 AI 的坏习惯固化成检查项
通用规则集覆盖不全 AI 特有的坏习惯,所以我会维护一份自己的规则文件。Semgrep 的规则写起来不复杂,把上面提到的几个高频模式固化下来,比人肉搜索可靠得多。
# .semgrep/ai-common.yml rules: - id: python-verify-false languages: [python] severity: ERROR message: 关闭了 TLS 证书校验,禁止上线 patterns: - pattern: requests.$M(..., verify=False, ...) - id: flask-debug-on languages: [python] severity: ERROR message: Flask 以调试模式启动,禁止上线 patterns: - pattern: $APP.run(..., debug=True, ...) - id: yaml-unsafe-load languages: [python] severity: ERROR message: 使用不安全的 YAML 加载方式 patterns: - pattern: yaml.load(...) - pattern-not: yaml.load(..., Loader=yaml.SafeLoader) - pattern-not: yaml.load(..., Loader=yaml.CSafeLoader) - id: js-cors-wildcard languages: [javascript, typescript] severity: WARNING message: 跨域配置使用了通配符,确认是否允许携带凭据 patterns: - pattern: $RES.setHeader("Access-Control-Allow-Origin", "*")semgrep --config=.semgrep/ai-common.yml --error .这份规则我建议从三到五条起步,边踩坑边加。不要一上来写几十条,误报太多会让人直接不看了。
4.3 人工审计的四个切口
自动扫描跑完,接下来是人的活。我不会逐行读代码,那样效率太低,我用四个切口去定位重点区域。
第一个切口是入口。把所有对外暴露的路由、消息队列消费者、定时任务、命令行参数列出来,每个入口问一遍:输入从哪来、有没有长度和格式限制、有没有鉴权。AI 生成的定时任务特别容易漏掉,因为它看起来"不对外",但很多定时任务会读取数据库里的任务表,任务参数如果可写就是一条越权链路。
第二个切口是数据出口。哪些接口会返回数据、返回的字段是怎么来的。如果是SELECT *加自动序列化,基本可以确定有字段泄露。我会故意构造一个请求,看返回体里有没有不该出现的字段,这个动作花不了两分钟。
第三个切口是外部调用。所有出网的请求都要看:目标地址是硬编码的还是用户可控的、有没有超时、有没有跟随重定向、会不会访问内网地址。AI 写爬虫和 webhook 转发时,经常直接把用户传进来的 URL 丢给请求库,这就是标准的服务端请求伪造。
from urllib.parse import urlparse import requests ALLOW_HOSTS = {"api.partner.com", "cdn.example.com"} def safe_fetch(url: str) -> str: p = urlparse(url) if p.scheme not in ("http", "https"): raise ValueError("scheme not allowed") if p.hostname not in ALLOW_HOSTS: raise ValueError("host not allowed") # allow_redirects=False 很关键,否则白名单会被 302 绕过 resp = requests.get(url, timeout=5, allow_redirects=False) resp.raise_for_status() return resp.text第四个切口是文件操作。所有涉及上传、下载、解压、读写的代码都过一遍。检查点:文件名是不是用户可控(路径穿越)、文件类型是按扩展名还是按内容判断(扩展名可以伪装)、解压有没有做总大小和文件数量限制(解压炸弹)、存储路径是不是在 Web 根目录之外。
4.4 修复与回归:别只改一处
找到问题之后,修复有两个原则。第一,同类问题一并改完。发现一个接口存在越权,就要把所有同类接口筛一遍,因为 AI 生成代码的一致性很强,它犯的错往往是一整套。第二,修复要写进测试。AI 写的测试只覆盖正常路径,要给它补上异常路径的用例,尤其是权限相关。一个"用户 A 不能读到用户 B 的订单"的测试用例,比十条规范文档有用。
改完之后重新跑一遍make sec,然后重点看两件事:一是原来报的问题是否消失,二是有没有引入新的告警。有时候为了绕过扫描器报错,开发者会把拼接改成另一种拼接,看起来不报了,实际上换个入口还能利用。
5. 常见问题与排查技巧实录
5.1 扫描器报了一堆,怎么分辨真假
刚接入工具的时候,第一次跑通常几百条告警,看两眼就放弃了。我自己的处理顺序是这样的:先按严重级别过滤,只看高危;再按"是否在对外接口的调用链上"过滤,不在链上的降级;最后按"输入是否用户可控"过滤。三轮下来通常只剩十几条需要真正处理。
误报集中在这几类:测试代码和数据构造脚本(可以加白名单排除目录)、常量拼接的查询(参数是常量不是用户输入)、内网工具里的宽松配置。这些可以在规则里加排除,但排除规则要写清楚原因,加一行注释说明为什么忽略,否则半年后没人敢动。
漏报更麻烦,也更值得警惕。工具查不出业务逻辑漏洞,查不出权限设计缺陷,查不出跨接口的组合利用。所以我的做法是:工具的结论只作为"可以进入下一阶段"的门槛,不作为"安全"的证明。
5.2 那些工具永远查不出来的问题
说几个具体的。第一个是状态机缺陷。AI 写订单、工单、审批这类流程代码时,往往只校验当前状态能不能执行某个动作,不校验操作人。比如"已取消的订单可以退款"这条规则它写对了,但谁都能触发取消再退款。第二个是并发问题。优惠券、库存、余额这类涉及扣减的逻辑,AI 默认写成"先查再改",没有任何锁或者原子操作。
# 反例:先查后改,并发下必然超卖 stock = db.query("SELECT stock FROM items WHERE id = %s", (item_id,))["stock"] if stock > 0: db.execute("UPDATE items SET stock = %s WHERE id = %s", (stock - 1, item_id)) # 正例:把判断放进更新语句,靠数据库保证原子性 affected = db.execute( "UPDATE items SET stock = stock - 1 WHERE id = %s AND stock > 0", (item_id,), ) if affected == 0: raise RuntimeError("库存不足")第三个是幂等性。支付回调、消息重投、用户连点,这些场景 AI 基本不会主动处理。解决办法是在关键写操作上加唯一约束或者幂等键,让重复请求在数据库层被拒绝,而不是靠业务代码里的 if 判断。
5.3 常见问题速查表
| 现象 | 可能原因 | 定位方法 | 处理方式 |
|---|---|---|---|
| 扫描器报密钥泄露 | 硬编码或历史提交残留 | gitleaks detect --source . | 外置到环境变量,历史提交需重写并轮换密钥 |
| 接口返回大量不需要的字段 | SELECT *加自动序列化 | 抓包看响应体 | 显式列出字段,建响应模型白名单 |
| 同一资源换个 ID 就能访问 | 缺少归属校验 | 用两个账号交叉请求 | 把归属条件写进查询语句 |
| 上传后能访问到非预期文件 | 文件名用户可控 | 传../或双扩展名测试 | 随机文件名,扩展名走白名单映射 |
| 依赖扫描报高危 | 锁文件缺失或基础镜像老旧 | trivy fs、npm audit | 锁定版本,更新基础镜像,按可利用性排期 |
| 生产报错回显堆栈 | 调试模式未关或缺少全局异常处理 | 故意触发一次异常 | 关闭调试,统一异常响应结构 |
| 日志里出现敏感数据 | 直接打印请求体或对象 | 检索日志关键词 | 脱敏中间件,禁止打印完整请求体 |
5.4 我自己踩过的几个坑
第一个坑是以为加了.gitignore就安全了。实际上.env被提交、删除、再提交,历史记录里依然有。判断标准是"有没有进过版本库",进过就得当作已泄露处理,光删文件不够,要么重写历史,要么直接轮换密钥。轮换更省事也更可靠。
第二个坑是过度依赖扫描结果,把"扫描通过"当成了放行条件。有一次扫描全绿,上线后还是被薅了一波,问题出在一个看起来无关紧要的查询接口上——它没有做分页限制,攻击者用一个循环把整张用户表拉走了。这种"功能正常但设计有问题"的情况,工具永远发现不了,只能靠人对着接口清单问一遍。
第三个坑是只在发版前检查,中间几周的开发过程完全裸奔。后来我把密钥扫描放到了提交阶段,把静态规则接到了流水线的每次构建上,只有深度审计留在发版前。做安全这件事,检查点的位置比检查的强度更重要,越靠前成本越低。
最后分享一个小习惯:每次让 AI 生成一段涉及数据库、文件、网络、权限的代码后,我会立刻在同一个对话里追加一句"这段代码在生产环境上线前需要做哪些安全加固,请逐条列出"。它给出的清单不一定完整,经常漏掉越权和并发,但当作自查的提示词是够用的,能让我在写代码的当下就把大部分低级问题挡掉,剩下的靠上面的流程兜底。