你把一段失败请求的错误日志粘进 AI 对话里求助,而日志里带着 Authorization: Bearer sk-proj-...。这个 token 现在已经进了模型的上下文,而且几乎可以肯定也进了 API 提供商的日志、你应用的对话历史表,以及任何记录 prompt 内容的监控系统。从界面上删掉这条消息并不能撤销这一切。
最快的修复:立刻轮转这个被泄露的凭证,先别管别的调查。 泄露的密钥在你吊销它之前一直有效。一个 2024 年提交或粘贴出去的密钥,到 2026 年 6 月仍然可用,除非有人手动轮转过它。轮转是唯一真正关闭暴露面的动作;之后的所有步骤都只是清理和预防。
本文按顺序讲:先轮转,再排查它流向了哪里,最后加上控制措施,让这种事不再发生。
先判断你属于哪种情况
正确的清理方式取决于 secret 是怎么进上下文的。先确定你的场景。
| 进入路径 | 去哪里排查 | 杠杆最大的控制措施 |
|---|---|---|
| 手动粘贴了错误日志 / 配置片段 | 对话历史、提供商日志 | 对 user 消息做发送前扫描 |
Agent 把 .env / secrets.yaml 当项目上下文读了 | Agent 文件访问记录、工具调用记录 | 文件路径黑名单(注意下方 Claude Code 警告) |
工具输出(cat、env、printenv)被追加进下一轮 | 工具调用记录 | 工具输出重新进入上下文前先 redaction |
| Secret 被插值进了 system prompt 模板 | 构建 prompt 的源代码 | lint 规则 + 服务端凭证处理 |
| CI/CD 把环境变量 dump 进了 prompt 构建步骤 | CI 日志、流水线配置 | 从 prompt 输入里剥掉 printenv / 被遮罩的 CI 变量 |
| 被持久化到对话历史 DB / 缓存 / 训练导出 | 数据库、Redis、对象存储、日志转发 | 写入前 redaction |
不论是哪个厂商,暴露面都一样:prompt 内容应当被当成可能保留数年的日志条目来对待。
常见原因
1. 粘贴了含 authorization header 的错误日志
HTTP 错误日志通常包含完整的请求和响应 header。Authorization: Bearer sk-...、X-API-Key: ... 或 Cookie: session=... 就内嵌在里面。一旦日志进了 prompt,里面每个 secret 都暴露了。
怎么判断:把任何日志粘进 AI 对话前,先 grep 一遍:
grep -iE "(authorization|x-api-key|bearer|password|secret|token|cookie|aws_|sk-)" /path/to/error.log
只要有一行命中,粘贴前就先脱敏。
2. Agent 把 .env 或配置文件当项目上下文读取
为了”让模型了解项目”,给 AI 编程助手开放了大范围文件系统访问,它读了 .env、config/production.yaml 或 terraform.tfvars,而这些文件里有生产环境的密钥。
怎么判断:检查这次会话中 Agent 碰过哪些文件。在 Claude Code 里看工具调用记录中的 Read 调用;在 Cursor 里看挂在消息上的 context pills。
2026 年 6 月的重要警告:在 Claude Code 里给 .env 配 permissions.deny 规则并不是硬保证。2025 年 8 月到 2026 年 2 月之间提交的多个未关闭 issue(例如 anthropics/claude-code#24846)反映,read-deny 规则可能静默失效,文件照样被读。把黑名单当成纵深防御,而不是一堵墙。唯一可靠的保护,是让工作目录里根本读不到真实密钥(用 secret 管理服务、把 .env 放在仓库根目录之外,或者本地用占位值)。
3. System prompt 模板通过字符串插值带进了 secret
开发者动态构建 system prompt,把 secret 插值进去了:
const systemPrompt = `You are a support agent for our API. The internal admin key is ${ADMIN_KEY}. Use this for escalations.`;
怎么判断:在所有构建 prompt 的文件里 grep process.env.、os.environ,以及任何含 key、secret、token、password 的变量名。
4. 工具调用的输出被原样追加进下一轮
Agent 跑了一个 bash 工具,执行了 cat config.yaml、env 或 aws configure list,编排层把原始输出直接追加进下一轮的上下文。
怎么判断:记录所有工具输出,在它们重新进入对话前跑一遍 secret 模式扫描。确认凭证是以明文字符串传递的,还是以不透明的 handle 传递的。
5. CI/CD 流水线把环境变量带进了 prompt 模板
自动化流水线用 CI 环境变量构建 prompt,而某个调试步骤(printenv、echo $VARIABLE,或一个被遮罩但仍被记录的 secret)的输出流进了 prompt 构建阶段。
怎么判断:检查 CI 脚本里每一处 echo $VARIABLE 和 printenv,确认它们的输出是否被某个 prompt 构建步骤消费。注意:大多数 CI 系统只在自己的界面里遮罩 secret,但不会遮罩你传给外部 API 的数据里的 secret。
6. 对话历史持久化把 secret 永久留存了
即使你从界面上删了消息,secret 仍可能残留在数据库行、反馈导出、Redis 缓存,或提供商侧的运维日志里。从界面删除不等于从系统删除。
怎么判断:在对话历史存储里(数据库表、对象存储、日志转发目的地)搜索这个值,并确认谁有读权限。
最短修复路径
Step 1: 立刻轮转被泄露的凭证
这一步不能等调查完。吊销是即时且不可逆的——这正是你想要的效果。
# OpenAI: https://platform.openai.com/api-keys
# 点击被泄露 key 旁边的垃圾桶图标,然后创建一个新的。
# 项目级 key (sk-proj-...) 只影响那一个 project;轮转该 project 的 key 即可。
# GitHub: https://github.com/settings/tokens(fine-grained 在 settings/personal-access-tokens)
# 删掉被泄露的 token,生成替换,重新授权所有依赖方。
# AWS access key pair:
aws iam delete-access-key --access-key-id AKIA_EXPOSED_KEY_ID --user-name svc-account-name
aws iam create-access-key --user-name svc-account-name
轮转后,更新每一个依赖方(CI secret、部署配置、运行中的服务),并确认旧凭证现在已经失效。旧 key 返回 401/403,就是吊销成功的证明。
Step 2: 确认被泄露的 key 是否真的被用过
在往最坏处想之前,先确认这个凭证在暴露窗口期是不是活的、有没有被调用过。TruffleHog 可以拿密钥去提供商那边验证,告诉你它是否还能认证通过:
# 只显示扫描目标里确认仍然有效的凭证
trufflehog filesystem ./exported-chat-logs --results=verified
然后查提供商侧的使用记录(OpenAI usage dashboard、AWS CloudTrail、GitHub audit log),看看暴露窗口期内有没有你没发起过的调用。出现意外用量,事情就从”轮转完就行”升级为安全事件了。
Step 3: 排查 secret 流向了哪里
# 应用和 Web 服务器日志
grep -r "EXPOSED_KEY_VALUE" /var/log/app/ /var/log/nginx/
# 对话历史数据库(在只读副本上执行)
# SELECT COUNT(*) FROM chat_messages WHERE body LIKE '%EXPOSED_KEY_VALUE%';
# 缓存(以 Redis 为例)
# redis-cli --scan --pattern 'chat:*' | xargs -I{} redis-cli get {} | grep -F 'EXPOSED_KEY_VALUE'
# 同样在你的监控 / SIEM 索引里搜这个明文值。
凡是找到的地方,把存的值脱敏或清除。这么做不是为了”清理干净让它变安全”(你已经轮转过,这个值早就死了),而是为了移除一个死掉的 secret,否则审计或扫描器仍会把它标红。
Step 4: 给 prompt 构建器加上发送前的 secret 扫描
要用匹配当前密钥格式的模式。OpenAI 默认 key 现在是项目级的(sk-proj-...),token 中段内嵌字面量 T3BlbkFJ;GitHub fine-grained PAT 用 github_pat_ 前缀。光靠老的 sk-[A-Za-z0-9]{20,} 模式会漏掉今天的 key。
const SECRET_PATTERNS = [
/sk-(proj|svcacct|admin)-[A-Za-z0-9_-]{20,}/g, // 当前 OpenAI key
/\bT3BlbkFJ[A-Za-z0-9_-]{20,}/g, // OpenAI key 主体标记
/\bgithub_pat_[0-9A-Za-z_]{82}\b/g, // GitHub fine-grained PAT
/\bghp_[A-Za-z0-9]{36}\b/g, // GitHub classic PAT
/\bAKIA[A-Z0-9]{16}\b/g, // AWS access key id
/\bAIza[0-9A-Za-z_-]{35}\b/g, // Google API key
/xoxb-[0-9]{11,}-[A-Za-z0-9-]+/g, // Slack bot token
/-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----/g,
/(?:password|api[_-]?key|secret)\s*[:=]\s*\S+/gi,
];
function containsSecret(text: string): boolean {
return SECRET_PATTERNS.some((re) => re.test(text));
}
function buildPromptSafe(userMessage: string, context: string): string {
if (containsSecret(context)) {
throw new Error("Context contains a potential secret. Redact before including in prompt.");
}
if (containsSecret(userMessage)) {
logger.warn({ event: "user_message_contains_secret" });
// 根据策略选择拦截或脱敏。
}
return `${systemPrompt}\n\nContext:\n${context}\n\nUser: ${userMessage}`;
}
正则形状匹配能抓住明显的情况,但必要不充分。在 CI 里配一个真正的扫描器(gitleaks/TruffleHog)做全历史覆盖。
Step 5: 禁止 Agent 访问符合 secret 模式的文件路径
const BLOCKED_FILE_PATTERNS = [
/\.env(\.\w+)?$/,
/secrets\.(yml|yaml|json)$/i,
/credentials\.json$/i,
/terraform\.tfvars(\.json)?$/i,
/\.netrc$/,
/.*\.pem$/,
/.*_rsa(\.pub)?$/,
/.*\.p12$/,
];
function canReadFile(path: string): boolean {
return !BLOCKED_FILE_PATTERNS.some((re) => re.test(path));
}
如果用的是 Claude Code,也在 settings.json 里配上 deny 规则,但记住原因 2 里提到的 2026 年 6 月警告(这些规则可能静默失效,所以别只依赖它们):
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)"
]
}
}
Step 6: 在 prompt 落库前先 redaction
function redactSecretsFromPrompt(prompt: string): string {
let redacted = prompt;
for (const pattern of SECRET_PATTERNS) {
redacted = redacted.replace(pattern, "[REDACTED_SECRET]");
}
return redacted;
}
// 只把脱敏后的版本存进对话历史数据库。
const safePromptForStorage = redactSecretsFromPrompt(fullPrompt);
await db.chatMessages.create({ body: safePromptForStorage, sessionId });
Step 7: 加 pre-commit hook,让 secret 永远进不了构建 prompt 的代码
注意:gitleaks protect 在 gitleaks v8.19.0 已被弃用,替换为 gitleaks git 命令。用当前的语法:
# 安装 gitleaks(当前命令模型:git / dir / stdin)
brew install gitleaks
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
gitleaks git --pre-commit --staged --redact --no-banner
if [ $? -ne 0 ]; then
echo "gitleaks: secrets detected in staged files. Commit blocked."
exit 1
fi
EOF
chmod +x .git/hooks/pre-commit
要在 CI 里做全历史扫描,对整个仓库跑 gitleaks git --redact,或者用 trufflehog git file://. --results=verified 只标出仍然有效的凭证。
如何确认已修复
- 旧凭证现在返回
401/403(确认已吊销)。 - 提供商侧审计日志显示暴露窗口期内没有意外用量。
- 重新扫描对话历史存储、缓存和日志,已经搜不到这个值。
- 在 staging 的 prompt 里故意埋一个测试 secret(
sk-proj-TESTplaceholder...),发送前扫描会拦下它。 - 对仓库跑
gitleaks git --redact返回零命中。
五项全过,事件就可以结案。如果第 2 步发现了意外用量,按数据泄露处理,走你的应急响应流程。
预防建议
- 把每个 prompt 内容都当成会保留数年的日志条目。永远不要把真实 secret 放进任何 prompt。
- 在 prompt 内容发送到模型 API 之前跑 secret 扫描,用匹配当前密钥格式的模式(
sk-proj-、github_pat_,以及T3BlbkFJ主体标记)。 - 禁止 Agent 访问任何路径看起来像凭证文件的文件;截至 2026 年 6 月,不要只靠 Claude Code 的
permissions.deny来挡.env。 - 存储 prompt 和响应内容时把 secret 脱敏。
- 在 pre-commit hook 和 CI 里跑
gitleaks git或trufflehog。 - 永远不要把原始环境变量插值进 system prompt 模板。如果某个工具需要凭证,把它交给服务端的工具 handler,而不是模型。
- 为每种 secret 类型准备一页轮转 runbook,让任何工程师都能在 10 分钟内完成吊销。
常见问答 (FAQ)
Q: 我已经从对话界面删了那条消息,secret 是不是就没了? A: 几乎可以肯定没有。界面删除通常只移除用户可见的那条记录。数据库行、提供商侧留存、日志聚合和监控抓取都可能还留着这个值。当它已经泄露,直接轮转。
Q: 我的提供商说不拿 API 数据训练,那 prompt 是不是就安全了? A: 不安全。不训练政策针对的是训练数据集,不包括用于滥用检测、调试或合规留存的运维日志。假设 prompt 会被保留一段时间,并据此设计系统。
Q: 我已经让 Claude Code deny 读 .env,为什么它还是读了?
A: 这是一个已知问题,在 2025 年 8 月到 2026 年 2 月之间有多份报告:read-deny 规则可能静默失效。把 deny 规则当成一层防护,但可靠的修复是让真实 secret 根本不在工作目录里(用 secret 管理服务,或本地用占位值)。
Q: 该用 secret 管理服务代替 .env 文件吗?
A: 该用。AWS Secrets Manager、HashiCorp Vault、GCP Secret Manager 在运行时通过 API 注入 secret,而不是把它持久化在文件或环境变量 dump 里,而且每次访问都可审计。这就堵住了 secret 进 prompt 最常见的那条路。
Q: 怎么知道被泄露的 key 是不是真的被滥用了?
A: 用 trufflehog --results=verified 拿密钥去提供商那边验证它当时是不是活的,再查提供商审计日志(OpenAI usage dashboard、AWS CloudTrail、GitHub audit log),看暴露窗口期内有没有你没发起过的调用。