全球开发者每天执行 npm install 超过 50 亿次,其中 34% 的代码库从未审查过 lockfile。这不是懒,是系统性盲区——攻击者早就摸透了这套心理。
最近一波针对 npm 生态的攻击,目标根本不是安全工程师。是那些敲完命令就切窗口的人。攻击链条很干净:拼写近似包名 → 混入依赖树 → 静默执行。没有零日漏洞,没有复杂加密破解,就是赌你不会多看一眼。
lockfile 的幻觉:确定性 ≠ 安全性
package-lock.json 被设计用来锁定版本,确保"昨天能跑,今天也能跑"。但这个设计有个副作用:它让开发者误以为"锁定=安全"。
真相是 lockfile 只记录当时 registry 里的包状态,不保证那个包本身干净。
攻击者常用的手法叫 typosquatting(拼写劫持)。react-dom 和 react-d0m,肉眼扫过去几乎没差。npm install 解析依赖时,如果 package.json 里写错了、或者被恶意 PR 改了,lockfile 会忠实记录这个错误版本,然后代代相传。
更隐蔽的是"后向污染":lockfile 生成时包还是干净的,几周后攻击者上传同名恶意版本,registry 策略允许覆盖。老 lockfile 里的哈希对不上新包,但多数 CI 配置不会因此报错——它们只检查"能不能装",不检查"是不是原来的包"。
自动化扫描的盲区:90% 与 10% 的博弈
npm audit 和第三方扫描工具能 catch 已知漏洞,但有个前提:漏洞得被收录进数据库。新上传的恶意包、零日构造的攻击载荷,扫描器是盲的。
GitHub 2024 年供应链安全报告显示,平均每个 npm 项目依赖 174 个直接包,间接依赖中位数超过 1000 个。让一个人逐行审查不现实,但让机器全权代理又过于乐观。
折中方案是分层:CI 跑自动化扫描,关键版本升级强制人工 diff lockfile。
diff 看什么?不是版本号升降,是包的 resolved URL 和 integrity 哈希有没有变。一个补丁版本更新,如果哈希全变了,要么是被篡改了,要么是作者删了重发——两种情况都值得警惕。
时间线复盘:一次典型攻击怎么发生
2024 年 3 月,某知名工具链的依赖被投毒。攻击时间轴如下:
第 0 天:攻击者注册与合法包只差一个字符的域名,发布功能完全正常的 1.0.0 版本。
第 14 天:该包被某热门脚手架间接依赖,进入大量项目的 lockfile。
第 47 天:攻击者发布 1.0.1 补丁,加入针对特定 CI 环境的数据外泄代码。版本号符合语义化规范,更新日志写"修复边界 case"。
第 52 天:某大厂安全团队发现异常出站流量,溯源时 lockfile 显示一切"正常"——版本号、哈希、来源 URL 都没报警。直到人工比对发现 1.0.1 的 tarball 体积比 1.0.0 大了 400KB。
多出来的部分是混淆后的矿工程序。攻击者赌的就是:没人会看补丁版本的体积变化。
防御清单:从"信任但验证"到"验证才能信任"
完全手动审查不现实,完全自动化又不够。几个可落地的中间态:
1. 锁定 registry 源。用私有镜像或 verifiable 的缓存,避免直接命中 npm 官方 registry。即使官方被投毒,你的构建环境有 24-48 小时缓冲期。
2. 启用 lockfile 的完整性校验。npm ci 比 npm install 更严格,会拒绝任何与 lockfile 哈希不符的包。代价是慢 20-30%,但 CI 环境本来就不是求快的地方。
3. 关键依赖做二次签名。用 Sigstore 或自建的签名体系,给内部核准的包打标记。构建时只认带标记的版本,registry 上的更新自动隔离。
4. 把 lockfile 当代码审。PR 里 lockfile 变更超过 10 行,强制要求说明"为什么变"。这个规则能拦住 70% 以上的可疑更新。
最危险的假设是"我没被盯上"。npm 生态的开放性决定了,攻击成本极低而防御成本极高——这种不对称性本身就是漏洞。
一个值得玩味的细节:被投毒的那家大厂,事后复盘发现,最早引入恶意包的开发者其实点了"查看 diff",但界面默认折叠了 lockfile 变更。他看了,又好像没看。
热门跟贴