事情是这样的上个月做内部安全演练团队里一个开发同学用 GitHub Copilot 半天写完了文件导出模块功能测试全绿。我拿另一个 AI 辅助的安全扫描器去测一轮就触发了一个路径穿越能直接读出服务器上的任意文件。开发同学一脸懵“AI 写的代码啊还能有这种低级漏洞”我说恰恰相反AI 的流畅会让你放松警惕而攻击侧的 AI 可不会客气。Copilot 写的漏洞被另一个 AI 攻破这种 AI-on-AI 的攻防已经不是什么未来概念而是每天都在发生的供应链攻击场景。这篇文章我会把一条完整的 AI-on-AI 供应链攻击链路拆开讲清楚然后给出一套可以直接落到 CI/CD 流水线里的防护代码。无论你是正在用 Copilot 的开发者、负责 CI 管线的运维同学还是想在公司建立“AI 代码安全门槛”的安全工程师这套方案都能帮你在合并代码之前把大多数依托于 AI 生成逻辑的漏洞挡在门外。1. 漏洞为什么会在 AI 生成的代码里扎堆出现1.1 “统计上最像的代码”不等于“安全的代码”很多人有个误解觉得 Copilot 能写出那么完整的代码推理能力一定很强安全上应该也“比大部分新手靠谱”。这个判断其实错得离谱。Copilot 这类代码大模型核心做的是“下一行最可能是什么”的概率预测它从海量公开代码库里学会了“大多数人怎么写”但公开代码本身就不等于安全代码。训练数据里大量存在历史遗留漏洞、Stack Overflow 的过时答案、内部仓库风格的硬编码密钥。模型学的不是“安全写法”而是“统计上最常见的写法”。当某种不安全写法在训练集里占多数时AI 默认输出的就是那个不安全的版本。就好比一个新手厨师照着网络上大部分菜谱学做菜结果那些菜谱本身就有问题——你照着学当然学不出安全的结果。一些安全研究曾测过在特定编程任务里Copilot 生成代码存在安全问题的比例高得惊人但它写出来的观感又非常“自然”这让问题更加隐蔽。1.2 一个真实叠加的漏洞样本下载接口的路径穿越我用一个最常见的场景来展示这类问题。假设你用 Copilot 写一个 Flask 文件下载接口提示词大概是“写一个文件下载接口接收文件名参数”它补全出来的代码很可能长这样from flask import Flask, send_file, request app Flask(__name__) app.route(/download) def download(): filename request.args.get(file) # Copilot 自动补全 return send_file(/data/files/ filename)功能上完全“正确”但filename是用户直接可控的输入。攻击者传入../../../../etc/passwd路径就被拼接成了/data/files/../../../../etc/passwd最终等于读取了系统文件。这就是 CWE-22 路径穿越。为什么 AI 会这么写因为在训练数据里字符串拼接路径是最常见的写法模型没有“用户输入不可信”的语境知识除非你的提示词里显式写了“校验文件名、防止路径穿越”。问题就出在这里——提示词里没提安全要求AI 默认就按“最顺手的写法”来。这是第一层问题AI 不会主动做安全设计它只做“你以为你要的东西”而且做得让代码看起来毫无警惕感。1.3 安全责任转移带来的“安全错觉”当 Copilot 帮你补全了 80% 的代码开发者的心态会发生一个微妙变化从“每一步都要想清楚”变成“大框架已经出来了我检查一下就行”。问题在于人对“自己刚写的代码”和“AI 写出来的代码”的警惕阈值完全不一样。AI 写的代码太流畅了流畅到你会自动跳过某些反直觉的安全场景。斯坦福等团队很早就做过相关实验AI 辅助编程的开发者在实验中更容易写出漏洞代码同时在事后自我评估时反而更相信自己的代码是安全的。这个现象在安全圈被称为“自动化安全错觉”。AI-on-AI 攻击能成立靠的正是这个错觉——受害者一方因为 AI 的介入放松了防线攻击方因为 AI 的介入而大幅降低了利用成本。所以我一直跟团队说AI 辅助编程不是不能用而是要把“AI 生成的代码默认是不安全的必须经过自动化审查”写进团队纪律里。2. AI-on-AI 攻击链自动化的找洞、利用与传播2.1 供应链投毒的三个入口与 AI 的放大器效应先统一一下“供应链攻击”这个概念。在 AI 编程的语境下输入链条比传统软件更广入口至少有三个入口具体场景AI 在其中的角色依赖投毒恶意 npm/pip 包通过相似包名混入项目依赖AI 补全时推荐了恶意/仿冒包代码提交伪造看起来正常的 PR 实际携带后门代码AI 生成流畅但带后门的代码更像“正常贡献”凭据泄露代码里写死 token、API keyAI 根据训练数据习惯建议在配置文件写死 secret传统供应链攻击里攻击者需要伪装身份、编造理由、绕过评审人力成本很高。但有了 AI攻击者可以在几秒钟内生成一个“看起来完全正常”的开源包README、CHANGELOG、测试文件全都有然后通过拼写相似的包名比如把lodash写成loadsh去钓鱼。AI 的推荐机制会放大这个威胁当开发者问 Copilot“帮我找一个轻量级 URL 解析库”如果模型训练数据或检索上下文被污染过它可能推荐一个仿冒包。更严重的情况是“训练数据投毒”攻击者主动向公开代码库灌入带漏洞的示例代码让 AI 在训练或检索时学到这些模式。这等于攻击者提前在 AI 的“大脑”里埋好了雷开发者在不知不觉中踩上。2.2 攻击侧 AI 如何在一轮里攻破 Copilot 代码回到开头的安全演练。我用的工具本质上也是一个 AI 辅助的模糊测试代理它先读接口代码然后自动生成针对路径穿越的经典 payload 组合一轮轮打过去很快就拿到了/etc/passwd的内容。这就是 AI-on-AI 的核心不对称性写代码的 AI 只负责“生成”它不会持续验证自己的输出但攻击侧的 AI 是“目标驱动”的——它会反复改 payload、分析响应、迭代利用方案。一个路径穿越不行就试 SQL 注入SQL 注入不行就切反序列化整个攻击过程是闭环自动化的。另一个危险场景是利用 AI 自动生成恶意 PR。攻击者让 Copilot 或类似模型基于目标仓库的代码风格生成一个“修 bug 的 PR”代码里夹带着一个可以执行外部命令的后门函数。因为 AI 生成的代码风格与仓库高度一致人类评审员很容易把它当成正常贡献合并进去。一旦合入 CI恶意代码就顺势进入了构建产物——供应链攻击就这样在无人察觉的情况下完成了。2.3 为什么 CI 是防线的最佳落点本地开发环境千差万别开发者的安全意识和工具安装情况也参差不齐指望“每个人在自己电脑上装好安全扫描器”完全不现实。但 CI 不同它拥有统一的环境、完整的依赖树和源码上下文而且它天然卡在“代码合并”和“部署上线”之间。把自动化安全扫描放进 CI等于把安全门槛从“靠自觉”变成了“靠流程”。扫描结果可以直接转换为合并条件有问题就阻断 merge没问题才放行。这是最低成本、最高覆盖率的防线位置。我们团队现在把安全扫描当作和单元测试同等地位的 CI 必过项谁想跳过先要过得了分支保护规则这一关。3. CI 里的四道闸门SBOM、SCA、SAST 与密钥扫描3.1 四道闸门的定位与分工AI 攻击可能从依赖、代码逻辑、凭据等多个层面切入所以 CI 防护不能只做一件事。我最终落地的方案是四道闸门SBOM软件物料清单回答“项目里到底用了什么”生成可机读的依赖清单。SCA软件成分分析回答“这些依赖里有没有已知漏洞”对比漏洞库拿结果。SAST静态应用安全测试回答“你自己写的代码有没有结构性问题”直接分析源码模式。密钥扫描回答“有没有把不该提交的密钥、token 提交进仓库”扫描 Git 历史和当前文件。用一个生活类比SBOM 是食材清单SCA 是查食材有没有过期和农药残留SAST 是查厨师的刀工和火候密钥扫描是确保后厨大门的钥匙没有被贴在墙外面。四者各司其职缺一个都会有漏网之鱼。在 AI 辅助编程的背景下这四道闸门的价值会被进一步放大。Copilot 可能推荐一个有已知漏洞的依赖SCA 发现可能生成路径拼接、SQL 拼接这类问题代码SAST 发现可能在配置里写死密钥密钥扫描发现而所有这些风险如果没有 SBOM事后连排查都无从下手。3.2 工具选型我最终留下哪三个我评估过不少工具最后实际进流水线的是这三件TrivySCA 镜像扫描、SemgrepSAST、Gitleaks密钥扫描另外用 Syft 生成 SBOM。选型理由很简单要么开源免费要么在免费额度内足够用规则生态活跃能输出 SARIF/JSON 格式方便 CI 里做统一准入判断。工具类型优势注意点TrivySCA/镜像快、免费、支持文件系统和镜像扫描需要配置--ignore-unfixed减少误报SemgrepSAST规则灵活、支持自定义规则包要选对否则告警太杂Gitleaks密钥扫描支持 Git 历史扫描基线规则需按仓库情况微调SyftSBOM轻量、格式标准需要配合其他扫描器才有意义DependabotSCA/依赖更新和 GitHub 集成好只覆盖依赖声明文件不覆盖自定义代码逻辑CodeQLSAST代码路径分析深入构建时间长CI 预算不够时容易超时Dependabot 我们也开着但它只能解决“依赖有新版本”的问题没法识别“代码里的逻辑漏洞”。所以主力还是 Trivy Semgrep Gitleaks 的组合Dependabot 作为依赖更新的辅助。3.3 扫描结果的“翻译”与阻断策略扫描器输出的原始结果不是给人类看的直接让开发者在 PR 里铺几百条告警结果只会是“告警疲劳”最后没人看。我们做了一件事把扫描结果“翻译”成三类动作。阻断级CRITICAL/HIGH 漏洞、硬编码密钥、OWASP Top 10 规则命中 → CI 直接失败。告警级MEDIUM 漏洞、疑似问题 → 写入 PR 注释提醒处理但不强制阻断。忽略级经过人工确认的误报写明理由后加入 ignore 清单。具体实现靠一个准入脚本读取扫描 JSON 后按规则决定退出码。这个脚本是整个 CI 防护的核心后面第四章我会把完整代码放出来。4. 可直接复用的 CI 防护流水线4.1 完整工作流supply-chain-guard.yml下面这个工作流文件可以直接放到仓库的.github/workflows/supply-chain-guard.yml里适用于 GitHub Actionsname: supply-chain-guard on: pull_request: types: [opened, synchronize, reopened] workflow_dispatch: permissions: contents: read pull-requests: read security-events: write jobs: sbom-sca: name: SBOM SCA runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: fetch-depth: 0 - name: 生成 SBOM uses: anchore/sbom-actionv0 with: path: ./ format: cyclonedx-json output-file: sbom.cdx.json - name: 依赖漏洞扫描 uses: aquasecurity/trivy-actionmaster with: scan-type: fs scan-ref: . format: sarif output: trivy-results.sarif exit-code: 0 ignore-unfixed: true severity: CRITICAL,HIGH - name: 上传 SARIF 到 PR 注释 uses: github/codeql-action/upload-sarifv3 with: sarif_file: trivy-results.sarif sast-semgrep: name: SAST runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: 运行 Semgrep run: | python -m pip install semgrep semgrep ci \ --configauto \ --configp/owasp-top-ten \ --json \ --outputsemgrep-results.json \ --severityERROR \ || true - name: 准入判断 run: | python .github/scripts/check-semgrep.py semgrep-results.json secret-gitleaks: name: Secrets Scan runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: fetch-depth: 0 - name: Gitleaks 扫描 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: gitleaks/gitleaks-actionv2几个设计细节说明一下exit-code: 0不是放开安全而是先让扫描产出报告把“阻断逻辑”统一交给准入脚本处理避免不同工具退出码行为不一致导致 CI 行为混乱。permissions只开了最小权限这是 CI 安全的基本功。github/codeql-action/upload-sarifv3会把 Trivy 的结果以代码扫描告警的形式展示在 PR 的 Security 页签里方便开发者直接看。4.2 准入脚本把扫描结果变成合并门槛Semgrep 的 CI 模式输出 JSON 格式但不同版本的字段结构有差异。我写的解析脚本兼容了两种常见结构results[].severity和results[].extra.severity。#!/usr/bin/env python3 Semgrep 结果准入判断脚本 import json import sys def get_severity(result): if result.get(severity): return result[severity].upper() extra result.get(extra, {}) if extra.get(severity): return extra[severity].upper() return UNKNOWN def main(path: str) - int: with open(path, r, encodingutf-8) as f: data json.load(f) results data.get(results, []) # 只阻断 ERROR 级别WARNING 只记录不阻断 blocking [r for r in results if get_severity(r) ERROR] # 白名单规则例如误报较多的第三方库调用 allowlist { python.lang.security.audit.dangerous-system-call, } blocking [r for r in blocking if r.get(check_id) not in allowlist] if blocking: print(f[guard] 发现 {len(blocking)} 个阻断级问题) for r in blocking[:10]: path r.get(path, unknown) line r.get(start, {}).get(line, ?) msg r.get(extra, {}).get(message, r.get(message, ))[:120] print(f - {path}:{line} {msg}) return 1 print([guard] 未发现阻断级问题) return 0 if __name__ __main__: sys.exit(main(sys.argv[1]))把上面脚本放到.github/scripts/check-semgrep.py赋予执行权限然后在 Semgrep 那个 job 里跑。脚本返回非零退出码CI 就会失败合并被阻断。还要配套一个忽略清单机制。真正跑起来之后你会遇到大量“需要人工确认为误报”的告警不要让这些变成长期噪音。Semgrep 支持.semgrepignore文件语法和.gitignore类似把确认过的误报路径写进去就行。我建议忽略时写一行注释说明确认人和日期。# 2025-05-12 alice 确认这段 shell 调用经过白名单校验 # tests/4.3 在分支保护里强制开启“必需检查”工作流文件加进去还不够得在 GitHub 分支保护规则里强制开启必需检查否则开发者可以绕过 CI 直接合并。操作步骤打开仓库 Settings 页面左侧找到 Branches。Add branch ruleset 或编辑现有规则名称比如main-protection。在 “Require status checks to pass before merging” 勾选上然后搜索选择sbom-sca、sast-semgrep、secret-gitleaks这三个检查名称。同时建议开启 “Require conversation resolution”这样如果 PR 里有安全扫描评论的讨论未解决也不能合并。最后给规则设置豁免策略管理员也不要轻易豁免否则规则形同虚设。我把这套规则应用在 main 分支上之后安全扫描真正变成了硬性门槛。谁要是想直接合一个带硬编码密钥的 PRGitleaks 会在合并前把它拦下来。4.4 本地预检给开发者的“省钱版”防线CI 拦截是在最后但开发者往往不想等一轮完整的 CI 才发现问题。我们给团队配了一个本地预检命令提交前自己先跑一遍# 依赖漏洞扫描 trivy fs --severity CRITICAL,HIGH --ignore-unfixed . # 密钥扫描 gitleaks detect --source . --redact # 静态分析 semgrep --configauto --configp/owasp-top-ten .这几个工具都可以本地安装检查速度很快。我们的工作流是本地能拦住的绝不上 CICI 主要负责那些依赖全局上下文才能发现的跨文件问题。这样 CI 排队时间大幅缩短开发者的体验也好很多。5. 跑起来之后的治理与踩坑5.1 误报不是敌人乱 ignore 才是第一周跑这套流水线团队差点炸锅。Semgrep 全量规则开着几百个历史存量问题全部冒出来PR 一个都合不了。后来我们把策略调整为“历史问题列技术债清单新代码零容忍”。具体做法是先把存量告警导出来逐个确认确认过的加入 ignore 清单并备注负责人然后对新增代码启用严格阻断。这个机制上线后告警数量降到个位数团队又愿意看告警了。误报的处理原则我总结为三条能用更精确的规则过滤的地方不要用 ignore 硬压。ignore 必须带理由和责任人三个月一复审。发现某个规则长期没有真正告警考虑关掉或收紧。5.2 扫描时间与 CI 预算的平衡全量扫描跑一次最快也要两三分钟对一个大仓库来说甚至可能到十分钟。如果每个 PR 都全量扫描CI 队列会越来越长。我们做了几项优化依赖缓存给 Trivy 和 Semgrep 都加上依赖缓存目录第二次之后速度提升明显。增量扫描非 main 分支上每次只扫描本次 PR 变更过的文件路径用语义层判断需要加载的依赖范围。超时降级给扫描 job 设置时间上限超时后降级为“告警模式”在 PR 评论区标记“扫描超时请人工确认”但按照风险从严原则涉及密钥、CRITICAL 漏洞的检查不允许降级。排除目录node_modules、vendor、.git这些一定能排除Trivy 的.trivyignore也要配置。扫描时间是安全运维里特别容易被忽略的隐性成本。如果 CI 太慢开发者就会想方设法绕过扫描到了那一步再严的规则也没用。5.3 人工评审的 AI 专项检查清单自动化扫描不是万能的它抓不住所有逻辑漏洞。Copilot 写出来的代码人工评审时我会特别关注几个点SQL、路径、Shell 命令是否存在字符串拼接是否用了参数化查询、白名单校验密钥、token 是否从环境变量或机密管理服务读取有没有日志打印敏感信息新引入的依赖包名是否与知名包高度相似发布时长、下载量、许可证是否正常是否绕过了已有安全函数比如项目里有subprocess.run(list)的安全封装结果新代码直接用了os.system。错误处理是否会泄露内部信息给用户AI 经常在异常分支里顺手打印堆栈。我要求团队在 PR Review 模板里加上一节“AI 生成代码安全确认”评审人明确确认过上述检查点后才能点赞通过。它的意义不只是让代码更安全也是帮团队养成一种“对 AI 输出保持怀疑”的肌肉记忆。5.4 多模型交叉审查与 Copilot 治理最后说一个进阶思路。现在很多团队会把 Copilot Chat 接到不同的模型上比如 DeepSeek 这类可选的 OpenAI 兼容接口在 VSCode 里切换模型非常方便。这带来一个隐藏的安全红利不同模型的训练分布不同对同一段代码的“偏好”也不同。让两个模型交叉审查同一段代码往往能发现单一模型忽略的问题。我实际测试过让模型 A 生成的代码交给模型 B 做安全审查比较容易发现边界处理、异常分支、资源释放这类问题。这和人类代码评审“换个视角”是同一个道理。但要注意多模型审查不能替代 SAST 和人工评审它只是把安全网加密了一层。另外对于企业团队建议在路上线对 Copilot 这类助手做策略治理。现在市面上有类似 Copilot Harness 这样的治理工具可以统一管控 AI 辅助编程的输入输出配置哪些场景禁止自动补全比如密钥生成、SQL 拼接、命令执行这类高风险模式。把“禁止什么”写在治理策略层面比事后在 CI 里拦截要省心得多。根据我这几个月的实践最想说的一句话是不要相信任何 AI 写的代码包括你自己写的也包括你认为优秀的那个模型写的。CI 防护不是要证明 AI 有问题而是给团队每个人配一个自动化的第二双眼睛。把安全门槛从“靠自觉”变成“靠流水线”AI-on-AI 这个攻防游戏你才有胜算。