19.12-敏感信息脱敏

要点

  • 敏感信息脱敏(Data Masking):在输出中把敏感数据部分隐藏,只展示必要的部分
  • 需要脱敏的数据:手机号、邮箱、身份证号、银行卡号、IP 地址、API Key、内部 URL
  • 脱敏不是加密——加密是可逆的(有密钥就能还原),脱敏是不可逆的(去掉了一部分信息)
  • 脱敏的场景:API 响应返回给用户、日志记录、错误报告、AI 模型的输入/输出
  • 实现方式:字段级脱敏(返回响应时处理)、响应中间件(统一处理)、数据库层脱敏
  • AI 场景的特殊性:LLM 的输入和输出都可能需要脱敏——防止 prompt 里包含敏感数据,防止输出里泄露敏感信息

1. 为什么需要脱敏

即使权限控制正确(用户只能看到自己的数据),某些敏感信息也不应该以明文形式展示:

场景:客服系统需要查看用户信息
 
客服看到:
姓名:张三
手机号:13812345678  ← 完整手机号
身份证号:110101199001011234  ← 完整身份证号
银行卡:6222021234567890123  ← 完整银行卡号

客服只需要知道「这个用户的手机尾号是 5678」就够了,不需要看到完整手机号。完整手机号可能被客服记住、截图、或在不安全的环境里传输。

脱敏后:

姓名:张三
手机号:138****5678
身份证号:110101********1234
银行卡:6222****0123

2. 常见脱敏规则

数据类型脱敏规则示例
手机号保留前 3 后 4,中间 4 位 *138****5678
邮箱保留首字母和域名z***@example.com
身份证号保留前 6 后 4110101********1234
银行卡保留后 4 位************0123
姓名保留姓,名用 *张**
IP 地址保留前两段192.168.*.*
API Key保留前 8 位sk-abc12...
URL替换域名和路径参数https://***.com/api?token=***

3. 脱敏函数实现

3.1 基础脱敏函数

// src/utils/mask.ts
 
// 手机号脱敏
export function maskPhone(phone: string): string {
  if (!phone || phone.length < 7) return phone
  return phone.slice(0, 3) + '****' + phone.slice(-4)
}
 
// 邮箱脱敏
export function maskEmail(email: string): string {
  if (!email || !email.includes('@')) return email
  const [local, domain] = email.split('@')
  if (local.length <= 1) return `*@${domain}`
  return `${local[0]}${'*'.repeat(Math.min(local.length - 1, 3))}@${domain}`
}
 
// 身份证号脱敏
export function maskIDCard(id: string): string {
  if (!id || id.length < 10) return id
  return id.slice(0, 6) + '*'.repeat(id.length - 10) + id.slice(-4)
}
 
// 银行卡脱敏
export function maskBankCard(card: string): string {
  if (!card || card.length < 4) return card
  return '*'.repeat(card.length - 4) + card.slice(-4)
}
 
// 姓名脱敏
export function maskName(name: string): string {
  if (!name || name.length <= 1) return name
  return name[0] + '*'.repeat(name.length - 1)
}
 
// API Key 脱敏
export function maskAPIKey(key: string): string {
  if (!key || key.length <= 8) return key
  return key.slice(0, 8) + '...'
}
 
// 通用:保留前后 N 位
export function maskMiddle(value: string, keepStart: number, keepEnd: number): string {
  if (!value || value.length <= keepStart + keepEnd) return value
  return value.slice(0, keepStart) + '*'.repeat(value.length - keepStart - keepEnd) + value.slice(-keepEnd)
}

3.2 使用示例

// 返回用户信息时脱敏
app.get('/api/users/me', authMiddleware, async (c) => {
  const user = c.get('user')
 
  return c.json({
    id: user.id,
    name: maskName(user.name),
    phone: maskPhone(user.phone),
    email: maskEmail(user.email),
    // 不返回身份证号和银行卡号(如果需要展示,脱敏后返回)
    // idCard: maskIDCard(user.idCard),
    // bankCard: maskBankCard(user.bankCard),
  })
})

4. 响应中间件统一脱敏

每个接口都手动脱敏太繁琐——用中间件统一处理:

// src/middleware/mask-response.ts
import { Context, Next } from 'hono'
 
