供应链安全实战:从依赖管理到构建防护的完整方案

程序猿雯雯 安全 阅读 2,091
赞 15 收藏
二维码
手机扫码查看
反馈

又踩坑了,npm install 之后项目被悄悄植入后门

上周五下午,我正准备上线一个新功能,本地跑 npm install 装完依赖,顺手点了个构建,结果 CI 报错说打包体积暴涨了 300KB。我第一反应是“谁又往项目里塞了 lodash 全家桶?”,但打开 bundle 分析一看,发现多出来一堆不认识的模块,名字长得像随机字符串,比如 @xm1k3/evil-payloadcrypto-miner-lite 这种。

供应链安全实战:从依赖管理到构建防护的完整方案

我当时就懵了——这玩意儿哪来的?翻了 package.json,没看到这些包。查了 package-lock.json,发现它们是某个我正常依赖的第三方库的子依赖,而且版本号是 ^1.2.3 这种模糊匹配。再一查 npm 上那个包的更新记录,好家伙,三天前刚发了一个 minor 版本,里面悄悄加了恶意脚本,会在 postinstall 阶段往 ~/.cache 写个挖矿程序。

这已经不是第一次遇到类似问题了。之前还碰到过 UI 组件库里偷偷加了统计代码,把用户操作行为发到某个未知域名。供应链攻击真不是危言耸听,尤其是前端项目动不动就几百个依赖,根本没法一个个盯着看。

折腾了半天,先搞个临时防火墙

当时离上线 deadline 就剩两小时,我哪有时间去审计所有依赖。第一反应是:能不能在安装阶段就拦住可疑包?

我试了 npm audit,结果只报了几个低危漏洞,完全没提那些后门包。后来才知道,npm audit 只认已知 CVE,这种新冒出来的恶意包它根本不知道。白忙活。

接着想到用 --ignore-scripts 参数装包,这样至少能阻止 postinstall 执行。但问题来了:有些包确实需要脚本(比如 native 模块编译),全禁了项目可能跑不起来。而且这招只能防“执行”,不能防“引入”——恶意代码还在 node_modules 里躺着,万一被 webpack 打包进去照样出事。

最后灵机一动:既然问题出在依赖树不可控,那能不能锁定所有依赖版本,连 patch 版都不自动升级?于是我把 package-lock.json 提交到仓库,CI 里强制用 npm ci 安装。这招确实能保证环境一致,但治标不治本——如果 lock 文件里已经混进了坏包,那所有环境都中招。

核心代码就这几行:用 npm hooks 拦截可疑依赖

真正解决问题的办法,是我后来在 GitHub 上扒到的一个冷门方案:利用 npm 的 preinstallpostinstall 钩子,在安装前扫描依赖树。

具体做法是:在项目根目录建个 scripts/verify-deps.js,然后在 package.json 里加个钩子:

{
  "scripts": {
    "preinstall": "node scripts/verify-deps.js"
  }
}

这个脚本会在每次 npm install 前运行,读取 package.json 的 dependencies 和 devDependencies,逐个检查包名、版本、作者等信息。关键逻辑如下:

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

// 已知恶意包名单(可对接外部威胁情报)
const BLACKLIST = [
  '@xm1k3/evil-payload',
  'crypto-miner-lite',
  // ...其他已知恶意包
];

// 高风险关键词(用于启发式检测)
const SUSPICIOUS_KEYWORDS = ['miner', 'tracker', 'spy', 'backdoor'];

