あのパッケージは誰でも公開できた
8 月 28 日、誰かが人気の OpenAPI コードジェネレーターにプルリクエストを開き、そこに二語のコメントを書き込みました。すると、そのプロジェクト自身のリリースワークフローが、有効なプロヴェナンス証明つきで汚染された 10 個のバージョンを npm へ公開しました。攻撃者は資格情報を一切持っていません。第一波にはインストールスクリプトすらなく、node-gyp が Python を評価する場所にペイロードを隠していました。窓が開いていたのは合計 3 時間 11 分。そして Bromure Agentic Coding の経過日数ゲートは、既定で二日に設定されており、攻撃について何も知らないままこの時間に勝ちます。
誰かがフォークからプルリクエストを開き、そこに二語のコメントを残しました。 プロジェクトのリリースパイプラインはそのコメントを読み、その人のコードを チェックアウトし、実行し、出てきたものに署名しました。そこに至るまでに、 彼らは何ひとつ盗んでいません。
@7nohe/openapi-react-query-codegen は OpenAPI スキーマを TanStack Query の
フックに変換します。仕様を指し示せば、クライアントコードを書いてくれます。
Aikido は週あたり 15 万件を超えるダウンロードを数えています。こういうパッケージ
を入れるのは、午後の半日が浮くからで、コーディングエージェントは二度と尋ねずに
それをインストールします。
8 月 28 日、それは資格情報スティーラーを配り始めました。
Aikido Security はその日のうちに分析を公開し、 SafeDep も独自の分析を ワークフローの仕組みと分単位のタイムラインつきで出しました。マルウェアは自分の 文字列の中で自らを名乗っています。Trinitite です。両チームともその手口を、 今年ずっとメンテナーのアカウントを渡り歩いている自己複製型 npm ワーム、 Mini Shai-Hulud の系譜に位置づけています。
読む価値があるのは、それが npm にたどり着いた経路です。
リリースパイプラインは見知らぬ他人の指示を受け取った
プロジェクトの release.yml には issue_comment トリガーがありました。これは
誰かが Issue やプルリクエストにコメントしたときに発火する GitHub Actions の
イベントで、よくある便利機能です。メンテナーは UI をクリックして回る代わりに、
一語打つだけでリリースできるようこれを配線します。
このワークフローには author-association のチェックがありませんでした。GitHub は ワークフローに対して、コメントを書いた人がリポジトリのオーナーなのか、 メンバーなのか、過去のコントリビューターなのか、まったくの他人なのかを伝えます。 あとはワークフローがそれを見るかどうかです。これは見ませんでした。
SafeDep がその手順を追いました。フォークからプルリクエストを開く。そこに
npm publish とコメントする。パイプラインが目を覚まし、攻撃者のフォークに
あるコードをチェックアウトし、pnpm install を走らせる。それが攻撃者の
preinstall フックを、プロジェクト自身のランナー上で実行します。そのジョブは
id-token: write を持っていました。npm の OIDC トラステッドパブリッシング用
トークンを発行させるには十分です。
10 個の悪意あるバージョンが出ていきました。そのどれもが有効なプロヴェナンス 証明を携えています。プロジェクト自身のパイプラインが、そのすべてをビルドし 公開したからです。覚えておくべきは Aikido の一文です。「ワークフロー自体が 侵害されている場合、その証明書は信頼できないトラストシグナルになる。」
パッケージのプロヴェナンスを確認して分かることは一つだけです。それがそのリポジ トリのパイプラインから出てきたかどうか。ここでは、出てきていました。誰も何も 偽造していませんし、正しい署名がマルウェアの上に載っていても、どちらの事実も おかしく見えません。5 月には 同じ結末が盗まれた OIDC トークン経由で届くのを見ました。 今回、攻撃者は盗みの工程を省いています。
第一波には見つけるべきインストールスクリプトがなかった
最初の 8 バージョンを危険なインストールフックで走査しても、何も出てきません。
それらの package.json は、実行されるものを何ひとつ宣言していないのです。
ペイロードは binding.gyp の中にありました。node-gyp がネイティブモジュール
のコンパイルに使うビルド記述ファイルです。Aikido がそのからくりを説明しています。
「npm install が binding.gyp を含むパッケージを処理すると、ネイティブ
モジュールをコンパイルするために node-gyp が呼ばれる。node-gyp はこの
ファイルの conditions フィールドを Python で評価する。つまり、任意の Python 式
をそこに置けて、package.json に preinstall スクリプトが宣言されていなくても、
インストール中にそれが実行される。」
そこで攻撃者は、ビルドファイルの中に Python の式を書きました。それは Python の
内部サブクラスツリーを catch_warnings にたどり着くまで歩き、そのクラスを使って
__builtins__ に手を伸ばし、そこから os をインポートして、tarball に同梱した
JavaScript ファイルに対して os.system() を呼びます。SafeDep は、この走査が
Unicode エスケープで書かれているため、os.system を grep しても何も出てこないと
指摘しています。
その下の JavaScript は三つの層 — XOR、次に AES-128-GCM、次に商用の難読化ツール
— をほどいてから、GitHub から Bun 1.4.0 を trinnyyyy- を接頭辞とする一時
ディレクトリに落とし、本物のペイロードを走らせます。
その 19 分後、攻撃者は package.json に preinstall を予備として書き戻した
第二波を出しました。SafeDep はこの波を、最初の公開セキュリティ報告から一分と
経たないうちと日付づけています。これを回していた人物は、公開情報を読みながら、
それに対抗して出荷していたわけです。
3 時間 11 分
SafeDep のタイムラインは UTC です。最後の正常なリリース 3.0.2 は 8 月 11 日 に出ています。第一波は 8 月 28 日 20:00 に着地し、20:02 に終わりました。 第二波は 20:19 から 20:21。npm が 10 個すべてを引き上げたのは、およそ 23:11 です。
残るのは 3 時間 11 分の露出窓です。^3.0.0 を使っているプロジェクト、つまり
npm があなたの代わりに書く当たり前のキャレット範囲は、まっすぐそこへ解決しました。
その窓の中でクリーンインストールを走らせたなら、あるいは CI が走らせたなら、
コーディングエージェントが走らせたなら、あなたはワームを手に入れました。
いったん走り出すと、それは 150 を超えるグロブパターンでホームディレクトリを
さらいました。SSH の秘密鍵、.env ファイル、~/.docker/config.json、AWS・
Azure・GCP の資格情報、npm・PyPI・RubyGems のトークン、Kubernetes の
サービスアカウントトークン、暗号通貨のウォレット。同じリストの上に
~/.claude.json、~/.claude/、~/.claude/mcp.json が並んでいました。
コーディングエージェント自身の設定と、それが話しかけるすべての MCP サーバーの
アドレスです。
手の届くリポジトリには .claude/settings.json を書き込み、そこに
開発者がそのプロジェクトを Claude Code で開くたびに setup.mjs を走らせる
SessionStart フックを仕込みました。集めたばかりのレジストリトークンを使って、
被害者が npm・PyPI・RubyGems で保守しているすべてのパッケージへ、自分自身を
再公開しました。これがワームの部分です。着地した開発者一人ひとりが、次のリリース
の候補になります。
二日は 3 時間に勝つ
ここで Bromure Agentic Coding のプロファイルを経路に置いてみます。
Bromure は各プロファイルを Apple Silicon 上の独立した仮想マシンとして動かし、 エージェントが取得するすべてのパッケージ(npm、PyPI、Cargo、RubyGems、Maven、 NuGet、Go モジュール、Packagist)は、1 バイトでもゲストに届く前に、ハイパー バイザーの Mac 側にあるプロキシを通ります。
そのプロキシが最初に確かめるのは、そのバージョンがどれだけ古いかです。
Supply Chain → 経過日数ゲートは既定で有効、最小二日。浮動する参照
(latest、キャレット範囲、チルダ範囲)は、何も言わずに基準日より古い最新の
バージョンへ解決します。新しすぎるものを固定すれば、明確なエラーとともに 451 が
返ります。
これを 3 時間 11 分という敵対的な窓に当ててみましょう。8 月 28 日の 20:30 UTC、
攻撃の真っ最中に、マニフェストに ^3.0.0 を持ったまま npm install を走らせる。
エージェントは 8 月 11 日の 3.0.2 へ解決します。3.0.3 が存在することを、
そもそも知りません。
このゲートは、署名も、評判スコアも、アドバイザリもなしにそこへ着きました。読んだ のは公開日です。ここではそれが効いてきます。第二波は最初の公開警告から一分と 経たずに出ており、報告を待つ防御はすでに出遅れているからです。
それでも走ってしまったとき、掃討が見つけるもの
あるパッケージについてゲートを切っていたとしましょう。あるいは誰かが気づく前に、 そのバージョンがしきい値を越えて古くなっていたとします。ペイロードはそれでも 四つのことをしなければならず、Bromure のプロファイルではそのどれもが、ゲストの 手の届かない Mac 上に住む制御に出会います。
走らなければならない。 Supply Chain → Strip install scripts は npm の
tarball から preinstall、install、postinstall、prepare をその場で取り
除き、tarball を書き換え、レジストリのメタデータハッシュを更新するので、npm 自身
の検証はそのまま通ります。これで第二波は消えます。その上にさらに検査を積めます。
OSV 脆弱性照会、そして socket.dev によるパッケージフィルタリング。その
「侵害されたパッケージをブロックする」チェックは、まさにこの種のもの(不正な
インストールスクリプト、マルウェア)を対象にしています。あるいは代替レジストリ
としての Delpi。どれ一つとして、証明が存在するかどうかを尋ねません。証明が
本物であるときには、それが効いてきます。
何かを見つけなければならない。 150 個のグロブによる掃討が走るのは、仮想
マシンの中の /home/ubuntu であって、あなたの Mac のホームディレクトリでは
ありません。フォルダ共有は明示的で、上限は八つです。見つかる資格情報はおとりです。
ゲストの中のトークンは brm_… というプレースホルダーで、ホスト側のプロキシが
通信の途中で本物の値に差し替えるため、本物の材料が VM のアドレス空間に入ることは
ありません。SSH の秘密鍵は
そもそもゲストにありません。
プロファイルごとの ssh-agent がホスト側で署名を行い、~/.docker/config.json に
入っているのは偽の base64 です。kubeconfig は合成されたもので、使い捨ての
クライアント証明書つき。AWS のリクエストはホスト側で署名し直されるので、プロキシ
を迂回したものは認証済みではなく InvalidSignatureException として返ってきます。
ある資格情報に Require approval to use を入れておけば、差し替えのたびに
あなたの Mac 上にダイアログが出て、時間で区切られた許可を与える形になります。
外へ電話しなければならない。 Guardrails → Outbound connections は pf 風
のルールセットです。アクション、プロトコル(tcp、udp、web、any)、
ホスト名か IPv4 の CIDR、ポート、そして web なら許可する HTTP メソッド。ルール
は上から下へ照合され、最初に一致したものが勝ちます。Unmatched traffic を Deny
にすれば、このリストは許可リストになります。同じポリシーが独立した二つの層で
評価されます。仮想スイッチは宛先 IP と DNS から盗み見たホスト名で、すべての
プロトコルにわたって。ホストプロキシは TLS の SNI とメソッドで。Bun のバイナリを
取ってくることも、盗んだ秘密をデッドドロップのリポジトリへ押し込むことも、どちら
にも宛先が要り、宛先はリストに載っていなければなりません。
公開しなければならない。 Guardrails → GitHub を Read-only にすると、
git-receive-pack は書き込みとして扱われ、REST の書き込みはブロックされ、
エージェントがごく普通の API 障害として読む硬い 403 が返ります。汚染された
コミットも、デッドドロップも、その先のリリースもありません。同じモードは GitLab、
Bitbucket、Kubernetes、AWS、DigitalOcean、コンテナレジストリにも及びます。
次回のために残そうとした SessionStart フックは、Resources → Storage の
Erase home… が拭き取るホームディレクトリの中にあります。その外に書かれたもの
には Reset to base… が同じことをします。道中のあらゆる判断、許可あるいは拒否
された宛先、パッケージごとの判定、資格情報の差し替えは、すべてホスト上の
Security Log ウィンドウに届きます。ゲストのコードがそれを編集できないのは、
ゲストのコードがそこへ届かないからです。
なぜこれが繰り返されるのか
トラステッドパブリッシングとプロヴェナンスは持つ価値があります。一年前に主流 だった攻撃、つまりメンテナーの npm トークンの窃取を、これらは封じました。今週の 侵害は、トークンに一切触れずにその横を通り抜けました。見知らぬ他人がリリース プロセスに手を届かせることができたので、パイプラインは敵対的なコードの上に、 真実の証明を署名したのです。
そこから監査で抜け出すことはできません。次に破綻するワークフローは、誰か別の人の ものだからです。できるのは、何かの新しいバージョンを最初に走らせるマシンで あることをやめる、それだけです。
プロファイルを開き、Supply Chain を見て、経過日数ゲートが有効かどうかを 確かめてください。あなたが何も触っていなければ、二日で有効になっているはずです。 そのうえで、あと二分を使いましょう。OSV vulnerability check を有効に、鍵が あるなら socket.dev か Delpi を選び、Strip install scripts を有効に。 Guardrails では Unmatched traffic を Deny にして短い許可リストを置き、 出荷用でないプロファイルの GitHub は Read-only に。Credentials では、 お金を使えるものすべてに Require approval to use を入れてください。
あとは、エージェントに必要なものを入れさせればいい。手に入るのは 3.0.2 です。 Bromure Agentic Coding をインストールして、経過日数ゲートは そのままにしておいてください。