尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

npm包被攻破后的爆炸半径:依赖链分析与防护实践

npm包被攻破后的爆炸半径:依赖链分析与防护实践 当一个 npm 包被攻破时最先要回答的问题不是“这个包有没有漏洞”而是“这个包一旦出现问题会影响多少人、多少个项目、多少条依赖链”。这个受影响范围就是依赖层面的 blast radius爆炸半径。在 npm 生态里几乎所有项目都不会只依赖几个顶层包。一个常见的工程会同时安装几十个直接依赖再通过它们隐式引入几百个传递依赖。如果其中一个底层工具包被恶意发布新版本所有依赖它的父包以及这些父包所在的项目都会被波及。更麻烦的是很多开发者只关心自己写的那层代码很少去看 lock 文件里的包来源、版本解析和依赖链导致即使依赖被替换成恶意版本也很难第一时间发现。这篇文章围绕“Blast Radius”这个主题讨论 npm 包被攻破之后的真实影响范围。会先解释 npm 依赖树里的暴露方向再给出用 npm 自带命令分析依赖链的方法然后提供一个可以输出受影响路径的最小脚本最后补充攻击路径、排查流程和日常防御清单。内容以工程排查为主适用于前端、Node.js 服务端、工具链维护者和负责 CI/CD 的开发者。1. 先理解 npm 依赖中的 blast radius1.1 直接依赖、间接依赖和 devDependencies 的暴露差异从 package.json 看依赖项目通常只看到一层{ name: my-app, version: 1.0.0, dependencies: { dep-a: ^1.2.0 } }但安装完成后实际依赖树可能长这样my-app └── dep-a1.2.3 └── dep-common0.9.1如果dep-common被攻破受影响的不只是dep-a还包括所有直接或间接依赖dep-common的包。在 npm 的解析结构里一个底层包被提升到顶层node_modules后多个父包可能引用同一份安装副本当攻击者替换了这个包这些父包全部暴露在同一份恶意代码下。devDependencies 也很容易被人低估。很多项目把构建工具、代码生成器、测试框架放在 devDependencies 里认为“生产环境不会安装”就安全。实际上开发机、CI 流水线、发布前构建阶段都会执行这些依赖的安装脚本。一旦一个构建工具被攻破攻击者可以在构建阶段读取环境变量、上传制品、篡改打包结果影响范围甚至比运行时依赖更大。下面表格列出了不同依赖类型在供应链事件中的暴露特征依赖类型安装时机典型影响风险重点dependencies生产环境安装运行时行为异常、数据泄露服务是否依赖该包、是否暴露敏感数据devDependencies开发、测试、构建环境构建产物被篡改、密钥被窃取CI 还是本地开发、是否有网络外传能力peerDependencies宿主环境安装版本冲突、api 不匹配与主依赖版本不兼容导致行为异常optionalDependencies平台相关安装安装失败被忽略错误日志被吞掉不安全版本被误用1.2 让爆炸半径成立的三个前提一个包被攻破后爆炸半径并不是“所有安装了它的项目”这个简单集合它依赖三个条件第一这个包确实被安装到了目标环境。如果项目使用npm ci安装结果由 package-lock.json 决定如果使用npm install则会按照 package.json 的 semver 范围重新解析可能安装到被污染的新版本。第二这个包的代码最终会被执行。npm 包不一定会在安装后立即运行很多库只有被require或import时才执行。但如果包带有 preinstall、install、postinstall 脚本那么在安装阶段就已经开始执行代码了。第三执行环境中存在攻击者可以利用的资源。例如process.env里的 NPM_TOKEN、云厂商密钥、数据库连接字符串、用户上传文件或者生产服务器的网络访问权限。没有这些敏感资源攻击面会小很多但仍可能造成拒绝服务或包行为异常。判断爆炸半径时不能只看依赖数量还要看这些包在哪个环节执行、拿到什么权限。这也是为什么后面要区分运行链路和构建链路。1.3 package-lock.json 记录了什么package-lock.json 是评估爆炸半径最重要的文件。它不只是“锁版本”还记录了每个安装路径对应的版本号、下载地址和完整性校验值。一个典型的 package-lock v3 条目如下{ name: my-app, version: 1.0.0, lockfileVersion: 3, packages: { node_modules/dep-common: { version: 0.9.1, resolved: https://registry.npmjs.org/dep-common/-/dep-common-0.9.1.tgz, integrity: sha512-xxxxxx } } }其中integrity字段是包内容的哈希校验值。正常情况下同一个版本号的包应有可预期的哈希如果 package-lock.json 发生变更或者完整性校验值异常说明供应商已经被替换。可以把它理解成一个可以审计的“依赖物料清单”。因此排查某个包是否被攻破时第一步就是看 package-lock.json 的 diff而不是只看 package.json 里是否写了这个依赖。2. 用 npm 自带命令把依赖链“拉出来”2.1 npm ls定位目标包出现在哪里在项目根目录执行下面的命令可以列出目标包在依赖树中的位置npm ls package-name例如npm ls lodash输出会显示一条只包含lodash的依赖分支my-app1.0.0 /home/user/my-app └─┬ dep-a1.2.3 └── lodash4.17.21如果想看所有层级可以加上--allnpm ls --all lodash--all会把隐藏的传递依赖也展示出来。对于 scoped 包需要写完整名字npm ls sentry/node需要注意npm ls只能从“根项目往目标包”的方向展示路径也就是告诉你“为了安装这个包经历了哪些父包”。如果要看某个包反过来影响了谁需要结合npm explain。2.2 npm explain解释依赖为什么会存在npm explain是 npm 7 之后提供的命令用来解释某个包为什么会被安装。用法npm explain lodash输出会显示包在 node_modules 中的位置以及哪个依赖引入了它。和npm ls相比它更适合排查“这个包不是我写的为什么会出现在项目里”这类问题。在分析 blast radius 时建议把两个命令配合使用npm ls package-name npm explain package-name前者看路径后者看原因。如果一个包是某个已停止维护的旧包带入的那么即使你没有直接引用它它依然会出现在安装产物中并且可能成为攻击入口。2.3 npm audit用已知漏洞库评估风险npm 自带的 audit 会读取 lock 文件把已安装版本和公开漏洞库做比对npm audit只检查生产依赖npm audit --omitdev输出为 JSON方便接入脚本或日志平台npm audit --json audit-result.jsonaudit 的结果里通常会包含漏洞等级、影响包名、修复版本以及引入链。这些信息非常适合用来评估“当前已安装的依赖中有哪些已知风险”。但要注意audit 依赖的是已经公开的漏洞数据。对于一个刚发布几小时、尚未被安全厂商收录的恶意版本audit 不一定能识别出来。所以audit 是评估 blast radius 的起点不是终点。它只能回答“已知漏洞里哪些依赖受到了影响”不能回答“如果某个底层代码被投毒哪些路径会遭殃”。3. 写一个最小脚本计算受影响路径上面的 npm 命令适合单条链排查。当项目依赖数量很大或者一个底层包被十几个包依赖时手动看输出比较低效。可以写一个简单的 Node 脚本把npm ls --json的输出解析成可读路径。3.1 数据来源npm ls 的 JSON 输出先导出依赖树的 JSONnpm ls --json --all lodash tree.json生成的tree.json结构包含了根项目和所有依赖的关系。可以简单用 Node 把它读进来再遍历所有dependencies节点。3.2 遍历依赖树并输出完整链路创建blast-radius.jsconst fs require(fs); function loadTree(file) { return JSON.parse(fs.readFileSync(file, utf8)); } function matches(node, target) { const name node.name || ; const path node.path || ; return name target || path node_modules/${target} || path.endsWith(/node_modules/${target}); } function packageLabel(node) { if (node.name) return node.name; const parts (node.path || ).split(/node_modules/); return parts[parts.length - 1] || node.path || root; } function collect(node, ancestors, target, result) { const label packageLabel(node); const chain [...ancestors, label]; if (matches(node, target)) { result.push(chain); } const deps node.dependencies || {}; for (const key of Object.keys(deps)) { collect(deps[key], chain, target, result); } } function main() { const [, , file, target] process.argv; if (!file || !target) { console.error(用法: node blast-radius.js tree.json package-name); process.exit(1); } const tree loadTree(file); const result []; collect(tree, [], target, result); console.log(Blast radius for ${target}:); if (result.length 0) { console.log( 未在依赖树中找到该包); return; } result.forEach((chain, index) { console.log( ${index 1}. ${chain.join( - )}); }); } main();这个脚本的重点是collect函数。它从根项目开始带着当前路径往下走一旦遇到目标包就把从根到目标包的整条链路记录到结果里。也就是说它输出的不是“有多少包依赖目标包”的数字而是一批完整依赖链。3.3 运行方式和结果解读先导出依赖树npm ls --json --all lodash tree.json再运行脚本node blast-radius.js tree.json lodash可能的输出Blast radius for lodash: 1. my-app - dep-a - lodash 2. my-app - dep-b - dep-c - lodash 3. my-app - lodash输出第 3 条说明项目直接依赖了lodash第 1、2 条说明它是通过其他包传递引入的。如果目标包是新发布的恶意版本那么这些链上的每一个包都是潜在暴露点需要评估它们所在的代码路径。这个脚本只是为了帮助理解 blast radius 的形状不是生产级工具。真实项目里还需要处理 dedupe、嵌套 node_modules、peerDependencies 和可选依赖建议以 npm 输出的实际结构为准并在自己的业务目录里做调整。4. 从已知漏洞到未知投毒攻击路径与防御4.1 一个包被攻破的常见方式npm 包被攻破不一定要“破解源码”。从已经公开的供应链事件来看常见的方式包括维护者账号被盗攻击者拿到 npm 账号权限后直接发布一个“新版本”。安装脚本投毒通过 preinstall 或 install 脚本在安装阶段执行恶意命令。仿冒包名创建一个和知名包名字很像的包诱导开发者装错。依赖混淆在公共 registry 上发布和内部私有包同名的包让解析规则不严格的项目拉到公共版本。废弃包复活长期无人维护的包被攻击者接管维护权然后在老版本上发布恶意提交。这些方式有一个共同点它们不一定通过 CVE 触发所以漏洞扫描器不一定能发现。4.2 为什么 npm audit 不够npm audit 基于公开漏洞库覆盖的是“已知问题”。而 supply chain attack 的核心风险恰好在于未知性。一个包今天是安全的明天维护者账号可能被攻破一个包今天没有已知漏洞但新发布的版本里可能已经包含恶意代码。因此判断 blast radius 至少需要两份数据一份是漏洞库数据来自npm audit回答“已知风险有哪些”。另一份是依赖关系数据来自 package-lock.json 和依赖树回答“如果某个底层包出现问题会有哪些路径被影响”。只有把两份数据叠加起来才是一个更接近真实风险的视角。4.3 缩小依赖爆炸半径的工程手段减少影响范围可以从依赖数量和依赖解析规则两个方向入手。第一减少依赖数量。不是每个小工具都值得以 npm 包形式引入。一个只在两个文件里用到的工具函数复制到项目内部可能比引入一个长期不更新的第三方包更安全。依赖越少潜在攻击面越小。第二固定版本。可以把 npm 的默认行为改成保存精确版本npm config set save-exact true这样npm install pkg会写入精确版本而不是^范围。锁文件仍然重要但精确版本能减少“下次 install 时解析到不同小版本”的波动。第三对传递依赖使用 overrides 强制版本。当确定某个传递依赖需要固定到安全版本时可以在 package.json 里增加{ overrides: { lodash: 4.17.21 } }overrides会强制统一依赖树里lodash的版本。它适合在已经确认安全版本时使用但不能代替依赖审查。第四CI 里使用npm ci而不是npm install。npm cinpm ci会严格按照 lock 文件安装不会重新解析 semver 范围也不会擅自修改 lock 文件。这样能保证构建环境和本地环境使用同一份依赖清单。5. 发现可疑依赖后的排查与回滚流程5.1 确认引入渠道发现可疑依赖后先不要急着删 node_modules。第一步是确认它到底从哪来。执行npm ls package-name npm explain package-name再检查 package-lock.json 中是否存在这个包grep -n node_modules/package-name package-lock.json如果npm ls显示包存在但 package.json 里没有直接引用说明它是某个父依赖带入的。这时要重点检查引入它的父包版本是否正常。5.2 判断真实风险面使用下面的问题清单快速评估检查维度要确认的内容安装范围是 dependencies 还是 devDependencies执行时机是否在安装脚本、构建脚本、运行时被加载运行权限是否可读环境变量、文件系统、网络版本范围当前安装的是否正好是受影响版本锁文件变化package-lock.json 是否在最近一次变更中被改过依赖来源resolved 地址是否来自预期 registry变更历史这个包最近的发布记录是否异常如果一个包安装在 devDependencies 里但在构建阶段执行并且能访问 CI 密钥那么它的风险级别不亚于生产依赖。5.3 处理、验证和回滚处理过程应该是可回退的。先看 npm audit 提供的修复预览npm audit fix --dry-run确认修复不会破坏项目后再执行npm audit fix如果问题包没有被 audit 收录需要手动固定安全版本npm install package-namesafe-version --save-exact如果恶意包是由传递依赖引入的可以使用overrides强制替换版本然后重新生成 lock 文件rm -rf node_modules npm install部署前再执行一次干净安装npm ci这里有一个常见误区很多人直接删掉 node_modules 再npm install但如果没有修改 package-lock.json安装结果可能还是旧的、有问题的组合。正确做法是先审查并修改 lock 文件再执行安装。5.4 常见 npm 环境问题顺带排查排查依赖时经常还会遇到 npm 本身的环境问题。下面几种比较常见。问题现象常见原因处理建议npm 不是内部或外部命令Node.js 安装后没有加入 PATH重新运行 Node 安装包或手动把 Node 目录加入系统环境变量PowerShell 下提示无法加载文件 npm.ps1PowerShell 脚本执行策略限制以当前用户身份执行Set-ExecutionPolicy -ExecutionPolicy RemoteSignednpm ERR! code EBADENGINE当前 Node 版本不满足包 engines 要求查看package.json里的 engines使用 nvm 切换到兼容 Node 版本npm install后 lock 文件大量改动Node/npm 版本升级导致依赖解析变化在 CI 和本地使用相同 Node 版本保留 lock 文件变更记录这些环境问题虽然不直接等于供应链风险但会影响排查效率。尤其是 CI 和本地 Node 版本不一致时npm ci可能安装出完全不同的依赖树导致安全问题难以复现。6. 把 blast radius 纳入日常开发规范6.1 每次依赖变更前检查清单依赖变更前建议执行下面的流程先看变更范围git diff package.json package-lock.json再查新依赖是否是必要依赖是不是有更成熟、维护更频繁的替代方案检查 target 包的作者、仓库地址、最近发布时间使用npm audit --json检查已知漏洞用npm ls package-name查看它会进入哪条依赖链本地执行测试确认安装后项目行为没有发生变化不要把“能装、能跑”作为唯一判断标准。一个包能跑通功能不代表它在安全层面适合长期留在依赖树里。6.2 发布前与 CI 安全检查发布和 CI 阶段可以加入更严格的安全检查npm ci npm audit --audit-levelhigh npm audit signatures这里要说明npm audit signatures是否可用取决于 npm 版本执行前先确认当前版本支持情况。它的作用是校验 lock 文件中的包与 registry 签名是否匹配适合作为发布前的一道检查。如果项目同时被多个团队维护建议在 CI 中对 package-lock.json 做“变更即审查”的机制。例如不允许未审查的依赖更新直接合入主分支至少要有npm audit --omitdev和测试步骤。6.3 下一步可以扩展的方向blast radius 不只是事故后的排查概念它还可以延伸到更系统的工程能力。比较直接的下一步是生成项目级 SBOM软件物料清单通过npm query或专门的工具导出依赖清单再结合漏洞数据做自动化比对。另一个方向是检查私有 registry 的依赖解析规则避免内部包名冲突。还可以把“依赖更新”从手动操作改成定时任务配合自动化测试在合并前验证新版本行为。真正应该养成的习惯不是等到包被攻破后才展开依赖图而是在每次依赖变更时就把 package-lock.json 的 diff 当作安全审计材料。一个被认真审查过的依赖清单比任何事后扫描工具都更能控制爆炸半径。
返回列表