别轮换 npm Token,直接删除它们 - Optimism

  • optimism
  • 发布于 2026-03-31 11:20
  • 阅读 84

文章讨论了 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 这一类)。_

  • 轮换不能关闭被盗窗口。删除才可以。

唯一无法在静态状态下被盗的凭据,就是根本不存在的凭据。

为什么:反对静态 token

多年来,从 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”

这两类 token

  • 类别 1,CI/CD tokens。 通过 OIDC Trusted Publishing 解决。CI 不再需要存储 token;构建环境里也就没有任何东西可供平台泄露、worm 或 insider 外泄。

  • 类别 2,maintainer-laptop tokens。 即使完成了一次干净的 OIDC 迁移,这类 token 依然存在,因为 OIDC 只约束来自 CI 的发布。Registry 仍然会接受来自其他任何地方的有效静态 token。只有通过 registry 侧策略 才能修复,即彻底拒绝基于 token 的发布。

**底层真相:**如果一个凭据是持久存在的,它就能被盗。轮换不能关闭窗口。攻击者活动的窗口,就是两次轮换之间的窗口。

如何:身份,而不是 secret

2026 年 4 月 6 日,npm 为 CircleCI 添加了 Trusted Publishing 支持。我们很快就完成了迁移。

OIDC exchange

在发布时:

  1. CircleCI 签发一个已签名的 JWT,用来标识正在运行的 workflow、job 和 branch。

  2. npm registry 根据 package 上的 Trusted Publisher 配置,验证 issuer、subject 和 audience。

  3. 匹配后,npm 签发一个只作用于该单个 package 的短期 token

  4. 构建使用该 token,随后将其丢弃。

由此得到的凭据属性:

属性
生命周期 几分钟
范围 单个 package
绑定 Workflow 身份(JWT)
静态存在?

将它接入 CircleCI

围绕 NPM_TOKEN 的三个旧步骤(检查、写入 .npmrcnpm 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 操作,或者一个脚本化循环(见下文)。

branch-identity 漏洞

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

  1. 创建一个带 branch restriction 的 CircleCI context(只有 main 可以访问它)。

  2. 将 publish job 绑定到那个 context。

  3. 把该 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。

为每个 package 编写 setup 脚本

使用 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 仍然都很重要。

停止轮换。开始删除。

迁移流程(每个 package 大约 30 分钟)

  1. 在 npm 上添加 Trusted Publisher(package Settings → Publishing access → CircleCI)。

  2. 升级 publish job(对我们来说是 npm >= 11.5.1、Node >= 22.14.0),并把 NPM_TOKEN 的设置替换为 circleci run oidc get

  3. 运行一次 release。 确认 publish 成功,并且构建不再读取 NPM_TOKEN

  4. 删除账户上的每一个静态 npm token,并退役那些唯一用途只是保存这些 token 的 service account。这一步才真正改变你的安全态势。

  5. 把每个 package 设置为 “Require 2FA and disallow tokens.” 让 static-token 发布在 registry 层面变得不可能。

  6. 把发布团队改为 npm 上只读。 CI 通过 OIDC 发布;人工发布则由启用硬件 2FA 的 org owner 走流程。一个被钓鱼的开发者账户将不再具备发布价值。

你将拥有的最后一个 token

如果通过 UI 做第 5 步,就意味着要逐个点进每个 package。基于 passkey 的 CLI 2FA 则意味着每个 package 要点一次。规模一大,这两种方式都不轻松。

**更务实的做法:**专门为这个操作创建一个短期、权限较广、可绕过 2FA 的 token,用完后立刻删除。

  1. 在 npm 上创建一个 granular access token,满足以下条件:

    • 对你的整个 scope(@your-scope/*)具备读写权限

    • 启用 Bypass 2FA

    • 过期时间:1 day

    • 给它起一个容易记住的名字:super-powerful-temporary-delete-me-now-I-mean-it

  2. 先在一个 package 上验证 flag 映射。

npm CLI 中用于 “disallow tokens” 单选项的 flag 在不同版本之间发生过变化。mfa=publishmfa=automation 在不同时间都曾产生正确的 UI 状态。先在一个 package 上运行命令,在浏览器中打开 Settings → Publishing access,并确认选中的选项是 “Require two-factor authentication and disallow tokens”,再批量执行任何操作。

  1. 运行循环:
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
  1. 删除这个 token。(它仍然可以进行 admin 级设置更改,所以这一步不是可选项。)

这正是 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 已经删除

如果任何一项未勾选,你就仍然持有一个可被盗的凭据。轮换它?不。删除它。

参考资料

  • 原文链接: optimism.io/blog/stop-ro...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
optimism
optimism
江湖只有他的大名,没有他的介绍。