← すべての投稿に戻る
公開日 · 著者 Renaud Deraison

古い名前のままでも公開できた

10 月 13 日、Bloom Security が SlugFest を公表した。npm のトラステッドパブリッシングは GitHub リポジトリを「オーナー/名前」の文字列で照合していたため、名前を変更されたり削除されたりしたリポジトリの旧名を手に入れた人は誰でも、認証情報を一切盗むことなく、他人のパッケージの新バージョンを公開できた。Bloom は合計 1,000 万回以上ダウンロードされた 1,500 超のパッケージが影響を受けると数え、誰も見ていないところで新バージョンを取り込むインストール元として AI エージェントを挙げる。Bromure Agentic Coding では、そこまで新しいバージョンはエージェントにとって存在せず、インストールは身代わりしか置かれていない VM の中で走り、穴を開けるリネームはクリックを待つ。

npm のトラステッドパブリッシングは、リポジトリの名前は覚えていたが、それがどのリポジトリだったかは覚えていなかった。メンテナが名前を変えると、その名前で次にリポジトリを作った人が公開できた。

10 月 13 日(月)、Bloom Security の Golan Myers が SlugFest を公表した。npm のトラステッドパブリッシングの欠陥だ。The Hacker News も同日に報じている。Myers は錠前にたとえて説明する。「家の持ち主が誰かではなく、ドアの住所を確かめる錠前のようなものだ。持ち主が引っ越せば、次に越してきた人が鍵を手にする」

Bloom が数えた影響パッケージは 1,500 超、合計ダウンロード数は 1,000 万回以上、月間でも 100 万回を超える。攻撃に必要なのは「内部情報でも、認証情報の窃取でも、競合状態でもない」。Bloom はパッケージ名を公表していない。「問題がまだ広く残っているため、影響を受けるパッケージの一覧は公開しない」

Bloom は、いまこうしたパッケージを誰がインストールしているかも指摘する。同社の集計では、コーディングエージェントは平均で月 70 パッケージ、開発者のマシンは約 350 パッケージをインストールする。Bloom の言葉を借りれば、取り戻された名前から公開された悪意あるバージョンは「取り込まれ、人間の介在なしに実行される」。

トラステッドパブリッシングはリポジトリをどう確かめていたか

npm は 2025 年 7 月にトラステッドパブリッシングを全員に開放した。CI に npm トークンを保存する代わりに、メンテナは npm trust github --allow-publish を実行し、どの GitHub リポジトリのどのワークフローファイルがパッケージを公開してよいかを npm に登録する。公開時には GitHub Actions がワークフロー実行を記述した短命の OIDC トークンを発行し、npm はそれをバインディングと照合して公開用の認証情報を返す。盗める長期のシークレットは存在しない。

バインディングはリポジトリをスラッグ owner/repo として、ワークフローのファイル名と一緒に保存していた。GitHub が恒久的に割り当てる数値の repository_id と repository_owner_id は保存していなかった。npm がそれらを記録するのは、最初の公開が成功したあとだった。それまでは、正しい名前を名乗り、正しいワークフローファイルを実行するリポジトリなら、どれでも一致した。

GitHub の名前は空く。メンテナがリポジトリの名前を変える、組織に移管する、削除する、あるいは自分のアカウント名を変える。古いスラッグは空きになり、別の誰かが登録できる。Bloom の手順は次のとおり。

  1. 空いたスラッグでリポジトリを作る。GitHub は新しい数値 ID を割り当てる。
  2. バインディングと同じファイル名で、id-token: write 権限を持つワークフローを追加し、実行する。
  3. GitHub は、リポジトリのクレームが古いスラッグである OIDC トークンを発行する。
  4. npm の交換エンドポイントはスラッグとファイル名を照合し、公開用の認証情報を発行する。数値 ID はトークンに入っているが、npm には比べる相手がなかった。
  5. 自分のものではないパッケージの新バージョンを、その名前、そのダウンロード数、それに依存するすべてのプロジェクトごと公開する。

Bloom はこのチェーンを、自社が管理する npm スコープとリポジトリに対して端から端まで実行し、スラッグだけが一致する 2 つ目の組織から公開してみせた。GitLab のトラステッドパブリッシングにも同じ穴があった。

こうしてできたリリースには署名まで付く。公開リポジトリからのトラステッドパブリッシングはプロヴェナンス証明を生成し、この証明は、メンテナが設定したスラッグの下で、攻撃者のリポジトリのワークフロー実行を指すことになる。8 月には、 プルリクエストへのたった 2 語のコメント で、あるプロジェクト自身のリリースパイプラインが、有効なプロヴェナンス付きの汚染されたバージョンを 10 個公開した。SlugFest は被害者のパイプラインすら必要としない。

