Secret 被无意写入 prompt 上下文

API key、数据库密码或 token 不经意间出现在 prompt context 里——识别 secret 进入 context 的常见路径并通过 redaction 和 secret 注入规范从源头阻断泄露。

代码审查时发现一处问题:prompt 构建函数把整个 process.env 对象序列化后塞进了 context,其中包含 DATABASE_URLOPENAI_API_KEYAWS_SECRET_ACCESS_KEY。或者在 Agent 的工具调用日志里,某次 bash 工具执行了 printenv 并把输出完整地追加进了下一轮的 context。Secret 被写入 prompt context 通常不是有意为之,而是开发者在调试时的临时措施被遗留到生产环境,或者对 context 构建逻辑缺乏安全审查的结果。Secret 一旦进入 context,就可能出现在模型输出、日志、缓存或前端渲染的任何位置。

常见原因

1. 调试代码把环境变量序列化进 context

开发阶段为了让模型”知道”当前环境,把 JSON.stringify(process.env)os.environ 追加进 system prompt。这段代码被遗留到生产部署中。

怎么判断:在代码仓库里搜索 process.envos.environdotenv 出现在 prompt 构建相关函数里的位置,确认是否有环境变量被完整序列化进 context。

2. 工具返回值包含密钥且未经过 redaction

bash 工具执行了 cat config.yamlenvaws configure list,返回值包含真实密钥,这个返回值被直接追加进 context 的下一轮输入。

怎么判断:检查所有工具返回值处理代码,确认是否在追加到 context 之前经过了 secret 脱敏处理。

3. 用户粘贴了含密钥的配置片段

用户在调试时粘贴了 .env 文件内容、AWS 凭证或数据库连接字符串,这些内容进入 user 消息并被保留在对话历史里,后续每一轮都重新放入 context。

怎么判断:扫描对话历史中的 user 消息,检查是否包含等号赋值格式且值部分为长随机字符串的内容(KEY=value 模式)。

4. System prompt 模板包含硬编码的凭证占位符被真实值替换

Prompt 模板里有 ${DATABASE_URL} 这样的占位符,用于插入连接字符串让模型”了解”数据库。运维在部署时用真实值填充了这些占位符,密钥就固化在每次对话的 system prompt 里。

怎么判断:检查所有 prompt 模板文件,搜索是否有连接字符串格式的占位符(包含 ://@password= 的字符串)。

5. 对话历史缓存未做 secret 扫描

对话历史被缓存到 Redis 或数据库,缓存层没有对 secret 做额外扫描。攻击者若能读取缓存(通过缓存注入或未授权访问),可以直接获取密钥。

怎么判断:检查对话历史的缓存存储,确认在写入缓存前是否经过 secret 脱敏,以及缓存的访问控制是否足够严格。

6. 日志系统记录了完整 prompt,包括 context 里的 secret

即使前端和模型输出没有暴露密钥,若后端日志记录了完整的 prompt(包括 system message 和 context),密钥会被持久化到日志系统,增大暴露面。

怎么判断:检查日志配置,确认 prompt 日志是否经过脱敏再落盘,或者是否有针对 secret 格式的日志过滤规则。

最短修复路径

Step 1: 在 context 构建函数里统一应用 secret redaction

const SECRET_RE = [
  /sk-[a-zA-Z0-9_-]{20,}/g,
  /AIza[0-9A-Za-z_-]{35}/g,
  /AKIA[0-9A-Z]{16}/g,
  /ghp_[a-zA-Z0-9]{36}/g,
  /(?:password|pwd|secret|token|key|api_key)\s*[:=]\s*['"]?([^\s,'";\n]{8,})['"]?/gi,
  /[a-z][a-z0-9+.-]+:\/\/[^:@\s]+:[^@\s]+@/gi, // URI 里的密码 (user:pass@host)
];

function redactSecrets(text: string): string {
  let result = text;
  for (const re of SECRET_RE) {
    result = result.replace(re, (match) => {
      // 保留前缀,遮盖实际值
      const prefix = match.split(/[:=]/)[0] ?? '';
      return `${prefix}=[REDACTED]`;
    });
  }
  return result;
}

function buildContext(pieces: string[]): string {
  return pieces.map(redactSecrets).join('\n');
}

Step 2: 禁止把 process.env 序列化进 context

// eslint 规则:禁止在 prompt 相关文件中使用 process.env
// .eslintrc.json
{
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "CallExpression[callee.object.name='JSON'][callee.property.name='stringify'][arguments.0.object.name='process'][arguments.0.property.name='env']",
        "message": "不要把 process.env 序列化进 prompt context"
      }
    ]
  }
}

