SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-27

如何防止服务器上的 npm 供应链攻击

了解 npm 供应链攻击如何通过恶意补丁版本、postinstall 脚本和拼写劫持进入 VPS,并掌握一种锁定依赖版本的部署实践。

服务器上的 npm 供应链攻击是什么

npm 供应链攻击通过您选择安装的软件包进入服务器。攻击不需要开放端口,也不需要利用漏洞。npm(Node package manager)会安装代码,而安装代码就会执行代码。因此,一个小型 Node 应用也会拉取数百个您从未审查过的软件包,其中任何一个都可能在一小时后发布新版本。

由于安装命令要求获取最新的匹配版本,部署过程会拉取恶意版本。该代码随后会以执行安装命令的用户权限运行。下面的所有内容都由这两点引出。

以下攻击形式按其影响单个用户将一个 Node 应用部署到一台 VPS 的常见程度排序。大型公司采用的顺序不会相同,因为大型公司有内部注册表、审查团队以及公共注册表的镜像。您只有一个部署脚本。

形态 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(安全 shell)部署密钥的 ~/.ssh/id_ed25519、~/.aws/credentials、~/.docker/config.json,以及 shell 中导出的所有变量;DATABASE_URL 通常就保存在其中。

这样的 payload 不需要持久化,也不需要权限提升。它读取几个文件,通过 HTTPS 将其发送到某台主机,然后以状态 0 退出。你看不到任何内容,因为 npm 默认隐藏安装脚本的输出。关闭此行为,然后观察实际运行的内容:

npm ci --foreground-scripts

foreground-scripts 与 npm 进程共享标准输入、标准输出和标准错误。因此,构建脚本会直接输出到终端,而不是写入 npm 在安装成功后丢弃的缓冲区。

形态 3:typosquat,以及你没有完全输入的名称

typosquat 是使用接近热门包名称的名称发布的软件包,等待用户输入错误或粘贴错误的安装命令。这里的攻击机制是命令,而不是代码,因此 lockfile 无法提供帮助:你只需添加一次错误的名称,之后 lockfile 就会如实锁定这个名称。

会影响团队而不只是个人的变体是依赖混淆。你的内部软件包名为 billing-utils,位于私有 registry 上。如果公共 registry 中不存在名为 billing-utils 的软件包,任何人都可以发布一个同名包。npm 会将未限定作用域的名称解析到默认公共 registry,因此公共副本可能被优先使用。解决方法是使用你拥有的作用域,并在 .npmrc 中为该作用域配置 registry 映射:

@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}

现在,@yourorg/billing-utils 只会从该主机获取,因为系统会先查询作用域到 registry 的映射,再查询默认 registry。未限定作用域的内部名称没有映射,因此不受保护。

添加任何新依赖之前,应查看软件包本身,而不是只看下载量徽章:

npm view some-lib repository.url maintainers time.created time.modified

一个上个月才创建、发布者无法与任何公开代码仓库关联的软件包,与一个已有 6 年历史的软件包,风险不同。以上任何单项事实都不能作为证据。但两项检查的成本都很低。

形态 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-scripts

npm ci 与 npm install 的行为差异在这里都很重要。它要求锁定文件必须存在。它会在开始前删除现有的 node_modules,因此上一次部署留下的文件不会继续存在于本次部署中。它不会写入 package.json 或锁定文件,因此安装过程不会悄悄将版本推进到更新版本。如果锁定文件与 package.json 不一致,它会直接退出并报告错误,而不是自行解析差异。

这个错误是该功能的一部分,而不是烦人的问题。它表示依赖项变更必须以经过审核的提交形式进入,而不能成为 02:00 部署时的副作用。

每次获取文件时都会检查完整性哈希。如果 tarball 的字节内容与记录的哈希不匹配,安装会因 code EINTEGRITY 失败,而不会继续解包。需要准确理解这一机制的作用:它能证明你收到的文件就是锁定文件固定的文件;这与使用校验和验证下载内容提供的是同一种保证,局限性也相同。它无法说明发布时固定的版本是否包含恶意代码。

关于 --omit=dev,还需注意一点:这些软件包仍会被解析,并仍会写入锁定文件。它们只是不会被放置到磁盘上。磁盘上的软件包更少,意味着安装脚本更少,运行时加载的代码也更少,因此值得这样做。但这不会从依赖树中删除依赖项。

将安装脚本视为代码,并知道如何拒绝执行

您可以关闭安装脚本。将以下内容写入项目的 .npmrc,并与锁文件一起提交:

ignore-scripts=true
save-exact=true

