PyPI 惊现"anthropic-sdk"李鬼包:给 pip 装个"安检门"

PyPI 惊现"anthropic-sdk"李鬼包:给 pip 装个"安检门"

先说背景:这篇讲什么

10 月 4 日,开源供应链追踪项目 compromised-packages-check 在例行扫描中记录到 PyPI 新增恶意包 anthropic-sdk 0.1.0(OSV 公告编号 MAL-2026-17472,10 月 4 日发布)。它的手法很"体贴":把自己包装成 Anthropic 官方 Python SDK 的"平替",代码里直接 from anthropic import *,把官方 SDK 的 Anthropic、AsyncAnthropic 等符号原样转手,让你用起来毫无违和感——而它在安装阶段就会窃取机器上的凭证和环境变量(社区追踪记录)。

先说结论:Anthropic 官方 Python SDK 的包名就叫 anthropic,根本不存在 anthropic-sdk 这个官方包。截至 10 月 18 日我写稿时,anthropic-sdk 已在 PyPI 下架(直接查无此包),官方 anthropic 最新版是 1.15.0。

这不是孤例。10 月 16 日 OSV 又收录了 pyyi 包的恶意公告(MAL-2026-17866 公告):它注册了一个叫 python2 的命令行入口,背地里从远程服务器拉代码到内存里执行。而 10 月 4 日到 5 日那一波,社区追踪项目总共记录了 59 个新增恶意包(npm + PyPI)。

为什么写 Python 脚本的人最该看这篇?因为我们天天 pip install,而 pip 包的安装脚本在安装阶段就能执行任意代码——中招不需要你 import 它,装上的那一秒就已经晚了。拼写相近的包名(typosquatting)正是冲着"凭记忆手打包名"这个习惯来的。

实战:写个 pip 安装前安检脚本

与其每次靠肉眼分辨,不如写个小脚本 pip_guard.py,装包之前先过三道检查。零依赖,只用 Python 标准库:

  1. PyPI 在不在:查 https://pypi.org/pypi/<包名>/json,404 说明包不存在——可能是拼错、改名,也可能是刚被下架的恶意包;
  2. OSV 有没有恶意公告:调 OSV 查询接口,看这个包名有没有 MAL- 开头的公告,有就直接判死;
  3. 拼写相近提醒:用 difflib 跟一份常见官方包名表比对,request 这种一眼看出是想装 requests 的,直接点出来。
#!/usr/bin/env python3
"""pip-guard.py -- pip install 之前的包名安检(零依赖,只用标准库)"""
import difflib
import json
import re
import sys
import urllib.error
import urllib.request

OSV_API = "https://api.osv.dev/v1/query"
PYPI_API = "https://pypi.org/pypi/{name}/json"

# 常见被仿冒的官方包名(小写)
KNOWN = [
    "anthropic", "openai", "requests", "httpx", "numpy", "pandas",
    "pillow", "django", "flask", "fastapi", "boto3", "sqlalchemy",
    "pytest", "click", "rich", "tqdm", "lxml", "cryptography",
    "pydantic", "transformers", "urllib3", "beautifulsoup4",
]

UA = {"User-Agent": "pip-guard/1.0"}


def http_get_json(url, timeout=15):
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.load(r)


