PII 脱敏
本页提供:常见 PII 正则识别与脱敏实现(姓名/手机/身份证/邮箱/IP)、日志脱敏中间件代码(自动脱敏请求/响应日志)、发给 LLM 前的 PII 检测与替换代码,以及脱敏 vs 加密 vs 标记化的选择标准对比。
SKILL 要求 Agent 在生成采集、日志与 RAG 管道代码时使用配置化规则库(而非脆弱的一次性正则),并区分直接标识符与准标识符的组合重识别风险。
入管道前扫描;RAG 块可替换为 token 或哈希引用,原始仅存受控库。假阳性通过业务 token 白名单与字段级策略消化。
PII 处理主流程(skill-flow-block)
[ 入口:API / 上传 / 同步 / Agent 工具回包 ]
│
▼
┌─────────────┐ 字段级分级;默认不落原始进向量与提示
│ 分类与最小化 │──── 准标识符组合评估;业务 ID → 内部假名
└─────────────┘
│
▼
┌─────────────┐ 规则库 + NER/字典;白名单与抑制路径
│ 扫描与掩码 │──── 可逆 token vs 不可逆哈希/截断
└─────────────┘
│
▼
┌─────────────┐ 块级替换;引用受控原文需鉴权
│ RAG / 模型 │──── 提示模板禁止拼接未脱敏自由文本
└─────────────┘
│
▼
┌─────────────┐ 结构化日志字段脱敏;trace 属性分级
│ 日志与 Trace │──── 采样与动态脱敏;高敏仅哈希或省略
└─────────────┘
│
▼
┌─────────────┐ 保留期、处理目的、供应商 DPA;评测集
│ 审计与合规 │──── UI 回显前二次扫描;跨境与主体权利流程
└─────────────┘
原则:能不进模型与索引的字段就不要进;必须进的用假名或掩码,并保证检索与排障仍可用(例如稳定哈希关联同一会话)。
常见 PII 正则识别与脱敏实现
// pii-redactor.ts — 常见 PII 正则规则库
interface PiiRule {
name: string;
pattern: RegExp;
mask: (match: string) => string;
}
export const PII_RULES: PiiRule[] = [
{
name: 'cn_mobile',
pattern: /(?<!\d)(1[3-9]\d{9})(?!\d)/g,
mask: (m) => m.slice(0, 3) + '****' + m.slice(7), // 138****5678
},
{
name: 'cn_id_card',
pattern: /\b(\d{6})(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b/g,
mask: (m) => m.slice(0, 6) + '********' + m.slice(14), // 110101********34
},
{
name: 'email',
pattern: /\b([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g,
mask: (m) => {
const at = m.indexOf('@');
return m.charAt(0) + '***' + m.slice(at); // z***@example.com
},
},
{
name: 'ipv4',
pattern: /\b(\d{1,3}\.){3}(\d{1,3})\b/g,
mask: (m) => m.replace(/\.\d+$/, '.***'), // 192.168.1.***
},
{
name: 'cn_name',
// 简单规则:2-4 个汉字,出现在"姓名:"/"联系人"等上下文附近
pattern: /(?:姓名|联系人)[::]\s*([^\s,,。]{2,4})/g,
mask: (m) => m.replace(/([^\s,,。]{2,4})$/, (n) => n.charAt(0) + '*'.repeat(n.length - 1)),
},
];
export function redactText(text: string, rules = PII_RULES): string {
let result = text;
for (const rule of rules) {
rule.pattern.lastIndex = 0;
result = result.replace(rule.pattern, rule.mask);
}
return result;
}
// 白名单:订单号与内部 ID 不被误识别为 PII
const WHITELIST_PATTERNS = [
/\border-[A-Z0-9]{8,}\b/g, // 订单号
/\breq-[a-f0-9]{8,}\b/g, // request ID
];
export function redactWithWhitelist(text: string): string {
// 先替换白名单为 placeholder
const placeholders: string[] = [];
let masked = text;
WHITELIST_PATTERNS.forEach((re, i) => {
masked = masked.replace(re, (m) => {
placeholders.push(m);
return `__WL${i}_${placeholders.length - 1}__`;
});
});
// 执行 PII 脱敏
masked = redactText(masked);
// 还原白名单
masked = masked.replace(/__WL\d+_(\d+)__/g, (_, idx) => placeholders[Number(idx)]);
return masked;
}
日志脱敏中间件(自动脱敏请求/响应日志)
// pii-log-middleware.ts — Express 请求/响应日志自动脱敏
import { redactText } from './pii-redactor';
const PII_LOG_FIELDS = ['email', 'phone', 'mobile', 'id_card', 'password', 'token'];
function redactObject(obj: any, depth = 0): any {
if (depth > 5 || obj === null || obj === undefined) return obj;
if (typeof obj === 'string') return redactText(obj);
if (Array.isArray(obj)) return obj.map(v => redactObject(v, depth + 1));
if (typeof obj === 'object') {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
if (PII_LOG_FIELDS.some(f => key.toLowerCase().includes(f))) {
result[key] = '[REDACTED]'; // 字段名匹配,整体脱敏
} else {
result[key] = redactObject(value, depth + 1);
}
}
return result;
}
return obj;
}
export function piiLogMiddleware(req, res, next) {
// 脱敏请求日志
const safeBody = redactObject(req.body);
const safeQuery = redactObject(req.query);
console.log(JSON.stringify({
level: 'info',
type: 'request',
method: req.method,
path: req.path,
query: safeQuery,
body: safeBody,
request_id: req.headers['x-request-id'], // 用 request_id 关联,不记录完整 PII
timestamp: new Date().toISOString(),
}));
// 拦截响应日志
const originalJson = res.json.bind(res);
res.json = (body) => {
const safeBody = redactObject(body);
console.log(JSON.stringify({
level: 'info',
type: 'response',
status: res.statusCode,
body: safeBody,
request_id: req.headers['x-request-id'],
}));
return originalJson(body);
};
next();
}
发送给 LLM 前的 PII 检测与替换
// llm-pii-guard.ts — 发给 LLM 前自动检测和替换 PII
import { redactWithWhitelist } from './pii-redactor';
import OpenAI from 'openai';
interface RedactionMap {
[placeholder: string]: string; // { '[PHONE_1]': '13812345678' }
}
// 可逆脱敏:替换为占位符,保存映射以便恢复
function redactForLLM(text: string): { redacted: string; map: RedactionMap } {
const map: RedactionMap = {};
let counter = 0;
let redacted = text;
const rules = [
{ name: 'PHONE', pattern: /1[3-9]\d{9}/g },
{ name: 'EMAIL', pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
{ name: 'ID_CARD', pattern: /\d{17}[\dXx]/g },
];
for (const rule of rules) {
rule.pattern.lastIndex = 0;
redacted = redacted.replace(rule.pattern, (match) => {
const placeholder = `[${rule.name}_${++counter}]`;
map[placeholder] = match;
return placeholder;
});
}
return { redacted, map };
}
// 在 LLM 回复中还原占位符(如需要展示原始值)
function restoreFromLLM(text: string, map: RedactionMap): string {
return Object.entries(map).reduce(
(t, [placeholder, original]) => t.replace(placeholder, original),
text
);
}
// 使用示例
const client = new OpenAI();
async function safeChat(userMessage: string): Promise<string> {
// 发给 LLM 前脱敏
const { redacted, map } = redactForLLM(userMessage);
const response = await client.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: '你是一个客服助手。' },
{ role: 'user', content: redacted }, // 发送脱敏后的内容
],
});
const reply = response.choices[0].message.content ?? '';
// 如需在回复中展示原始值(根据业务决定是否还原)
// return restoreFromLLM(reply, map);
return reply;
}
// RAG:向量化前对文档块脱敏
async function indexDocument(content: string) {
const { redacted } = redactForLLM(content);
// 向量库中存储脱敏版本;原文存受控存储
await vectorStore.upsert({ text: redacted });
await secureStorage.store({ original: content });
}
- Agent 工具:工具参数与返回值走同一套 redaction 配置。
- 评测:对抗样本与多语言 PII 形态覆盖;记录假阳性率与漏报用例。
脱敏 vs 加密 vs 标记化 — 选择标准
| 方案 | 可逆性 | 适用场景 | 不适用场景 |
|---|---|---|---|
脱敏(Masking)138****5678 |
不可逆 | 日志展示、对外报表、客服界面只需部分可见 | 需要恢复原始值的业务流程(如发短信) |
| 加密(Encryption) AES-256-GCM |
可逆(有密钥) | 需要按业务逻辑解密使用原始值(如发通知) | 需要字段级搜索(加密后无法直接查询) |
标记化(Tokenization)tok_abc123 |
可逆(有保险库) | 支付卡 PAN 存储(PCI DSS);跨系统引用同一用户 | 需要基于原始值做范围查询或统计 |
| 哈希(Hashing) SHA-256 + salt |
不可逆 | 去重检查(是否存在)、会话关联(用稳定哈希) | 需要展示原始值;手机号熵低(彩虹表攻击) |
- 跨境:传输机制与区域部署选项在 SKILL 输出中占位,由法务确认。
- 不可将「已通过脱敏」作为法律结论自动输出;重大策略须人工复核。
客户端脱敏演示(示意)
下方为浏览器内示意正则替换,仅用于理解展示形态;生产环境应使用经评审的规则库与专用引擎,勿照搬本页脚本。
输入变化后需再次点击「应用脱敏」;复制结果可用于文档草稿,勿用于真实个人数据处理合规证明。
---
name: pii-redaction-pipeline
description: 识别并脱敏全链路 PII:正则规则库、日志中间件、LLM 前替换,选择正确的脱敏方案
---
# 步骤
1. 建立 PII 规则库:手机/身份证/邮箱/IP/姓名,规则集版本化,支持多语言变体
2. 白名单机制:订单号/request_id 等先 placeholder 替换,脱敏后还原
3. 日志中间件:在序列化边界拦截,字段名匹配 email/phone 则 [REDACTED],值做正则脱敏
4. 请求日志:用 request_id 关联,不记录完整手机号或身份证
5. 响应日志:拦截 res.json,对响应体做同样的 redactObject 处理
6. LLM 发送前:redactForLLM — 替换为 [PHONE_1]/[EMAIL_1] 占位符,保存可逆映射
7. RAG 索引前:对文档块脱敏后存向量库;原文存受控存储
8. 选择方案:展示 → 脱敏(不可逆);发通知 → 加密(AES-256-GCM);支付 → 标记化;去重 → 哈希+salt
9. 评测:记录假阳性率(订单号被误识别)和漏报率(变体格式未覆盖)
10. UI 回显前二次扫描:防止 LLM 输出中含有 PII
11. 跨境:传输机制与区域部署选项由法务确认
12. 合规记录:处理目的、保留期限、访问日志写入数据清单
# 禁止
- 禁止将未脱敏 PII 写入日志(包括 error.message 中的请求体)
- 禁止将原始 PII 发给 LLM(包括 RAG 检索内容)
- 禁止将「已通过脱敏」作为法律结论自动输出