0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIが提案したパッケージ名を、インストール前にレジストリで確かめる PyPIとnpmの公開メタデータを使う

0
Posted at

はじめに

AIが提案したコードに、見覚えのないパッケージ名が入っていたとき、そのまま pip install や npm install を打つ前に何を見ればよいか、という記事です。レジストリの公開メタデータに問い合わせる短いスクリプトと、実際の出力を載せています。

内容は4つです。なぜインストールの前なのか、スクリプトと出力、レビューへの組み込み方、この確認で分からないこと。スクリプトだけ持ち帰ってもらっても構いません。

掲載したスクリプトは、この記事のために書いたものです。使うのはPythonの標準ライブラリだけで、追加のインストールは要りません。

環境

macOS 26.6.2、Python 3.14.6、bash 3.2.57、git 2.54.0。2026年10月11日に実行しました。出力に出てくるバージョン番号や日付、リリース数は、その時点のものです。

なぜインストールの前に見るのか

生成AIは、実在しない情報をそれらしく出すことがあります。文章なら誤った一文で済みますが、コードの場合は、存在しないパッケージ名が import 文やインストール手順に入ります。

IPAの「情報セキュリティ10大脅威 2026」の解説書(組織編)は、AIが作り出したパッケージ名を悪用するサプライチェーン攻撃を取り上げています(Koi Securityの報告を引用)。AIが間違えて出しやすい名前を攻撃者が先に登録しておき、開発者がインストールするのを待つ、という手口です。スロップスクワッティング(slopsquatting)という非公式な呼び名もあります。同じ解説書には、AIが生成したコードに脆弱性が含まれる場合がある、という記述もあります。

確認をインストールの前に置く理由は単純で、PyPIでもnpmでも、インストールの過程でパッケージ側のコードが動くことがあるからです。入れてから調べる、では順番が逆になります。

この記事をざっくり図解

終了コード0でも、最後に判断するのは人です。スクリプトがやるのは、判断の材料を1画面に並べるところまでです。

1. スクリプト