function verifyPackage(pkgName, version) {
  // 1. 检查是否在黑名单
  if (BLACKLIST.includes(pkgName)) {
    throw new Error([SECURITY] Blocked blacklisted package: ${pkgName});
  }

  // 2. 检查包名是否包含可疑关键词
  const lowerName = pkgName.toLowerCase();
  for (const keyword of SUSPICIOUS_KEYWORDS) {
    if (lowerName.includes(keyword)) {
      console.warn([WARNING] Suspicious package name: ${pkgName});
      // 这里可以改成 throw,但为了不阻断开发,先 warn
    }
  }

  // 3. 检查作者是否可信(示例:只允许公司内部 scope 或知名作者)
  try {
    const info = JSON.parse(execSync(npm view ${pkgName}@${version} --json, { stdio: 'pipe' }).toString());
    const author = info.author?.name || '';
    const maintainers = info.maintainers?.map(m => m.name) || [];
    
    // 示例:只信任 @mycompany scope 或特定作者
    if (!pkgName.startsWith('@mycompany/') && !['gaearon', 'sindresorhus'].includes(author)) {
      console.warn([WARNING] Untrusted author for ${pkgName}: ${author});
    }
  } catch (e) {
    // npm view 失败可能是私有包,跳过
  }
}

function main() {
  const pkgPath = path.resolve(process.cwd(), 'package.json');
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
  
  const allDeps = {
    ...pkg.dependencies,
    ...pkg.devDependencies,
    ...pkg.optionalDependencies
  };

  for (const [name, version] of Object.entries(allDeps)) {
    verifyPackage(name, version);
  }
}

main();

这段代码的核心思路是:在安装前做一次“安检”。虽然不能 100% 防住所有攻击(比如包名很正常的恶意包),但能挡住大部分低级攻击。而且因为是在 preinstall 阶段运行,就算检测到问题,也不会污染 node_modules。

这里我踩了个坑:一开始直接用 npm view 查包信息,结果在 CI 环境里因为网络问题超时,导致整个 install 失败。后来加了 try-catch,并且只对非私有包做检查,才算稳定下来。

更彻底的方案:用私有 npm 代理 + 自动扫描

上面那个脚本适合小团队快速上手,但长期来看还是得靠基础设施。我们后来在公司内网搭了个 Verdaccio 私有 npm 代理,配合 Trivy 做自动扫描。

配置 Verdaccio 很简单,在 config.yaml 里加个中间件:

middlewares:
  audit:
    enabled: true

然后写个脚本,每次有新包发布到私有源时,自动调用 Trivy 扫描:

# scan-package.sh
PACKAGE_NAME=$1
PACKAGE_VERSION=$2

# 从 npm 下载 tgz
npm pack $PACKAGE_NAME@$PACKAGE_VERSION

# 用 Trivy 扫描
trivy fs --security-checks vuln,config,malware ./$PACKAGE_NAME-$PACKAGE_VERSION.tgz

# 如果有高危问题,拒绝入库
if [ $? -ne 0 ]; then
  echo "Malware detected! Rejecting $PACKAGE_NAME@$PACKAGE_VERSION"
  exit 1
fi

这样所有依赖都必须经过安全扫描才能进私有源,开发机装包时自然就安全了。不过这套方案要运维成本,小团队可能觉得重了点。

踩坑提醒:这三点一定注意

  • 别信模糊版本号^1.2.3 看着方便,但可能半夜就给你升个带后门的 patch。生产项目建议锁死版本,或者用 ~ 只允许 patch 升级(但也要配合 lock 文件)。
  • 定期清理 unused deps:我用 depcheck 扫了一次,发现项目里有 17 个完全没用的依赖,其中 3 个还是高危漏洞。删掉它们,攻击面直接缩小。
  • 别在 CI 里用 –legacy-peer-deps:这参数会跳过 peer dependency 冲突检查,但也会让一些恶意包绕过依赖解析。宁可花时间解决冲突,也别关安全检查。

改完这些后,虽然偶尔还会收到 npm 的安全警告邮件,但至少没再出现过“神秘代码”了。当然,现在方案也不是完美的——比如那个 verify-deps 脚本,如果恶意包名字起得很 normal(比如叫 utils-helper),还是可能漏掉。但总比啥防护没有强。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有人用 Snyk 或 Socket.dev?听说它们能实时监控依赖变更,但我还没试过。这个领域的水还挺深,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论