Secret 被无意写入 prompt 上下文

API key、密码或 token 不小心进了 prompt——如何第一时间轮转、追踪它流向了哪里,并从源头阻止 secret 再次进入模型上下文。

你把一段失败请求的错误日志粘进 AI 对话里求助,而日志里带着 Authorization: Bearer sk-proj-...。这个 token 现在已经进了模型的上下文,而且几乎可以肯定也进了 API 提供商的日志、你应用的对话历史表,以及任何记录 prompt 内容的监控系统。从界面上删掉这条消息并不能撤销这一切。

最快的修复:立刻轮转这个被泄露的凭证,先别管别的调查。 泄露的密钥在你吊销它之前一直有效。一个 2024 年提交或粘贴出去的密钥,到 2026 年 6 月仍然可用,除非有人手动轮转过它。轮转是唯一真正关闭暴露面的动作;之后的所有步骤都只是清理和预防。

本文按顺序讲:先轮转,再排查它流向了哪里,最后加上控制措施,让这种事不再发生。

先判断你属于哪种情况

正确的清理方式取决于 secret 是怎么进上下文的。先确定你的场景。

进入路径去哪里排查杠杆最大的控制措施
手动粘贴了错误日志 / 配置片段对话历史、提供商日志对 user 消息做发送前扫描
Agent 把 .env / secrets.yaml 当项目上下文读了Agent 文件访问记录、工具调用记录文件路径黑名单(注意下方 Claude Code 警告)
工具输出(catenvprintenv)被追加进下一轮工具调用记录工具输出重新进入上下文前先 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 编程助手开放了大范围文件系统访问,它读了 .envconfig/production.yamlterraform.tfvars,而这些文件里有生产环境的密钥。

怎么判断:检查这次会话中 Agent 碰过哪些文件。在 Claude Code 里看工具调用记录中的 Read 调用;在 Cursor 里看挂在消息上的 context pills。

2026 年 6 月的重要警告:在 Claude Code 里给 .envpermissions.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,以及任何含 keysecrettokenpassword 的变量名。

4. 工具调用的输出被原样追加进下一轮

Agent 跑了一个 bash 工具,执行了 cat config.yamlenvaws configure list,编排层把原始输出直接追加进下一轮的上下文。

怎么判断:记录所有工具输出,在它们重新进入对话前跑一遍 secret 模式扫描。确认凭证是以明文字符串传递的,还是以不透明的 handle 传递的。

5. CI/CD 流水线把环境变量带进了 prompt 模板

自动化流水线用 CI 环境变量构建 prompt,而某个调试步骤(printenvecho $VARIABLE,或一个被遮罩但仍被记录的 secret)的输出流进了 prompt 构建阶段。

怎么判断:检查 CI 脚本里每一处 echo $VARIABLEprintenv,确认它们的输出是否被某个 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 只标出仍然有效的凭证。

如何确认已修复

  1. 旧凭证现在返回 401/403(确认已吊销)。
  2. 提供商侧审计日志显示暴露窗口期内没有意外用量。
  3. 重新扫描对话历史存储、缓存和日志,已经搜不到这个值。
  4. 在 staging 的 prompt 里故意埋一个测试 secret(sk-proj-TESTplaceholder...),发送前扫描会拦下它。
  5. 对仓库跑 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 gittrufflehog
  • 永远不要把原始环境变量插值进 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),看暴露窗口期内有没有你没发起过的调用。

相关阅读

标签: #ai-security #prompt-injection #排查