1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

npmの連続侵害から学ぶ--ignore-scriptsでは防げない攻撃

1
Posted at

2026年7月、正規のnpmパッケージに攻撃コードが混入する事件が2件続きました。
どちらでも、パッケージを読み込んだ時点で攻撃コードが走るバージョンが公開されています。

これまでは、パッケージを入れるときに自動実行されるスクリプトに攻撃コードを仕込むのが主流でした。
今回の一部のバージョンでは、攻撃コードがパッケージ本体の先頭に置かれ、アプリやCIがそのパッケージを読み込んだ時点で走ります。

対策として共有されてきたnpm install --ignore-scriptsは、installスクリプトの実行を止めるものです。
2026-07-08に出たnpm v12では、依存パッケージのinstallスクリプトが既定で実行されなくなりました。
どちらも止めるのはinstallスクリプトで、読み込んだ時点で走るコードは守備範囲の外です

7月14日に差し替えられた@asyncapiのマルウェアには、資格情報を集めるコードが入っていました。
対象は100を超える環境変数名と、.npmrc.aws/credentials・SSH鍵・Dockerの設定です。
ただしMicrosoftが解析したビルドでは、この収集機能は無効化されていたと報告されています(Microsoft Security Blog)。

読み込んだ時点で走るなら、攻撃コードが触れるのはビルドやテストのプロセスが参照できる環境変数とファイルです。

installの時ではなく、読み込んだ時点で走る

npmはパッケージを入れるとき、そのパッケージのpackage.jsonに書かれたpreinstallinstallpostinstallを自動で実行します。
--ignore-scriptsはこの自動実行を止めます。

今回確認された読み込み時実行型のバージョンでは、パッケージ本体のコード(dist/index.jsなど)の先頭に攻撃コードが置かれていました。
installスクリプトを使わないため、インストールしただけでは攻撃コードは実行されません。

Microsoftも@asyncapiについて同じ説明をしています。
よくあるpostinstallフック型とは違い、モジュールの読み込み(import/require)の時点で実行される、というものです(Microsoft Security Blog)。

7月11日、JavaScriptの難読化サービスであるJscramblerの、npm公開用の認証情報が奪われました。
先に出た8.14.0・8.16.0・8.17.0はpreinstallから起動します。
後の8.18.0・8.20.0では、dist/index.jsdist/bin/jscrambler.jsの先頭に即時実行関数が埋め込まれました。
パッケージを読み込むか、CLIを実行した時点で同じマルウェアが起動します(Socket)。

同じ攻撃者が、数時間のうちに実行経路を変えたことになります
npm v12との因果関係は、時系列だけでは確かめられません。
ただ、installスクリプトに頼らない実行方法が実際に使われた以上、installを塞ぐだけでは足りないことになります。

7月14日の@asyncapiは4パッケージ・計5バージョンが差し替えられ、こちらは最初から読み込んだ時点での実行でした。

環境

  • macOS 26.5.1 (Apple Silicon)
  • Node.js v25.9.0 / npm 11.12.1
  • 2026-07-28時点

実際に動かして確かめる

--ignore-scriptsを付けて入れてもrequireした時点でパッケージ本体のコードが動くことを、無害な自作パッケージで確かめます。

JavaScriptのモジュールはトップレベルに書いた文がそのまま評価されるので、読み込んだ時点で走るコードはindex.jsの1行目に1文置くだけで再現できます。
用意したのは、どのタイミングで実行されたかを1行出力するだけの確認用パッケージです。

marker-pkg/package.json
{
  "name": "marker-pkg",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "postinstall": "node postinstall.js"
  }
}
marker-pkg/index.js
console.log("[marker-pkg] index.js のトップレベルが実行された");
module.exports = { hello: () => "hi" };

postinstall.jsも、実行されたことを1行出力するだけです。
npm packでtgzにして、--ignore-scripts付きで入れます。

# --foreground-scripts はスクリプトの出力を隠さないための指定
npm install --ignore-scripts --foreground-scripts ../marker-pkg/marker-pkg-1.0.0.tgz
added 1 package, and audited 2 packages in 520ms

found 0 vulnerabilities

postinstallは走りませんでした。
この状態のまま読み込みます。

node -e "require('marker-pkg')"
[marker-pkg] index.js のトップレベルが実行された

import()でも出力は同じでした。
--ignore-scriptsを外して入れ直すと、postinstallのほうは走ります。

> marker-pkg@1.0.0 postinstall
[marker-pkg] postinstall が実行された
added 1 package, and audited 2 packages in 438ms

--ignore-scriptsが止めるのはinstallスクリプトで、パッケージ本体のトップレベルのコードは止めません

--ignore-scriptsとnpm v12が止める範囲

攻撃コードの実行契機 --ignore-scripts npm v12の既定
依存のpreinstall/install/postinstall 止まる 止まる
パッケージ本体のimport/require、CLIの実行 止まらない 止まらない

