フォルダを開くだけでマルウェアが実行される — 採用選考を装う開発者狙いの攻撃を VS Code folderOpen と npm ライフサイクルで安全に再現するデモ

重岡 正 ·  Mon, June 1, 2026

git でリポジトリを clone するだけなら、基本的にはファイルがディスクに書き込まれるだけです。ところが、そのフォルダをエディタで開いたり、npm install を打ったりした瞬間に、リポジトリ側が仕込んだコードが自動で実行されることがあります。

最近は、採用やコーディングテストを装って開発者にマルウェア入りリポジトリを踏ませる攻撃が増えています。Palo Alto Networks の Unit 42 が「Contagious Interview」と名付けた、北朝鮮系とされる一連のキャンペーンはその典型です(Unit 42 のレポート)。偽の採用担当が「課題」として GitHubBitbucket のリポジトリを渡し、コンテナ外で npm install させてマルウェア(BeaverTail / InvisibleFerret)を実行させます(The Hacker News の報道)。

このデモを作る直接のきっかけになったのは、開発者の Fabio Vedovelli 氏による次の投稿です。web3 / ポーカーの「MVP」を技術テストの体裁でクローンしてローカル実行させようとする、まさに上記の手口で、危うく自分のマシンでマルウェアを実行しかけたという生々しい報告でした。

この「開いた瞬間に走る」挙動を、安全に手元で再現して理解するためのデモリポジトリを作りました。vscode-folderopen-malware-demo です。本記事では、その設計判断と、デモから読み取れる防御のポイントを整理します。

リポジトリは安全性を最優先に設計しています。process.env 全体を送るコードや curl | bash 系の実行可能なペイロードは一切含めず、実際にネットワークへ送るのは CODENOTE_DEMO_ で始まるデモ専用の環境変数だけ、宛先も 127.0.0.1:47391 のローカルループバックに固定しています。

なぜ「リポジトリを開く」が危険なのか

前提を 1 つ揃えておきます。git clone 自体は、基本的にファイルを書き込むだけの操作です。危ないのはその後の 2 つの操作です。

  1. エディタでフォルダを開く
  2. パッケージマネージャのコマンドを打つ

この 2 つには、それぞれ「開いた、もしくはインストールした瞬間にコードを走らせる」正規の仕組みがあります。攻撃者はその正規の仕組みに乗っかってくるので、特別な脆弱性を突く必要すらありません。

VS Code / Cursor の folderOpen タスク

VS Code には、フォルダを開いたときに自動でタスクを実行する Tasks の仕組みがあります。.vscode/tasks.json に次のように書くだけです。

{
  "runOptions": {
    "runOn": "folderOpen"
  }
}

runOn: "folderOpen" を指定したタスクは、そのフォルダを開いた時点で実行対象になります。Cursor など VS Code 派生のエディタも同じ挙動を持ちます。

npm のライフサイクルスクリプト

npm は、インストール処理の前後で ライフサイクルスクリプト を実行します。preinstallinstallpostinstallprepare などです。package.jsonscripts にこれらを書いておくと、npm install を打つだけで実行されます。

つまり、git clone の次に何気なくやる「エディタで開く」「npm install する」という操作が、そのままコード実行のトリガーになり得るわけです。

デモリポジトリの全体像

