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 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 个需要检查的依赖: 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 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/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-package3. 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 许可证检查 构建环境网络隔离 短期凭据 可信发布与 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 和自动化工具让开发者添加依赖越来越容易时,供应链检查反而应该成为默认流程,而不是发生事故后的补救措施。