
1. 从 pip install 到 K8s 集群LiteLLM 供应链投毒链路复盘LiteLLM 是一个把 OpenAI、Anthropic、Google、Azure 等多家模型接口统一成 OpenAI 兼容格式的 Python 中间件月下载量在千万级很多 Agent 框架、MCP Server、LLM 编排工具都会把它作为传递依赖拉进来。这次 PyPI 上出现的 1.82.7 / 1.82.8 两个版本被人绕过官方 CI/CD 直接上传包里塞了一个litellm_init.pth。.pth文件在 Python 解释器启动阶段就会被自动执行所以只要你在被污染的版本上跑任何 Python 命令恶意逻辑就已经落地了。它的载荷分三段先收集 SSH 私钥、云厂商凭证、K8s 配置、.env、git 凭证、数据库密码、shell history再用内置 RSA 公钥配合 AES-256-CBC 加密外传到伪装域名如果检测到 K8s 凭证就横向读取所有命名空间的 Secret并在节点上创建特权 Pod 做持久化。最讽刺的是攻击者自己的.pth启动器写崩了——子进程反复触发同一个文件形成 fork bomb把内存吃满导致机器崩溃这才被 Callum McMahon 团队在 Cursor 里用 MCP 插件时发现。这篇文章不复述新闻而是把这条链路拆成可操作的排查动作依赖锁定、镜像源校验、本地复现验证、CI 里快速定位同类风险。适合正在用 LiteLLM、dspy、各类 Agent 框架或者负责 CI/CD 安全的同学跟着做一遍。2. 用 TaoToken 统一 Key 做依赖审计前的环境准备排查供应链风险时最怕的是环境里散落着一堆真实 KeyOpenAI 的、Anthropic 的、云厂商的一旦机器被投毒这些就是第一批被外传的东西。所以在做任何pip install之前我习惯先把模型调用收敛到一个统一入口机器上只留一把 Key出事时轮换成本最低。TaoToken 在这里的角色就是那个统一入口它提供 OpenAI 兼容的 Base URL把多家模型的调用收敛到一处你本地和 CI 里只配置一个 API Key不用在.env里堆十几个厂商凭证。官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 注意 API 地址不带 UTM 参数。具体操作上先到控制台创建 Key控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite创建完 Key 之后本地只保留这一个环境变量比如TAOTOKEN_API_KEY。这样即使某台开发机被投毒泄露的也只是一把可以随时吊销的 Key而不是 AWS、GCP、数据库、SSH 一整套凭证。这一步不是安全银弹但它把「凭证爆炸半径」从十几个降到一两个配合后面的依赖锁定才有意义。如果你还想先验证模型能不能正常调通可以用模型对话页面直接测模型对话https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite长期做编码和 Agent 的团队可以考虑 Coding Plan把日常调用和审计环境分开Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite接入文档在这里配置细节以文档为准接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite3. 可复制的依赖锁定与镜像源校验配置这一节是重点直接给可复制的配置。核心思路有三条锁定精确版本、校验哈希、禁用不受控的额外索引源。先看requirements.txt的锁定写法。不要写litellm1.64.0这种范围范围意味着每次 CI 都可能拉到新版本而新版本可能就是你没审过的。用pip-compile生成带哈希的锁文件pip install pip-tools pip-compile --generate-hashes --output-filerequirements.lock requirements.in生成的requirements.lock长这样每个包带--hashlitellm1.82.6 \ --hashsha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ --hashsha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb安装时强制走锁文件并校验哈希pip install --require-hashes -r requirements.lock--require-hashes会让 pip 拒绝任何哈希不匹配的包投毒版本即使版本号相同、内容被替换也会直接失败。再看pyproject.toml里的配置如果你用 Poetry 或 PDM把源固定住[tool.poetry.dependencies] python ^3.11 litellm 1.82.6 [[tool.poetry.source]] name pypi url https://pypi.org/simple/ priority primary [tool.poetry.source] name internal url https://your-internal-mirror.example.com/simple/ priority supplemental注意priority的设置官方 PyPI 设成primary内部镜像设成supplemental避免镜像源里混入未审版本。如果你用 pip 的pip.conf可以这样写[global] index-url https://pypi.org/simple/ extra-index-url https://your-internal-mirror.example.com/simple/ require-hashes trueextra-index-url是很多投毒事件的入口因为 pip 会在多个源之间选版本号最高的攻击者只要在镜像源上传一个高版本就能覆盖官方源。所以要么不用extra-index-url要么配合--require-hashes。镜像源校验脚本用来在 CI 里检查锁文件里的包是否都来自可信源、哈希是否齐全#!/usr/bin/env python3 import re import sys from pathlib import Path LOCK Path(requirements.lock) TRUSTED_HOSTS {pypi.org, files.pythonhosted.org} def parse_lock(text): entries [] current None for line in text.splitlines(): line line.strip() if not line or line.startswith(#): continue m re.match(r^([A-Za-z0-9_.\-])([^\s\\]), line) if m: current {name: m.group(1), version: m.group(2), hashes: []} entries.append(current) continue if line.startswith(--hashsha256:) and current is not None: current[hashes].append(line.split(:, 1)[1]) return entries def main(): if not LOCK.exists(): print(f[FAIL] {LOCK} not found) return 1 entries parse_lock(LOCK.read_text()) bad [] for e in entries: if not e[hashes]: bad.append(f{e[name]}{e[version]} 缺少 hash) if bad: print([FAIL] 以下包没有哈希锁定) for b in bad: print( -, b) return 1 print(f[OK] {len(entries)} 个包全部带哈希锁定) return 0 if __name__ __main__: sys.exit(main())这个脚本放进 CI 的 pre-install 阶段锁文件里任何一个包缺哈希就直接 fail防止有人手动改锁文件绕过校验。4. 验证请求与本地复现确认你的环境是否干净配好锁定之后要验证两件事一是模型调用能正常走通二是本地环境里没有残留的恶意.pth。先验证模型调用。用统一 Key 发一个最小请求确认 Base URL 和 Key 生效export TAOTOKEN_API_KEYsk-你的key curl -s https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: gpt-4o-mini, messages: [{role: user, content: ping}] }返回里能看到choices数组就说明链路通了。如果返回 401说明 Key 不对如果返回local proxy failed之类说明网络层有问题先排查网络再继续。再验证本地环境。检查 site-packages 里有没有可疑的.pth文件python -c import site; print(site.getsitepackages()) find $(python -c import site; print( .join(site.getsitepackages()))) -name *.pth -newer /tmp/marker 2/dev/null更直接的方式是列出所有.pth并检查内容for f in $(python -c import site; print( .join(site.getsitepackages())))/*.pth; do echo $f cat $f done正常的.pth通常只有一行路径比如/usr/lib/python3/dist-packages。如果看到import语句、exec、subprocess、base64 字符串基本可以判定有问题。LiteLLM 那次投毒用的就是litellm_init.pth里面塞了启动器。本地复现验证动作可以在隔离环境里装一个已知安全版本确认行为正常python -m venv /tmp/litellm-audit source /tmp/litellm-audit/bin/activate pip install --require-hashes -r requirements.lock python -c import litellm; print(litellm.__version__)如果版本号和你锁定的不一致说明锁文件被绕过如果 import 过程中机器内存飙升、进程数暴涨立刻kill掉并检查.pth。K8s 侧也要查。如果开发机连着集群检查是否有异常特权 Podkubectl get pods --all-namespaces -o json | \ jq .items[] | select(.spec.containers[].securityContext.privilegedtrue) | .metadata.name正常业务很少用特权 Pod出现就要人工确认。再查 Secret 有没有被异常读取的审计日志kubectl get events --all-namespaces --field-selector reasonSecretAccess不同集群审计配置不一样这条命令不一定有输出但值得在 CI 里定期跑。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth排查过程中会碰到几类典型报错逐个说清楚。401 Unauthorized最常见。先确认TAOTOKEN_API_KEY有没有导出到当前 shellecho $TAOTOKEN_API_KEY看有没有值。再确认请求头是Authorization: Bearer sk-xxx不是x-api-key。如果 Key 是从控制台复制的注意别把前后空格带进去。还有一种情况是 Key 被吊销了去 API Keys 页面确认状态。local proxy failed这个报错通常出现在你本地配了 HTTP 代理但代理进程没起来或者端口不对。检查HTTP_PROXY/HTTPS_PROXY环境变量env | grep -i proxy。如果不需要代理直接unset掉。注意这里说的是本地网络配置不是任何绕过网络限制的手段纯粹是排查本机代理进程状态。reading choices 报错一般是响应体不是预期的 JSON可能是 Base URL 写错了比如漏了/v1或者把 API 地址写成了官网地址。确认请求地址是https://taotoken.net/api/v1/chat/completions。另外检查model字段是不是当前账号可用的模型 ID模型名写错有时会返回非标准错误体导致解析choices失败。OAuth 相关报错如果你用 Claude Code 或类似工具走的是 OAuth 流程报错通常和 token 过期、回调地址不匹配有关。Claude Code 的接入可以参考文档里的 Anthropic 兼容配置ClaudeCodeAnthropichttps://taotoken.net/doc/claudecode-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecode-anthropicutm_campaignrewrite如果你用 CC Switch、Cline MCP 或 Codex 的auth.json配置三件套要写全Base URL、Key、Model ID。缺任何一个都会导致鉴权失败或模型找不到。以auth.json为例{ base_url: https://taotoken.net/api, api_key: sk-你的key, model: gpt-4o-mini }Base URL 不要带/v1后缀的情况取决于客户端实现以接入文档为准。Cline MCP 的配置类似在 MCP server 的 env 里把这三个值传进去。还有一个容易忽略的点CI 里如果用了缓存pip可能命中旧缓存里的投毒版本。清理缓存再装pip cache purge pip install --require-hashes --no-cache-dir -r requirements.lock--no-cache-dir强制重新下载配合哈希校验能挡住缓存投毒。6. 把统一 Key 和依赖锁定接进你的 CI复盘到这里动作其实很清晰机器上只留一把可吊销的 Key依赖全部锁版本加哈希CI 里跑镜像源校验脚本K8s 侧定期查特权 Pod 和 Secret 访问。这几步做完同类供应链投毒的风险会降一个量级。统一 Key 的入口再放一次方便你直接配API Keyshttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite模型对话验证https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite长期编码与 Agenthttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite最后留一个我实际踩过的坑--require-hashes和extra-index-url同时用时pip 会对每个源都要求哈希如果内部镜像的包没带哈希安装会直接失败。解决办法是内部镜像也用pip-compile --generate-hashes生成锁文件或者干脆去掉extra-index-url只走官方源加哈希。这个坑不解决CI 会一直红但红得有价值——它挡住了未审版本。