デモは、ローカルループバックの中で完結する小さな部品の組み合わせで成り立っています。

  • collectorapps/collector/server.js): 127.0.0.1:47391 で待ち受け、受け取った変数をログに出すローカルサーバ。攻撃者の収集サーバに相当する役割を、ローカル内で安全に演じます。
  • safe-env-clientpackages/safe-env-client/send-demo-env.js): process.env を読み、CODENOTE_DEMO_ で始まる変数だけを抽出して collector に送るクライアント。マルウェアの「環境変数を盗んで送る」部分の安全版です。
  • 2 つのフィクスチャ: npm の prepare スクリプトを使う fixtures/npm-prepare-env-demo と、folderOpen タスクを使う fixtures/vscode-folderopen-env-demo
  • inert なサンプルsamples/*.sample.json): curl | bash などの危険なペイロードを「実行できない」形で例示したファイル。スキャナの検出対象として使います。
  • scan.jsscripts/scan.js): リポジトリを静的に走査し、危険なパターンを報告するスキャナ。
  • walkthrough.jsscripts/walkthrough.js): 手順を決め打ちで案内するスクリプト。

実行トリガーから collector までの流れは次のとおりです。

flowchart LR
    subgraph trigger[実行トリガー]
      A[VS Code<br/>folderOpen タスク]
      B[npm prepare<br/>スクリプト]
    end
    A --> C[safe-env-client]
    B --> C
    C -->|CODENOTE_DEMO_ のみ| D[collector<br/>127.0.0.1:47391]
    D --> E[機微キーをマスクして<br/>ログ出力]
    F[scan.js 静的スキャナ] -.検出.-> A
    F -.検出.-> B

実際のマルウェアでは、ここの safe-env-client が process.env をまるごと外部の収集サーバへ送り、AWS のアクセスキーや GitHub トークン、.env の中身ごと持っていきます。デモはその構図だけを保ったまま、送る対象と宛先を安全な範囲に絞り込んでいます。

安全に作るための設計判断

このデモで一番気を遣ったのは、「攻撃の構図は忠実に再現しつつ、実害が出る要素は徹底的に削る」という線引きです。具体的な設計判断は以下です。

  • 送るのは CODENOTE_DEMO_ 変数だけ: クライアントはプレフィックスでフィルタしてから送信します。process.env 全体を送るコードはリポジトリ内に存在しません。
  • 宛先はループバック固定: クライアント側の収集先 URL は定数で固定し、環境変数による上書きを受け付けません。collector 側もバインド先を 127.0.0.1 に固定しているため、COLLECTOR_HOST を渡してもループバックの外には広がりません。
  • 機微っぽいキー名はマスクする: collector は SECRETTOKENKEYPASSWORDCREDENTIAL を含むキー名を ***masked*** に置き換えてからログ出力します。
  • 危険な例は実行できない形にする: curl | bash や PowerShell のダウンロード実行は、実行可能なコードとしては置かず、.sample.json の中の文字列としてだけ持っています。
  • root の workspaces からフィクスチャを外す: npm のフィクスチャを root の workspaces に含めていないため、リポジトリ直下で npm ci を実行してもデモの prepare は走りません。トリガーは明示的にフィクスチャを指定したときだけ発火します。
  • 余計なメタデータを送らない: クライアントの送信ペイロードからは pidcwdnodeVersion を落とし、デモに必要な情報だけに絞っています。

「危険な挙動を学ぶためのリポジトリ」が、それ自体で危険になっては本末転倒です。設計の大半は、この安全境界をシンプルに保つことに費やしています。

脅威パターン A: VS Code の folderOpen タスク

folderOpen タスクのフィクスチャ(fixtures/vscode-folderopen-env-demo/.vscode/tasks.json)は次のようになっています。

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "safe folderOpen env demo",
      "type": "shell",
      "command": "node ../../packages/safe-env-client/send-demo-env.js --demo vscode-folderopen-env-demo",
      "presentation": {
        "reveal": "always"
      },
      "runOptions": {
        "runOn": "folderOpen"
      },
      "problemMatcher": []
    }
  ]
}

実際のマルウェアと違うのは 2 点です。実行するのはローカルの安全なクライアントだけで、presentation.reveal"always" にして挙動が必ずターミナルに見えるようにしています。攻撃側はここを "never" にして実行を隠そうとするので、後述のスキャナは reveal: "never" を疑わしいサインとして検出します。

重要なのは、VS Code 自身がこの自動実行に対するガードレールを持っていることです。信頼していないフォルダを開くと Workspace Trust の確認が入り、制限モードではタスクが走りません。さらに、folderOpen タスクを初めて含むフォルダを開くと「自動タスクの実行を許可するか」を尋ねられ、Tasks: Allow Automatic Tasks を明示的に許可しない限り実行されない仕組みになっています。このデモは、攻撃経路であると同時に、これらのガードレールの動作確認にもなります。

クリーンな状態から folderOpen の挙動を確かめたいときは、使い捨てプロファイルで VS Code を起動するのが安全です。--user-data-dir--extensions-dir を一時ディレクトリに向ければ、毎回まっさらな状態で Workspace Trust と自動タスク許可のプロンプトを確認でき、普段の設定も汚しません。

rm -rf /tmp/vscode-demo-profile /tmp/vscode-demo-ext
 
CODENOTE_DEMO_REGION=ap-northeast-1 \
CODENOTE_DEMO_GITHUB_TOKEN=fake-gh-token \
code --user-data-dir /tmp/vscode-demo-profile \
     --extensions-dir /tmp/vscode-demo-ext \
     fixtures/vscode-folderopen-env-demo

環境変数は新しく起動したインスタンスにしか引き継がれないため、CODENOTE_DEMO_*code のコマンドラインで渡すか、既存の VS Code を一度完全に終了してから起動する必要があります。

脅威パターン B: npm ライフサイクルスクリプト

npm のフィクスチャ(fixtures/npm-prepare-env-demo/package.json)は、prepare スクリプトだけを持つ最小構成です。

{
  "name": "npm-prepare-env-demo",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "prepare": "node ../../packages/safe-env-client/send-demo-env.js --demo npm-prepare-env-demo"
  }
}

このフィクスチャに対して、デモ用の変数を付けて npm install を打つと prepare が走ります。

CODENOTE_DEMO_REGION=ap-northeast-1 \
CODENOTE_DEMO_GITHUB_TOKEN=fake-gh-token \
npm install --package-lock=false --prefix fixtures/npm-prepare-env-demo

ここでの安全策が、先ほど触れた「フィクスチャを root の workspaces から外す」設計です。これがないと、リポジトリ直下で npm ci を実行しただけでデモの prepare が連鎖的に走ってしまいます。攻撃の観点で言えば、これはまさに「依存関係のインストールだけのつもりが、仕込まれたライフサイクルスクリプトまで実行される」状況そのものです。デモでは、トリガーが意図したときだけ発火するように、root インストールとフィクスチャの実行を明確に切り離しています。

脅威パターン C: 環境変数の収集

safe-env-client は、process.env を読んでから CODENOTE_DEMO_ で始まる変数だけを取り出して送ります。抜き出しの中核はこれだけです。

function collectDemoEnv(env) {
  return Object.fromEntries(
    Object.entries(env)
      .filter(([name]) => name.startsWith(DEMO_ENV_PREFIX))
      .sort(([left], [right]) => left.localeCompare(right)),
  );
}

受け取った collector 側は、機微っぽいキー名をマスクしてからログに出します。prepare 経由で 2 つの変数を送った場合の出力は次のようになります。

[collector] received /collect from npm-prepare-env-demo
[collector] variableCount=2
CODENOTE_DEMO_GITHUB_TOKEN=***masked***
CODENOTE_DEMO_REGION=ap-northeast-1

CODENOTE_DEMO_GITHUB_TOKEN はキー名に TOKEN を含むのでマスクされ、SHOULD_NOT_SEND のようなプレフィックスの付かない変数はそもそも送信対象になりません。実際のインフォスティーラーは、ここで process.env を丸ごと送り、AWS のアクセスキー、GitHub トークン、.env の中身までまとめて持っていきます。前回の記事(Vercel 侵害は Roblox チート検索から始まった)で取り上げた Lumma Stealer のようなマルウェアが、まさにこの収集を自動でやってのける存在です。

静的スキャナで未知のリポジトリを検査する

scan.js は、リポジトリを開く前に静的に検査するためのスキャナです。エディタで開かずにファイルを読むだけなので、実行トリガーを踏まずに危険なパターンを洗い出せます。

npm run scan

スキャナが検出するのは次のようなパターンです。

  • runOptions.runOn: "folderOpen" の自動実行タスク
  • presentation.reveal: "never" による実行の隠蔽
  • curlwgetbashpowershellInvoke-WebRequestiwriex といったダウンロード実行系の語
  • npm のライフサイクルスクリプト
  • process.envhttp.request / fetch( などのネットワーク呼び出しの組み合わせ

実装で効いているのは、検出の網を細かくしている点です。タスクの command だけでなく args の中身まで連結して検査し、語の照合は大文字小文字を区別しないため、CURLPowerShellIEX のような大文字混じりの記述も拾えます。さらに、process.env がネットワーク呼び出しと同時に現れるコードは、環境変数の外部送信を疑うサインとして個別に報告します。

CI で危険度の高い検出を失敗扱いにしたいときは、--fail-on-high を付けます。

npm run scan -- --fail-on-high

既定では検出があっても終了コードは 0 のままで、CI 上では「報告はするがブロックはしない」挙動になります。フィクスチャやサンプルの期待値が固まってから、HIGH をブロック対象に切り替える運用を想定しています。

ローカルで再現する

ランタイムは miseNode.js 26.2.0 に固定しています。手順はおおむね次のとおりです。

# ランタイムを用意
mise install
 
# 依存をインストール(フィクスチャの prepare は走らない)
npm ci
 
# 別ターミナルで collector を起動
npm run collector
 
# デモ変数を付けて npm ライフサイクルのトリガーを発火
CODENOTE_DEMO_REGION=ap-northeast-1 \
CODENOTE_DEMO_GITHUB_TOKEN=fake-gh-token \
npm install --package-lock=false --prefix fixtures/npm-prepare-env-demo
 
# 静的スキャナで検査
npm run scan

手順を対話的に案内したいときは npm run walkthrough を使います。Node.js のバージョンと collector の到達性をチェックしたうえで、上記のコマンド列を順に表示します。npm run walkthrough -- --run-safe-npm-demo を付けると、安全な npm フィクスチャだけを実際に実行します。なお walkthrough は VS Code を自動では開きません。フォルダを開く操作は、必ず中身を確認してから手動で行う設計です。

リポジトリには Claude CodeCodex 向けのウォークスルー用スキルも同梱していますが、実行可能な挙動はあくまで通常のスクリプトとして置き、スキルはそれを参照して案内するだけにしています。重要な挙動をエージェント固有の指示の中に隠さず、普通のプロジェクトファイルとして監査可能にしておくためです。

未知のリポジトリを開く前にチェックすること

採用課題、技術テスト、見覚えのない OSS など、出所のはっきりしないリポジトリをエディタで開く前には、ブラウザや静的ツールで次を確認するのが安全です。

  • .vscode/tasks.jsonrunOn: "folderOpen" のタスクがないか
  • package.jsonpreinstall / postinstall / prepare などのライフサイクルスクリプト
  • シェルスクリプトやインストーラスクリプトの中身
  • curl | bashwget | sh、PowerShell のダウンロード実行といったパターン
  • process.env を読んでネットワークに送るコード

git clone は基本的にファイルを書き込むだけですが、エディタでフォルダを開く、パッケージマネージャのコマンドを打つ、という操作はコードを実行し得ます。出所不明のリポジトリは、エディタで開く前にブラウザで眺めるか、静的ツールにかける一手間を挟むだけでリスクが大きく下がります。

もし、すでに怪しいリポジトリを開いたりインストールしたりしてしまった場合は、侵害された前提で動くのが安全です。

  • ローカルの認証情報が流出した前提で、トークン、API キー、SSH 鍵、クラウド認証情報をローテーションする
  • そのリポジトリ由来のプロセスを停止し、能動的な送信が疑われるならネットワークを切る
  • シェル履歴、エディタのタスク、パッケージマネージャのログ、プロセス履歴を確認する
  • 信頼が揺らいだ開発環境は作り直す

まとめ

今回作ったデモから引き出せる要点は以下に集約されます。

  • git clone は安全寄りだが、「エディタで開く」「npm install」はコード実行になり得る
  • その実行は、folderOpen タスクと npm ライフサイクルスクリプトという正規の仕組みに乗ってくる
  • VS Code の Workspace Trust と自動タスク許可は効くガードレールであり、使い捨てプロファイルでその挙動を確認できる
  • 出所不明のリポジトリは、開く前に静的に検査するだけでリスクを大きく下げられる

攻撃の構図は再現しつつ、送る対象と宛先を安全に絞り込んでおくと、「開いた瞬間に何が起きるのか」を手を動かして理解できます。採用課題を装ったマルウェア配布が現実の脅威になっている以上、この「開く前の一手間」を習慣にしておく価値は十分にあると考えています。

以上、開発者を狙う実行トリガーを安全に再現するデモを組み立てている、現場からお送りしました。

参考リンク