文章讨论了 npm 在 CircleCI 上使用 OIDC Trusted Publishing 替代静态 NPM_TOKEN 的迁移实践,核心观点是:持续存在的凭证总是可被窃取,真正消除风险的方式不是轮换而是删除。
TL;DR
过去五年中,几乎每一起由 CI 介导的 npm 供应链泄露事件,最终都能追溯到某个不该存在的地方放着一个长期有效的凭据。
我们将三个 repo 和六个 package 迁移到 CircleCI 上的 OIDC Trusted Publishing,然后删除了该账户上的每一个静态 npm token,包括那个唯一用途只是保存 token 的 service account。
仅靠 OIDC 还不够。你还需要把每个 package 设置为 “Require 2FA and disallow tokens”,以堵住 maintainer-laptop 这条路径(axios _ / WAVESHAPER 这一类)。_
轮换不能关闭被盗窗口。删除才可以。
唯一无法在静态状态下被盗的凭据,就是根本不存在的凭据。
多年来,从 CI 发布到 npm,意味着要把一个长期有效的 access token 作为环境变量保存。三个事件塑造了我们的判断,它们也清晰地分成了两类不同的威胁。
| 年份 | 事件 | 根本原因 | 类别 | 修复 |
|---|---|---|---|---|
| 2023 | CircleCI breach | 恶意软件窃取了一个 2FA SSO session,攻击者外泄了客户环境变量,包括存储的 NPM_TOKENs |
1: CI 常驻 token | OIDC Trusted Publishing |
| 2025 | Shai-Hulud worm | 自我复制的 package 从开发/CI 环境中收集 NPM_TOKENs 和 GitHub PATs,然后重新发布植入木马的 package |
1: CI 常驻 token | OIDC Trusted Publishing |
| 2026 | axios / WAVESHAPER |
通过社工手段诱导 maintainer;maintainer 的 laptop 上长期存在的 publish token 被用来直接发布恶意版本 | 2: Maintainer 常驻 token | Registry 侧:“Require 2FA, disallow tokens” |
类别 1,CI/CD tokens。 通过 OIDC Trusted Publishing 解决。CI 不再需要存储 token;构建环境里也就没有任何东西可供平台泄露、worm 或 insider 外泄。
类别 2,maintainer-laptop tokens。 即使完成了一次干净的 OIDC 迁移,这类 token 依然存在,因为 OIDC 只约束来自 CI 的发布。Registry 仍然会接受来自其他任何地方的有效静态 token。只有通过 registry 侧策略 才能修复,即彻底拒绝基于 token 的发布。
**底层真相:**如果一个凭据是持久存在的,它就能被盗。轮换不能关闭窗口。攻击者活动的窗口,就是两次轮换之间的窗口。
在 2026 年 4 月 6 日,npm 为 CircleCI 添加了 Trusted Publishing 支持。我们很快就完成了迁移。
在发布时:
CircleCI 签发一个已签名的 JWT,用来标识正在运行的 workflow、job 和 branch。
npm registry 根据 package 上的 Trusted Publisher 配置,验证 issuer、subject 和 audience。
匹配后,npm 签发一个只作用于该单个 package 的短期 token。
构建使用该 token,随后将其丢弃。
由此得到的凭据属性:
| 属性 | 值 |
|---|---|
| 生命周期 | 几分钟 |
| 范围 | 单个 package |
| 绑定 | Workflow 身份(JWT) |
| 静态存在? | 否 |
围绕 NPM_TOKEN 的三个旧步骤(检查、写入 .npmrc、npm whoami)会收敛为一次 OIDC fetch:
## Requires: npm >= 11.5.1, Node >= 22.14.0
## (这是我们实际可用的版本。请查看当前 npm 文档以确认今天的最低版本。)
- run:
name: Obtain OIDC token for npm trusted publishing
command: |
set -e
# Audience 必须严格等于 "npm:registry.npmjs.org",不能是裸 registry URL。
NPM_ID_TOKEN=$(circleci run oidc get --claims '{"aud":"npm:registry.npmjs.org"}')
if [ -z "$NPM_ID_TOKEN" ]; then
echo "ERROR: circleci run oidc get returned an empty token" >&2
exit 1
fi
# 通过 $BASH_ENV 导出,这样 token 会保留到后续步骤。
# 直接 `export` 会随着这个 shell 结束;发布步骤将看不到它。
echo "export NPM_ID_TOKEN=$NPM_ID_TOKEN" >> "$BASH_ENV"
工作参考:ethereum-optimism/ecosystem``.circleci/config.yml。原始 PR #992 需要后续修复。请从链接的 commit 复制,而不是从 PR 复制。
我们踩过的坑:
预装的 CI image 往往自带一个过旧的 npm,不支持 OIDC publishing。请显式升级。
audience claim 是 npm:registry.npmjs.org。不是 URL。不能带 scheme。必须完全一致。
Trusted Publisher 配置位于 npm package 页面上,不在 account 或 org settings 中。它是按 package 粒度的:没有 org 级开关。十个 package 就意味着十次 UI 操作,或者一个脚本化循环(见下文)。
npm 针对 CircleCI 的 Trusted Publisher 配置允许你固定 org、project 和 pipeline definition,但 不能 固定 branch。 仅靠这一点,npm 会接受来自运行了匹配 workflow 的任意 branch 的 publish。
如果你只依赖 workflow YAML 中的 filters: branches: only: main,就会留下暴露面:任何有 push 权限的人都可以创建一个 feature branch,删除那个 filter,提交恶意代码,并触发一次真实的 publish。
要真正把 publish 限制在 main:
创建一个带 branch restriction 的 CircleCI context(只有 main 可以访问它)。
将 publish job 绑定到那个 context。
把该 context ID 传给 npm Trusted Publisher 配置(--context-id)。
这如何关闭漏洞:CircleCI 会在每个 OIDC token 中发出一个 oidc.circleci.com/context-ids claim,列出该 job 有权访问的 context 的 UUID。feature branch 运行无法访问受 branch 限制的 context,因此这个 UUID 不会出现在 claim 中,而 npm 会因为 JWT 与 trust policy 中固定的 --context-id 不匹配而拒绝 publish。
使用 npm trust 一次性配置每个 package:
## 不同 npm 版本之间,flag 名称一直在变。运行 `npm trust circleci --help`
## 来确认,因为 `--pipeline-definition-id` 以前曾表现为 `--pipeline-id`。
for pkg in "@your-scope/pkg-one" "@your-scope/pkg-two" "@your-scope/pkg-three"; do
# 若要重新配置已有的 trust:
# npm trust revoke "$pkg" --id="<current-trust-id>"
# --context-id 是那个具备 branch restriction 的 CircleCI context:
# 如果没有它,feature-branch 运行就可能签发出一个可用于发布的 OIDC token。
npm trust circleci "$pkg" \
--org-id <CIRCLECI_ORG_ID> \
--project-id <CIRCLECI_PROJECT_ID> \
--pipeline-definition-id <CIRCLECI_PIPELINE_DEFINITION_ID> \
--vcs-origin github.com/<your-org>/<your-repo> \
--context-id <CIRCLECI_CONTEXT_ID> \
--yes
done
如果 package 数量超过两三个,强烈建议不要用 UI,而用这种方式。它也适合作为修复配置错误的一条命令。
CircleCI 中持久存在的 NPM_TOKEN。没了。
CI token 外泄路径(CircleCI 2023、Shai-Hulud 2025)。对我们的 package 来说已关闭。
OIDC 将一次发布绑定到 workflow 和 branch。它不能防御以下情况:
恶意 commit 被合并进 main 发布分支。OIDC exchange 会乐意为坏代码的发布签名。
攻击者能够修改 workflow 文件或 https://www.npmjs.com/ 上的 Trusted Publisher 配置。
构建时上游依赖树被攻破。
维护者账户被攻破,且其 laptop 上存在一个 static token。 删除 CI token 并不会禁用从工作站通过 password 或 token 进行发布。
要关闭最后这条路径,把每个 package 的 Publishing access 设置为 “Require two-factor authentication and disallow tokens.” OIDC 签发的凭据不受这个设置影响,所以 CI 仍然可以工作,而 registry 会拒绝任何 static token,不管是谁签发的。
Trusted Publishing 只是一层,不是终点。branch protection、required reviews、dependency pinning 和 build provenance 仍然都很重要。
在 npm 上添加 Trusted Publisher(package Settings → Publishing access → CircleCI)。
升级 publish job(对我们来说是 npm >= 11.5.1、Node >= 22.14.0),并把 NPM_TOKEN 的设置替换为 circleci run oidc get。
运行一次 release。 确认 publish 成功,并且构建不再读取 NPM_TOKEN。
删除账户上的每一个静态 npm token,并退役那些唯一用途只是保存这些 token 的 service account。这一步才真正改变你的安全态势。
把每个 package 设置为 “Require 2FA and disallow tokens.” 让 static-token 发布在 registry 层面变得不可能。
把发布团队改为 npm 上只读。 CI 通过 OIDC 发布;人工发布则由启用硬件 2FA 的 org owner 走流程。一个被钓鱼的开发者账户将不再具备发布价值。
如果通过 UI 做第 5 步,就意味着要逐个点进每个 package。基于 passkey 的 CLI 2FA 则意味着每个 package 要点一次。规模一大,这两种方式都不轻松。
**更务实的做法:**专门为这个操作创建一个短期、权限较广、可绕过 2FA 的 token,用完后立刻删除。
在 npm 上创建一个 granular access token,满足以下条件:
对你的整个 scope(@your-scope/*)具备读写权限
启用 Bypass 2FA
过期时间:1 day
给它起一个容易记住的名字:super-powerful-temporary-delete-me-now-I-mean-it
先在一个 package 上验证 flag 映射。
npm CLI 中用于 “disallow tokens” 单选项的 flag 在不同版本之间发生过变化。mfa=publish 和 mfa=automation 在不同时间都曾产生正确的 UI 状态。先在一个 package 上运行命令,在浏览器中打开 Settings → Publishing access,并确认选中的选项是 “Require two-factor authentication and disallow tokens”,再批量执行任何操作。
export NPM_TOKEN=npm_XXX
## 随着每个 package 加固,registry 都会拒绝对它进行基于 token 的发布,
## 包括使用这个 token 本身。到最后,这个 token 将无法发布任何内容。
for pkg in $(npm access list packages "@your-scope" --json | jq -r 'keys[]'); do
npm access set mfa=publish "$pkg" # 使用你上面验证过的值
done
这正是 Axios 漏掉的那类凭据:一个在被攻破机器上的长期有效、可绕过 2FA 的 token,被 WAVESHAPER 用来发布。教训不是“永远不要创建 bypass-2FA token”(我们刚刚就这么做了),而是**“永远不要让它们活得比需要它们的操作更久。”**
当且仅当以下所有条件都满足时,你的账户才达标:
每个用于发布的 package 都在 npm 上配置了 Trusted Publisher。
每个 Trusted Publisher 都绑定到一个受 branch restriction 的 CircleCI context(而不仅仅是 workflow YAML filter)。
publish job 在运行时获取 OIDC token;不再引用 NPM_TOKEN。
已在新配置下端到端成功完成一次 release。
账户上的每一个静态 npm token 都已删除。
那些唯一用途只是保存 publish token 的每一个 service account 都已退役。
每个 package 的 Publishing access 都已设置为 “Require 2FA and disallow tokens.”
开发者/publish 团队在 npm 上都是只读;写权限仅限于使用硬件 2FA 的 org owner。
用于批量加固的临时 token 已经删除。
如果任何一项未勾选,你就仍然持有一个可被盗的凭据。轮换它?不。删除它。
ethereum-optimism/ecosystem CircleCI config:参考实现。原始版本:PR #992(需要后续修复)。
Google Cloud Threat Intelligence: North Korea threat actor targets axios。
- 原文链接: optimism.io/blog/stop-ro...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码