LiteLLMのサプライチェーン攻撃から考える、Python仮想環境の横断セキュリティチェック

2026年3月24日、LLM界隈で広く使われているPythonパッケージ「LiteLLM」にマルウェアが仕込まれたバージョンがPyPIに公開されるという事件が起きました。

github.com

SSHキーやクラウドの認証情報、暗号資産ウォレットまで根こそぎ持っていくという、かなりえげつない内容です🥲

このニュースをみて、まず思ったのが、「え、自分のプロジェクトにLiteLLM入ってたっけ...?」

これが最初の感想でした。そして次に思ったのが、「仮想環境がプロジェクトごとに散らばってるから、全部チェックするの大変じゃない?」というところです。

この事件をきっかけにPythonの仮想環境を横断的にスキャンする方法を考えてみました。(むしろもっといい方法があれば知りたいくらいです🥲)事件の詳細については先程のリンクを参照ください。

LiteLLMはAIエージェントフレームワークやMCPサーバーの推移的依存関係に含まれているパッケージなので、「自分ではインストールしていないのに入っている」ケースが十分にありえます。実際、発見者もCursorのMCPプラグイン経由で入り込んできたことで気づいたそうです。

怖い😫


Pythonの仮想環境、ライブラリの管理が分散する問題

こういうとき、真っ先にやりたいのは「自分の環境に該当パッケージが入っているかの確認」ですが、ここで問題が出てきます。

Pythonの仮想環境(venv)は、プロジェクトごとに依存関係を分離できる素晴らしい仕組みですよね。でも、セキュリティの観点で見ると、この「分離」が裏目になっているのではないかと。

普段の開発では、プロジェクトのディレクトリに.venvを作って、その中にパッケージをインストールしています。プロジェクトが増えれば増えるほど、仮想環境も増えていく。しかも最近はuvを使うことも多いので、uvのキャッシュディレクトリにもパッケージが残っている可能性がある。

「今すぐ全環境のLiteLLMのバージョンを確認してください」と言われても、プロジェクトごとにcdしてpip show litellmを繰り返すのは...正直しんどい😫

自分の場合、ホームディレクトリ以下にプロジェクトフォルダがたくさんあって、それぞれに.venvが作られています。中には半年以上触っていないプロジェクトもある。そういうのに限って、当時の最新版をインストールしていたりするので油断できません。

「やらないといけないのはわかってるけど、面倒」という感じ。

横断スキャンという発想

そこで考えたのが、ホームディレクトリ以下の仮想環境を自動検出して、インストール済みパッケージを一覧化するスクリプトです。

仕組みは意外とシンプルです。

venvで作られた仮想環境には、必ずpyvenv.cfgという設定ファイルが存在します。これをfindコマンドで再帰的に探せば、マシン上のすべての仮想環境の場所がわかります。

場所がわかれば、あとは各環境内のpipを使ってpip listすればパッケージ一覧は取得できます。pipが使える環境ならJSON形式で出力してもらうのが楽で、pip list --format=jsonでパースしやすいデータが返ってきます。

uvを使っている場合は~/.cache/uv以下にもキャッシュされたパッケージがあるかもしれないので、そこも対象に含めるとより安全です。condaを使っている人は~/miniconda3/envs~/anaconda3/envs以下も見たほうがいいかも。(私はanacondaはあんまり使ってないので確認できてないです🙇)

今回はPythonスクリプトとして作りました。後から機能を追加したくなったときにPythonのほうが拡張しやすいかなと。外部ライブラリへの依存はゼロにしているので、Pythonさえあればどこでも動きます。

スクリプト本体(LLMに手伝ってもらいました)

"""
venv_scanner.py - Python仮想環境スキャナー

~/以下のvenv と uvキャッシュを横断的にスキャンし、
各環境のインストール済みパッケージ一覧を出力します。

使い方:
    python venv_scanner.py                        # ~/以下をスキャン
    python venv_scanner.py --root /projects       # 指定ディレクトリ
    python venv_scanner.py --include-uv           # uvキャッシュも含む
    python venv_scanner.py --json                 # JSON出力
    python venv_scanner.py --grep litellm         # 特定パッケージを検索
"""

import argparse
import json
import os
import subprocess
import sys
from pathlib import Path


def find_venvs(root: str, include_uv: bool = False) -> list[dict]:
    """pyvenv.cfgを手がかりにvenvを検出"""
    venvs = []
    root_path = Path(root).expanduser().resolve()

    try:
        result = subprocess.run(
            ["find", str(root_path), "-name", "pyvenv.cfg", "-type", "f",
             "-not", "-path", "*/node_modules/*",
             "-not", "-path", "*/.git/*"],
            capture_output=True, text=True, timeout=120,
        )
        for line in result.stdout.strip().split("\n"):
            if not line:
                continue
            cfg = Path(line)
            venv_dir = cfg.parent
            # pyvenv.cfgからPythonバージョンを読む
            py_ver = ""
            try:
                for l in cfg.read_text().splitlines():
                    if l.startswith("version"):
                        py_ver = l.split("=", 1)[1].strip()
                        break
            except Exception:
                pass
            venvs.append({"path": str(venv_dir), "type": "venv", "python": py_ver})
    except Exception as e:
        print(f"Warning: venv検出エラー: {e}", file=sys.stderr)

    if include_uv:
        uv_cache = Path.home() / ".cache" / "uv"
        if uv_cache.is_dir():
            venvs.append({"path": str(uv_cache), "type": "uv", "python": ""})

    return venvs


