最近は不正アクセスや脆弱性の話題が多い。npm や PyPI でも、正規のパッケージのメンテナのアカウントを乗っ取って、マルウェア入りのバージョンを公開する事件が何度も起きてる。そのたびに「自分の環境に入ってないか」が気になるけど、毎回手で調べるのはけっこうな手間。しかも気づくきっかけがニュースなので、見逃したらそのまま。
なので、マルウェア入りや脆弱性のあるバージョンのパッケージを使っていた場合に検出して通知する仕組みを作ってみた。
つくったもの
毎朝 9 時半に、Python のスクリプトが 1 本走る。手元のディスクから npm と PyPI のパッケージ名とバージョンを集めて、OSV.dev にまとめて問い合わせる。マルウェアや重大な脆弱性が見つかったら Telegram に通知する。
[手元のディスク] node_modules / 仮想環境 / npx・uv のキャッシュ / uv.lock
↓ パッケージ名・バージョン・パスを集める
[OSV.dev] querybatch で 500 件ずつ照合
↓ 当たった ID の中身を取ってきて分類
[分類] マルウェア / CRITICAL の脆弱性 / それ以外(件数だけ)
↓ まだ通知していないものだけ
[Telegram] パスつきで通知。通知済みは記録しておく 定期実行は launchd に任せている(launchd の設定まわりはlaunchctl の使い方にまとめた)。スクリプトは Python の標準ライブラリだけで書いてる。
マルウェアも OSV.dev で引ける
OSV.dev は Google が運営しているオープンソースの脆弱性データベース。CVE のような脆弱性だけじゃなく、マルウェアが混入したバージョンも MAL- で始まる ID で持っている。OpenSSF がまとめている悪性パッケージの一覧が、ここに入ってくる形。これを知らないと「脆弱性スキャナでしょ」で終わって、今回みたいな事件には使えないと思い込みやすい気がする。
セキュリティ企業のブログを RSS で追う案も考えた。ただ記事が教えてくれるのは「世の中で何が起きたか」までで、自分に関係あるかは結局手で調べることになる。OSV に自分が使っているバージョンを投げれば、関係あるものだけが返ってくる。試しに、2026 年 8 月に乗っ取られてクレデンシャルを盗むワームが仕込まれた npm の keyv で問い合わせてみた。
curl -s -X POST https://api.osv.dev/v1/querybatch -d '{
"queries": [
{"package": {"name": "keyv", "ecosystem": "npm"}, "version": "6.0.0"},
{"package": {"name": "keyv", "ecosystem": "npm"}, "version": "5.6.0"}
]
}' {"results":[{"vulns":[{"id":"MAL-2026-11524","modified":"2026-08-05T00:37:58.501913Z"}]},{}]}
汚染された 6.0.0 にだけ MAL-2026-11524 が返ってきて、5.6.0 は空。バージョン単位で判定できるのを確かめてから作り始めた。最初の実行では npm 718 件・PyPI 370 件を 4 秒で照合して、マルウェアは 0 件。
lockfile じゃなく、ディスクにある実体を数える
最初は lockfile を読めば済むと思ってた。でも keyv の件で手元を見て回ったとき、npx のキャッシュ(~/.npm/_npx/)に keyv・cacheable・flat-cache の実体が残っていた。汚染されたバージョンではなかったけど、npx で一時的に落としたものなので、どのプロジェクトの lockfile にも載っていない。lockfile だけ見ていたら、ここは素通りしてた。
なので node_modules の下の package.json を直接読んで、実際に入っているパッケージ名とバージョンを拾うことにした。入れ子の node_modules も見る。推移的な依存はそこにいるので。
def collect_npm() -> dict:
"""node_modules の下の package.json から、実際に入っているパッケージ名とバージョンを拾う"""
found = {}
for root in SCAN_ROOTS: # ~/code や ~/.npm/_npx など
for dirpath, dirnames, _ in walk(root):
if dirpath.name != "node_modules":
continue
for entry in dirnames:
# @scope/name の形はもう一段下にある
if entry.startswith("@"):
targets = [p for p in (dirpath / entry).iterdir() if p.is_dir()]
else:
targets = [dirpath / entry]
for t in targets:
pkg = t / "package.json"
if not pkg.is_file():
continue
data = json.loads(pkg.read_text(encoding="utf-8"))
key = ("npm", data["name"], data["version"])
found.setdefault(key, set()).add(str(t)) # パスも持っておく
return found パッケージ名とバージョンだけじゃなく、インストール先のパスも一緒に持っておくのがポイント。見つかったとき、最初に知りたいのは「どこにあるか」。これがないと結局ディスクを手で探すことになる。
PyPI 側は、仮想環境の中の *.dist-info を読む。仮想環境はディレクトリ名じゃなく pyvenv.cfg があるかどうかで見分けている。.venv 以外の名前のこともあるので。
点検してみると、PyPI でも lockfile と実体はずれていた。常駐させているサービスの仮想環境に、uv.lock に載っていないパッケージが入ってた。uvx や uv run --with で作られる一時的な環境(uv のキャッシュ)も、46 件中 17 件が照合から漏れていた。どちらも収集対象に入れてある。
uv のキャッシュにしかないものは「過去に一度入った痕跡」として扱う。インストール時に動くマルウェアなら、今はもう消えていても被害は済んでる。なのでマルウェアに限っては、キャッシュに痕跡があるだけでも通知する。
もう 1 つハマったのが、グローバルに入れた npm のパッケージ。npm root -g でインストール先を取得していたら、ターミナルから動かしたときと launchd から動かしたときで違う場所が返ってきた。原因は node が 2 つ入っていたこと。PATH の順番で、呼び出し元ごとに別の方を引いてた。npm root -g は使わず、候補になるディレクトリを全部直接見るようにして、片方しか監視できていない状態を直した。
「0 件」でも安全とは限らない
作って 1 か月半くらい経ったころ、コードを見直してみた。気になったのは、OSV に問い合わせが届かなかったときの扱い。失敗した分は警告を出して飛ばすだけで、結果の件数には何も反映していなかった。
読めばわかる類の穴ではある。ただ警告が出ているなら気づけるのでは、とも思えたので、実際に起こしてみた。問い合わせ先を存在しないホストに差し替えて、汚染された keyv@6.0.0 を混ぜて流すと、結果は「マルウェア 0 件」で終了コードは 0。警告は標準エラー出力に出るだけで、普段見るログと結果ファイルには「0 件」としか残らない。汚染されたバージョンが入っていても、OSV に届かなければ「クリーン」と報告される状態になってた。
なので照合できなかった件数を数えて、終了コードを分けた。
| 終了コード | 意味 |
|---|---|
| 0 | 全件を照合できて、何も見つからなかった |
| 10 | マルウェアを検出した |
| 11 | 照合できなかったパッケージがある。「0 件」でも安全とは言えない |
| 12 | 検出したのに Telegram に送れなかった(次の実行で再送する) |
| 13 | CRITICAL の脆弱性を検出した。マルウェアはなし |
1 と 2 を使わずに 10 から始めてるのは、Python が勝手に使う値だから。最初は「1 = マルウェア検出」にしていて、スクリプトが例外で落ちたときの 1 と区別がつかなかった。クラッシュが「検出あり」に見えちゃう。
launchd から呼んでいるシェルスクリプトでは、0・10・13 以外で終わったら「正常に終わらなかった」と Telegram に送る。この通知だけは Python を通さず curl で送っている。Python ごと落ちても届くように。
通知経路が生きているかどうかも、同じ種類の問題を抱えてた。平時は何も言わない作りなので、作った日に疎通を確かめてから 44 日間、送信の処理は一度も呼ばれていない。そのあいだにボットのトークンが失効していても、こちらからは見えない。正常だから静かなのか、壊れていて静かなのかが見分けられない。
そこで、実在した汚染バージョンを照合対象に混ぜて、問い合わせから通知まで通しで流す --test を用意した。送信の API だけ叩くテストにしなかったのは、確かめたいのが「マルウェア入りのバージョンがあるときに、通知まで届くか」だから。API が 200 を返しても端末に届いたとは限らないので、最後はスマホに届いたのを目で見て確認した。
ちなみに最初の実装は、--test で混ぜた汚染バージョンを「通知済み」として記録しちゃってた。そのままだと、本当に keyv@6.0.0 が入ったときに「もう通知した」として黙る。テストのときは記録しないように直した。月 1 回テスト通知を自動で流す案もあったけど、平時にも通知が来るようになると無視する癖がつきそうなのでやめた。半年に 1 回くらい手で流す。
MAL- が付かない乗っ取りもある
最初は通知を MAL- だけに絞っていた。手元の依存には通常の脆弱性が 300 件くらいあって、全部通知してたら埋もれて肝心のものを見落とすので。
ただ見直してみると、「マルウェア = MAL-」という前提が正しくなかった。過去の乗っ取り事例を OSV で引いてみると、こうなっている。
- ultralytics 8.3.41(2024 年、暗号通貨のマイナーが混入):
PYSEC-2024-154だけ - ctx 0.2.6(2022 年の乗っ取り): GHSA と PYSEC の ID だけ
どちらも MAL- がなく、CWE のような機械的に判定できる目印もない。要約に「contained malicious crypto miner software」「Embedded Malicious Code in ctx」と書いてあるだけ。keyv と同じ「正規のパッケージが乗っ取られた」型なのに、今の作りだと「その他の脆弱性」の件数に混ざって終わってしまう。
なので要約の言い回しでも判定するようにした。
MALICIOUS_SUMMARY = re.compile(
r"malicious (code|software|package|version|release|payload)|contain(s|ed)? malicious"
r"|embedded malicious|backdoor|crypto ?miner|compromised",
re.I,
) 本文まで見ないのは、本文には「悪意あるファイルを渡すと〜」のような攻撃条件の説明が普通に出てくるから。手元の 394 件で試したら、本文だと 118 件当たって、要約だけなら 0 件。既知の 2 例にはちゃんと当たる。
要約から判定したものには、通知に「[MAL 未登録・要約から判定]」と付けている。確定情報なのか、こちらの推定なのかを通知を見た時点で区別できるように。compromised のような広めの語もあえて残した。誤検出は起きたら直せばいいけど、見逃しは取り返しがつかないので。この compromised が、1 か月もしないうちに当たってしまう。
実際に検知して対応できた 2 件
運用を始めてから、実際に検知して対応までできたのが次の 2 件。どちらもアドバイザリの公開から 2 日以内に通知が届いて、その日のうちに修正版へ上げた。
証明書の件: certifi と uv が同梱してる CA の秘密鍵が漏れてた
1 件目は、Python の certifi と uv が同梱しているルート証明書の件。ある CA の中間証明書の秘密鍵が漏れていて、その CA のルート証明書を同梱しているバージョンが対象になった。鍵を持った攻撃者が通信経路にいれば、任意のホスト名の証明書を作って TLS を盗み見したり書き換えたりできる。アドバイザリは CRITICAL で、公開は日本時間の 10 月 21 日の早朝。
通知はその 5 時間ほどあと、21 日 9 時半の定期実行で届いた。影響のあるバージョンは手元の十数か所に入っていて、修正版も出ていたので、その日のうちに certifi は 2026.10.19、uv は 0.13.4 に全部上げた。
ただ、このとき届いた通知は「マルウェア: 14 件」だった。全部に[MAL 未登録・要約から判定]が付いていた。判定が反応したのは、要約の「…with compromised intermediate CA keys」の compromised。本物のマルウェアなら、該当を消してトークンを全部回すところまでやらないといけない。本文を読んで、パッケージに悪いコードが入ったわけじゃないとわかったので、トークンを回す必要はなかった。
14 件という数も膨らんでいただけで、中身は 2 件。同じアドバイザリが別の ID で二重に載っていて、certifi は複数のバージョンに当たっていたので。
マルウェアとしては誤検出。でも誤検出だからと compromised を外していたら、この件は「その他の脆弱性」の件数に埋もれて、通知は来なかった。なので消すのではなく、通知を 2 つの枠に分けた。
- 🚨 マルウェア → 該当を削除して、トークンを回す
- 🔶 CRITICAL の脆弱性 → 修正版へ上げる
compromised だけで当たって、要約が証明書や鍵の話をしていたら、マルウェアからは外す。CRITICAL なら、もう片方の枠で届く。
# compromised だけで当たって、要約が証明書・鍵の話をしていたらマルウェアに入れない
KEY_COMPROMISE = re.compile(r"\b(CAs?|certificates?|(private|signing|root) keys?|intermediate)\b", re.I)
def is_malicious_summary(summary: str) -> bool:
words = {m.group(0).lower() for m in MALICIOUS_SUMMARY.finditer(summary)}
if not words:
return False
if words == {"compromised"}:
return not KEY_COMPROMISE.search(summary)
return True compromised 自体を外さなかったのは、乗っ取りの要約にもよく出る語だから(「〜 compromised to steal credentials」みたいな)。乗っ取りを「マルウェアではない」側に落とすほうが、誤検出よりずっと高くつく。同じ理由で、CRITICAL の通知文も「マルウェアではない」とは書かず、「マルウェアの印は無い」にとどめている。言い回しで拾えなかった乗っ取りが、こっちの枠に落ちてくることもありうるので。
HIGH 以下は入れていない。手元では CRITICAL だけなら 1 桁なのに、HIGH まで入れると 160 件になって、毎朝読む量じゃなくなる。
brotli の件: 自分で入れてない依存に Node2Shell が来てた
2 件目は、Python の brotli パッケージの「Node2Shell」。brotli は圧縮形式 Brotli のパッケージで、デコーダに範囲外書き込みがあった。細工した圧縮データを展開させると、メモリの破壊や任意のコード実行につながる可能性がある。アドバイザリは CRITICAL で、公開は日本時間で 10 月 23 日に日付が変わったころ。
23 日朝の定期実行ではまだ出てこなくて、通知が届いたのは 24 日の朝。brotli を自分で入れた覚えはなかったけど、通知に載っているパスを見ると 5 か所に入っていて、どれも別のライブラリの依存として入ってきたものだった。通知はこんな形で届く(パスは伏せて、別名は省略した)。
🔶 依存に重大な脆弱性(CRITICAL)を検出(1 件)
対応: 修正版へ上げる。マルウェアの印(MAL-*・要約の言い回し)は無い
[PyPI] brotli@1.2.0
GHSA-9v8h-qqgm-4m35(別名 …)Brotli: Out-of-bounds write in the Brotli decoder ("Node2Shell") also affects the PyPI Brotli package
修正版: 1.2.1
https://osv.dev/vulnerability/GHSA-9v8h-qqgm-4m35
場所: ~/code/(プロジェクト)/.venv/lib/python3.12/site-packages/brotli-1.2.0.dist-info
場所: ~/code/(プロジェクト)/uv.lock
場所: ~/code/(プロジェクト)/uv.lock
…他 2 か所(全件は logs/last-report.txt) 修正版の 1.2.1 も出ていたので、その日のうちに 5 か所とも上げた。uv で管理しているプロジェクトは、brotli だけを指定して上げる。
# brotli だけを上げる。ほかの依存は動かさない
uv lock --upgrade-package brotli
uv sync uv lock --upgrade だと、プロジェクトによっては 50〜220 個の依存が一度に動く。それで何か壊れたら、どれが原因かわからない。1 個だけ上げれば、uv.lock の差分も brotli の節だけで済む。上げたあとは通知を送らない dry-run モードで照合し直して、brotli が出なくなったのを確認した。
自分で入れた覚えのないパッケージでも、どこに入っているかまで通知に載っていたので、探す手間はかからなかった。
CRITICAL が届いても、自分に当たるかは別の話
証明書の件で CRITICAL の枠を作った次の朝には、ほかの CRITICAL がまとめて 6 件届いた。深刻度は一般的な評価なので、全部を同じ重さで扱うと本当に危ないものが埋もれる。1 件ずつアドバイザリの本文を読んで、攻撃が成り立つ条件と自分の使い方を突き合わせた。「細工したファイルを読み込ませると〜」なら、自分のコードが外から来たファイルをその処理に渡しているか。「この設定で動かしたとき」なら、その設定を使っているか、という感じ。
結果、6 件のうち自分の使い方で実際に当たるものはなかった。一度「これは当たる」と判断したものも、呼び出しの流れを最後まで追ったら当たっていなかった。アドバイザリが名指しする関数を呼んでいるかだけじゃなく、その関数に渡る時点でデータがどういう状態かまで見ないと判断できない。
当たっていないと判断できても、それが呼び出しの順番の偶然で守られてるだけなら、コードを少し変えた拍子に当たり始める。なので結論だけじゃなく、なぜ当たらないのかまで記録に残している。依存の脆弱性が危ないかどうかは、依存の側じゃなくて、自分のコードが何を入力にしているかで決まる。
やってみて
振り返ると、困ってたのは事件そのものより、そのたびに人の手で調べないといけない構造のほうだった気がする。今はニュースを追っていなくても、毎朝照合が終わってる。
通知を絞ったのはずっと効いている。通常の脆弱性まで全部届くようにしてたら、とっくに自分でも見なくなってたと思う。マルウェアと CRITICAL だけにして、パスと対応まで載せたので、通知を見れば次にやることがすぐわかる。
既知のマルウェアと突き合わせる以上、OSV に載るまでは検知できない。そこは npm の ignore-scripts でインストール時のスクリプトを止めておくなど、予防のほうに任せている。