Step 3: 在工具返回值进入 context 之前做 redaction

async function safeToolExecution(
  toolName: string,
  params: Record<string, unknown>
): Promise<string> {
  const rawOutput = await runTool(toolName, params);
  const sanitized = redactSecrets(rawOutput);
  if (sanitized !== rawOutput) {
    logger.warn('secret_redacted_from_tool_output', {
      toolName,
      redactedCount: (rawOutput.match(/sk-|AKIA|AIza/g) ?? []).length,
    });
  }
  return sanitized;
}

Step 4: 在日志落盘前做 secret 脱敏

# Logstash filter 示例:在日志写入前脱敏 API key
filter {
  mutate {
    gsub => [
      "message", "sk-[a-zA-Z0-9_-]{20,}", "[REDACTED_OPENAI]",
      "message", "AKIA[0-9A-Z]{16}", "[REDACTED_AWS]",
      "message", "ghp_[a-zA-Z0-9]{36}", "[REDACTED_GITHUB]"
    ]
  }
}

Step 5: 对 context 做落库前的安全扫描

async function saveConversationHistory(
  sessionId: string,
  messages: Message[]
): Promise<void> {
  for (const msg of messages) {
    if (SECRET_RE.some(re => re.test(msg.content))) {
      logger.error('secret_detected_in_message_before_save', {
        sessionId,
        role: msg.role,
        snippet: msg.content.slice(0, 100),
      });
      msg.content = redactSecrets(msg.content);
    }
  }
  await db.conversations.upsert({ sessionId, messages });
}

预防建议

  • 永远不要把 process.envos.environ 序列化进 prompt context;只传入任务所需的最小信息。
  • 在 context 构建管道的最末端统一应用 secret redaction,作为最后一道防线。
  • 对所有工具返回值在追加到 context 前做 secret 扫描,不仅仅是 bash 工具。
  • 在 CI 里加入针对 prompt 构建代码的静态分析规则,禁止把环境变量对象直接传入 prompt。
  • 对话历史缓存在写入前经过 secret 扫描;对缓存存储设置严格的访问控制。
  • 日志系统配置 secret 过滤规则,不让明文密钥持久化到任何可被多人访问的存储系统。
  • 定期对生产环境的 context 构建代码做安全审查,重点关注任何把配置对象传入 prompt 的位置。
  • 使用 secret 管理服务(HashiCorp Vault、AWS Secrets Manager)代替环境变量文件,并为每个服务分配最小权限的短期凭证。

常见问答 (FAQ)

Q: 密钥已经在 context 里出现了几百次,怎么评估影响范围? A: 首先确认密钥是否出现在模型输出中(搜索输出日志);其次检查对话历史缓存里是否有明文密钥;最后审查 API 调用日志,评估密钥是否在泄露期间被异常使用。完成评估后立即轮转密钥。

Q: redaction filter 会影响模型的正常功能吗? A: 通常不会。模型执行任务一般不需要知道真实的密钥值——只需要知道”这里是一个 API key”就足够。若某个场景确实需要模型处理密钥(如密钥格式验证),应使用测试密钥而非真实密钥。

Q: 如何防止用户粘贴密钥到对话里? A: 在 user 消息进入 context 之前做实时扫描,发现疑似密钥时返回提示:“您的消息似乎包含 API key,已自动隐藏。请确认是否需要继续。“同时把已识别的密钥替换为 [REDACTED] 再存入历史。

Q: 对话历史里的密钥被清理后,模型会不会因为 context 缺失而出错? A: 可能会影响依赖密钥值的后续对话逻辑,但这是正确行为——密钥不应在 context 中流通。若确实需要跨轮传递认证信息,应通过 server-side session 而非 context。

相关阅读

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