详细分析了 LLM API 密钥在 git 提交、前端暴露、notebook 输出、日志等场景下的泄密概率排序,并给出对应防护建议。
LLM API key 是一种附着在账户余额上的持有者凭证。任何拿到它的人都可以花你的钱,而通常的结果不是惊天大泄露,而是一笔账单。选择能真正防止你所面临的那种泄露的存储方式。
五种 key 实际泄露的途径
按每种方式实际发生的频率排序,而非按听起来有多严重排序。
其中四种靠"静态加密"解决不了。它们靠的是让 key 永远不会出现在会被复制的地方——这正是下面这个排序的原因。
进程环境,由平台注入。 各地通用的生产环境答案。你的托管服务商、容器编排工具或 systemd 单元把值写入环境;没有文件,也没有任何路径能让它进入仓库。
.env 文件,列入 .gitignore。 开发环境的答案。结构可以通过提交一个空值的 .env.example 来方便地共享,而且只要忽略规则写得够早就足够安全。
操作系统密钥链。 在笔记本上比 .env 更好,因为密钥不是明文文件,不会被备份工具、同步客户端或共享屏幕捕获。下面有详细说明。
密钥管理器。 Vault,或云服务商自带的。当你需要轮换、审计和按服务分配访问权限而不只是存储时,值得用。
永不使用:源码中的常量、命令行参数。 前者会被提交。后者会出现在 shell 历史记录和进程表中,机器上的任何用户都能用 ps 读取它。
# config.py — 一个读取环境变量的地方,缺失时直接报错
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
base_url: str
api_key: str
model: str
def __repr__(self) -> str: # 这样 traceback 就打印不出 key
return f"Settings(base_url={self.base_url!r}, api_key='***', model={self.model!r})"
def load_settings() -> Settings:
missing = [name for name in ("LLM_BASE_URL", "LLM_API_KEY")
if not os.environ.get(name)]
if missing:
raise RuntimeError(
f"missing environment variables: {', '.join(missing)}. "
"Copy .env.example to .env and fill it in."
)
return Settings(
base_url=os.environ["LLM_BASE_URL"].rstrip("/"),
api_key=os.environ["LLM_API_KEY"],
model=os.environ.get("LLM_MODEL", "openai/gpt-4o-mini"),
)
自定义的 __repr__ 很小,但第一次用包含 settings 对象的本地变量抛出异常时就值回票价了。框架和错误报告器会打印局部变量;dataclass 默认的 repr 会把 key 也打出来。
pip install keyring。它对接 macOS Keychain、Windows Credential Locker 以及 Linux 上的 Secret Service(GNOME Keyring、KWallet)。密钥由操作系统存储并释放给进程,所以项目中永远不会有文件包含它。
# secrets_store.py
import getpass
import os
import keyring
SERVICE = "multigrid-llm"
ACCOUNT = "default"
def store_key_interactively() -> None:
"""运行一次,由人手动在终端执行。永远不要写在脚本或 notebook 里。"""
value = getpass.getpass("API key (input hidden): ")
keyring.set_password(SERVICE, ACCOUNT, value)
print("stored in the OS keyring")
def get_api_key() -> str:
"""环境变量优先,这样 CI 和容器就不需要 keyring。"""
from_env = os.environ.get("LLM_API_KEY")
if from_env:
return from_env
try:
value = keyring.get_password(SERVICE, ACCOUNT)
except keyring.errors.KeyringError as exc:
raise RuntimeError(
f"no LLM_API_KEY set and the keyring is unavailable ({exc})"
) from exc
if not value:
raise RuntimeError("no key stored; run store_key_interactively() first")
return value
用 getpass.getpass 而不是 input,这样 key 不会回显到终端,也不会进入屏幕录像、共享会话或终端回溯缓冲区,进而被粘贴到其他地方。
环境变量优先于 keyring,永远如此。无头服务器或 CI runner 没有 keyring 后端,如果代码先去找 keyring,会以一种令人困惑的方式失败——异常提到的是 D-Bus,而不是你缺少的配置。
.ipynb 文件会存储 cell 输出。任何打印出来的东西都会被保存、提交,并由所有带有预览功能的 git 托管平台渲染出来。
永远不要 print(os.environ),也永远不要不带参数地执行 %env。两者都会倾倒所有变量,而输出 cell 会把它保留下来。
永远不要把 key 粘贴到 cell 里,即使一分钟后就想删除。Jupyter 会自动保存检查点,而检查点目录很容易意外被提交。
小心 traceback。当异常在接收了 key 作为参数的调用内部抛出时,会在帧中打印该参数。这是人们没有预料到的泄露方式,也是上面的 settings 对象要有自定义 repr 的原因。
提交前剥离输出。nbstripout --install 添加了一个 git filter,在进入索引时移除输出,同时保留你的工作副本。这也让 notebook 的 diff 可读,光这一点就值了。
Notebooks for LLM work without the usual mess 涵盖了其余的 notebook 规范,包括如何把代码提取到模块中以便测试。
检测优于纪律。pre-commit hook 只需五分钟就能设置好,能捕获那种克隆了仓库但不读 README 的人的情况。
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: detect-private-key
- id: check-added-large-files
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
pip install pre-commit
pre-commit install # 安装 git hook
# 创建已知、经审查发现的基线
detect-secrets scan > .secrets.baseline
固定 rev 值,有意更新:pre-commit hook 每次提交时都会从远程仓库运行代码,所以未固定的修订版本是一个你未曾审查的供应链依赖。不要照抄上面的版本,而是检查当前的 tag。
如果你的托管商提供,也加上服务端那半——GitHub 的推送保护会阻止包含已知密钥格式的推送,而且对没有安装你 hook 的人也有效。
顺序很重要,第一步是人们最后才做的。
撤销密钥。 第一步。现在就做。在弄清楚它怎么发生的之前不要做决定,在重写历史之前不要做任何事,在告诉任何人之前不要做任何事。存在于 git 对象中的 key 在每一个克隆、每一个 fork、每一个 CI 缓存和每一个镜像里都存在;重写历史触及不到其中任何一个。撤销是唯一真正能阻止消费的举动。
发放一个新的并部署。 通过环境变量,而不是通过那个泄露的文件。
检查使用日志,算出暴露窗口。 从第一次提交到撤销。意想不到的模型、异常时段或用量激增能告诉你它是否被使用了,这会改变你需要披露的内容。
现在才清理历史。 用 git filter-repo 或等价工具,然后 force-push。这是家务活,这样密钥才不会因为后续的 fork 再次泄露。
加上本该捕获它的防护。 同时降低密钥的消费限额。预算控制和钱包拒绝服务是为了把泄露密钥在被发现之前造成的损失控制在上限。
密钥的爆炸半径是你可以提前设置的属性。在 Multigrid 上,密钥是独立的凭证,有各自独立的消费限额和独立的请求日志,所以笔记本用的密钥可以限制在每天几欧元并可单独撤销——这把上述事件从一笔账单变成了一个小麻烦。
Your First LLM Call in Python
Notebooks for LLM Work Without the Usual Mess
Packaging an AI Script as a CLI