ignore-scripts=true 会阻止 npm 运行依赖项中声明的脚本。save-exact=true 会让 npm install some-lib 将 1.4.2 写入 package.json,而不是写入 ^1.4.2,这样解析后的版本范围就不会意外进入清单文件。

这会导致部分功能失效,因此启用前应了解具体影响。用于编译原生插件或下载预构建二进制文件的软件包,会在安装脚本中执行这些操作。关闭脚本后,安装过程本身仍会成功,但问题会在运行时出现,表现为模块无法加载其绑定文件。解决方法是使用允许列表:

npm ci --ignore-scripts
npm rebuild better-sqlite3

npm 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.target

ProtectSystem=strict 会将此服务使用的整个文件系统挂载为只读,但 /dev、/proc、/sys 以及你在 ReadWritePaths 中列出的路径除外。因此,应用程序尝试写入 node_modules 时会因 EROFS: read-only file system 失败。你可以在自己的日志中复现这一点,整个过程大约需要 1 分钟。NoExecPaths 保护可写的上传目录:服务可以在其中写入文件,但内核会拒绝执行这些文件。此选项需要 systemd 249 或更高版本,而 Ubuntu 24.04 随附 255。

这个 unit 文件有两个容易踩坑的地方。第一,不要添加 MemoryDenyWriteExecute=yes。它会出现在大多数 systemd 加固配置列表中,但会阻止 Node 启动,因为 V8 会在运行时将 JavaScript 编译为机器代码,需要同时可写和可执行的内存页。第二,从 command -v node 获取 ExecStart 路径。如果 Node 是通过版本管理器安装的,它会位于部署用户的主目录下;此时 ProtectHome=yes 会将该目录对服务隐藏,unit 会立即因 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/probe

systemd-analyze security 会列出每项加固设置及其暴露情况,因此你可以看到哪些设置仍使用默认值。touch 应因 Permission denied 失败,因为 nodeapp 不拥有 current 下的任何内容。如果命令成功,说明文件所有权配置错误,systemd 设置正在悄悄掩盖这个问题。

关于 EnvironmentFile 需要注意:systemd 会先以 root 身份读取它,然后再降权为 User=nodeapp,因此该文件可以使用模式 600,并设置为 root:root。应用程序仍会收到这些变量。任何拥有 nodeapp shell 的人仍可以从 /proc/<pid>/environ 读取它们,因此这只能保护静态存储的密钥,不能保护运行中的进程。

让部署凭据离开构建环境

安装脚本会继承环境变量。仅这一点就足以决定构建位置。

最稳妥的做法是在生产服务器之外进行构建,然后将生成的目录复制过去。这样,构建机只需保存一个只读的 registry token。不要在其中保存 SSH deploy key、云访问密钥、数据库密码或容器 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 会下载 registry 将提供的确切 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-01

before 选项会使用不晚于指定日期发布的版本重新构建依赖树。刷新依赖时将日期设为一两周前,这样可以避开恶意版本已发布但尚未报告的时间窗口。这是一种粗粒度的方法,因为它也会阻止真正的安全修复。使用它解析版本范围,查看变更内容,然后提交 lockfile。同样的原则也适用于你从 npm 安装而不是作为依赖使用的命令行工具:未固定版本的 npx 调用会获取当天发布的任意版本,而 固定确切的 dsh 版本 才能让两台机器运行相同的代码。

如何确认实际发布的是哪个版本?

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 证明的软件包。Provenance 将已发布的 tarball 关联到生成它的公开持续集成(CI)构建,因此,验证通过的证明可以将代码追溯到某个 commit,而不是某台未知的笔记本电脑。覆盖范围并不完整,因此缺少证明应解读为“没有信息”,而不是“软件包有问题”。

恶意版本到达服务器后要做什么

从实际运行过的代码入手,并确认代码以哪个用户身份运行。

如果代码在安装期间运行,应假定构建用户可读取的所有内容都已泄露。轮换 registry token、该用户主目录中的 SSH 密钥、云凭据,以及该 shell 中导出的所有密钥。轮换是唯一严谨的处理方式,因为你无法证明某个文件未被读取。

如果代码在运行时以受限服务账户身份运行,可访问的范围会小得多:应用自身的环境变量,以及该账户通过网络访问能够触及的内容。这正是在 VPS 上以非特权用户运行服务的全部意义。这样做不能阻止服务器被入侵,但能限制入侵者获得的机器范围,并决定入侵是否会在重启后持续存在。

