如何防止npm供应链攻击入侵Node服务器
了解恶意补丁版本、postinstall脚本和拼写仿冒包如何进入VPS,并掌握停止风险的部署实践:锁定依赖版本,提交package-lock.json。
您的服务器如何遭遇 npm 供应链攻击
npm 供应链攻击会通过您选择安装的软件包进入服务器。攻击不涉及开放端口,也不需要执行漏洞利用步骤。npm(Node 包管理器)会安装代码,而安装代码就会运行代码。因此,一个小型 Node 应用也可能拉取数百个您从未查看过的软件包,其中任何一个都可能在一小时后发布新版本。
您的部署会获取恶意版本,因为安装命令要求安装最新的匹配版本。该代码随后会以执行安装命令的用户权限运行。下面的所有内容都源于这两点。
以下各类攻击按照其影响一个人在一台 VPS 上部署一个 Node 应用的常见程度排序。大型企业采用的顺序可能不同,因为大型企业有内部 registry、审核团队,以及公共 registry 的镜像。您只有一个部署脚本。
方式 1:维护者账户遭到入侵并发布补丁
npm registry 不允许任何人修改已经存在的版本内容。因此,攻击者即使通过钓鱼获取维护者账户,或窃取发布令牌,也无法改写 4.18.2。但攻击者可以发布 4.18.3。
查看您的 package.json。类似 "express": "^4.18.2" 的行并不表示版本 4.18.2。插入符号表示“该版本及以上的任意 4.x 版本”,而 ~4.18.2 表示“任意 4.18.x 版本”。npm install 会在运行时解析这个范围。因此,同一个 git commit 在同一天下午部署两次,也可能安装两组不同的代码。这段时间差就是攻击面。即使您的机器没有遭到入侵,也可能因此暴露风险。
恶意版本通常会被报告并撤下,但撤下发生在用户安装之后。在这段时间内执行部署的主机,磁盘上已经保存了恶意代码。每次运行都解析版本范围的流水线,会自动进入这段时间窗口;每周可能发生多次,而且没有人需要主动做出决定。
形态 2:安装脚本以执行部署的用户身份运行
软件包的 package.json 可以在其 scripts 代码块中声明 preinstall、install、postinstall 和 prepare。npm 会在安装期间运行这些脚本。它们不在沙箱中运行,也不会经过人工审查。它们是由输入安装命令的用户执行的 shell 命令,运行在该用户的主目录中,使用该用户的网络访问权限和该 shell 的完整环境。
因此,有用的问题不是软件包能做什么,而是该用户能读取什么。在普通的部署服务器上,答案包括保存 registry token 的 ~/.npmrc、用作 SSH(secure shell)部署密钥的 ~/.ssh/id_ed25519、~/.aws/credentials、~/.docker/config.json,以及 shell 中导出的所有变量;DATABASE_URL 通常就存放在这些变量中。
这样的 payload 不需要持久化,也不需要权限提升。它读取几个文件,通过 HTTPS 将文件发送到某台主机,然后以状态码 0 退出。您看不到任何内容,因为 npm 默认会隐藏安装脚本的输出。关闭此行为,查看实际运行的内容:
npm ci --foreground-scriptsforeground-scripts 会与 npm 进程共享标准输入、标准输出和标准错误,因此构建脚本会将输出打印到您的终端,而不是写入 npm 在安装成功后丢弃的缓冲区。
形式 3:typosquat,以及您没有完全输入正确的名称
typosquat 是指使用接近热门名称的名称发布软件包,等待用户在安装命令中输入错误或粘贴错误。其作用机制在于命令,而不是代码,因此 lockfile 无法在这里提供帮助:您只需错误地添加一次名称,之后 lockfile 就会如实固定这个错误的软件包。
会影响团队而不只是个人的变体是依赖混淆。您的内部软件包名为 billing-utils,存放在私有 registry 中。如果公共 registry 中不存在名为 billing-utils 的软件包,任何人都可以发布一个同名软件包。npm 会将未限定作用域的名称解析到默认公共 registry,因此公共副本可能会被优先使用。解决方法是使用您拥有的作用域,并为该作用域配置 registry 映射,配置位置为 .npmrc:
@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}现在,@yourorg/billing-utils 只会从该主机获取,因为 npm 会在默认 registry 之前查询作用域到 registry 的映射。未限定作用域的内部名称没有映射,因此不受保护。
添加任何新依赖项之前,应查看软件包本身,而不是只看下载量徽章:
npm view some-lib repository.url maintainers time.created time.modified上个月才创建、且发布者无法与任何公开代码仓库关联的软件包,与拥有六年历史的软件包具有不同的风险。这两点都不能单独证明存在问题,但检查它们的成本都很低。
形态 4:依赖项的维护者已悄然变更
维护者会将软件包交给其他人维护。有人精疲力竭,有陌生人提出帮助,发布权限发生转移,但依赖该软件包的项目不会收到任何通知。系统没有遭到入侵。您在 2021 年建立的信任,现在由另一个人持有。
这是最缓慢、也最难检测的形态,没有任何命令可以直接回答这个问题。两个步骤可以缩小范围。在采用某个软件包前,使用上面的 npm view 行检查谁拥有发布权限。然后,当您实际依赖的软件包发生变更时,阅读差异:
npm diff --diff-name-only --diff=some-lib@1.4.2 --diff=some-lib@1.4.3
npm diff --diff=some-lib@1.4.2 --diff=some-lib@1.4.3第一种形式只输出发生变更的文件名,因此每次升级您关注的软件包时都可以快速执行。补丁版本如果修改了构建脚本、在软件包根目录添加文件,或编辑了 scripts 块,就值得在部署到服务器前完整阅读。
使用已提交的锁定文件通过 npm ci 构建
package-lock.json记录依赖树中每个软件包的确切版本、每个软件包的来源 URL、每个 tarball 的 sha512 完整性哈希,以及依赖它的软件包。请提交该文件。只有它能说明你实际测试过的内容。
然后使用 npm ci 安装。在任何不是开发者笔记本的机器上,都不要使用 npm install:
npm ci --omit=dev --ignore-scriptsnpm ci 与 npm install 的行为差异在这里都很重要。它要求锁定文件必须存在。它会在开始前删除现有的 node_modules,因此之前部署留下的文件不会残留到本次部署中。它不会写入 package.json 或锁定文件,因此安装过程不会悄悄将版本升级。如果锁定文件与 package.json 不一致,它会直接报错退出,而不是自行解决差异。
这个错误是设计目的,不是麻烦。它意味着依赖变更必须作为经过他人审查的提交引入,而不能成为凌晨 02:00 部署时产生的副作用。
每次获取文件时都会检查完整性哈希。如果 tarball 的字节内容与记录的哈希不匹配,安装会因 code EINTEGRITY 失败,而不会解压该文件。需要准确理解它提供的保障:它证明你收到的文件就是锁定文件固定的文件。这与使用校验和验证下载内容提供的是同一种保障,局限性也相同。它无法说明发布时固定版本是否已包含恶意代码。
关于 --omit=dev,还需注意一点:这些软件包仍会被解析,也仍会写入锁定文件,只是不会放置到磁盘上。磁盘上的软件包更少,意味着安装脚本更少,运行时加载的代码也更少,因此值得这样做。但它不会从依赖树中移除依赖。
将安装脚本视为代码,并明确知道何时拒绝执行
您可以关闭安装脚本。在项目的 .npmrc 中加入以下内容,并将其与锁文件一起提交:
ignore-scripts=true
save-exact=trueignore-scripts=true 会阻止 npm 运行依赖项中声明的脚本。save-exact=true 会让 npm install some-lib 将 1.4.2 写入 package.json,而不是写入 ^1.4.2,这样解析后的范围就不会意外进入清单。
关闭脚本会导致某些功能失效。启用前,您应了解具体影响。编译原生 addon 或下载预构建二进制文件的软件包,会在安装脚本中执行这些操作。关闭脚本后,安装本身仍会成功,但稍后运行时会失败,表现为模块无法加载其绑定文件。解决方法是使用允许列表:
npm ci --ignore-scripts
npm rebuild better-sqlite3npm rebuild <package> 会运行该软件包的构建脚本。这样,您可以逐个软件包作出决定,而不是向几百个素未谋面的第三方软件包授予不加区分的执行权限。
要了解当前授予的范围有多大,请让 npm 检查:
npm query ":attr(scripts, [postinstall])"该命令会输出已安装依赖树中包含 postinstall 脚本的所有软件包。对于典型应用,这个列表通常比预期短,这正是允许列表具有实用性的原因。
将构建与提供流量的进程分离
部署用户需要写入 node_modules。处理 HTTP 请求的进程不需要写入。如果它们使用同一个账户,那么安装期间运行的代码可以改写为用户提供服务的代码,运行时的代码也可以改写它。
将两者分开。使用一个用户构建,使用另一个用户提供服务,并让提供服务的账户只能读取服务目录:
sudo useradd --system --home-dir /srv/nodeapp --shell /usr/sbin/nologin nodeapp
sudo chown -R deploy:nodeapp /srv/nodeapp
sudo chmod -R o-rwx /srv/nodeapp然后让 systemd 强制执行这些限制。写入 /etc/systemd/system/nodeapp.service:
[Unit]
Description=Node application
After=network-online.target
[Service]
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/nodeapp/current
EnvironmentFile=/etc/nodeapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/nodeapp/shared
NoExecPaths=/srv/nodeapp/shared
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.targetProtectSystem=strict 会将此服务使用的整个文件系统挂载为只读,但 /dev、/proc、/sys 以及你在 ReadWritePaths 中列出的路径除外。因此,应用程序尝试写入 node_modules 时会因 EROFS: read-only file system 而失败。你可以在自己的日志中重现这一点,耗时大约一分钟。NoExecPaths 负责可写的上传目录:服务可以在那里写入文件,但内核会拒绝执行这些文件。此选项需要 systemd 249 或更高版本,而 Ubuntu 24.04 随附 255。
这个单元文件有两个容易出错的地方。第一,不要添加 MemoryDenyWriteExecute=yes。它出现在大多数 systemd 加固配置列表中,但会阻止 Node 启动,因为 V8 会在运行时将 JavaScript 编译为机器代码,需要同时可写和可执行的内存页。第二,从 command -v node 获取 ExecStart 路径。如果 Node 是通过版本管理器安装的,它会位于部署用户的主目录下;此时 ProtectHome=yes 会将该目录对服务隐藏,单元会立即因 status=203/EXEC 失败,并在日志中显示找不到可执行文件。
不要只相信文件内容,应检查实际结果:
sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probesystemd-analyze security 会列出每项加固设置及其暴露程度,因此你可以看到哪些设置仍为默认值。touch 应因 Permission denied 失败,因为 nodeapp 不拥有 current 下的任何内容。如果它成功,说明文件所有权配置错误,而 systemd 设置正在悄悄掩盖这个问题。
关于 EnvironmentFile,需要注意一点:systemd 会在降权为 User=nodeapp 之前以 root 身份读取它,因此该文件可以使用 root:root 和权限模式 600。应用程序仍会接收到这些变量。任何拥有 nodeapp shell 的人仍可从 /proc/<pid>/environ 读取它们,因此这只能保护静态存储的机密,不能保护运行中的进程。
将部署凭据排除在构建环境之外
安装脚本会继承环境变量。这一点应决定构建位置。
最安全的做法是在生产服务器之外的其他位置构建,然后将完成的目录复制过去。这样,构建机只保存一个只读的 registry token,不保存其他凭据。不保存 SSH deploy key、cloud access key、数据库密码或 container registry 登录凭据。
npm token create --read-only只读 token 可以获取软件包,但不能发布软件包。如果该 token 从构建环境中被窃取,攻击者只能下载公开软件包。
如果必须在服务器上构建,请使用 deploy 用户,并为其设置经过严格限制的环境。同时将运行时机密保存在 /etc/nodeapp/env 中,使 deploy 无法读取。相同原则也适用于自行托管的构建自动化:自托管的 GitHub Actions runner 会保存 token,并在每个作业中执行任意已发布的代码,因此在小型部署中,它是价值最高的机器。任何由您未编写、且能接收完整环境的程序都属于同一类别。因此,将机密排除在 AI agent 的环境之外,本质上也是同一个问题,只是中间运行的是另一个程序。
固定或纳入版本控制,处理无法审计的依赖
固定版本的依赖,只有提交变更后才能更改版本。整个依赖树已经通过提交 lockfile 实现了这一点。但以下两种情况还需要额外处理。
第一种情况是传递依赖。您无法控制自己的依赖依赖了哪些软件包。overrides 在 package.json 中会强制整个依赖树中的对应依赖使用指定版本:
{
"overrides": {
"some-transitive-lib": "1.4.2"
}
}添加后运行一次 npm install,使 lockfile 记录结果,然后提交这两个文件。
第二种情况是您无法审计、也无法移除的软件包。请将其纳入项目版本控制。npm pack 会下载软件包注册表将提供的确切 tarball,而 file: 依赖则从您的副本安装:
npm pack some-lib@1.4.2
mkdir -p vendor && mv some-lib-1.4.2.tgz vendor/{
"dependencies": {
"some-lib": "file:vendor/some-lib-1.4.2.tgz"
}
}现在,tarball 保存在您的代码仓库中,不会在您不知情的情况下发生变化。但您也需要永久负责更新它。因此,这种方式适用于您不得不继续使用的小型废弃软件包,不适用于 Web 框架。
此外,还可以使用一个不产生额外成本的冷却期:
npm install --before=2026-08-01before 选项会仅使用在指定日期或更早发布的版本重新构建依赖树。刷新依赖时,将日期设为一到两周前,可以避开恶意版本已发布但尚未报告的时间窗口。这个方法比较粗糙,因为它也会阻止真正的安全修复。使用它解析版本范围,查看变更内容,然后提交 lockfile。
如何确认实际发布的是哪个版本?
Git 中的 lockfile 记录了本应安装的内容。磁盘上的文件记录了实际安装的内容。只有后者是证据。
npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"npm ls 会读取 node_modules,因此它报告的是磁盘上实际存在的内容,而不是 lockfile 中计划安装的内容。node -e 行按路径读取已安装的 manifest。即使软件包的 exports 字段阻止子路径导入,这种方式仍然有效,并且只输出一个版本号,不绘制依赖树。
至于比较的另一部分,请读取 Git:
git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'将 commit 写入部署布局,使这两项信息永久关联。将版本发布到 /srv/nodeapp/releases/<short commit sha>,再通过符号链接让 /srv/nodeapp/current 指向它。这样,“当前正在运行的版本是什么”的答案就是 readlink /srv/nodeapp/current;即使在 03:00,由未执行部署的人员查看时也能得到相同结果。
最后,检查 registry 可以为哪些内容提供证明:
npm audit signatures此命令会验证已安装依赖树中软件包的 registry 签名,并验证具有来源证明的软件包的 provenance attestation。Provenance 会将已发布的 tarball 关联到生成它的公开持续集成(CI)构建。因此,已验证的 attestation 表示可以将代码追溯到某个 commit,而不是某台未知的笔记本电脑。覆盖范围并不普遍,因此应将缺少 attestation 理解为“没有信息”,而不是“软件包有问题”。
恶意版本到达服务器后该怎么做
从实际运行的代码和运行时使用的用户入手,逐步扩大排查范围。
如果代码在安装过程中运行,应假设构建用户可以读取的所有内容都已泄露。轮换 registry token、该用户主目录中的 SSH 密钥、云凭据,以及该 shell 中导出的任何 secret。轮换是唯一诚实的处理方式,因为您无法证明某个文件没有被读取。
如果代码在运行时以受限服务账户身份执行,可访问的范围会小得多:应用自身的环境变量,以及其网络访问能够到达的资源。这正是在 VPS 上以非特权用户运行服务的全部意义。它无法阻止服务器遭到入侵,但能限制入侵影响的机器范围,并决定入侵是否会在重启后继续存在。
随后应重建,而不是清理现有环境。删除 node_modules,在 package.json 中将受影响的软件包固定到恶意版本之前,运行一次 npm install 以更新 lockfile,提交更改,然后使用 npm ci 部署。不要直接修复现有目录。您无法完整列出安装脚本修改过的内容。
同时记录影响时间窗口:可能拉取该版本的首次部署,以及移除该版本的部署。这个时间范围会告诉您需要查看哪些内部日志;只有当发布版本以 commit 命名时,才能准确回答这个问题。
这些措施都无法解决的问题
锁文件并不能让依赖项变得安全。它会将您接受该依赖项的时刻记录为一个有日期、经过审核的决策,而不是部署产生的副作用。上述每项实践都完成了相同的转换:将意外变成选择。
npm audit 在这里不是防御措施。它会将您的依赖树与已报告漏洞数据库进行比对,因此只能发现已经公开并命名的问题。供应链攻击在其整个有效期内都不会有名称。使用 npm audit 检查已知的旧漏洞,但不要指望它能发现四小时前发布的版本中的问题。
减少依赖项数量比本指南中的任何工具都更有帮助,但这是最不受欢迎的建议。您不添加的每个软件包,都意味着少一个可以代表您遭受网络钓鱼的发布者,也意味着少一个不会以部署用户身份运行的安装脚本。
这些问题也不只存在于 npm。相同的四种模式同样适用于 PyPI、RubyGems、容器镜像和您的发行版软件包管理器。npm 中的问题最明显,因为其依赖树最深,而且安装脚本默认会运行。周围机器中有多少部分需要由您负责防御,取决于它运行的位置;这也是VPS 托管是否安全这一更大问题的一部分。
FAQ
npm ci 能防止恶意 npm 软件包危害我吗?
它可以防止软件包版本在您不知情的情况下发生变化。npm ci 会严格安装 package-lock.json 记录的内容,使用 sha512 完整性哈希校验每个 tarball;如果 package.json 与 lockfile 不一致,它会直接报错退出,而不是自动解决差异。它无法说明固定的版本是否安全。如果您提交的 lockfile 固定了恶意版本,npm ci 每次都会在您管理的每台服务器上忠实地安装该版本。
是否应该为所有软件包设置 ignore-scripts=true?
先设置,再加入允许列表。项目的 .npmrc 中启用 ignore-scripts=true 后,依赖项安装脚本将无法运行,从而切断恶意软件包获取部署用户凭据的最直接途径。编译 native addon 或获取预构建二进制文件的软件包确实需要运行安装脚本;禁用脚本后,这些软件包会在运行时因缺少 binding 文件而失败,而不是在安装时失败。运行 npm ci --ignore-scripts,然后仅为您决定信任的少数软件包运行 npm rebuild <package>。npm query ":attr(scripts, [postinstall])" 会显示实际需要允许的数量。
如何确认服务器实际安装了哪个版本的软件包?
读取磁盘上的内容,不要只看 lockfile。npm ls <package> 会报告 node_modules 中实际存在的内容,node -e "console.log(require('./node_modules/<package>/package.json').version)" 只输出版本字符串。git 中的 lockfile 回答的是另一个问题:原本应该安装什么版本。比较这两个结果正是其意义所在。将应用部署到以 git commit 命名的目录中,可以在数月后仍同时保留这两个结果,便于排查问题。
npm audit 能发现供应链攻击吗?
不能。npm audit 会将您的依赖树与已报告漏洞的数据库进行匹配,因此只能发现已经公开并分配了标识符的问题。恶意版本发布后,可能在相关信息公开前的数小时或数天内被安装。npm audit signatures 是更有用的命令:它会验证已安装依赖树中的 registry 签名,并在发布者提供时检查来源证明,从而确认 tarball 来自公开构建,而不是未知机器。
如果攻击发生在安装时,为什么仍然要以非特权用户运行应用?
因为这两类失败的影响范围不同,您需要同时防御它们。安装时的代码以部署用户身份运行,可以读取该用户的 SSH 密钥、registry token 和云凭据。运行时的代码以服务账户身份运行;配合 User=nodeapp、ProtectSystem=strict,并且不在磁盘上保存凭据后,它能够读取的范围仅限于应用自身的环境和数据库。分离这些账户还意味着负责处理网络流量的进程无法改写 node_modules,因此运行时遭到入侵后,下一次重启即可清除影响,而不会永久保留。