// 脱敏规则配置
const MASK_RULES: Record<string, (value: string) => string> = {
  phone: maskPhone,
  mobile: maskPhone,
  email: maskEmail,
  idCard: maskIDCard,
  bankCard: maskBankCard,
  apiKey: maskAPIKey,
  secret: maskAPIKey,
  password: () => '******',  // 密码永远不返回
}
 
// 递归处理对象
function maskObject(obj: any, rules: Record<string, Function>): any {
  if (typeof obj !== 'object' || obj === null) return obj
  if (Array.isArray(obj)) return obj.map(item => maskObject(item, rules))
 
  const masked: any = {}
  for (const [key, value] of Object.entries(obj)) {
    const rule = rules[key]
    if (rule && typeof value === 'string') {
      masked[key] = rule(value)
    } else if (typeof value === 'object' && value !== null) {
      masked[key] = maskObject(value, rules)
    } else {
      masked[key] = value
    }
  }
  return masked
}
 
// 脱敏中间件
export function maskResponse() {
  return async (c: Context, next: Next) => {
    await next()
 
    // 只处理 JSON 响应
    const contentType = c.res.headers.get('Content-Type')
    if (!contentType?.includes('application/json')) return
 
    // 读取并脱敏响应体
    try {
      const body = await c.res.json()
      const maskedBody = maskObject(body, MASK_RULES)
      c.res = new Response(JSON.stringify(maskedBody), {
        status: c.res.status,
        headers: c.res.headers,
      })
    } catch {
      // 解析失败,不处理
    }
  }
}
 
// 使用
app.use('/api/*', maskResponse())

5. 日志脱敏

日志是敏感信息泄露的重灾区——开发调试时经常把完整数据打到日志里:

// 危险:日志里打印了完整用户数据
console.log('User data:', user)
// { phone: "13812345678", email: "[email protected]", ... }
 
// 正确:日志里只打印脱敏后的数据
console.log('User data:', {
  id: user.id,
  phone: maskPhone(user.phone),
  email: maskEmail(user.email),
})
// { phone: "138****5678", email: "u***@example.com", ... }

5.1 自动日志脱敏

// src/lib/logger.ts
const SENSITIVE_KEYS = new Set([
  'password', 'secret', 'token', 'apiKey', 'api_key',
  'phone', 'mobile', 'email', 'idCard', 'bankCard',
  'authorization', 'cookie',
])
 
function maskLogData(data: any): any {
  if (typeof data !== 'object' || data === null) return data
  if (Array.isArray(data)) return data.map(maskLogData)
 
  const masked: any = {}
  for (const [key, value] of Object.entries(data)) {
    if (SENSITIVE_KEYS.has(key.toLowerCase())) {
      masked[key] = typeof value === 'string' ? '***' : value
    } else if (typeof value === 'object' && value !== null) {
      masked[key] = maskLogData(value)
    } else {
      masked[key] = value
    }
  }
  return masked
}
 
export const logger = {
  info: (message: string, data?: any) => {
    console.log(message, data ? maskLogData(data) : '')
  },
  error: (message: string, error?: any) => {
    console.error(message, error ? maskLogData(error) : '')
  },
}
 
// 使用
logger.info('User login', { userId: user.id, phone: user.phone })
// User login { userId: "123", phone: "***" }

6. AI 场景的脱敏

6.1 发送给 LLM 之前脱敏

用户数据可能被拼到 prompt 里发给 LLM——如果 prompt 里有手机号、身份证号等敏感信息,LLM 提供商可以看到这些数据。

// 危险:把用户完整信息发给 LLM
const prompt = `
用户信息:
姓名:${user.name}
手机:${user.phone}
身份证:${user.idCard}
 
用户问题:${question}
`
const result = await callLLM(prompt)
// LLM 提供商看到了完整的手机号和身份证号
// 正确:发给 LLM 之前脱敏
const prompt = `
用户信息:
姓名:${maskName(user.name)}
手机:${maskPhone(user.phone)}
 
用户问题:${question}
`
const result = await callLLM(prompt)
// LLM 只看到脱敏后的信息

注意:有些场景 LLM 需要完整信息才能正确处理(比如验证身份证号格式)。这种情况需要评估数据泄露风险和业务需求的平衡。

6.2 LLM 输出中的敏感信息

LLM 可能「记住」训练数据中的敏感信息,并在输出中泄露:

