Coding Agent Horror Stories: The CVE That Did Not Exist

TL;DR · AI 摘要
AI Coding Agent Horror Stories: The Fake CVE Docker Coding Agent Horror Stories: The CVE That Did Not Exist Posted Oct 2...
核心要点
- 主题聚焦:Coding Agent Horror Stories: The CVE That Did No
- 来源:Docker,建议结合原文判断细节。
- AI 分析暂不可用,本条为保底评分与摘要。
AI 代码代理恐怖故事:虚假的 CVE | Docker
代码代理恐怖故事:并不存在的 CVE
发布于 2026 年 10 月 29 日
Ajeet Singh Raina
和
Pratik Singh
这是我们的 AI 代码代理恐怖故事系列的第 6 部分,我们将探讨涉及 AI 代码代理的真实安全事件,并分析 Docker 沙箱如何在边界而非命令行处限制代理的执行。
到目前为止,本系列中的每个故事都是关于代理触及了本不该触及的范围。我们已经看到代理进入家庭目录、泄露生产数据库、泄露凭证,以及运行与开发人员预期完全不同的 shell 命令。在每种情况下,损害都源于代理拥有了超出需求的访问权限。
这个故事正好相反。这个故事中的代理根本触及不到任何东西,因为它被刻意设计成无法触及。然而,在那个特定的夜晚,它仍然是流水线中最危险的元素。
这个故事由 Pratik Kumar Singh 在我们的代理耻辱馆社交媒体竞赛中分享。
今日恐怖故事:凭空捏造的审查
一位平台工程师为他的团队构建了一个代码审查机器人。这个想法简单且实用。每当有人推送代码时,机器人会读取更改内容,并在流水线中生成审查意见,这种初步审查与人类同事在查看更改前进行的首次检查类似。
他给这个机器人取名为 BuaJi。在印度,Bua 是你父亲的姐姐,许多家庭中她是你所有行为的检查者,总是能发现你做的事情中的问题。对于代码审查者来说,这是一个贴切的名字。
让 BuaJi 引人注目的是它的设置方式非常谨慎。该机器人通过 Ollama 在本地模型上运行,这意味着模型权重存储在工程师自己的硬件上,没有任何请求会发送到 AI 供应商。它还作为 CI 流水线中的一个任务运行,每当有人推送代码时就会触发。综合来看,这种设置是完全自托管的。它读取的代码是公司源代码,且通过这种设计,这些代码永远不会离开公司大楼。这是有意为之的选择,因为他的公司对此非常重视。
他还给 BuaJi 设定了一条明确的规则:它可以阅读,可以评论,仅此而已。它不能运行 terraform apply(该命令用于创建或更改真实云基础设施),不能运行 kubectl apply(该命令会更改 Kubernetes 集群中实际运行的内容),也不能推送提交,因此它没有能力直接修改代码本身。这些是会改变现实世界的命令,而 BuaJi 被赋予了其中的任何一条。从纸面来看,这正是你希望 AI 审查者被构建的方式。
配套仓库中的 BuaJi 版本是一个刻意设计的极简审查者。它仅检查 Go 文件是否符合少数几条风格规则。以下报告是任何审查者在允许其以散文形式而非通过/失败方式评论后,都会生成的自由形式发现。这正是这些机器人在实践中被推动的方向。
他那个夜晚推送的更改
他的产品附带了 PostgreSQL 的捆绑版本。较旧的 PostgreSQL 版本存在带有 CVE 编号的已发布漏洞,升级到更新的补丁版本是消除这些已知问题的方法。因此他提升了版本。
- POSTGRES_VERSION=16.11
+ POSTGRES_VERSION=16.13这是一次极其乏味且负责任的代码变更。它只修改了少数几行代码,更改了一个版本号,其唯一目的是关闭已被公开的安全漏洞。任何评审者,无论是人类还是机器,都应该在几秒钟内查看并批准它。在那次代码差异中没有任何可疑之处,而正是这一点使得接下来发生的事情变得至关重要。
问题回溯
BuaJi用大约一千五百个字拒绝了这次更改。作为背景参考,正常的一条评审意见通常只有一两句话。
报告将版本升级描述为“架构接管”,声称这是以常规维护为掩护,悄悄植入代码库的结构性变更。它声称新的PostgreSQL构建是向第三方公司发送内部数据的工具,并直接点名了该公司。但这家公司实际上并不存在。
报告更进一步。它通过名称引用了特定的Kubernetes资源,仿佛已经检查过集群并发现了它们。这些资源从未存在过,而BuaJi根本没有访问集群的权限。它随后还编写了Go脚本,声称要“隔离受感染的PostgreSQL节点”,尽管实际上没有任何感染发生。最后,它要求工程师立即撤销所有IAM访问权限,这一操作将导致组织内所有人员被锁定,包括那些真正需要解决问题的人员。
支撑整个论点的是一个从未被任何机构发布的CVE编号。在报告的所有内容中,这一点值得特别关注。在安全领域,CVE标识符是 easiest 的验证对象之一,因为只需几秒钟就能在公共注册表中查询。这个机器人在最容易被发现的领域编造了一个事实。
然而,这份报告看起来却完全令人信服。Markdown格式整洁且结构清晰。它提出的YAML代码在解析时不会出现任何错误。语气紧迫而自信,就像资深安全工程师发现严重问题时,需要你立即采取行动时的写作风格。工程师花了大约两分钟阅读CI日志,意识到所有内容都是虚假的,然后又回到自己原本的工作中。
在本案例中,你将学到:
- 为什么一个只能写注释的只读代理仍然可能导致你的集群被关闭
- 为什么伪造的安全发现比明显错误的发现更难以被驳回
- 为什么代理的输出应与代理的输入一样,被归类为不可信内容
- 如何逐步将真正的AI代码评审器迁移到Docker沙箱中,以及这样做能保护你什么、不能保护你什么
图注:漫画说明了AI评审器生成的自信且格式良好的安全报告可能完全是伪造的,以及当有人根据这份报告采取行动时会发生什么。
问题本质
需要明确指出的是,只读规则确实发挥了作用。BuaJi无法执行自己的任何建议,它从未拥有过集群的访问权限。正因如此,整个事件只让工程师浪费了晚上两分钟的时间,没有造成其他影响。
问题在于这条规则只控制了BuaJi能做什么,没有人考虑过BuaJi能说什么。一个只读代理仍然可以告诉人们该怎么做,而阅读其输出的人通常拥有代理被禁止访问的全部权限。BuaJi无法运行自己编写的脚本,但它可以将这些脚本记录下来并交给能够执行的人。
这位工程师之所以能发现问题,是因为他非常了解自己的集群。他清楚那些Kubernetes资源并非真实存在,也没有任何安全风险被暴露。现在想象一下,一位经验较少的工程师在凌晨两点值班,半睡半醒之间,正盯着流水线中一份冗长且令人警觉的安全报告。在深夜撤销生产环境中的所有IAM权限本身就会导致服务中断。而运行一个未经审查的Go脚本对数据库节点进行操作,后果可能更加严重。在这两种情况下,BuaJi都不会采取任何行动。所有操作都必须由人来完成,出于对报告内容的信任,他们会诚实地执行所有步骤。
这种攻击方式之所以难以防御,是因为没有任何权限模型会发出警告——因为从未发生权限违规。代理始终在自己的边界内运行。工程师完全有权限在自己的系统上执行这些命令。整个链条中唯一错误的部分,只是报告的内容本身。
Docker沙箱如何隔离此类风险
诚实地说,沙箱本身并不是事实核查者。任何微虚拟机边界都无法让审查评论变得准确。边界改变的是,一旦生成了伪造的输出,这些输出能造成什么影响。
回想一下BuaJi的只读规则实际是如何定义的——它依赖于一种约定。一位工程师需要在自己的流水线中手动配置该规则,并且必须始终记得保留它。这类规则会一直有效,直到有人急于推进工作,或者新成员复制配置时遗漏了不理解的部分,或者某个出于合理原因设置的小例外逐渐变成永久规则。这些情况并不需要恶意意图,这正是真实团队运作的常态。
Docker沙箱在隔离的微虚拟机中运行代理,每个虚拟机都有自己的内核、文件系统,以及默认拒绝所有网络连接的网络环境。沙箱内的代理可以随意编造任何内容,但这些编造的内容无处可去:没有可用的凭证、无法访问集群、无法推送提交,除非有人提前授权了这些权限。只读规则不再依赖人工记忆,而是成为环境本身的属性。当报告声称已检查你的集群时,策略日志就是证明它从未发生过的铁证。
这种设计能保护你免受代理的威胁,但对阅读输出结果的人类毫无帮助,任何沙箱都无法改变这一点。对于这部分内容,请像对待代理的输入一样对待其审查输出:始终将其视为不可信内容,直到有验证机制确认其真实性——标注清楚避免其被同事的权威所影响,核实所有CVE编号,且在任何人阅读并理解之前,绝不运行代理生成的修复脚本。
这与Pillar在7月发布的沙箱逃逸案例遵循相同模式。边界本身从未被突破;只是在沙箱内写入了一个文件,之后外部工具错误地信任了该文件。代理的输出始终是下一个读取它的系统的输入,而下游某个环节总会信任它接收到的内容。很多时候,这个信任的环节就是人类本身。
让我们逐步将BuaJi迁移至Docker沙箱。最终,只读规则将由环境强制执行,而非依赖人工记忆。
BuaJi当前的运行方式
BuaJi是一个小型Python脚本,文件名为buaji.py。其GitLab作业会在GPU运行器上安装Ollama,启动ollama serve,加载LLM中指定的模型,并运行脚本。
该脚本从rules.md读取审查规则,使用git diff HEAD^ HEAD --name-only获取变更文件列表。之后将每个文件和规则发送至http://localhost:11434的Ollama服务,并将结果输出至CI日志。
问题在于所有内容共享一个容器:模型、脚本、代码库和每个 CI 变量(包括 CI_JOB_TOKEN)。BuaJi 之所以是只读的,只是因为脚本恰好没有写入任何内容。
我们真正想要的方案
我们将任务拆分为两个部分。模型保留在运行器上,与 GPU 并存。BuaJi 运行在一个微虚拟机中,该虚拟机可以读取代码并与模型通信,但无法执行其他操作。
模型开放一条访问路径,其余一切均被阻断,并在退出时执行 CVE 检查。
步骤 1:让 BuaJi 从非 localhost 的位置找到 Ollama
在沙箱中,localhost 指向沙箱本身。因此我们从环境变量中读取 Ollama 地址,并将旧值设为默认:
- url = "http://localhost:11434/api/generate"
+ base = os.environ.get("OLLAMA_URL", "http://localhost:11434")
+ url = f"{base}/api/generate"在非沙箱环境中,一切保持不变。在沙箱中,我们将 OLLAMA_URL 指向运行器。
步骤 2:一次性将 BuaJi 的依赖打包进沙箱镜像
目前每次运行任务时都会执行 apt-get 和 pip3 install,这需要每次都能访问互联网。
相反,我们应一次性构建一个包含 BuaJi 所需所有内容的小型镜像,使沙箱永远无需下载任何内容:
FROM docker/sandbox-templates:shell
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-requests git \
&& rm -rf /var/lib/apt/lists/*
USER agent将该镜像推送到你的镜像仓库,并将其名称存储在一个名为 BUAJI_TEMPLATE 的 CI 变量中。
步骤 3:在运行器上保留模型
Ollama 保留在运行器上并与 GPU 一起运行。在运行器上一次性安装,而不是在每个流水线中运行 curl | sh:
ollama serve > /tmp/ollama.log 2>&1 &
sleep 2
ollama pull "$LLM"模型永远不会进入沙箱。BuaJi 通过第 5 步打开的端口与其通信。
步骤 4:以只读方式挂载代码创建沙箱
这一步决定了 BuaJi 能看到和修改的内容。
mkdir -p "$CI_BUILDS_DIR/buaji-scratch"
sbx run -d --name "$SBX_NAME" -t "$BUAJI_TEMPLATE" \
shell "$CI_BUILDS_DIR/buaji-scratch" "$CI_PROJECT_DIR:ro"各部分的作用说明如下:
- --name 为每个流水线分配独立的沙箱。
- -t 使用第 2 步创建的镜像。
- shell 是代理类型。BuaJi 只是一个普通脚本,因此 shell 沙箱已足够。
- buaji-scratch 是一个空文件夹,是 BuaJi 唯一可以写入的目录。
- :ro 以只读方式挂载仓库。BuaJi 可以读取文件并运行 git diff,但无法修改任何内容。
注意缺失的部分:没有 GitLab 令牌、没有 kubeconfig、没有云凭证。即使 BuaJi 尝试运行 kubectl 或 terraform,也没有任何连接目标。
步骤 5:仅开放一条网络路径连接模型
沙箱默认会阻止所有出站流量。我们仅为此沙箱开放一条路径(假设运行器的默认策略是拒绝所有;如果是平衡或开放策略,需先将默认策略设为拒绝所有):
sbx policy allow network --sandbox "$SBX_NAME" localhost:11434允许目标(localhost:11434)和 BuaJi 连接的地址(host.docker.internal:11434)是同一运行器端口的两个名称:前者是从运行器视角的名称,后者是微虚拟机内部的名称。Kubernetes、你的云 API 和 GitLab 仍然被阻止。如果报告声称 BuaJi “检查了集群”,这条规则将证明它实际上无法做到。
Kubernetes、你的云 API 和 GitLab 仍然被阻止。如果报告声称 BuaJi “检查了集群”,这条规则将证明它实际上无法做到。
步骤 6:运行代码审查
BuaJi 仍以相同方式运行,只是现在位于新位置且仅包含两个变量:
sbx exec -w "$CI_PROJECT_DIR" \
-e LLM="$LLM" \
-e OLLAMA_URL=http://host.docker.internal:11434 \
"buaji-$CI_PIPELINE_ID" python3 buaji.py | tee buaji-review.log- -w 从代码库的检出目录开始,这是 buaji.py 期望找到 rules.md 的位置。
- 两个 -e 参数是 BuaJi 能看到的唯一变量。
- tee 会将审查结果保存到 runner 上的文件中,该文件位于沙箱之外。
现在审查结果仅是纯文本。BuaJi 无法发布它、根据它采取行动或改变它后续的处理方式。
第 7 步:在任何人根据报告采取行动前检查每个 CVE 编号
这个检查可以在几秒钟内发现伪造报告。它在 runner 上运行,因此 BuaJi 无法跳过它。
它会在 NVD 数据库中查找每个 CVE ID,如果发现任何不存在的 ID 则会失败:
unverified=""
for id in $(grep -oE 'CVE-[0-9]{4}-[0-9]{4,}' buaji-review.log | sort -u); do
count=$(curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=$id" \
| jq -r '.totalResults // 0')
[ "$count" = "0" ] && unverified="$unverified $id"
done
if [ -n "$unverified" ]; then
echo "⚠️ BuaJi 输出提到了不存在的 CVE ID:$unverified"
echo "⚠️ 将此审查视为未验证。不要运行它建议的任何命令。"
exit 1
fi由于该作业设置为 allow_failure: true,GitLab 会将其标记为“带有警告的通过”。流水线继续执行,但没有人会误将报告视为可信。这会降低信号质量,但不会阻止人类操作。警告只是一个提示,而非防护措施——疲惫的值班工程师仍可能滚动过去。
它无法发现所有伪造细节,比如假的 Kubernetes 资源名称。但它能发现使大多数伪造报告成立的关键要素。
第 8 步:检查 BuaJi 尝试访问的内容,然后清理
最后,查看策略日志并删除沙箱:
sbx policy log "$SBX_NAME"
sbx stop "buaji-$CI_PIPELINE_ID"
sbx rm -f "buaji-$CI_PIPELINE_ID"日志会显示 BuaJi 尝试访问的所有主机。在拒绝所有流量的 runner 上,正常运行只会显示端口 11434 上的模型。任何其他访问都是值得关注的信号。
删除沙箱意味着每个流水线都能从干净状态开始。
完整的流水线作业
整合后,.gitlab-ci.yml 中的 buaji 作业如下所示:
# BuaJi 已移入 Docker 沙箱。只读规则现在是环境的属性,而非脚本中的约定:代码以只读方式挂载,未传递任何凭证,且仅开放一条网络路径(到模型)。
buaji:
stage: buaji
tags:
- self-hosted-gpu-kvm # 用于 Ollama 的 GPU,用于 sbx 微虚拟机的 KVM
variables:
LLM: gemma
GIT_DEPTH: "2" # buaji.py 将 HEAD^ 与 HEAD 进行差异对比
SBX_NAME: buaji-$CI_PIPELINE_ID
before_script:
- ollama serve > /tmp/ollama.log 2>&1 &
- sleep 2
- ollama pull "$LLM"
- mkdir -p "$CI_BUILDS_DIR/buaji-scratch"
- sbx run -d --name "$SBX_NAME" -t "$BUAJI_TEMPLATE"
shell "$CI_BUILDS_DIR/buaji-scratch" "$CI_PROJECT_DIR:ro"
- sbx policy allow network --sandbox "$SBX_NAME" localhost:11434
script:
- sbx exec -w "$CI_PROJECT_DIR"
-e LLM="$LLM" -e OLLAMA_URL=http://host.docker.internal:11434
"$SBX_NAME" python3 buaji.py | tee buaji-review.log
- ./scripts/verify-cves.sh buaji-review.log
after_script:
- sbx policy log "$SBX_NAME"
- sbx stop "$SBX_NAME" && sbx rm -f "$SBX_NAME"
allow_failure: true
only:
changes:
- "**/*.go"执行环境需要 GPU 和 KVM,这通常意味着必须自建环境。GIT_DEPTH: "2" 为 buaji.py 提供了两个提交记录用于比较。清理操作位于 after_script 中,因此即使任务失败也会执行。
变化之处
此前只读规则是通过脚本实现的。现在该规则通过环境配置实现:只读代码库、单一网络路径、无凭证、仅允许文本输出。
未发生变化的是人工阅读输出结果的环节。沙箱机制阻止了 BuaJi 运行其脚本。只有 CVE 检查和仔细阅读才能阻止其他人运行这些脚本。
最佳实践
应将代理的安全发现视为假设而非结论。AI 审核器的报告是带有良好格式的声明。当人工审核者标记安全问题时,隐含着他们已验证过自身判断的步骤。对于代理系统,这个验证步骤尚未完成,因此必须由人工自行完成验证。
在采取行动前务必验证每个 CVE 编号。这是整个流程中最简单的检查步骤,却能发现伪造报告中最具说服力的部分。如第7步所示,应自动化执行该检查,确保每次审核都能自动运行,无需人工记忆。
切勿在未审查的情况下运行代理生成的修复脚本。代理提供的脚本和命令是虚假发现演变为真实事件的关键点。应像审查来自互联网的未知脚本一样谨慎对待它们。
将只读设置为环境属性而非人为习惯。通过流水线配置手动实施的限制总可能因仓促修改而消失。应如步骤4到6所示,以只读方式挂载代码,不传递凭证,仅开放代理所需的网络路径。
明确标注代理审核结果为代理输出。当机器人审核与资深工程师的审核结果完全相同时,它借用了尚未获得的权威性。清晰的标注有助于读者正确应用质疑程度。
关注信噪比指标。curl 的案例就是警示。当审核通道被噪音填满时,最终会停止被阅读。无人阅读的审核通道比没有审核通道更糟糕,因为人们仍会假设它提供了保护。
结论
构建 BuaJi 的工程师确实认识到只读规则对他的保护作用,且确实如此。BuaJi 无法触碰集群,无法撤销任何人的访问权限,也无法运行自己精心编写的一行代码。但该规则无法阻止 BuaJi 请求其他人执行它。
本系列其他案例都涉及代理权限越界的问题。这个案例却从未越界。它始终停留在边界内,只执行被允许的操作,但仍可能在阅读者稍显疲惫或对集群不够熟悉时,导致真实的服务中断。
随着我们在流水线中赋予代理更多责任,限制其行为只是任务的一半。沙箱机制处理了这一部分。另一半是决定我们对代理报告的信任程度,这部分仍然取决于我们自己。
作者介绍
Docker 开发者倡导者
Ajeet Singh Raina 是 Docker 的开发者倡导者,撰写并发表关于容器、Docker Compose 和 AI 的文章,帮助开发者自信构建应用。
GitLab 分发工程师
AI 代理
Docker 沙箱
沙箱
社区
目录
相关文章
/think
- 2026年9月24日 为AI代理构建信任 | Docker WeAreDevelopers主题演讲 Docker WeAreDevelopers主题演讲展示了沙箱、工具包和云沙箱如何为AI代理提供强隔离性和可复现的权限管理。Deanna Sparks 立即阅读
- 2026年10月29日 代码代理的恐怖故事:不存在的CVE 某AI代码审查代理虚构了一个CVE并推荐了危险的修复方案。学习Docker沙箱如何约束代理,以及为何需要验证其输出内容。Ajeet Singh Raina和Pratik Singh 立即阅读
- 2026年10月28日 共同赢得智能代理时代:推出Docker Navigator合作伙伴计划 Docker推出Navigator合作伙伴计划,包含三个层级、联合销售权益以及针对每项注册AI治理协议的新激励机制。Leon Jones 立即阅读
- 可信基础 2026年10月23日 PHP2Shell和eMudhra补丁 PHP2Shell漏洞与eMudhra证书泄露事件同期出现。Docker在24小时内发布了修复后的PHP镜像,并重建了整个目录中的信任存储库。Vishrut Iyengar 立即阅读