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 检索内容)
- 禁止将「已通过脱敏」作为法律结论自动输出

返回技能库 更多技能入口