// 过滤 LLM 输出中的敏感信息
function filterSensitiveInOutput(output: string): string {
  let filtered = output
 
  // 过滤手机号格式
  filtered = filtered.replace(/1[3-9]\d{9}/g, '1**********')
 
  // 过滤身份证号格式
  filtered = filtered.replace(/\d{17}[\dXx]/g, '******************')
 
  // 过滤银行卡格式
  filtered = filtered.replace(/\d{16,19}/g, (match) =>
    '*'.repeat(match.length - 4) + match.slice(-4)
  )
 
  // 过滤邮箱格式
  filtered = filtered.replace(
    /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
    '***@***.***'
  )
 
  return filtered
}

6.3 RAG 检索结果的脱敏

RAG 检索到的文档可能包含敏感信息,在送给 LLM 之前脱敏:

app.post('/api/ai/rag-query', async (c) => {
  const { query } = await c.req.json()
 
  // 1. 向量检索
  const results = await searchVectorIndex(query, c.env)
 
  // 2. 脱敏检索结果
  const maskedResults = results.map(doc => ({
    ...doc,
    content: maskSensitiveInDocument(doc.content),
  }))
 
  // 3. 送给 LLM
  const prompt = buildRAGPrompt(query, maskedResults)
  const answer = await callLLM(prompt, c.env)
 
  return c.json({ answer })
})
 
function maskSensitiveInDocument(content: string): string {
  let masked = content
  // 脱敏手机号、身份证号、银行卡号等
  masked = masked.replace(/1[3-9]\d{9}/g, '1**********')
  masked = masked.replace(/\d{17}[\dXx]/g, '******************')
  // ...
  return masked
}

7. 不同场景的脱敏策略

7.1 用户端 vs 管理端

同一个接口,给不同角色返回不同脱敏程度:

app.get('/api/users/:id', authMiddleware, async (c) => {
  const user = await getUser(c.req.param('id'), c.env)
  const currentRole = c.get('user').role
 
  if (currentRole === 'admin') {
    // 管理员看完整信息(但日志里仍然脱敏)
    return c.json(user)
  } else if (currentRole === 'support') {
    // 客服看部分脱敏
    return c.json({
      ...user,
      phone: maskPhone(user.phone),
      email: maskEmail(user.email),
      idCard: maskIDCard(user.idCard),
    })
  } else {
    // 普通用户只看自己的完整信息,别人的脱敏
    if (user.id !== c.get('user').id) {
      return c.json({
        id: user.id,
        name: maskName(user.name),
        phone: maskPhone(user.phone),
      })
    }
    return c.json(user)
  }
})

7.2 调试模式

开发环境可以看完整数据,生产环境必须脱敏:

function shouldMask(): boolean {
  return process.env.NODE_ENV === 'production'
}
 
app.get('/api/debug/user', async (c) => {
  const user = await getUser(c.get('user').id, c.env)
  if (shouldMask()) {
    return c.json({
      ...user,
      phone: maskPhone(user.phone),
      email: maskEmail(user.email),
    })
  }
  // 开发环境返回完整数据
  return c.json(user)
})

8. 脱敏检查清单

检查项说明
API 响应脱敏手机号、邮箱、身份证、银行卡等
日志脱敏所有敏感字段在日志里脱敏
错误响应脱敏错误信息不包含敏感数据
密码永远不返回响应里不包含密码字段
AI prompt 脱敏发给 LLM 的数据脱敏处理
AI 输出过滤LLM 输出过滤敏感信息格式
RAG 文档脱敏检索结果脱敏后送给 LLM
不同角色不同策略管理员、客服、普通用户不同脱敏程度
内部 URL / IP 不暴露错误信息不包含内部架构信息

9. 小结

敏感信息脱敏是在输出中把敏感数据部分隐藏。脱敏不是加密——加密可逆,脱敏不可逆。

常见需要脱敏的数据:手机号、邮箱、身份证号、银行卡号、API Key、内部 URL。

实现方式:字段级脱敏函数、响应中间件统一处理、日志自动脱敏。

AI 场景的脱敏:发给 LLM 之前的数据脱敏(防止 LLM 提供商看到敏感信息)、LLM 输出的敏感信息过滤(手机号、身份证号格式)、RAG 检索结果脱敏。

不同角色不同脱敏策略——管理员看完整数据,客服看脱敏数据,普通用户只看自己的。

下一篇讲 AI 输出安全过滤——跟脱敏不同,过滤是判断 AI 输出是否安全、是否应该返回给用户。