当 npm/pip 包被植入恶意 postinstall 脚本时,按时间顺序执行检查、锁定、回滚、凭证轮换的完整操作手册。
有大量优秀的文章在讲如何防范供应链攻击:ignore-scripts=true、minimumReleaseAge、冻结 lockfile、来源校验。去读,去用。
但所有这些文章都在同一个悬崖边戛然而止。当防范失效时,该怎么办?
你打开 Slack,看到一条消息:"lib-x 4.2.1 到 4.2.3 版本已被攻陷,存在恶意 postinstall,请轮换凭证。"你的心率翻倍了。你现在真正该在终端里敲什么?
没人写过那篇文章,所以我来写。一份 60 分钟的应急预案,按顺序排列,包含具体命令。假设你用的是 npm/pnpm/yarn 或 pip/uv,但逻辑可以迁移到任何平台。
先不要轮换任何东西。先不要慌乱删除 node_modules。首先确认你是否曾经暴露过,因为"我们依赖 lib-x"和"我们安装了 lib-x 4.2.2"是完全不同的两件事。
检查当前安装了什么
# npm / pnpm / yarn: 显示每个已解析的副本,包括传递依赖
npm ls lib-x --all
pnpm why lib-x
yarn why lib-x
# pip
pip show lib-x
uv pip list | grep lib-x
检查 lockfile,包括其历史
你当前的 lockfile 可能很干净,但上周二的可能不干净。暴露窗口是每一个引用了坏版本的 commit:
# 每个曾在 lockfile commit 中提到过这个包的 commit
git log --follow -p -- package-lock.json | grep -n -B2 -A2 '"lib-x"'
git log -S '4.2.2' --oneline -- package-lock.json pnpm-lock.yaml yarn.lock
git log -S 'lib-x==4.2.2' --oneline -- requirements.txt uv.lock poetry.lock
别忘了分支和 open 的 PR
Renovate 或 Dependabot 上有一个带着坏版本的 PR 正处于 open 状态,CI 在每次向该分支 push 时都已经安装过了:
# 在所有分支中搜索坏版本
git grep '4.2.2' $(git for-each-ref --format='%(refname)' refs/remotes) -- '*lock*'
决策点。如果坏版本从未在任何地方出现过,你就完成了。在事件频道里写两句话,添加防范措施,回去继续工作。如果它在任何 lockfile、任何分支、任何时间点出现过,继续读。你现在进入事件响应模式了。
被攻陷不等于被执行。弄清楚你面对的是哪一类有效载荷。安全公告里通常会说明。
A 类:安装时有效载荷(preinstall/postinstall、setup.py)。它在每台安装过的机器上都运行过。每台开发笔记本、每个 CI runner、每次 Docker build。如果你运行时有 ignore-scripts=true(或者 pnpm 默认的脚本拦截,或 Bun),你的笔记本可能是安全的。但要单独检查 CI,因为 CI 配置往往和本地不同。
# 当前环境是否允许脚本?
npm config get ignore-scripts # 希望返回: true
pnpm config get ignore-scripts
B 类:运行时有效载荷(恶意代码在模块本身内部)。安装它是无害的。导入它就不是了。现在的问题是哪些进程加载了它:你的生产应用、CI 中的测试套件、还是构建脚本?
# 它实际被导入了吗,还是只是存在于依赖树中?
grep -rn "require(['\"]lib-x" src/ scripts/
grep -rn "from ['\"]lib-x" src/
grep -rn "import lib_x|from lib_x" .
用一句话记下你的爆炸半径。类似这样:"A 类有效载荷,CI 中脚本启用但笔记本未启用,CI 在 8 月 12 日到 8 月 18 日之间运行了 14 次安装。"之后所有步骤都以此为边界。
这是人们顺序最容易搞错的步骤。先轮换再清理,因为你的清理 commit 会触发 CI,而如果 CI 凭证已泄露,你刚干净的代码又会暴露在已被攻陷的环境中。
最近的真实有效载荷(类似沙虫式的蠕虫)直冲令牌而去。假设有效载荷已经收割了它运行环境中所有可读的凭证。
如果它在 CI 中运行了:
# npm: 列出并撤销令牌
npm token list
npm token revoke <id>
# GitHub: 检查你未创建的令牌/密钥
gh api /user/keys
gh auth status
如果它在笔记本上运行了:
这些正是有效载荷会抓取的目标。
还要检查持久化。有几个 npm 蠕虫用被盗令牌添加了恶意的 GitHub Actions workflow 或新建了仓库:
# 你组织中最近创建/修改的 workflow
gh api "search/code?q=org:YOUR_ORG+path:.github/workflows&sort=indexed" \
--jq '.items[].repository.full_name' | sort -u
# 在暴露窗口期间创建的仓库
gh repo list YOUR_ORG --limit 200 --json name,createdAt \
--jq '.[] | select(.createdAt > "2026-08-12")'
修复 package.json 是简单的 10%。tarball 还存在于各种缓存中,它们会非常乐意重新提供服务。
1. 在所有地方强制使用安全版本,包括传递依赖
// package.json (npm)
"overrides": {
"lib-x": "4.2.0"
}
// pnpm: pnpm-workspace.yaml (或 package.json pnpm.overrides)
// overrides:
// lib-x: 4.2.0
// yarn
"resolutions": {
"lib-x": "4.2.0"
}
echo "lib-x==4.2.0" >> requirements.txt # 或 constraints.txt
uv lock --upgrade-package lib-x==4.2.0
然后手动重新生成 lockfile 并 diff,确认后再提交。你要看的是坏版本消失且没有可疑内容出现。
2. 清除每一层缓存
# 本地
rm -rf node_modules
npm cache clean --force
pnpm store prune
yarn cache clean
pip cache purge && uv cache clean
gh cache list
gh cache delete --all
docker builder prune --all
如果你运行了 registry 代理(Artifactory、Verdaccio、Nexus),也在那里清除坏版本。否则你组织中未来每次安装都会从你自己的镜像重新下载它。
3. 重建并重新部署在窗口期间构建的任何产物
任何在坏版本可解析期间构建的产物(Docker 镜像、Lambda zip、前端 bundle)都值得怀疑。从修复后的 lockfile 重新构建,重新部署,如果你保留了镜像摘要,记录哪些镜像是窗口期内构建的。
# 确认坏版本已从依赖树中消失
npm ls lib-x --all
npm ci # 或: pnpm install --frozen-lockfile
npm audit signatures
然后写一份简短的事后分析。五句话,发在事件频道,今天:
最后一条才是所有那些防范文章的用武之地:ignore-scripts=true、发布年龄冷却时间(npm 11.10+ 中的 minimum-release-age,pnpm 11 默认启用,Yarn 4.10+ 中的 npmMinimalAgeGate)、CI 中冻结 lockfile 安装、以及用 OIDC 替代静态云密钥。防范文章告诉你做这些事。事件告诉你你真正需要的是哪一个。
1. git log -S '<bad version>' -- -> 我是否曾暴露?(所有分支!)
2. 安装时还是运行时有效载荷?-> 范围:笔记本 vs CI vs 生产
3. 轮换:发布令牌 > CI 密钥 > 云密钥 > 笔记本凭证
4. 检查持久化:新 workflow、新仓库、新令牌
5. overrides/resolutions 固定版本 + 清除 npm/pnpm/CI/Docker 缓存
6. 重建窗口期内的产物;重新部署
7. npm ci + npm audit signatures -> 验证
8. 五句话事后分析 + 一个防范 ticket
防范文章会被写出来,因为防范是舒服的。事件响应被跳过,因为那是说明你已经被坑了的阶段。"攻陷公开"到"攻陷被发现"之间的窗口永远不可能为零,所以这份预案不是可选项。它是供应链安全那被所有人停止谈论的另一半。
如果你经历过其中一次,在评论区留下你的实战经历。特别是你忘掉的那些缓存层。