Blast Radius,直译过来是爆炸半径。放到 NPM 供应链安全的语境里,它指的是:当一个 package 被攻陷后,还有多少项目、多少条依赖链会跟着暴露。我们平时排查前端依赖问题时,最常用的是npm install、npm run build,但很少有人在安装之前先想清楚一件事:如果这个包被恶意版本污染,受影响的到底只有我自己,还是会顺着依赖关系一路波及到公司在跑的业务系统。
这是供应链安全里最容易被低估的问题。直接依赖好查,但 npm 的依赖树是嵌套的,某个底层工具包一旦被劫持,可能通过几十条不同的依赖链把风险渗透到项目各个角落。真正影响你的,往往不是你直接写进 package.json 的那些包,而是那些“间接被装进来”的传递依赖。要应对这种场景,就需要一套能快速量化影响范围的方法,也就是 Blast Radius 分析。
这篇文章会做三件事:先说清楚 npm 依赖树里的爆炸半径是怎么被一层层放大的;然后给出可以直接运行的命令和脚本,帮你在一个包被标记为 compromised 之后快速定位“还有谁暴露”;最后从工程层面总结收缩依赖面、降低供应链风险的做法。文中命令和脚本基于通用 npm 项目结构编写,读者可以直接复制到自己的仓库里验证,也可以按项目情况二次调整。适合 Node.js 开发者、前端负责人、DevSecOps 和安全工程师阅读。
1. 核心能力速览
先给一个整体能力清单,方便判断这套分析思路合不合你的场景。
| 能力项 | 说明 |
|---|---|
| 项目/概念 | Blast Radius(NPM 包被攻陷后的影响面分析) |
| 解决的问题 | 量化一个 compromised package 影响到了哪些项目、哪些依赖路径 |
| 主要手段 | 依赖树反向定位、lockfile 解析、npm audit、依赖链可视化 |
| 适用对象 | Node.js 开发者、前端负责人、DevSecOps、安全工程师、开源维护者 |
| 前置环境 | Node.js LTS、npm 7+,推荐同时安装 pnpm 做隔离结构对比 |
| 常用命令 | npm ls、npm explain、npm audit、npm query |
| 扩展成本 | 无需额外服务,本地命令和轻量脚本即可完成第一轮筛查 |
| 适用场景 | 安全事件应急、依赖审计、上线前风险评估、私有仓库托管 |
| 局限 | 只能量化公开依赖关系,无法检测恶意代码在运行时已经造成的破坏 |
需要说明的是,Blast Radius 并不是一个“装完就能看结果”的独立工具,它更多是一种安全排查方法和工程习惯。npm 生态里我们能拿到的数据足够完整:package.json、package-lock.json、npm audit 结果,把这几类数据组合起来,就能比较清楚地回答“还有谁暴露了”。
2. 为什么 NPM 供应链的 Blast Radius 值得认真对待
NPM 生态的依赖关系非常密集。一个中型前端项目,package-lock.json 里通常会出现几百到上千个包;一个大型 Node.js 服务,依赖数量过万也常见。这些包之间的关系不是线性的,而是网状嵌套。你直接依赖 A,A 依赖 B,B 依赖 C,C 依赖 D,只要 D 被攻陷,理论上从 A 到你的项目,整条链路都处在风险中。
更麻烦的是传递依赖版本的不确定性。npm install 时会根据 semver 范围拉取满足条件的版本,如果 lockfile 没有提交到仓库,一次 install 可能装到不同版本;即使提交了 lockfile,如果某个被污染的版本恰好落在 semver 允许的范围内,重新安装时命中的概率也不小。过去几年里,npm 生态已经出现过多次高影响力的供应链污染事件,有的是维护者账号被钓鱼,有的是域名过期被抢注,有的是恶意提交混进了正式版本。这些事件有一个共同特征:受害方往往不是一开始就被盯上的大公司,而是那些“间接依赖了问题包”的中下游项目。
从应急角度看,最不可接受的情况是:安全公告都发了,自己还在确认“项目里到底有没有这个包”。直接扫 node_modules 目录是不准的,因为版本散落、嵌套深、同名包也可能存在多个副本。更可靠的做法是直接解析 package-lock.json,把依赖关系和版本号一次性读出来,再基于这份数据做反向追踪。这就是 Blast Radius 分析最基础、也最有效的一步。
3. npm 依赖树的爆炸半径是怎么形成的
npm 的依赖规则可以用一句话概括:除了根项目 package.json 里列出的直接依赖,npm 还会把每个包在 package.json 中声明的依赖递归安装到 node_modules 里。早期 npm 的依赖结构是嵌套的,每个包有自己独立的 node_modules,这样能避免版本冲突;后来 npm 3+ 改成了按需提升(hoisting)的扁平结构,尽量把公共依赖提升到顶层,但在版本冲突时依然会保留嵌套副本。
这样的结构带来一个直接后果:同一个逻辑包名,可能在 node_modules 里出现多个版本副本。你写代码时 import 的可能是顶层版本,但某个传递依赖用的可能是嵌套版本。当安全公告说某个版本有问题时,你不能只看顶层 node_modules 里有没有它,而要去 package-lock.json 的 packages 字段里查找对应路径是否存在嵌套副本。这也是很多人在排查供应链事件时漏掉深层依赖的原因。
package-lock.json 是爆炸半径分析的“唯一事实来源”。其中 packages 字段记录了每个依赖的解析路径、版本号、依赖关系,node_modules/xxx这样的 key 就是依赖在磁盘上的真实位置。只要 lockfile 是经过npm install真实验证过的产物,用它做分析就比直接扫描目录可靠得多。
看一个典型结构:
my-app │ ├─ node_modules/axios │ └─ node_modules/webpack └─ node_modules/css-loader └─ node_modules/postcss └─ node_modules/source-map-js如果 source-map-js 被攻陷,你可能根本不会在 package.json 里直接看到它,但它在依赖链中真实存在。这种多层传递关系,就是 Blast Radius 被放大的根源。
4. 用 npm 自带命令快速定位“还有谁暴露”
大多数排查工作不需要先写脚本,npm 自带命令就能完成第一轮定位。
4.1 npm ls:找到某个包在依赖树里的位置
npm ls axios如果 axios 在依赖树中,npm ls 会输出它出现的路径;如果它不存在,会输出 empty。在大型项目里,这个命令可以作为第一道筛查:先确认项目里有没有这个包,以及它出现在哪一层。
想看所有出现的位置,可以加--all:
npm ls --all axios不过 --all 在依赖很多的场景下输出会很啰嗦,建议不加 --all,直接看顶层结果。
4.2 npm explain:解释某个包为什么被安装
npm explain axiosnpm 7+ 的 explain 命令是排查“这个包从哪条路径被装进来”最直观的工具。它会输出从根项目到目标包的完整依赖链,例如:
axios@1.6.8 node_modules/axios ──┬ @nestjs/common@10.3.0 └─┬ @nestjs/axios@3.0.0 └─┬ app-controller@1.0.0 └─ root project这里的重点是看路径:如果只有顶层项目直接依赖,处理起来很简单;如果它出现在某个深层嵌套依赖中,那就要顺着说明往上找到引入方。
4.3 npm audit:看已知漏洞的影响范围
npm audit npm audit --jsonnpm audit 会把本地依赖树和已知漏洞库做比对,输出漏洞级别、受影响的包、修复建议。--json 输出适合写进 CI 脚本,解析后按严重级别判断是否阻断构建。
npm audit --audit-level=high高版本 npm 还可以结合--audit-level只报告 high 及以上级别,减少噪音。
4.4 npm query:从依赖树里做高级查询
npm 8.16+/9+ 提供了npm query,用类似 CSS 选择器的语法在依赖树上做查询:
npm query '#axios' npm query '.depends("axios")'第一行按包名查找,第二行查询所有直接依赖 axios 的包。这个命令比npm ls更适合脚本化处理,但具体语法会随 npm 版本变化,使用时先执行npm query --help确认当前版本支持的能力。
5. 写一个脚本,把 package-lock.json 里的传递依赖捞出来
只靠自带命令的问题在于:当你需要同时分析几十个仓库时,手工执行 npm explain 效率太低。更实际的做法是写一个轻量 Node.js 脚本,直接解析 package-lock.json,反向收集所有受影响路径。
5.1 脚本实现
// blast-radius.js // 用法:node blast-radius.js <package-name> [lockfile-path] // 运行前提:当前目录存在 package-lock.json,且已执行过 npm install const fs = require('fs'); function loadLockfile(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function getPackageNameFromPath(path) { if (path === '') return '<root>'; const segments = path.split('/node_modules/'); return segments[segments.length - 1]; } function findParents(targetName, packages) { const parents = []; for (const [path, info] of Object.entries(packages)) { if (!info) continue; const allDeps = { ...(info.dependencies || {}), ...(info.devDependencies || {}), ...(info.optionalDependencies || {}) }; // 只要该路径的依赖中包含目标包,就视为受影响路径 if (allDeps[targetName]) { parents.push({ path, name: getPackageNameFromPath(path) }); } } return parents; } function collectBlastRadius(targetName, packages, visited = new Set()) { if (visited.has(targetName)) return []; visited.add(targetName); const parents = findParents(targetName, packages); let result = []; for (const parent of parents) { result.push(parent); // 继续向上追溯父包的上游依赖,直到根项目 const ancestors = collectBlastRadius(parent.name, packages, visited); result = result.concat(ancestors); } return result; } const target = process.argv[2]; if (!target) { console.error('用法:node blast-radius.js <package-name> [lockfile-path]'); process.exit(1); } const lockfilePath = process.argv[3] || 'package-lock.json'; const lockfile = loadLockfile(lockfilePath); const packages = lockfile.packages || {}; const hasTarget = Object.keys(packages).some( path => path === `node_modules/${target}` || path.endsWith(`/node_modules/${target}`) ); if (!hasTarget) { console.error(`lockfile 中没有找到 ${target},请确认包名并已执行 npm install。`); process.exit(1); } const affected = collectBlastRadius(target, packages); console.log(`\n=== ${target} 的 Blast Radius 分析 ===`); console.log(`受影响路径总数:${affected.length}\n`); affected.forEach((item, i) => { const label = item.path === '' ? '<根项目>' : item.path; console.log(`${String(i + 1).padStart(3)}. ${label}`); });5.2 运行方式
node blast-radius.js axios输出会列出所有依赖了 axios 的路径,并且继续向上追踪,告诉你是哪一层依赖把 axios 引入的。如果输出中出现了<根项目>,说明项目根 package.json 直接依赖了它,这时需要优先确认根项目里的使用方式。
针对单个 lockfile 也可以传路径:
node blast-radius.js axios ./dist/package-lock.json5.3 输出结果怎么读
脚本输出的每一行都是一个依赖路径。路径越深,说明目标包离顶层越远,但受影响程度并不因为深度而降低。反过来说,如果某个路径同时出现在多个一级业务模块下,说明该包被大量复用,爆炸半径更大。实际排查时,优先级应该是:先看<根项目>直接依赖,再看有多少条独立业务路径引用该包,最后看每个路径的维护者是谁。
5.4 改成批量扫描
如果公司有大量仓库,可以把脚本接受一个 lockfile 路径参数这个能力利用起来,配合 find 命令批量执行:
find . -name "package-lock.json" -not -path "*/node_modules/*" | while read file; do echo "==> $file" node blast-radius.js axios "$file" done这轮命令会把你当前工作区下所有非 node_modules 的 lockfile 都扫一遍。建议把输出重定向到文件,再做去重和统计:
find . -name "package-lock.json" -not -path "*/node_modules/*" | while read file; do echo "==> $file" node blast-radius.js axios "$file" done > blast-radius-report.txt6. 供应链攻击的典型路径
理解了爆炸半径怎么量化之后,再回头看看它通常是怎么被触发的。供应链攻击并不是某一种单一手法,下面这些路径都曾真实发生在 npm 生态里,也是做风险分析时需要重点盯防的方向。
6.1 维护者账号劫持
npm 包发布依赖账号权限。一旦维护者账号被钓鱼、密码复用导致泄露,攻击者可以直接向核心包推送恶意版本。由于 npm 生态高度信任已发布的包,恶意版本会迅速通过传递依赖扩散。这也是 npm 官方一直推动 2FA 全量开启的原因。
6.2 恶意版本发布会
攻击者不一定要长期控制账号,只要在某一个版本里混入恶意代码,就能乘着用户 update 的顺风车进入生产环境。比如在 install 脚本里执行挖矿程序、窃取环境变量、加密密钥,或者在 postinstall 阶段读取.npmrc认证信息。
6.3 依赖混淆(Dependency Confusion)
企业内部包名如果和公共 npm 仓库里的包名冲突,攻击者可以先把同名恶意包发布到公共仓库,而企业内部配置又同时使用了公共源和私有源,安装时优先级如果被错误配置,就可能把公共仓库的恶意包当成内部包安装进来。
6.4 域名劫持与重名包(Typosquatting)
把常见包名故意改一个字母,发布到 npm,等待开发者手误安装。这类攻击利用的是人类的惯性,而不是代码漏洞。类似手法还包括伪造官方组织的 scope 前缀,比如@babel/core和某个仿冒 scope。
6.5 install 脚本注入
npm 包可以在 install 时执行任意脚本。这是供应链攻击里最省事的路径。攻击者只需要在 package.json 的 scripts 中加入preinstall、postinstall,就能在包安装到目标机器时立即执行命令。这也是为什么现在很多安全策略会要求关闭自动执行脚本或对 install 脚本做审计。
6.6 CI/CD 管道投毒
攻击面不只在开发者的电脑。如果 CI 里运行了被污染的依赖,恶意代码可以读取流水线密钥、篡改构建产物、甚至向内部制品库推送伪造包。很多团队只关注测试用例能不能过,没有关注依赖锁定和审计步骤,这块很容易变成盲区。
6.7 构建产物污染
某些包会在构建后把压缩文件、二进制文件直接提交到 npm 包中。由于源码仓库和发布产物分离,安全审计看到的源码可能干净,但实际下载的包已经包含恶意代码。这也是为什么只读 GitHub 仓库审计还不够,要直接审计 registry 上发布的 tarball。
7. 如何把 NPM 包的影响半径压到最小
量化爆炸半径只是第一步,更重要的是从工程层面把半径本身压到最小。
7.1 把 lockfile 提交进仓库
package-lock.json不只是给人看的,它应该作为依赖分析的唯一事实来源进入版本管理。CI 环境安装依赖时统一使用:
npm cinpm ci会严格按照 lockfile 安装,不会因为改动 package.json 中的范围而引入新版本。它同时会先删除 node_modules,保证每次安装环境一致。
7.2 用 overrides 强制覆盖被污染版本
当一个传递依赖被污染,但上层包没有及时发版时,可以在根 package.json 用 overrides 强制指定安全版本:
{ "name": "my-app", "version": "1.0.0", "overrides": { "axios": "1.6.8", "lodash": "4.17.21" } }overrides 会覆盖依赖树中所有 axios 和 lodash 的版本,是安全应急时最直接的止血手段。但要注意:overrides 只能覆盖版本,无法消除已安装节点的历史影响,解决后要尽快推动上游升级。
7.3 用签名和 audit 做发布前检查
npm audit signatures这条命令会检查已安装包是否与 registry 的注册签名匹配,适合在 CI 完成后、打包之前执行。发布前再结合:
npm audit --audit-level=high --audit-type=production只检查生产依赖,减少开发依赖带来的噪音,同时保证上线链路不引入已知高危漏洞。
7.4 换用 pnpm,减少幽灵依赖
npm 的 hoisting 结构会生成大量不在 package.json 声明却可以访问的“幽灵依赖”。一旦某个被提升的包被污染,影响面会被放大。pnpm 使用符号链接和隔离式 node_modules 结构,默认只暴露显式声明的依赖,能在结构上大幅收缩爆炸半径。
pnpm install pnpm auditpnpm 与 npm 的 lockfile 不兼容,切换前需要重新生成 pnpm-lock.yaml,并把 CI 里的安装命令同步修改。如果团队不想切换包管理器,至少要在 npm 的 strict 模式下开启严格依赖检查。
7.5 私有仓库与镜像同步
公司内部建议建立私有 npm 仓库,把公共依赖与内部依赖分开管理。公共源同步到私有仓库时,只同步白名单内的包和版本;内部发布的包统一使用自己组织的 scope,比如@company/xxx。这样即使公共仓库出现大规模投毒,也不会直接冲击公司全部项目。
7.6 最小依赖原则
每增加一个依赖,就多一个被攻陷的可能。给团队定一条规则:引入新依赖之前,先回答“这个包解决了什么核心问题、有没有替代方案、维护活跃度如何”。对于只有一个很简单的函数,与其引入一个几十 MB 的依赖,不如自己实现。依赖数量少了,Blast Radius 自然就小了。
8. npm 供应链排查常见问题清单
实际执行命令时,很多开发者会被 npm 本身的环境问题卡住。下面结合常见场景给出排查表。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| “npm 不是内部或外部命令” | Node.js 未安装或 PATH 未配置 | 执行node -v、npm -v | 重新安装 Node.js LTS,确认 PATH 包含 Node 安装目录 |
| PowerShell 报“禁止运行脚本” | 执行策略限制 npm.ps1 | 执行Get-ExecutionPolicy | 以管理员或当前用户执行Set-ExecutionPolicy -Scope CurrentUser RemoteSigned |
| npm ERR! code EBADENGINE | Node 版本与依赖 engines 不匹配 | 查看node -v与报错中要求的版本 | 切换到要求的 Node 版本,例如用 nvm 管理多版本 |
| collecting package metadata failed | 镜像源不可用或网络异常 | 执行npm config get registry | 检查镜像地址,必要时切换公共源或公司私有源 |
| npm warn deprecated xxx | 依赖已被维护者标记为暂停维护 | 执行npm ls xxx查看引用路径 | 更换替代包或升级到仍然维护的版本 |
| npm audit 提示 vulnerabilities | 依赖版本存在已知漏洞 | 执行npm audit --json查看详情 | 按建议升级,无法立即升级时用 overrides 临时固定安全版本 |
| “1 package is looking for funding” | npm 提示包作者请求赞助 | 不是错误,无需处理 | 如需关闭输出,可设置npm config set fund false |
| lockfile 版本冲突 | 不同机器用不同 npm 版本生成 lockfile | 查看 lockfile 头部的 lockfileVersion | 统一 npm 版本,建议使用 npm 9+,并提交 lockfile 到仓库 |
这些问题的共性是:尽量保证本机和 CI 的 npm、Node 版本一致,否则同样的命令在不同机器上解析依赖树的结果可能出现差异,进而影响 Blast Radius 分析的准确性。
9. 团队落地建议:把 Blast Radius 检查做成常态化
Blast Radius 分析不能只靠安全事故发生后的应急操作,应该沉淀成团队日常开发流程的一部分。
第一,把package-lock.json和pnpm-lock.yaml列入必须提交的文件。Code Review 时发现 lockfile 缺失,直接打回。对于任何 merge request,检查 diff 里是否增加了敏感依赖,比如模型下载脚本、install 阶段执行命令、奇怪的二进制资源。
第二,在 CI 中加入依赖审计门槛。最简单的做法是:
# GitHub Actions 示例,其他 CI 平台思路一致 - name: npm audit run: npm audit --audit-level=high --audit-type=production当审计出现 high 或 critical 时让流水线失败,阻断合并。如果团队需要更多时间修复,可以把策略从“失败阻断”改成“失败但手动跳过”,但每次跳过都要记录原因和负责人。
第三,建立一份依赖白名单和黑名单。白名单里列出关键业务依赖、维护者信息、开源许可证类型;黑名单里记录已确认有问题的包名和版本,并在 CI 中做一次文本级检查,防止黑名单包被重新引入。
第四,重大安全事件发生时,不再手工逐仓排查,而是用批量脚本扫全部仓库。建议把前面提到的 blast-radius.js 脚本收进团队工具目录,配合仓库扫描工具一起使用,输出统一格式的报告,按受影响仓库数量排序,优先处理影响面最大的几个。
第五,发布内部 npm 包时,强制开启 2FA,并使用npm publish --provenance生成来源证明。从源头减少账号劫持导致内部包被投毒的可能性。
10. 总结
Blast Radius 的核心价值,是把“这个包被攻陷了,我是否受影响”这种模糊的恐慌,转变成一份明确的依赖路径清单。你需要做的最重要三件事:第一,确认 lockfile 已经提交并在 CI 中使用npm ci固定安装;第二,在npm audit的帮助下把已知漏洞控制在低风险范围;第三,把本文的脚本保存好,把它接入团队应急工具链,一旦收到安全公告,第一时间扫描全部仓库,输出受影响清单。
最容易踩的坑有两个:一是只检查直接依赖,忽略嵌套中的传递依赖;二是只修 lockfile 而不更新 CI 的安装逻辑,导致下次构建又拉回问题版本。记住,npm 依赖树不是线性的,你的攻击面就是你所有依赖的依赖之和。
后续想继续深入,可以关注三个方向:一是把 Blast Radius 分析接入 SBOM(软件物料清单)生成流程,每次发布都附带一份依赖清单;二是用基于 lockfile 的运行时分析工具替代扫目录的方式,提高检测准确率;三是给团队搭建内部依赖评审面板,把新增依赖、安全审计、版本升级记录统一纳管。NPM 供应链安全不是一次性的整改,而是一套持续循环的工程习惯,越早建立,后续事件发生时的损失就越可控。