19.12-敏感信息脱敏
要点
- 敏感信息脱敏(Data Masking):在输出中把敏感数据部分隐藏,只展示必要的部分
- 需要脱敏的数据:手机号、邮箱、身份证号、银行卡号、IP 地址、API Key、内部 URL
- 脱敏不是加密——加密是可逆的(有密钥就能还原),脱敏是不可逆的(去掉了一部分信息)
- 脱敏的场景:API 响应返回给用户、日志记录、错误报告、AI 模型的输入/输出
- 实现方式:字段级脱敏(返回响应时处理)、响应中间件(统一处理)、数据库层脱敏
- AI 场景的特殊性:LLM 的输入和输出都可能需要脱敏——防止 prompt 里包含敏感数据,防止输出里泄露敏感信息
1. 为什么需要脱敏
即使权限控制正确(用户只能看到自己的数据),某些敏感信息也不应该以明文形式展示:
场景:客服系统需要查看用户信息
客服看到:
姓名:张三
手机号:13812345678 ← 完整手机号
身份证号:110101199001011234 ← 完整身份证号
银行卡:6222021234567890123 ← 完整银行卡号客服只需要知道「这个用户的手机尾号是 5678」就够了,不需要看到完整手机号。完整手机号可能被客服记住、截图、或在不安全的环境里传输。
脱敏后:
姓名:张三
手机号:138****5678
身份证号:110101********1234
银行卡:6222****01232. 常见脱敏规则
| 数据类型 | 脱敏规则 | 示例 |
|---|---|---|
| 手机号 | 保留前 3 后 4,中间 4 位 * | 138****5678 |
| 邮箱 | 保留首字母和域名 | z***@example.com |
| 身份证号 | 保留前 6 后 4 | 110101********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 输出是否安全、是否应该返回给用户。