def get_packages(venv_path: str) -> list[dict]:
    """pip list または .dist-info からパッケージ一覧を取得"""
    # pipが使えるならそれを使う
    for name in ("pip", "pip3"):
        pip_bin = Path(venv_path) / "bin" / name
        if not pip_bin.exists():
            pip_bin = Path(venv_path) / "Scripts" / (name + ".exe")
        if pip_bin.exists():
            try:
                r = subprocess.run(
                    [str(pip_bin), "list", "--format=json"],
                    capture_output=True, text=True, timeout=30,
                    env={**os.environ, "PIP_DISABLE_PIP_VERSION_CHECK": "1"},
                )
                if r.returncode == 0:
                    return json.loads(r.stdout)
            except Exception:
                pass

    # fallback: .dist-info を直接読む
    pkgs = []
    for sp in Path(venv_path).rglob("site-packages"):
        for di in sp.glob("*.dist-info"):
            meta = di / "METADATA"
            if not meta.exists():
                meta = di / "PKG-INFO"
            if not meta.exists():
                continue
            n, v = "", ""
            try:
                for line in meta.read_text(errors="ignore").splitlines():
                    if line.startswith("Name:"):
                        n = line.split(":", 1)[1].strip()
                    elif line.startswith("Version:"):
                        v = line.split(":", 1)[1].strip()
                    if n and v:
                        break
            except Exception:
                pass
            if n:
                pkgs.append({"name": n, "version": v})
    return pkgs


def main():
    ap = argparse.ArgumentParser(description="Python仮想環境スキャナー(軽量版)")
    ap.add_argument("--root", default="~", help="スキャン起点 (default: ~)")
    ap.add_argument("--include-uv", action="store_true", help="uvキャッシュも含む")
    ap.add_argument("--json", action="store_true", help="JSON形式で出力")
    ap.add_argument("--grep", metavar="PKG", help="指定パッケージ名を含む環境だけ表示")
    args = ap.parse_args()

    venvs = find_venvs(args.root, args.include_uv)

    if not venvs:
        print("仮想環境が見つかりませんでした", file=sys.stderr)
        sys.exit(0)

    results = []
    for v in venvs:
        pkgs = get_packages(v["path"])
        entry = {**v, "packages": pkgs}

        # --grep: 対象パッケージがなければスキップ
        if args.grep:
            needle = args.grep.lower()
            if not any(needle in p["name"].lower() for p in pkgs):
                continue

        results.append(entry)

    if args.json:
        print(json.dumps(results, ensure_ascii=False, indent=2))
    else:
        if not results:
            print("該当する環境はありませんでした")
            sys.exit(0)

        for entry in results:
            label = f"[{entry['type']}]"
            py = f" (Python {entry['python']})" if entry["python"] else ""
            print(f"\n{'─' * 60}")
            print(f"  {label} {entry['path']}{py}")
            print(f"  パッケージ数: {len(entry['packages'])}")
            print(f"{'─' * 60}")
            for p in sorted(entry["packages"], key=lambda x: x["name"].lower()):
                print(f"    {p['name']}=={p['version']}")

        print(f"\n合計: {len(results)}環境, "
              f"{sum(len(e['packages']) for e in results)}パッケージ")


if __name__ == "__main__":
    main()

--grepオプションが使いたい機能

スクリプトにはいくつかオプションをいれてありますが、一番活躍するのが--grepオプションです。

例えば--grep litellmと指定すると、LiteLLMがインストールされている環境だけがフィルタされて表示されます。「入っているか入っていないか」「入っているならバージョンはいくつか」が一発でわかる。今回のような「特定パッケージの影響範囲を迅速に調べたい」という場合にはぴったりかなと。

実行例

$ python3 venv_scanner.py --include-uv --grep litellm

今回のLiteLLMに限らず、今後また別のパッケージで同じようなことが起きたとき(残念ながら今後も起きるでしょう)、パッケージ名を変えて実行するだけで同じように確認できます。汎用的な道具として手元に準備するのが大事かなと。


「全体像を把握できていない」という不安

今回の件で改めて思ったのは、「自分の環境に何が入っているか、実は正確に把握できていない」ということです。

明示的にインストールしたパッケージは覚えていても、その依存関係として入ってきたものまでは追いきれない。pip install *した時に、裏側で30も40もパッケージが入っていたりする。その中の一つが汚染されていたら...というのが今回の怖い点です。

venvの「プロジェクトごとに分離する」という設計思想は、開発上はとても正しいのですが、「横断的に何かを確認したい」という場面では逆に壁になりますね…

事後対応だけど、やらないよりマシか🥲

正直なところ、こういうスキャンは事後対応でしかありません。マルウェアが仕込まれたパッケージをインストールしてしまった時点で、認証情報は漏洩している可能性が高い。

それでも「自分の環境に影響があるかどうかを素早く確認できる」ことには、一定の価値があると思います。 ただし、未知のバックドアには対応できないという限界はあります。

おわりに

「全仮想環境を横断的にスキャンする」という仕組みは、地味ですが安心できる材料かなと。事件が起きてから慌てて一つずつ調べるのではなく、ワンコマンドで確認できる環境を整えておくだけで、対応速度がまったく変わってきますしね。

参考

github.com