【npm】2025年8月26日以降のパッケージの危険性と対策【Shai-Hulud】

こんにちは、フリーランスエンジニアの太田雅昭です。

2025年9月18日現在、npmは危険性がある

現在web開発などで主流となっているJavaScriptによる開発は、npmが必須となっています。しかし2025年9月18日現在、npmが危険にさらされています。

https://zenn.dev/o_sup/articles/0580cf66b3f334

これによると、感染者のnpmパッケージからさらに拡大するといった現象が起こっているようです。そのため攻撃のあった2025年8月26日以降に更新されたnpmパッケージには、感染されている可能性があります。

対策

詳細は上記URLを参照いただいた方がいいかと思いますが、ひとまず対応できるのは下記かと思います。

Aikido safe chainを利用する

これがもっとも楽で確実かもしれません。npm, pnpmなどをラップして警告してくれるツールがあります。

https://github.com/AikidoSec/safe-chain

以下で解説されています。

https://zenn.dev/nix/articles/0a2910ec65b4a3

2025年8月26日以降更新のパッケージを洗い出す

下記のコードで、サブディレクトリを辿って更新日の抽出が可能です。

#!/usr/bin/env node
'use strict';

(async () => {
  const fs = await import('node:fs/promises');
  const path = await import('node:path');

  const ROOT = process.cwd();
  const IGNORE_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', 'coverage', 'out', 'tmp']);

  async function findPackageJsons(dir) {
    const out = [];
    async function walk(d) {
      let entries;
      try {
        entries = await fs.readdir(d, { withFileTypes: true });
      } catch {
        return;
      }
      for (const ent of entries) {
        if (ent.isDirectory()) {
          if (IGNORE_DIRS.has(ent.name)) continue;
          await walk(path.join(d, ent.name));
        } else if (ent.isFile() && ent.name === 'package.json') {
          out.push(path.join(d, ent.name));
        }
      }
    }
    await walk(dir);
    return out;
  }

  async function collectDeps(pkgFile) {
    try {
      const raw = await fs.readFile(pkgFile, 'utf8');
      const j = JSON.parse(raw);
      const depObjs = [j.dependencies, j.devDependencies, j.optionalDependencies].filter(Boolean);
      const names = new Set();
      for (const obj of depObjs) for (const k of Object.keys(obj)) names.add(k);
      return [...names];
    } catch {
      return [];
    }
  }

  async function fetchLatestTime(pkg) {
    const url = 'https://registry.npmjs.org/' + encodeURIComponent(pkg);
    let res;
    try {
      res = await fetch(url, { headers: { 'Accept': 'application/json' } });
    } catch {
      return null;
    }
    if (!res.ok) return null;
    let data;
    try {
      data = await res.json();
    } catch {
      return null;
    }
    const time = data && data.time;
    if (!time || typeof time !== 'object') return null;

    let latestIso = null;
    let latestTs = -Infinity;
    for (const [k, v] of Object.entries(time)) {
      if (k === 'created') continue;
      if (typeof v !== 'string') continue;
      const ts = Date.parse(v);
      if (Number.isFinite(ts) && ts > latestTs) {
        latestTs = ts;
        latestIso = v;
      }
    }
    return latestIso;
  }

  async function mapPool(items, limit, fn) {
    const results = new Array(items.length);
    let i = 0, running = 0;
    return await new Promise((resolve) => {
      const run = () => {
        while (running < limit && i < items.length) {
          const idx = i++;
          running++;
          Promise.resolve(fn(items[idx], idx))
            .then((r) => { results[idx] = r; })
            .catch(() => { results[idx] = null; })
            .finally(() => {
              running--;
              if (i >= items.length && running === 0) resolve(results);
              else run();
            });
        }
      };
      run();
    });
  }

  const pkgFiles = await findPackageJsons(ROOT);
  const depLists = await Promise.all(pkgFiles.map(collectDeps));
  const allDeps = new Set(depLists.flat());
  const deps = [...allDeps].sort();

  const dates = await mapPool(deps, 12, fetchLatestTime);
  const pairs = deps.map((pkg, idx) => ({ pkg, date: dates[idx] })).filter(x => Boolean(x.date));
  pairs.sort((a, b) => String(a.date).localeCompare(String(b.date))); // ISOは文字列ソートでOK
  for (const { pkg, date } of pairs) console.log(`${date} ${pkg}`);
})().catch((e) => {
  console.error(e);
  process.exit(1);
});

実行してみます。

% node check-npm.js
2022-06-13T02:21:09.392Z accept-language-parser
2022-06-18T00:22:51.890Z ffprobe
2022-06-18T00:22:53.893Z ffprobe-static
2022-06-22T05:28:01.927Z normalize-email
2023-07-12T19:06:34.291Z bytes
2023-08-08T23:46:05.829Z @paralleldrive/cuid2
2023-08-20T09:44:13.419Z js-cookie
2023-09-21T20:42:57.671Z handlebars
2023-12-12T05:09:10.608Z react-otp-input
2024-09-21T10:47:38.336Z @oslojs/crypto
2024-09-24T11:08:24.245Z @oslojs/encoding
2024-10-23T01:17:32.656Z @types/file-type
2024-12-09T08:43:28.232Z @emotion/react
2025-01-10T00:35:01.616Z jsonwebtoken
...

このように、すべてのパッケージの最終更新日が出るようになりました。これを使って、2025年8月26日以降更新のパッケージを洗い出せます。

危険なパッケージを確認する

下記ページで、今回の件で危険とされるパッケージが公開されています。

バージョンを固定する

一旦パッケージのバージョンは固定しておいた方がいいかと思います。

pnpm workspaceの場合は、catalog機能を使うと一括で固定できるので便利です。