然后重新构建,不要直接清理。删除 node_modules,在 package.json 中将受影响的软件包锁定到恶意版本以下,运行 npm install 更新 lockfile,提交更改,然后使用 npm ci 部署。不要在原目录中修复。你无法枚举安装脚本修改过的所有内容。

同时记录受影响的时间窗口:第一个可能拉取该版本的部署,以及移除该版本的部署。这个范围能告诉你应查看哪些内部日志;但只有在发布版本名称包含 commit 信息时,才能准确确定该范围。

这些措施都无法解决什么

锁定文件并不能使依赖项变得安全。它会把接受该依赖项的时刻转化为一个有日期记录、经过审查的决策,而不是部署时产生的副作用。上文的每项实践都完成了同样的转化:将意外变为选择。

npm audit在这里不是防御措施。它会将你的依赖树与已报告漏洞数据库进行比对,因此只能发现已经公开并命名的问题。供应链攻击在其整个有效期内都可能没有名称。运行 npm audit 可以发现旧的已知漏洞,但不要指望它能发现 4 小时前刚发布的版本中的问题。

减少依赖项数量比本指南中的任何工具都更有效,但这是最不受欢迎的建议。每个不添加的软件包,都意味着少一个攻击者可以冒充并向你发起钓鱼攻击的发布者,也意味着少一个不会以部署用户身份运行的安装脚本。

这些内容也并非 npm 独有。相同的 4 种模式同样适用于 PyPI、RubyGems、容器镜像和您的发行版软件包管理器。npm 中最常见,是因为依赖树最深,而且默认会运行安装脚本。任何扩展您已在使用的工具的组件都会继承同样的问题。因此,在安装 dsh 插件前明确它可以访问哪些内容,本质上与阅读 postinstall 脚本相同,只是此时使用的是代理程序的权限,而不是部署用户的权限。您需要防护周围主机的范围,取决于该组件的运行位置;这也是 VPS 托管是否安全这一更广泛问题 的一部分。

FAQ

npm ci能防止恶意的 npm 软件包危害我吗?

它可以防止版本在您不知情的情况下发生变化。npm ci会严格安装 package-lock.json 记录的内容,使用 sha512 完整性哈希校验每个 tarball。如果 package.json 与锁定文件不一致,它会直接报错退出,而不是尝试解决差异。它无法说明固定的版本是否安全。如果您提交的锁定文件固定了恶意版本,npm ci每次都会在您拥有的每台服务器上忠实安装该版本。

我应该为所有内容设置 ignore-scripts=true 吗?

设置它,然后使用允许列表放行需要的例外。项目的 .npmrc 中启用 ignore-scripts=true 后,可以阻止依赖项安装脚本运行,从而切断恶意软件包获取部署用户凭据的最直接路径。编译原生 addon 或获取预构建二进制文件的软件包确实需要运行安装脚本。禁用脚本后,这些软件包不会在安装时失败,而会在运行时因缺少绑定文件而失败。先运行 npm ci --ignore-scripts,然后仅对您决定信任的少数软件包运行 npm rebuild <package>。npm query ":attr(scripts, [postinstall])"会显示实际需要放行的软件包数量。

如何确认服务器实际安装了哪个版本的软件包?

读取磁盘上的内容,而不是锁定文件。npm ls <package>会报告 node_modules 中实际存在的内容,node -e "console.log(require('./node_modules/<package>/package.json').version)"只输出版本字符串。Git 中的锁定文件回答的是另一个问题:本应安装什么版本。比较这两个结果正是其用途。将应用部署到以 Git 提交命名的目录中,可以在数月后仍同时保留这两个结果,便于排查问题。

npm audit能发现供应链攻击吗?

不能。npm audit会将您的依赖树与已报告漏洞数据库进行匹配,因此只能发现已经公开并分配了标识符的问题。恶意版本发布后,可能在接下来数小时或数天内都没有报告,但这段时间内已经有人安装了它。npm audit signatures更有用:它会验证已安装依赖树中的 registry 签名,并在发布者提供时检查来源证明,从而确认 tarball 来自公开构建,而不是未知机器。

如果攻击发生在安装时,为什么仍要以非特权用户运行应用?

因为这两类故障的影响范围不同,您需要同时防御它们。安装时的代码以部署用户身份运行,可以读取该用户的 SSH 密钥、registry token 和云凭据。运行时的代码以服务账户身份运行;配合 User=nodeapp、ProtectSystem=strict,并且不在磁盘上保存凭据后,它能读取的内容仅限于应用自身的环境及其数据库。分离账户还意味着负责处理流量的进程无法改写 node_modules,因此运行时遭到入侵后,下一次重启即可清除影响,不会永久保留。