def http_post_json(url, payload, timeout=20):
    data = json.dumps(payload).encode()
    req = urllib.request.Request(url, data=data,
                                 headers={**UA, "Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.load(r)


def clean_name(spec):
    """'pkg[extra]==1.2.3' -> 'pkg'"""
    return re.split(r"[<>=!~\s\[;]", spec.strip(), 1)[0].strip().lower()


def check_pypi(name):
    try:
        data = http_get_json(PYPI_API.format(name=name))
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return {"exists": False}
        raise
    info = data.get("info", {}) or {}
    return {"exists": True,
            "summary": (info.get("summary") or "").strip(),
            "version": info.get("version")}


def check_osv(name):
    data = http_post_json(OSV_API, {"package": {"name": name, "ecosystem": "PyPI"}})
    return data.get("vulns", []) or []


def typosquat_hint(name):
    if name in KNOWN:
        return None
    close = difflib.get_close_matches(name, KNOWN, n=1, cutoff=0.82)
    return close[0] if close else None


def verdict(name):
    print(f"== 检查 {name} ==")
    try:
        pypi = check_pypi(name)
    except Exception as e:
        print(f"  [?] PyPI 查询失败:{type(e).__name__}")
        pypi = {"exists": None}
    try:
        vulns = check_osv(name)
    except Exception as e:
        print(f"  [?] OSV 查询失败:{type(e).__name__}(需要能访问 api.osv.dev)")
        vulns = None

    bad = [v for v in (vulns or []) if str(v.get("id", "")).startswith("MAL-")]
    if bad:
        print("  [x] 发现恶意包公告:")
        for v in bad:
            print(f"      {v.get('id')}:{v.get('summary')}")
        print("  结论:不要安装。")
        return 2
    if pypi.get("exists") is False:
        print("  [!] PyPI 上查不到这个包(可能拼错、改名,或刚被下架)。")
        hint = typosquat_hint(name)
        if hint:
            print(f"  [!] 你是不是想装官方包 {hint}?")
        print("  结论:先核对官方文档里的包名再装。")
        return 1
    if pypi.get("exists") is None and vulns is None:
        print("  [!] 两项在线检查都失败,本次无法给出结论。")
        print("  结论:等网络恢复后重查,不要盲装。")
        return 1
    if pypi.get("exists"):
        print(f"  [ok] PyPI 存在,最新版 {pypi.get('version')}")
        if pypi.get("summary"):
            print(f"       简介:{pypi['summary'][:60]}")
    hint = typosquat_hint(name)
    if hint:
        print(f"  [!] 包名与知名包 {hint} 拼写相近,确认这就是你要的。")
        print("  结论:人工复核后再装。")
        return 1
    print("  结论:未发现风险信号,可以安装(仍建议用虚拟环境)。")
    return 0


def main(argv):
    args = [a for a in argv if a != "-r"]
    targets = []
    if "-r" in argv:  # 从 requirements.txt 批量检查
        for f in args:
            with open(f, encoding="utf-8") as fh:
                for line in fh:
                    line = line.strip()
                    if line and not line.startswith("#"):
                        targets.append(clean_name(line))
    else:
        targets = [clean_name(a) for a in args if a.strip()]
    if not targets:
        print("用法:python3 pip_guard.py <包名...> 或 python3 pip_guard.py -r requirements.txt")
        return 1
    code = 0
    for t in targets:
        code = max(code, verdict(t))
    return code


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

用法很简单,装之前先问它一句:

python3 pip_guard.py anthropic-sdk
python3 pip_guard.py -r requirements.txt   # 批量检查整个依赖文件

真实演示:10 月 18 日的实测输出

下面是脚本在我机器上的真实运行结果(PyPI 查询走直连;演示环境的直连网络到不了 api.osv.dev,所以 OSV 行显示查询失败——公告内容我已用同一接口在另一网络下核实,见上文编号):

== 检查 anthropic-sdk ==
  [?] OSV 查询失败:TimeoutError(需要能访问 api.osv.dev)
  [!] PyPI 上查不到这个包(可能拼错、改名,或刚被下架)。
  结论:先核对官方文档里的包名再装。

== 检查 anthropic ==
  [?] OSV 查询失败:TimeoutError(需要能访问 api.osv.dev)
  [ok] PyPI 存在,最新版 1.15.0
       简介:The official Python library for the anthropic API
  结论:未发现风险信号,可以安装(仍建议用虚拟环境)。

== 检查 pyyi ==
  [?] OSV 查询失败:TimeoutError(需要能访问 api.osv.dev)
  [ok] PyPI 存在,最新版 2.4.2
       简介:small python companion shell
  结论:未发现风险信号,可以安装(仍建议用虚拟环境)。

== 检查 request ==
  [?] OSV 查询失败:TimeoutError(需要能访问 api.osv.dev)
  [!] PyPI 上查不到这个包(可能拼错、改名,或刚被下架)。
  [!] 你是不是想装官方包 requests?
  结论:先核对官方文档里的包名再装。

三点解读:

  • anthropic-sdk:PyPI 已经查无此包——它就是 10 月 4 日那颗雷,事发后被下架了。但下架不等于绝迹,换个马甲随时回来,所以"查无此包"本身就要当黄灯处理,先去官方文档核对包名。
  • pyyi 最有意思:PyPI 上"存在",简介也正常,单看第一道检查会直接放行。但 OSV 公告库里躺着它的 MAL-2026-17866(10 月 16 日发布)。"存在"不等于"安全"——这正是第二道检查(公告库)存在的意义,也是这个脚本最有价值的一行。
  • request:经典拼写陷阱,脚本直接反问"你是不是想装 requests?"——凭记忆手打包名的人,值得被问这一下。

pip install 之前的四条铁律

  1. 包名只从官方文档复制,不凭记忆手打——typosquatting 专门收割手打党;
  2. 装之前跑一遍上面的脚本,或至少搜一下"包名 + malicious";
  3. 项目一律用虚拟环境(venv),别往系统 Python 里装来路不明的包;
  4. 关键项目哈希锁定:requirements.txt 里钉死版本和哈希(pip hash-checking / --require-hashes),CI 里每次安装都校验。

脚本很小,但它把"安装前停一秒"这个习惯变成了自动化。下次再看到"官方 SDK 平替"这种包,先让脚本查一遍,10 秒钟,省的可能是整台机器的凭证。


系列相关:Python 3.15 正式发布:写脚本的人要知道的 7 个新特性