
先说背景:这篇讲什么
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 标准库:
- PyPI 在不在:查
https://pypi.org/pypi/<包名>/json,404 说明包不存在——可能是拼错、改名,也可能是刚被下架的恶意包; - OSV 有没有恶意公告:调 OSV 查询接口,看这个包名有没有
MAL-开头的公告,有就直接判死; - 拼写相近提醒:用
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 之前的四条铁律
- 包名只从官方文档复制,不凭记忆手打——typosquatting 专门收割手打党;
- 装之前跑一遍上面的脚本,或至少搜一下"包名 + malicious";
- 项目一律用虚拟环境(
venv),别往系统 Python 里装来路不明的包; - 关键项目哈希锁定:
requirements.txt里钉死版本和哈希(pip hash-checking/--require-hashes),CI 里每次安装都校验。
脚本很小,但它把"安装前停一秒"这个习惯变成了自动化。下次再看到"官方 SDK 平替"这种包,先让脚本查一遍,10 秒钟,省的可能是整台机器的凭证。
