很多开发者对 npm 依赖安全的理解还停留在npm audit只要没有高危漏洞就认为依赖没有问题。但已知漏洞和恶意软件包并不是同一类风险。一个包可能没有公开 CVE却在安装阶段执行恶意脚本也可能刚发布几个小时还没有进入常规漏洞数据库。2026 年 7 月 28 日GitHub 扩大了 Dependabot 的恶意软件包告警数据来源将 OpenSSF malicious-packages 项目的恶意包信息自动引入 GitHub Advisory Database覆盖 npm、PyPI 等更多生态。开启 Malware alerts 的仓库会自动获得更广的检测范围。这次更新提醒了一个很现实的问题npm install不只是下载代码它还可能执行第三方代码。npm 依赖可以定义preinstall install postinstall prepare这些脚本会在依赖安装过程中运行。脚本不一定是 JavaScript也可以调用 Shell、Python、编译工具或其他系统命令。所以今天不谈抽象的供应链理论直接给 Node.js 项目建立一套可用的安全安装流程。一、为什么npm audit不够npm audit主要根据依赖树查询已知安全漏洞并返回修复建议。它很重要但检测范围主要是已经进入漏洞数据库的问题。下面这些情况单靠普通漏洞扫描不一定能及时发现刚发布不久的恶意包 名字与知名包非常相似的仿冒包 被接管后重新发布的正常包 利用 postinstall 脚本窃取环境变量 从非标准 Registry 下载的依赖 直接引用 Git 仓库的依赖 本地 file: 依赖被替换 锁文件被恶意篡改例如一个依赖可能包含{ scripts: { postinstall: node scripts/setup.js } }而setup.js中可以执行import fs from node:fs; const env process.env; fs.writeFileSync( /tmp/project-env.json, JSON.stringify(env), );这段代码并没有利用传统软件漏洞。它只是利用了安装脚本可以在开发者权限下运行这一事实。因此需要把依赖安全拆成几层第一层固定依赖版本 第二层检查依赖来源 第三层限制安装脚本 第四层验证签名和来源证明 第五层接入恶意包与漏洞告警二、第一层生产和 CI 使用npm ci开发环境里很多人习惯执行npm install但 CI 和生产构建更推荐npm cinpm ci要求项目已经存在package-lock.json如果package.json与锁文件中的依赖不一致它会直接失败而不是自动修改锁文件。这可以避免 CI 在不同时间解析出不同依赖版本。推荐流程git diff --exit-code package-lock.json npm ci更严格一点npm ci --ignore-scripts--ignore-scripts会阻止依赖安装过程中的生命周期脚本执行。需要注意显式执行的npm test、npm start和npm run仍然会运行对应脚本只是不会自动执行关联的 pre、post 脚本。这适合先完成“只下载、不执行”的第一阶段。三、不要忽略package-lock.json里的信息现代package-lock.json不只是版本列表。它还记录了resolved integrity hasInstallScript link inBundle其中resolved表示依赖实际下载地址integrity用于校验下载内容hasInstallScript表示该依赖包含安装脚本link表示本地链接依赖。npm 官方文档明确说明hasInstallScript用于标记依赖是否存在preinstall、install或postinstall脚本。因此我们可以在安装前直接扫描锁文件。创建scripts/check-dependency-lock.mjs写入#!/usr/bin/env node import fs from node:fs; import path from node:path; import process from node:process; const lockPath path.resolve( process.cwd(), package-lock.json, ); if (!fs.existsSync(lockPath)) { console.error(BLOCK项目缺少 package-lock.json); process.exit(1); } let lock; try { lock JSON.parse( fs.readFileSync(lockPath, utf8), ); } catch (error) { console.error( BLOCK无法解析 package-lock.json${error.message}, ); process.exit(1); } if (!lock.packages) { console.error( BLOCK当前锁文件不包含 packages 字段请先更新锁文件版本, ); process.exit(1); } const results []; function getPackageName(packagePath) { const marker node_modules/; const index packagePath.lastIndexOf(marker); if (index -1) { return packagePath; } return packagePath.slice( index marker.length, ); } function classifySource(resolved ) { if (!resolved) { return unknown; } if (resolved.startsWith(file:)) { return local-file; } if ( resolved.startsWith(git) || resolved.startsWith(github:) || resolved.startsWith(gitlab:) ) { return git; } if (resolved.startsWith(http://)) { return insecure-http; } if ( resolved.startsWith( https://registry.npmjs.org/, ) ) { return npm-registry; } if (resolved.startsWith(https://)) { return external-https; } return other; } for ( const [packagePath, metadata] of Object.entries(lock.packages) ) { if (!packagePath) { continue; } if ( !packagePath.includes( node_modules/, ) ) { continue; } const name getPackageName(packagePath); const source classifySource( metadata.resolved, ); const warnings []; if (metadata.hasInstallScript) { warnings.push(包含安装脚本); } if ( source git || source local-file || source external-https || source insecure-http ) { warnings.push( 非标准依赖来源${source}, ); } if ( source insecure-http ) { warnings.push(使用非加密 HTTP); } if ( metadata.resolved source ! local-file source ! git !metadata.integrity ) { warnings.push(缺少 integrity); } if (warnings.length 0) { results.push({ name, version: metadata.version || unknown, source, resolved: metadata.resolved || -, warnings, }); } } if (results.length 0) { console.log( PASS未发现安装脚本或异常依赖来源, ); process.exit(0); } console.log( 发现 ${results.length} 个需要检查的依赖\n, ); for (const item of results) { console.log( ${item.name}${item.version}, ); console.log( 来源${item.source}, ); console.log( 下载${item.resolved}, ); console.log( 问题${item.warnings.join(、)}, ); console.log(); } const blocked results.some( (item) item.source insecure-http || item.warnings.includes( 缺少 integrity, ), ); if (blocked) { console.error( BLOCK发现必须人工处理的依赖风险, ); process.exit(1); } console.warn( WARNING请检查以上依赖后再执行安装脚本, ); process.exit(0);运行node scripts/check-dependency-lock.mjs可能输出发现 3 个需要检查的依赖 esbuild0.25.8 来源npm-registry 问题包含安装脚本 sharp0.34.3 来源npm-registry 问题包含安装脚本 internal-utils1.0.0 来源git 问题非标准依赖来源git注意存在安装脚本不代表依赖有问题。例如某些原生模块需要下载二进制文件或执行编译。脚本的作用是提醒开发者这个依赖安装时会执行代码需要明确审核。四、用npm query找出安装脚本如果依赖已经安装也可以使用 npm 自带查询功能。查询包含postinstall的依赖npm query \ :attr(scripts, [postinstall])只输出名称npm query \ :attr(scripts, [postinstall]) \ | jq -r .[].namenpm 官方文档也给出了通过npm query查找包含postinstall脚本依赖的示例。还可以分别检查npm query \ :attr(scripts, [preinstall]) npm query \ :attr(scripts, [install]) npm query \ :attr(scripts, [postinstall])不过最好不要等安装完成后才检查。锁文件扫描更适合放在 CI 的安装前阶段。五、新版 npm 开始管理依赖安装脚本新版 npm 提供了npm approve-scripts npm deny-scripts它们用于管理项目package.json中的allowScripts字段。npm approve-scripts会记录哪些依赖被允许执行安装脚本并且默认可以将批准范围固定到具体版本。先查看仍未审批的依赖npm approve-scripts \ --allow-scripts-pending批准一个具体依赖npm approve-scripts esbuild批准后package.json中会出现类似配置{ allowScripts: { esbuild0.25.8: true } }默认固定具体版本比写成{ allowScripts: { esbuild: true } }更安全。因为后者意味着未来所有版本都自动继承批准。明确拒绝某个依赖npm deny-scripts suspicious-package对应配置类似{ allowScripts: { suspicious-package: false } }npm 12 的文档说明依赖安装脚本默认会被阻止未审批依赖需要明确允许拒绝项也可以写入allowScripts避免后续被批量批准。对于旧版 npm可以继续采用npm ci --ignore-scripts审核后再单独执行npm rebuild esbuild npm rebuild sharpnpm rebuild会重新执行指定依赖的安装生命周期脚本适合在最初使用--ignore-scripts后有选择地构建必要依赖。六、验证 Registry 签名和 Provenance完成依赖安装后再执行npm audit signatures该命令会验证 Registry 签名和包的 Provenance 证明。如果签名或证明缺失、无效命令会返回错误提示包可能存在来源或完整性问题。在 CI 中可以直接写npm audit signatures需要 JSON 结果npm audit signatures --json如果要包含完整的 Sigstore 证明数据npm audit signatures \ --json \ --include-attestations官方配置文档说明include-attestations会在 JSON 输出中包含 DSSE、验证材料和透明日志数据。但要注意签名验证主要证明下载内容没有被中途篡改 发布来源可以被验证 构建和发布链路具备证明它不能证明包的代码一定安全。一个作者主动发布的恶意包也可以拥有有效签名。所以签名验证必须和恶意包告警、代码审查一起使用。七、开启 GitHub Dependabot Malware Alerts在 GitHub 仓库中进入Settings → Code security → Dependabot → Malware alertsGitHub 这次更新会将 OpenSSF malicious-packages 数据引入 Advisory Database。已经开启 Malware alerts 的仓库无需额外配置就会自动获得扩展后的检测范围。还可以在 GitHub Advisory Database 中使用type:malware筛选恶意软件包公告。这里建议同时开启Dependabot alerts Malware alerts Dependabot security updates Dependency graph不要只开自动更新。自动更新解决的是版本升级问题恶意包检测解决的是依赖本身是否已被标记为恶意两者不是一回事。八、完整 CI 安全流程创建.github/workflows/dependency-security.yml写入name: Dependency Security on: pull_request: paths: - package.json - package-lock.json - scripts/check-dependency-lock.mjs push: branches: - main jobs: dependency-security: runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout uses: actions/checkoutv4 - name: Setup Node.js uses: actions/setup-nodev4 with: node-version: 24 cache: npm - name: Check lockfile run: | node scripts/check-dependency-lock.mjs - name: Install without lifecycle scripts run: | npm ci --ignore-scripts - name: Audit known vulnerabilities run: | npm audit --audit-levelhigh - name: Verify signatures run: | npm audit signatures - name: Run project checks run: | npm run lint npm run typecheck npm test这个流程的重点是先扫描锁文件 再禁止脚本安装 然后检查漏洞和签名 最后运行项目测试如果项目确实依赖原生编译或安装脚本可以在审核后增加- name: Rebuild approved dependencies run: | npm rebuild esbuild npm rebuild sharp不要简单执行npm rebuild否则所有依赖的安装脚本可能重新运行。九、新增依赖时不要直接执行npm install xxx团队可以统一采用以下流程。第一步只更新锁文件npm install package-name \ --package-lock-only \ --ignore-scripts第二步查看 Diffgit diff -- package.json package-lock.json重点检查新增了多少间接依赖 是否出现 Git 或 file: 来源 是否出现非官方 Registry 是否出现缺少 integrity 的依赖 哪些依赖带有 hasInstallScript 锁文件是否出现异常大范围变化第三步执行扫描node scripts/check-dependency-lock.mjs第四步无脚本安装npm ci --ignore-scripts第五步漏洞与签名检查npm audit --audit-levelhigh npm audit signatures第六步审批必要脚本npm approve-scripts \ --allow-scripts-pending再批准确实需要的包。第七步运行测试npm run lint npm run typecheck npm test这套流程比npm install package-name git add . git commit慢几分钟。但它能让依赖变化从“黑盒下载”变成可审查的工程变更。十、不要把package-lock.json当成无意义的大文件不少团队 Review PR 时会认真看业务代码却直接跳过锁文件。实际上依赖攻击经常就藏在锁文件变化里。至少检查1. 依赖版本是否符合预期原计划增加一个包却升级了几十个无关依赖需要确认原因。2. 下载地址是否改变例如从https://registry.npmjs.org/变成https://example-cdn.invalid/ githttps://... file:../local-package3. integrity 是否改变package-lock.json中的integrity用于验证下载内容。Registry 依赖通常会记录对应完整性值。4. 是否新增安装脚本锁文件中的{ hasInstallScript: true }意味着这个依赖在安装阶段会执行代码。5. 是否出现 Git 分支依赖例如{ resolved: githttps://github.com/example/repo.git#main }使用可变分支比固定 Commit 风险更大。至少固定具体 Commitgithttps://github.com/example/repo.git#具体提交哈希十一、几个常见误区误区一有package-lock.json就绝对安全锁文件只能固定依赖和验证下载内容。如果锁定的版本本身就是恶意包锁文件不会自动保护你。误区二npm audit没报警就安全它主要检查已知漏洞。恶意脚本、可疑来源和刚发布的恶意版本需要额外检测。误区三所有安装脚本都危险不少正常依赖确实需要编译原生扩展或下载平台二进制文件。正确做法不是全部禁止而是默认阻止 逐个审核 固定版本批准 变更后重新审批误区四签名有效就代表代码安全签名证明来源和完整性不等于证明发布者没有恶意行为。误区五只检查直接依赖真正的风险可能来自第五层甚至第十层间接依赖。锁文件和 Dependabot 会比只看package.json更完整。十二、推荐落地清单可以先从下面这套最小方案开始[ ] 提交 package-lock.json [ ] CI 使用 npm ci [ ] 默认使用 --ignore-scripts [ ] 扫描 hasInstallScript [ ] 拦截 HTTP 和异常 Registry [ ] 检查缺失 integrity [ ] 运行 npm audit [ ] 运行 npm audit signatures [ ] 开启 Dependabot Malware alerts [ ] 安装脚本逐包审批 [ ] 依赖变更必须人工 Review对于高风险项目再增加私有 Registry 依赖代理缓存 允许依赖白名单 SBOM 许可证检查 构建环境网络隔离 短期凭据 可信发布与 OIDCnpm 的 Trusted Publishing 支持通过 CI/CD 和 OIDC 发布软件包减少长期 npm Token 的使用也可以进一步降低发布链路中的凭据风险。十三、AI 写代码越快依赖安全越不能省现在很多 AI 编程工具会在实现功能时自动建议npm install 某个包问题是Agent 的目标通常是尽快完成任务。它不一定会主动检查这个包是谁维护的 最近是否转移所有权 有没有安装脚本 是否存在仿冒名称 依赖树增加了多少包 是否支持签名验证 是否已被标记为恶意软件因此不能把依赖选择完全交给模型。更合理的要求是不要直接安装依赖。 先说明 1. 为什么需要新增依赖 2. 是否可以使用现有能力实现 3. 包的维护状态和替代方案 4. 是否包含安装脚本 5. 将增加多少间接依赖 6. 许可证和运行环境要求。 等待人工确认后只更新 package.json 和 package-lock.json。但最终的安全控制仍然应该由 CI、锁文件和脚本完成而不是依赖提示词。十四、工具订阅不能替代项目安全长期使用 ChatGPT Plus、Claude Pro、Cursor、Kiro 等 AI 编程工具时可以通过 gpt68.com 了解相关第三方 AI 会员充值服务。需要说明的是gpt68.com 解决的是订阅充值流程问题不是替代工具本身也不是相关产品的官方网站或授权合作方不提供共享账号。无论使用哪个 AI 编程工具依赖安全、密钥管理、测试和代码 Review 都需要项目自身负责。总结GitHub 扩大恶意依赖告警范围以后Node.js 项目可以立即补上几道防线用 package-lock.json 固定依赖 用 npm ci 保证可复现 用 --ignore-scripts 阻止自动执行 扫描 hasInstallScript 和异常来源 用 approve-scripts 逐包批准 用 npm audit signatures 验证来源 开启 Dependabot Malware alerts最重要的思路不是永远不要使用第三方依赖。而是任何新增依赖都必须被当成一段即将进入项目并可能执行的外部代码。业务代码需要 Review。依赖变化同样需要 Review。当 AI 和自动化工具让开发者添加依赖越来越容易时供应链检查反而应该成为默认流程而不是发生事故后的补救措施。