空いた名前、一致するワークフロー、公開用の認証情報npm trust バインディングrepo: acme/sdkworkflow: release.ymlrepo_id: (保存なし)メンテナが改名acme/sdk → acme/sdk-v2「acme/sdk」が空き、再登録できる攻撃者の新リポジトリ acme/sdkrepository_id: new.github/workflows/release.ymlpermissions: id-token: writeOIDC トークン:repository = acme/sdknpm の交換:slug ✓ workflow ✓ID:比べる相手がない→ 公開用の認証情報を発行npm レジストリ@acme/[email protected]latest タグ付きプロヴェナンス付きコーディングエージェント:npm install @acme/sdklatest を解決し、誰も見ずに実行名前は例示。Bloom はこの状態のパッケージを 1,500 超見つけたが、一覧は公開していない。
SlugFest のチェーン。バインディングは名前を保存し、リネームでその名前が空き、旧名で作られた新しいリポジトリが npm のチェックを通って元のパッケージとして公開する。

修正まで 4 か月

Bloom は 6 月 6 日、GitHub の HackerOne プログラムを通じてこの欠陥を報告した。 GitHub は 6 月 9 日に妥当性を認めた。中身のある最後の返信は 7 月 8 日で、チームは「問題の修正と、そのリスクと深刻度の判定に取り組んでいる」というものだった。6 回の問い合わせと HackerOne への仲裁依頼には返事がなかった。

GitHub の 10 月 2 日の changelog に修正が載っている。作成から 48 時間以内に一度も公開していないトラステッドパブリッシングのバインディングは失効し、もう公開を承認できない。最初の公開が成功したあと、バインディングはリポジトリの数値 ID に結び付けられる。リポジトリを変えるには、新しい 48 時間の猶予つきで新しいバインディングが必要になる。npm は issue_comment ワークフローが発行したトークンも拒否するようになった。8 月の攻撃の引き金だ。

changelog は、10 月 2 日より前に作られたバインディングがどうなったかには触れていない。Bloom がメンテナに勧めるのは、すべてのパッケージで npm trust list を実行し、各スラッグがまだ自分の管理するリポジトリを指しているか確かめ、リネームや移管のあとには必ずバインディングを作り直すことだ。

パッケージを公開する側ではなくインストールする側なら、この助言は何の役にも立たない。パッケージのトラストバインディングは npm install からは見えない。 1,500 の名前は公表されていない。取り戻されたスラッグからの悪意あるリリースは、プロヴェナンスも含めて、ほかのリリースと見分けがつかない。エージェントの場合についての Bloom 自身の一言は「エージェントが選ぶパッケージでありさえすればいい」だ。

同じインストールを Bromure のマシンから

Bromure Agentic Coding では、エージェントは Mac 上の Linux VM で動き、VM が出すあらゆるリクエストは、registry.npmjs.org も含めて、ホスト上のプロキシを通る。サプライチェーンのポリシーはこのプロキシの中、VM の外で動くので、マシン内でエージェントやパッケージが何をしても変えられない。SlugFest のリリースを Bromure のマシンまで追ってみよう。

それほど新しいバージョンは一覧に出ない

経過日数ゲートは、新しいマシンすべてで既定で有効になっており、最小値は 2 日だ。 npm がレジストリに @acme/sdk を問い合わせると、プロキシは直近 2 日以内に公開されたバージョンをすべて一覧から取り除き、latest を十分に古い最新バージョンへ移す。

エージェントはどこでも使うのと同じ npm install @acme/sdk を実行し、メンテナの最後の本物のリリースを受け取る。攻撃者のバージョンは、エージェントが見る一覧に載っていない。Web ページや issue に言われてエージェントがバージョン番号を指定しても、tarball のリクエストには HTTP 451 が返り、npm はその理由をエージェントのターミナルに表示する。

Bromure Supply-Chain Security blocked this request: npm package @acme/[email protected]
published 3 hours ago — policy requires 2 days minimum

ゲートが見るのは公開日だけなので、Bloom の一覧もマルウェアのシグネチャもいらない。スラッグを取り戻した攻撃者は新バージョンを公開するしかなく、新バージョンは若いバージョンだ。エージェントが前のバージョンをインストールしている間に、メンテナ、 npm、スキャナーには、チームの誰も出していないリリースに気づくための 2 日間がある。一度も使われていないバインディングを持つメンテナには、それが初めて公開したときに確かめる十分な理由がある。

2 日後のさらなるチェック

マシンの Supply Chain ペインでほかの層も有効にすれば、バージョンはゲートを越えたところでそれらに出会う。socket.dev を選ぶと、すべての tarball が配信前に検査され、マルウェアと判定されたパッケージには 451 が返る。OSV チェックを有効にすると、公開済みのアドバイザリがあるバージョンは選んだ深刻度でブロックされる。 Strip preinstall / install / postinstall / prepare from npm tarballs on the fly を有効にすると、プロキシは npm が tarball を見る前にインストールフックを取り除き、ほとんどの npm マルウェアが頼る配送経路をなくす。

ペイロードが見つけるのは身代わり

