
在 Node.js 生态中npm是开发者日常接触最频繁的工具之一从安装依赖到发布自己的包几乎贯穿了前端和 Node.js 后端开发的整个生命周期。然而随着供应链攻击事件的频发例如标题中提及的“Shai-Hulud”这类 NPM 供应链攻击一个更深层次的问题浮出水面我们高度依赖的包管理器及其背后的开源供应链其安全边界究竟在哪里仅仅依靠npm install命令和package.json中的版本锁定是否足以保障我们的应用安全更进一步像“软件物料清单”SBOM和“来源证明”Provenance这类旨在增强透明度的安全实践在面对精心设计的攻击时其效力是否存在我们未曾意识到的天花板本文将从一次典型的 NPM 供应链攻击模拟入手拆解攻击者如何利用开源生态的信任链和工具链的薄弱环节。我们将不局限于讨论某个具体的漏洞CVE而是深入攻击的路径、依赖混淆、恶意脚本注入等常见手段并探讨“来源证明”等安全机制为何在特定场景下会失效。对于每一位使用 Node.js 和 npm 的开发者、团队技术负责人或安全工程师而言理解这些攻击模式和安全机制的局限性是构建有效防御体系的第一步。通过本文你将能清晰地认识到从npm init到项目上线的整个流程中哪些环节可能被恶意利用以及除了依赖版本号之外我们还需要关注哪些更底层的安全信号。1. 理解 NPM 供应链攻击不只是漏洞更是信任滥用在讨论具体攻击之前必须厘清一个概念供应链攻击与普通软件漏洞有本质区别。普通漏洞如原型污染、命令注入是代码中的缺陷可能被攻击者利用。而供应链攻击是攻击者主动将恶意代码植入到软件分发渠道中利用的是开发者和社区对分发渠道如 NPM Registry、知名维护者或常用工具的信任。1.1 攻击的典型入口依赖混淆与包名抢注最常见的入口之一是“依赖混淆”Dependency Confusion。假设你所在的公司MyCorp内部有一个私有包在内部package.json中声明为mycorp/utils。攻击者会抢先在公共 NPM Registry 上发布一个同名的、版本号更高的包例如utils或mycorp/utils如果作用域未被保护。如果开发者的构建环境如 CI/CD未正确配置私有源优先级或者.npmrc配置有误npm install就可能从公共源拉取到这个恶意包而非内部私有包。另一种是“包名抢注”Typosquatting。攻击者发布与流行包名极其相似的包例如lodash被抢注为lodash字母‘l’和数字‘1’、moment被抢注为mooment。开发者或自动化脚本一旦拼写错误就会引入恶意依赖。1.2 恶意代码的载体安装脚本与后门恶意代码通常隐藏在包的package.json文件的scripts生命周期钩子中尤其是preinstall、install、postinstall。当执行npm install时这些脚本会自动以当前用户的权限运行。// 恶意 package.json 示例 { name: malicious-package, version: 1.0.0, scripts: { preinstall: node -e \const fs require(fs); const path require(path); const data Stolen data; // 此处可替换为实际恶意操作如收集环境变量、读取敏感文件、建立反向shell等\ } }更隐蔽的做法是将恶意代码放在包的主入口文件如index.js中但进行混淆或条件触发例如只在生产环境、特定时间或检测到特定文件时才执行恶意行为以此规避简单的代码审查。1.3 “来源证明”Provenance的承诺与局限“来源证明”是一种安全机制旨在回答“这个包是从哪里来的是谁在什么环境下构建的”。NPM 通过与 GitHub Actions 等 CI/CD 平台集成可以为包生成一个不可篡改的证明声明该包的确是由特定仓库的特定工作流程在特定提交上构建产生的。这听起来很完美但它存在几个关键假设也是其局限所在构建环境可信它假设 CI/CD 流水线本身是安全的。如果攻击者已经控制了源代码仓库或 CI 配置如通过泄露的令牌他们就可以用合法的流程发布恶意的包。发布流程唯一它假设包只通过这条受证明的流水线发布。如果维护者同时保留了通过npm publish命令行直接发布的能力攻击者一旦窃取维护者的 NPM 令牌就可以绕过证明机制直接发布恶意版本。无法验证代码意图证明只能说明“这个二进制产物是由这些源代码构建的”但无法判断源代码本身的意图是善是恶。如果攻击者通过社会工程学或账户劫持直接向合法仓库提交了恶意代码那么基于此构建的包依然拥有完美的“来源证明”。因此“来源证明”极大地提高了攻击门槛解决了“冒名顶替”发布的问题但无法解决“内部叛变”或“供应链上游污染”的问题。标题中的“Shai-Hulud”攻击所揭示的可能正是攻击者通过某种方式绕过了或利用了这些安全机制的盲区。2. 从零搭建一个模拟攻击环境理解攻击链为了深入理解我们将在隔离的环境中进行一次无害的模拟。警告以下所有操作仅限在虚拟机或完全隔离的实验环境中进行严禁对任何公共仓库或他人项目进行测试。我们的目标是构建一个看似正常但包含“侦察”行为的包并观察其如何被安装和执行。2.1 环境准备与隔离首先确保你使用的是一个独立的开发环境。可以使用 Docker 容器或者在本地使用nvm(Node Version Manager) 创建一个临时的 Node.js 环境。# 使用 nvm 创建隔离环境如果已安装 nvm nvm install 18.18.0 # 安装一个特定版本 nvm use 18.18.0 # 切换到该版本 node --version # 确认版本 # 创建一个全新的目录作为我们的“恶意包”项目 mkdir simulated-malicious-pkg cd simulated-malicious-pkg npm init -y # 快速生成 package.json2.2 构造一个“侦察型”恶意包我们的模拟包将不执行任何破坏性操作仅记录一些公开可访问的信息到日志文件模拟攻击者的侦察行为。修改package.json添加postinstall脚本{ name: simulated-useful-tool, version: 1.0.0, description: A simulated package for educational purpose, main: index.js, scripts: { postinstall: node .postinstall.js }, keywords: [], author: , license: ISC }注意我们将脚本逻辑放在一个单独的文件.postinstall.js中这比内联在package.json中更隐蔽。创建恶意脚本文件.postinstall.js// .postinstall.js - 模拟侦察行为 const fs require(fs); const path require(path); const os require(os); function collectInfo() { const info { timestamp: new Date().toISOString(), platform: os.platform(), arch: os.arch(), nodeVersion: process.version, npmVersion: process.env.npm_config_user_agent, currentDirectory: process.cwd(), // 模拟尝试读取项目根目录的 package.json (这是一个正常存在且可读的文件) projectName: (() { try { const pkg require(path.join(process.cwd(), package.json)); return pkg.name; } catch (e) { return unknown; } })(), envVarsSample: { NODE_ENV: process.env.NODE_ENV, USER: process.env.USER || process.env.USERNAME, // 注意绝不收集真实敏感环境变量如 AWS_ACCESS_KEY_ID } }; return info; } const logData collectInfo(); const logDir path.join(os.tmpdir(), npm_sim_logs); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } const logFile path.join(logDir, install_${Date.now()}.json); fs.writeFileSync(logFile, JSON.stringify(logData, null, 2), utf8); console.log([模拟包] 安装后脚本执行完毕。侦察数据已写入: ${logFile}); console.log([模拟包] 此为安全演示未执行任何破坏性操作。);这个脚本会收集系统、Node.js 环境和项目的基本信息并写入临时目录的一个 JSON 文件。在真实攻击中这些信息可能会被加密后发送到远程服务器。创建主入口文件index.js使其看起来像个正常工具// index.js module.exports { greet: () This is a simulated useful tool. For educational purpose only. };2.3 在另一个项目中“受害”现在我们切换到另一个模拟的“受害者”项目并安装我们刚刚创建的本地包。# 回到上级目录创建受害者项目 cd .. mkdir victim-project cd victim-project npm init -y # 通过文件路径安装我们模拟的恶意包 npm install ../simulated-malicious-pkg执行npm install后你将立即在终端看到来自.postinstall.js的日志输出并且可以在系统临时目录如/tmp/npm_sim_logs或C:\Users\...\AppData\Local\Temp\npm_sim_logs下找到生成的 JSON 日志文件。这个简单的模拟展示了一旦执行npm install包中定义的安装脚本就会自动、静默地以当前项目的权限执行。如果这是一个真实的恶意包损害可能在安装瞬间就已经发生。3. 防御视角如何检测和防范供应链攻击理解了攻击如何发生我们就可以从开发、构建、部署三个环节建立防线。3.1 开发环节提升依赖引入的安全意识仔细审查package.json在添加新依赖尤其是非知名依赖时花几分钟查看其 NPM 页面、GitHub 仓库、下载量、维护频率和 Issues。对名字相似但作者不同的包保持警惕。使用npm audit定期运行npm audit检查已知漏洞。但需明白它主要针对已收录的 CVE对于全新的、未知的恶意包无效。锁定依赖版本使用package-lock.json或yarn.lock锁定确切的依赖树。考虑使用npm ci代替npm install在 CI/CD 中确保安装的一致性。审查node_modules中的脚本对于安全要求极高的项目可以集成工具在安装后扫描node_modules中所有package.json的scripts字段。一个简单的检查脚本如下// scripts/check-install-scripts.js const fs require(fs); const path require(path); const { promisify } require(util); const readdir promisify(fs.readdir); const stat promisify(fs.stat); const readFile promisify(fs.readFile); async function findInstallScripts(dir) { const packages []; async function scan(currentPath) { const items await readdir(currentPath); for (const item of items) { const fullPath path.join(currentPath, item); const s await stat(fullPath); if (s.isDirectory()) { if (item node_modules) { // 深入扫描 node_modules await scan(fullPath); } else if (item.startsWith()) { // 处理作用域包 await scan(fullPath); } else { // 检查当前目录是否是包包含 package.json const pkgPath path.join(fullPath, package.json); if (fs.existsSync(pkgPath)) { try { const pkgContent await readFile(pkgPath, utf8); const pkg JSON.parse(pkgContent); if (pkg.scripts (pkg.scripts.preinstall || pkg.scripts.install || pkg.scripts.postinstall)) { packages.push({ name: pkg.name, version: pkg.version, path: fullPath, scripts: pkg.scripts }); } } catch (e) { console.warn(Failed to parse ${pkgPath}:, e.message); } } // 继续递归扫描子目录非 node_modules await scan(fullPath); } } } } await scan(dir); return packages; } findInstallScripts(process.cwd()).then(pkgs { if (pkgs.length 0) { console.log(发现包含 install 生命周期脚本的包:); console.log(JSON.stringify(pkgs, null, 2)); process.exit(1); // 可以设置为非0退出码让CI失败 } else { console.log(未发现包含 install 生命周期脚本的包。); } }).catch(console.error);3.2 构建与 CI/CD 环节实施强安全策略这是防御的核心阵地。使用私有 Registry 并正确配置源优先级对于企业部署像 Verdaccio 这样的私有 NPM Registry并将内部作用域包mycorp/*指向私有源。在.npmrc中明确配置防止依赖混淆。# .npmrc mycorp:registryhttps://private-registry.mycompany.com/ //private-registry.mycompany.com/:_authToken${NPM_PRIVATE_TOKEN} registryhttps://registry.npmmirror.com/ # 公共源使用国内镜像加速在 CI 中禁用脚本执行在持续集成环境中使用npm ci --ignore-scripts或设置环境变量npm_config_ignore_scriptstrue来禁止所有依赖包的生命周期脚本执行。这能从根本上阻断大部分通过postinstall发起的攻击。# 在 CI 脚本中 export npm_config_ignore_scriptstrue npm ci # 或者 npm ci --ignore-scripts注意这可能会破坏那些确实需要通过安装脚本编译原生扩展如node-gyp的包。对于这些包你需要将其加入白名单或者在一个更受控的先决步骤中单独处理它们。实施依赖来源白名单使用npm的--audit和--fund功能并结合第三方安全扫描工具如 Snyk, WhiteSource, Mend对引入的依赖进行深度成分分析。为你的包启用并强制要求“来源证明”如果你是一个开源包的维护者请通过 GitHub Actions 等支持的工作流来发布包并在package.json中设置publishConfig: {provenance: true}。作为消费者你可以通过npm audit signatures来验证已安装包的签名状态。3.3 部署与运行时环节最小权限原则容器化与最小镜像使用如node:18-alpine这样的最小化基础镜像来运行应用减少攻击面。在 Dockerfile 中以非 root 用户运行应用。FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --onlyproduction --ignore-scripts COPY . . FROM node:18-alpine WORKDIR /app COPY --frombuilder /app/node_modules ./node_modules COPY --frombuilder /app . RUN addgroup -g 1001 -S nodejs adduser -S nodejs -u 1001 -G nodejs USER nodejs EXPOSE 3000 CMD [node, server.js]网络与文件系统隔离在容器或服务器配置中限制应用容器的网络出口只允许访问必要的服务如数据库、Redis。使用只读文件系统挂载防止恶意脚本篡改系统文件。运行时行为监控对于关键应用部署运行时应用自我保护RASP或监控工具检测异常的子进程创建、文件系统访问或网络连接行为。4. 当安全机制失效排查与应急响应即使采取了预防措施也需要有预案。当怀疑或确认遭受供应链攻击时应按以下步骤响应立即隔离断开受影响系统与网络特别是内部网络的连接防止横向移动和数据外泄。确定影响范围检查package-lock.json或yarn.lock定位被引入的恶意包及其版本。使用npm ls malicious-package-name查看该包在依赖树中的位置是直接依赖还是被间接引入。审查构建日志和部署历史确定恶意代码被引入和生效的时间点。取证分析不要直接删除node_modules先对恶意包目录进行备份以供分析。分析恶意包的package.json中的脚本和入口文件。检查系统日志、网络连接记录寻找数据外泄的痕迹。清除与恢复在干净的隔离环境中从可靠的源代码重新构建应用。彻底删除受污染的构建产物和容器镜像。更新所有可能泄露的凭证NPM Token、CI/CD Token、服务器密钥等。事后复盘与加固根因分析是依赖混淆、令牌泄露、还是维护者账户被劫持加固安全策略是否需要在 CI 中全面启用--ignore-scripts是否需要对所有第三方包进行更严格的准入审查考虑引入更高级的软件供应链安全工具如 Sigstore 的 Cosign 用于签名验证或 SPIFFE/SPIRE 用于工作负载身份。5. 超越工具建立安全文化与流程最终技术工具只是辅助。面对不断进化的供应链攻击最坚固的防线是团队的安全意识和成熟的流程。依赖选择流程化建立引入新第三方库的审批流程即使是npm install一个工具包。安全左移将依赖安全检查如npm audit、Snyk 扫描集成到开发者的 IDE 和 Git 提交钩子pre-commit中而不仅仅是 CI 环节。持续教育让团队成员了解供应链攻击的常见模式保持对可疑包的警惕。漏洞管理闭环建立跟踪、评估、修复、验证安全漏洞的完整流程而不仅仅是依赖工具的自动报告。NPM 供应链安全是一个涉及开发、运维、安全的综合性挑战。正如“Shai-Hulud”事件所暗示的没有任何单一银弹包括“来源证明”能提供绝对安全。它是一场攻防对抗我们需要在理解攻击者思维的基础上层层设防将安全实践嵌入到软件交付的每一个环节从一行npm install命令开始构建起真正有韧性的软件供应链。