npm v12は依存パッケージのinstallスクリプトを既定でブロックし、package.jsonallowScriptsで許可した依存だけを実行します(npm Docs)。

既知の侵害バージョンが入っていないか調べる

@asyncapiの攻撃コードは、正規のソースファイルの1行目に、大量の空白で右へ押し出す形で置かれていました。
エディタでは画面の外に出るので、目で追うのではなくバージョン番号で機械的に判定します。

侵害されたのは次のバージョン。

  • jscrambler 8.14.0 / 8.16.0 / 8.17.0 / 8.18.0 / 8.20.0 (8.13.0・8.15.0・8.22.0は影響なし)
  • @asyncapi/specs 6.11.2 と 6.11.2-alpha.1
  • @asyncapi/generator 3.3.1 / @asyncapi/generator-components 0.7.1 / @asyncapi/generator-helpers 1.1.1

直接依存に書いていなくても、プラグインやツール経由で依存ツリーに入っていることがあります。
npm ls <パッケージ名>node_modulesを見るので、まだinstallしていないCIの段階では確認できません。
package-lock.jsonから既知の侵害バージョンを確認するなら、こんなスクリプトで済みます。

check-lock.mjs
// package-lock.json を読み、affected に載ったバージョンが混ざっていないか調べる
import { readFileSync } from "node:fs";

const affected = {
  "jscrambler": ["8.14.0", "8.16.0", "8.17.0", "8.18.0", "8.20.0"],
  "@asyncapi/specs": ["6.11.2", "6.11.2-alpha.1"],
  "@asyncapi/generator": ["3.3.1"],
  "@asyncapi/generator-components": ["0.7.1"],
  "@asyncapi/generator-helpers": ["1.1.1"],
};

const lock = JSON.parse(readFileSync("./package-lock.json", "utf8"));
const hits = [];
for (const [path, info] of Object.entries(lock.packages ?? {})) {
  const name = info.name ?? path.split("node_modules/").pop();
  if (affected[name]?.includes(info.version)) hits.push(`${name}@${info.version} (${path})`);
}

console.log(hits.length ? hits.join("\n") : "該当なし");
process.exit(hits.length ? 1 : 0);
$ node check-lock.mjs
該当なし

検証用に該当バージョンをaffectedへ足すと、該当行が出て終了コードは1になりました。
スコープ付き(@から始まる名前)でも同じように拾えます。

見ているのはnpmのpackage-lock.jsonpackagesだけなので、yarnやpnpmのlockfileには使えません。
このスクリプトで分かるのは、該当バージョンがlockfileに載っているかどうかだけです。
実際にインストールまたは実行されたか、資格情報が流出したかまでは判定できません。

新しい版をすぐ取り込まない

  • lockfileをリポジトリに固定し、CIではnpm ciを使う。npm ciはlockfileを書き換えず、記録された版をそのまま入れます
  • 公開直後の版をすぐ取り込まない。npm CLI v11.10.0以降のmin-release-ageなら、公開から指定した日数が経っていない版を除外できます
  • 不要な依存を減らす。推移依存も含めて、読み込むコードの量を小さくします

Socketはjscrambler 8.14.0を公開の6分後に検出しています。
それでも同じ攻撃者は、修復の試みと交互に、約3時間のあいだに4つの版を追加で公開しました。
検出の速さで先回りするより、取り込む速度を落とすほうが現実的ではないでしょうか。

資格情報の範囲と寿命を絞る

npmで入れたパッケージが読み込まれた時点で走るなら、攻撃コードが触れるのはそのCIプロセスの権限で参照できるものです。
被害を限定するには、そのプロセスへ渡す資格情報を最小限にし、権限と有効期間も絞る必要があります。

  • CIの資格情報は、そのジョブに必要なものだけを渡す
  • 長命のトークンを置かず、期限付きのものに寄せる
  • ビルドするジョブと、公開用の認証情報を持つジョブを分ける
  • 侵害された版を読み込んだ可能性があるなら、きれいな端末から資格情報を入れ替える

侵害された経路と、攻撃コードが走る経路は別の話です。
ただ、自分が公開する側なら同じ問題になります。

@asyncapiでは、pull_request_targetで動くGitHub Actionsが、PR側のコードをチェックアウトして実行していました(Wiz)。
このイベントはベースリポジトリ側の権限で走るため、外部PRのコードをそこで実行すると、ワークフローへ渡された資格情報を盗まれる危険があります。
実際に盗まれたのは、組織全体にアクセスできるasyncapi-botのPATでした。

まとめ

  • --ignore-scriptsとnpm v12の既定が防ぐのはinstallスクリプトだけで、読み込んだ時点で走る攻撃コードは防げない
  • Jscramblerの侵害では、数時間のうちにpreinstallから読み込み・CLI実行時への切り替えが起きた
  • 既知の侵害バージョンはlockfileから機械的に判定できる。ただし実際に実行されたかどうかまでは分からない
  • 重要になるのは、新しい版をすぐ取り込まないことと、資格情報のスコープと寿命を短くすること

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?