それでもリリースが入り込み、実行されたとしよう。実行されるのは VM の中で、そこにある認証情報は身代わりだ。GH_TOKEN、~/.git-credentials、Anthropic のキー、 AWS のキー。どれも本物と同じ形の文字列で、本物の値とインストールごとのソルトから導かれ、Mac 上のプロキシを経由して本来のホストへ送るリクエスト以外では使い道がない。ペイロードがそれを自分のサーバーへ送っても、どこでも認証に失敗する文字列を送るだけだ。

そのリクエストもプロキシを通る。プロキシは、間違ったホストへ向かう身代わりがないか、すべての送信リクエストを調べる。収集サーバーへ向かう ghp_ の身代わりには 451 が返り、VM は一時停止し、Mac には This environment may have been compromised が開いて、認証情報の名前、その本来のホスト、送信先のホストが表示される。

リネームはクリックを待つ

SlugFest はリネーム、移管、あるいはリポジトリの削除から始まる。そしてエージェントもそうした片付けをする。Bromure のマシンでは、GitHub は既定で Prompt before write の下にある。リポジトリのリネームは GitHub API への PATCH、削除は DELETE だ。どちらも macOS のダイアログ Allow write on "github.com" from workspace "…"? で止まり、通信上に現れたままのメソッドとパスが表示される。エージェントがプロジェクトを整理しようと acme/sdk を acme/sdk-v2 にリネームすれば、旧名が空く前にそれが見え、どの npm バインディングを作り直すべきかもわかる。

一覧が出たときに

Bloom はいずれ影響パッケージを公表する。そのとき誰かが、どのマシンがいつそれをインストールしたのかを尋ねるだろう。Security Timeline は、ポリシーにかかわらず、すべてのマシンのすべてのパッケージ取得を記録する。npm · @acme/[email protected]、マシン、時刻、そしてダウンロードされたのか、経過日数ゲートで保留されたのか、ブロックされたのか。パッケージ名で絞り込み、結果を CSV に書き出せばいい。記録は Mac 上にあり、エージェントは編集できない。

Bromure では:エージェントにとって新バージョンはまだ存在しないマシンの VM(Ubuntu)$ npm install @acme/sdk+ @acme/[email protected]$ npm i @acme/[email protected]451 published 3 hours agoGH_TOKEN=ghp_… (身代わり)ANTHROPIC_API_KEY=sk-ant-api03-brm-…VM に盗める本物の秘密はないプロキシ(Mac 上)経過日数ゲート(既定 2 日):2 日未満は除外、latest → 4.1.3オプション:socket.dev、OSV、インストールスクリプト除去身代わりが別ホストへ → 451、VM 停止PATCH /repos/acme/sdk(改名)→ Prompt before write ダイアログポリシーはホスト上、エージェントの手の外npm レジストリ4.2.0 3 時間前取り戻されたスラッグから4.1.3 5 か月前メンテナのリリースメンテナ、npm、スキャナーが気づくための 2 日間Security Timeline(Mac 上)npm · @acme/[email protected] · ダウンロード済み · マシン、時刻 · パッケージで絞り込み、一覧が出たら CSV で書き出し
同じインストールを Bromure のマシンから。新しいバージョンはエージェントが見る一覧になく、番号を指定したリクエストには 451 が返り、それでも動いたペイロードが見つけるのは身代わりだけで、すべての取得が記録に残る。

新バージョンは一覧に出ない

既定で 2 日に設定された経過日数ゲートが、新しいバージョンをレジストリの一覧から外し、latest を巻き戻す。エージェントは何が起きたかも知らずに、メンテナの最後の本物のリリースをインストールする。

番号指定のリクエストには 451

エージェントが新バージョンを番号で要求すると、ダウンロードは拒否され、npm はパッケージの経過時間とポリシーをエージェントのターミナルに表示する。

ペイロードが見つけるのは身代わり

VM の中で動くものが見つけるのは身代わりのトークンだけだ。間違ったホストへ送れば 451 が返り、VM は一時停止し、Mac に警告が出る。

リネームもインストールも記録に残る

Prompt before write が、スラッグを空けるリポジトリのリネームを止める。Security Timeline は、すべてのパッケージ取得をマシンとバージョンごとに記録する。

次の名前

GitHub の修正は、最初の公開後にバインディングをリポジトリの数値 ID に結び付け、一度も公開しないバインディングを失効させる。これでこの穴はふさがる。トラステッドパブリッシングは公開用の認証情報を守り、どのパイプラインがリリースをビルドしたかを教えてくれる。8 月、そのパイプラインは被害者自身のもので、見知らぬ人のコメントで起動された。今回は、見知らぬ人のリポジトリが正しい名前を名乗った。どちらのリリースにもプロヴェナンスが付いていた。

あなたのエージェントは、リゾルバが返すものを何でもインストールする。Bloom の集計では月 70 パッケージだ。エージェントは Bromure のマシンで動かそう。そこでは公開 2 日以内のリリースはまだ存在せず、それでも動いたペイロードが見つけるのは身代わりだけだ。経過日数ゲートについては、あのパッケージは誰でも公開できた と トークンの失効が引き金だった を読んでほしい。