PyPIはJSON API(https://pypi.org/pypi/{名前}/json)、npmはレジストリ(https://registry.npmjs.org/{名前})に、認証なしでGETします。取り出すのは、初回公開日、最終公開日、リリース数、メンテナー、ソースの場所です。PyPIは最新版について登録されている既知の脆弱性も返すので、その件数も表示します。

tools/check_pkg.py
#!/usr/bin/env python3
"""インストール前に、パッケージ名をレジストリの公開メタデータで確かめる。
使い方: python3 check_pkg.py {pypi|npm} 名前 [名前 ...]
終了コード: 0=気になる点なし 1=要確認あり 2=存在しない名前あり 3=問い合わせ失敗
"""
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone

MIN_AGE_DAYS = int(os.environ.get("MIN_AGE_DAYS", 90))  # 初回公開からこの日数未満なら要確認
MIN_RELEASES = int(os.environ.get("MIN_RELEASES", 5))   # リリース数がこれ未満なら要確認
NAME_OK = re.compile(r"^(@[a-z0-9._-]+/)?[A-Za-z0-9._-]+$")


def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": "pre-install-check/0.1"})
    with urllib.request.urlopen(req, timeout=15) as res:
        return json.load(res)


def parse(ts):
    return datetime.fromisoformat(ts.replace("Z", "+00:00"))


def pypi(name):
    d = fetch(f"https://pypi.org/pypi/{urllib.parse.quote(name)}/json")
    times = [parse(f["upload_time_iso_8601"])
             for files in d["releases"].values() for f in files]
    urls = d["info"].get("project_urls") or {}
    roles = (d.get("ownership") or {}).get("roles") or []
    return {
        "name": d["info"]["name"],
        "first": min(times) if times else None,
        "last": max(times) if times else None,
        "releases": sum(1 for files in d["releases"].values() if files),
        "latest": d["info"]["version"],
        "source": urls.get("Source") or urls.get("Repository") or urls.get("Homepage") or "(記載なし)",
        "maintainers": ", ".join(r["user"] for r in roles) or "(なし)",
        "vulns": len(d.get("vulnerabilities") or []),
    }


def npm(name):
    d = fetch(f"https://registry.npmjs.org/{urllib.parse.quote(name, safe='@')}")
    t = d.get("time", {})
    versions = d.get("versions", {})
    repo = d.get("repository") or {}
    return {
        "name": d["name"],
        "first": parse(t["created"]) if "created" in t else None,
        "last": max((parse(t[v]) for v in versions if v in t), default=None),
        "releases": len(versions),
        "latest": d.get("dist-tags", {}).get("latest", "(なし)"),
        "source": repo.get("url", "(記載なし)") if isinstance(repo, dict) else str(repo),
        "maintainers": ", ".join(m["name"] for m in d.get("maintainers", [])) or "(なし)",
        "vulns": None,  # このAPIの応答には含まれない
    }


def check(registry, name):
    if not NAME_OK.match(name):
        print(f"[{registry}] {name}\n  名前の形式が不正です")
        return 3
    try:
        m = {"pypi": pypi, "npm": npm}[registry](name)
    except urllib.error.HTTPError as e:
        if e.code == 404:
            print(f"[{registry}] {name}\n  判定: 存在しません(404)。インストールしないでください")
            return 2
        print(f"[{registry}] {name}\n  判定: 問い合わせ失敗(HTTP {e.code})。存在しないとは限りません")
        return 3
    except (urllib.error.URLError, TimeoutError) as e:
        print(f"[{registry}] {name}\n  判定: 問い合わせ失敗({e})。存在しないとは限りません")
        return 3

    notes = []
    if m["name"] != name:
        notes.append(f"レジストリ上の名前は {m['name']} です")
    if m["first"] is None or m["releases"] == 0:
        notes.append("公開済みのファイルがありません")
    else:
        age = (datetime.now(timezone.utc) - m["first"]).days
        if age < MIN_AGE_DAYS:
            notes.append(f"初回公開から{age}日(基準{MIN_AGE_DAYS}日未満)")
        if m["releases"] < MIN_RELEASES:
            notes.append(f"リリース数{m['releases']}(基準{MIN_RELEASES}未満)")

    if m["vulns"]:
        notes.append(f"最新版に既知の脆弱性の登録が{m['vulns']}件")

    day = lambda x: x.strftime("%Y-%m-%d") if x else "(不明)"
    print(f"[{registry}] {m['name']}")
    print(f"  初回公開   : {day(m['first'])}")
    print(f"  最終公開   : {day(m['last'])} (最新 {m['latest']})")
    print(f"  リリース数 : {m['releases']}")
    print(f"  メンテナー : {m['maintainers']}")
    print(f"  ソース     : {m['source']}")
    if m["vulns"] is not None:
        print(f"  既知の脆弱性: {m['vulns']}件(最新版、PyPIの登録分)")
    print("  判定: " + ("要確認。" + " / ".join(notes) if notes else "気になる点なし(安全の証明ではありません)"))
    return 1 if notes else 0


if __name__ == "__main__":
    if len(sys.argv) < 3 or sys.argv[1] not in ("pypi", "npm"):
        print(__doc__, file=sys.stderr)
        sys.exit(3)
    sys.exit(max(check(sys.argv[1], n) for n in sys.argv[2:]))

書くときに決めたことを、3つだけ挙げます。

404と通信失敗を分ける。 社内プロキシやレジストリ側の不調で応答が取れなかったときに「存在しません」と出すと、実在するパッケージを疑うことになります。存在しないと言えるのは404が返ったときだけにして、それ以外は終了コード3にしました。

名前の形式を先に確かめる。 渡された文字列をそのままURLに入れるので、英数字と . _ -、npmのスコープ(@scope/name)以外は問い合わせる前に弾いています。

基準値は目安。 初回公開から90日未満、リリース数5未満を「要確認」にしていますが、この数字に根拠となるデータはありません。新しくて良いパッケージも当然あります。環境変数で変えられるようにしてあるので、チームで決めた値に置き換えてください。

2. 実行結果

よく知られたパッケージ

python3 tools/check_pkg.py pypi requests
[pypi] requests
  初回公開   : 2011-02-14
  最終公開   : 2026-05-14 (最新 2.34.2)
  リリース数 : 160
  メンテナー : Lukasa, graffatcolmingov, nateprewitt
  ソース     : https://github.com/psf/requests
  既知の脆弱性: 0件(最新版、PyPIの登録分)
  判定: 気になる点なし(安全の証明ではありません)
python3 tools/check_pkg.py npm express
[npm] express
  初回公開   : 2010-12-29
  最終公開   : 2026-10-09 (最新 5.3.0)
  リリース数 : 290
  メンテナー : wesleytodd, jonchurch, ctcpip, ulisesgascon, sheplu
  ソース     : git+https://github.com/expressjs/express.git
  判定: 気になる点なし(安全の証明ではありません)

終了コードはどちらも0でした。メンテナーの欄は、PyPIでは ownership.roles、npmでは maintainers から取ったアカウント名です。最初はPyPIの author を出そうとしましたが、これは公開する側が自分で書く値でした。応答を見直して ownership があると分かり、そちらに替えています。

存在しない名前

説明用に作った名前で試します。2026年10月11日の時点で、PyPIにもnpmにも登録されていませんでした。

python3 tools/check_pkg.py pypi no-such-package-demo-20261011
[pypi] no-such-package-demo-20261011
  判定: 存在しません(404)。インストールしないでください

終了コードは2です。npmでも同じ結果でした。AIの提案に入っていた名前でこれが出たら、AIが作った名前か、書き間違いの可能性があります。その名前は入れずに、やりたかったことから正しいパッケージを探し直します。

一つ注意があります。記事に載せた名前は、あとから誰でも登録できます。みなさんが試すときには、結果が変わっているかもしれません。この確認が「今の時点」のものでしかない、という実例でもあります。

要確認になる場合

実在の新しいパッケージを名指しするのは避けたかったので、基準値のほうを極端にして分岐を確かめました。

MIN_AGE_DAYS=100000 python3 tools/check_pkg.py pypi requests
[pypi] requests
  初回公開   : 2011-02-14
  最終公開   : 2026-05-14 (最新 2.34.2)
  リリース数 : 160
  メンテナー : Lukasa, graffatcolmingov, nateprewitt
  ソース     : https://github.com/psf/requests
  既知の脆弱性: 0件(最新版、PyPIの登録分)
  判定: 要確認。初回公開から5717日(基準100000日未満)

終了コードは1です。試していて気づいた点が2つありました。

Requests と大文字で渡すと、PyPIは requests の情報を返します。PyPIは名前を正規化して扱うためです。スクリプトは、渡した名前とレジストリ上の名前が違うときも要確認にしているので、この場合は終了コード1になりました。表記の違いだけなら問題ありませんが、別の名前に解決されたことには気づけます。

urllib3 では、ソースの欄が「(記載なし)」になりました。リポジトリのURLは登録されているのに、項目名が Code で、スクリプトが見ている Source Repository Homepage のどれでもなかったためです。項目名はプロジェクトごとにばらつくので、「記載なし」は「リポジトリがない」という意味にはなりません。

3. レビューに組み込む

手元で1回打つだけでは、忘れた日に抜けます。依存を追加するプルリクエストで、追加された名前だけを取り出して確認する形にしました。

tools/check-new-deps.sh
#!/usr/bin/env bash
# 使い方: tools/check-new-deps.sh [比較元ブランチ]
# requirements.txt に追加された行だけを取り出して、check_pkg.py に渡す
set -euo pipefail
base="${1:-main}"
names=$(git diff "$base"...HEAD -- requirements.txt \
  | grep -E '^\+[A-Za-z0-9]' \
  | sed -E 's/^\+//; s/[^A-Za-z0-9._-].*$//' || true)
if [ -z "$names" ]; then
  echo "requirements.txt に追加された依存はありません"
  exit 0
fi
# shellcheck disable=SC2086
python3 tools/check_pkg.py pypi $names

requirements.txt に urllib3>=2.0 と、先ほどの存在しない名前を追加したブランチで実行した結果です。

tools/check-new-deps.sh main
[pypi] urllib3
  初回公開   : 2009-12-11
  最終公開   : 2026-09-15 (最新 2.8.0)
  リリース数 : 105
  メンテナー : SethMichaelLarson, illia-v, shazow
  ソース     : (記載なし)
  既知の脆弱性: 0件(最新版、PyPIの登録分)
  判定: 気になる点なし(安全の証明ではありません)
[pypi] no-such-package-demo-20261011
  判定: 存在しません(404)。インストールしないでください

終了コードは2でした。追加がないブランチでは「requirements.txt に追加された依存はありません」と出て、0で終わります。

出力はプルリクエストの説明欄に貼り、テンプレートに次の項目を足しておくと、レビューする側も同じものを見られます。

## 依存の追加(ある場合のみ)

- [ ] `tools/check-new-deps.sh` の出力を下に貼った
- [ ] 追加した名前は、公式ドキュメントやリポジトリのREADMEに書かれたインストール名と一致している
- [ ] AIの提案で知った名前の場合、提案以外の情報源でも確認した
- [ ] 「要確認」が出たものは、追加する理由を1行書いた

2つ目の項目が、実はいちばん効きます。スクリプトが教えてくれるのは「その名前で何かが登録されている」ことまでで、「自分が使いたかったライブラリの正しい名前である」ことは、公式の情報と見比べないと分かりません。

終了コードを分けてあるので、CIで2のときだけ失敗させ、1は警告にとどめる、といった使い方もできます。ただし、CI上では実行していません。確かめたのは手元のgitリポジトリだけです。

この確認で分からないこと

  • 存在することは、安全の証明になりません。 攻撃者が先に登録した名前は「存在する」ので、存在確認だけなら通ります。新しさとリリース数を見ているのはそのためですが、基準を超えるまで待つ相手には効きません。
  • 有名な名前に似せた名前は検出しません。 1文字違いの名前が実在すれば、その情報が普通に表示されます。
  • 古くて実績のあるパッケージが、ある版から悪意のあるものに変わる場合も見えません。 見ているのは登録の履歴で、中身のコードは読んでいません。
  • メタデータは公開する側が書きます。 ソースの欄に、他人のリポジトリのURLを書くこともできます。
  • 直接指定した名前しか見ていません。 そのパッケージが連れてくる依存は対象外です。
  • 利用者数は見ていません。 PyPIのJSON APIにはダウンロード数の項目がありますが、値は -1 で、公式ドキュメントにも使わないようにと書かれています。npmには別のAPIがありますが、スクリプトには入れていません。
  • 既知の脆弱性の表示は、PyPIの最新版の件数だけです。 0件は「登録がない」という意味で、「脆弱性がない」という意味にはなりません。npm側は見ていません。依存全体を照合する監査ツールや、ロックファイルでの版とハッシュの固定は別の層の対策で、この記事では試していません。

試していない範囲も書いておきます。check-new-deps.sh が読むのは requirements.txt だけで、確かめた書式は 名前==版、名前>=版、名前のみ、コメント行です。-r での読み込み、URL指定、extras、環境マーカーは試していません。package.json やロックファイルの差分、プライベートレジストリやミラー、レジストリのレート制限も未確認です。既知の脆弱性が1件以上あるときに要確認になる分岐も、該当するパッケージで実行してはいません。

最後に

AIが提案した名前は、インストールの前に一度レジストリに聞く。404なら入れない。存在していても、公式の情報と名前を見比べる。この記事のスクリプトは、その最初の一歩を手早く済ませるためのものです。まずは次に依存を追加するときに、1回だけ試してみてください。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?