GitHub 扩大恶意依赖告警后,我给 npm 项目加了一套安装前安全检查

很多开发者对 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 ci

npm ci要求项目已经存在package-lock.json;如果package.json与锁文件中的依赖不一致,它会直接失败,而不是自动修改锁文件。

这可以避免 CI 在不同时间解析出不同依赖版本。

推荐流程:

git diff --exit-code package-lock.json npm ci

更严格一点:

npm ci --ignore-scripts

--ignore-scripts会阻止依赖安装过程中的生命周期脚本执行。需要注意,显式执行的npm testnpm startnpm run仍然会运行对应脚本,只是不会自动执行关联的 pre、post 脚本。

这适合先完成“只下载、不执行”的第一阶段。


三、不要忽略package-lock.json里的信息

现代package-lock.json不只是版本列表。

它还记录了:

resolved integrity hasInstallScript link inBundle

其中:

  • resolved表示依赖实际下载地址;
  • integrity用于校验下载内容;
  • hasInstallScript表示该依赖包含安装脚本;
  • link表示本地链接依赖。

npm 官方文档明确说明,hasInstallScript用于标记依赖是否存在preinstallinstallpostinstall脚本。

因此,我们可以在安装前直接扫描锁文件。

创建:

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 个需要检查的依赖: esbuild@0.25.8 来源:npm-registry 问题:包含安装脚本 sharp@0.34.3 来源:npm-registry 问题:包含安装脚本 internal-utils@1.0.0 来源:git 问题:非标准依赖来源:git

注意:

存在安装脚本不代表依赖有问题。

例如某些原生模块需要下载二进制文件或执行编译。

脚本的作用是提醒开发者:

这个依赖安装时会执行代码,需要明确审核。


四、用npm query找出安装脚本

如果依赖已经安装,也可以使用 npm 自带查询功能。

查询包含postinstall的依赖:

npm query \ ":attr(scripts, [postinstall])"

只输出名称:

npm query \ ":attr(scripts, [postinstall])" \ | jq -r '.[].name'

npm 官方文档也给出了通过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": { "esbuild@0.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 sharp

npm 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 alerts

GitHub 这次更新会将 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/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 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-level=high - 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

第二步:查看 Diff

git diff -- package.json package-lock.json

重点检查:

新增了多少间接依赖 是否出现 Git 或 file: 来源 是否出现非官方 Registry 是否出现缺少 integrity 的依赖 哪些依赖带有 hasInstallScript 锁文件是否出现异常大范围变化

第三步:执行扫描

node scripts/check-dependency-lock.mjs

第四步:无脚本安装

npm ci --ignore-scripts

第五步:漏洞与签名检查

npm audit --audit-level=high 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/ git+https://... file:../local-package

3. integrity 是否改变

package-lock.json中的integrity用于验证下载内容。Registry 依赖通常会记录对应完整性值。

4. 是否新增安装脚本

锁文件中的:

{ "hasInstallScript": true }

意味着这个依赖在安装阶段会执行代码。

5. 是否出现 Git 分支依赖

例如:

{ "resolved": "git+https://github.com/example/repo.git#main" }

使用可变分支比固定 Commit 风险更大。

至少固定具体 Commit:

git+https://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 许可证检查 构建环境网络隔离 短期凭据 可信发布与 OIDC

npm 的 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 和自动化工具让开发者添加依赖越来越容易时,供应链检查反而应该成为默认流程,而不是发生事故后的补救措施。