根据 IBM《2025 年数据泄露成本报告》,全球数据泄露的平均成本已达 488 万美元,其中 78% 的泄露事件涉及结构化数据(JSON/数据库记录)中的 PII(个人可识别信息)泄露。在 API 日志、调试输出、数据导出等场景中,JSON 格式的用户数据如果未经脱敏就写入日志系统或传给第三方,后果不堪设想。
数据脱敏(Data Masking)不是简单地把字段值替换为 ***——它需要保留数据格式以便调试,需要支持嵌套结构,需要处理数组中的批量数据,还需要在高并发场景下保持性能。更重要的是,脱敏方案需要与业务逻辑解耦,让开发者在不改动业务代码的前提下,对任意 JSON 数据执行可配置的脱敏操作。本文将从最简单的正则替换出发,逐步演进到基于 AST 的精准脱敏方案,帮你构建一套生产级的 JSON 数据脱敏引擎。
🔐 一、三种脱敏方案对比与选型
1.1 方案概览
在实际工程中,JSON 脱敏主要有三种思路:正则字符串替换、基于 JSONPath 的递归遍历、以及基于解析树(AST)的精准匹配。每种方案的适用场景和局限性差异巨大。
| 方案 | 精准度 | 性能 | 嵌套支持 | 数组支持 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|---|---|
| 正则替换 | ❌ 低 | ⚡ 最快 | ❌ 不支持 | ❌ 不支持 | ✅ 简单 | 快速原型、简单日志 |
| JSONPath 递归 | ✅ 中 | ⚡ 快 | ✅ 支持 | ✅ 支持 | ✅ 中等 | 通用场景、API 日志 |
| AST 精准脱敏 | ✅ 高 | 🔶 中等 | ✅ 支持 | ✅ 支持 | ❌ 复杂 | 金融/医疗等高安全场景 |
1.2 脱敏规则设计
在写代码之前,先定义一套完整的脱敏规则。不同类型的敏感数据需要不同的脱敏策略:
| 数据类型 | 原始值 | 脱敏后 | 脱敏规则 |
|---|---|---|---|
| 手机号 | 13812345678 |
138****5678 |
保留前3后4 |
| 身份证 | 110101199001011234 |
110101****1234 |
保留前6后4 |
| 银行卡 | 6222021234567890123 |
6222****0123 |
保留前4后4 |
| 邮箱 | user@example.com |
u***@example.com |
保留首字母和域名 |
| 姓名 | 张三 |
张* |
保留姓氏 |
| 地址 | 北京市朝阳区xxx路xxx号 |
北京市朝阳区*** |
保留省市区 |
| IP 地址 | 192.168.1.100 |
192.168.*.* |
保留前两段 |
💡 提示:脱敏的关键原则是保留足够的上下文信息用于调试,同时彻底隐藏可识别的个人身份信息。过度脱敏(全部替换为
***)会导致日志失去排查价值。
🔧 二、三种方案的完整实现
2.1 方案一:正则字符串替换(快速但粗糙)
最直接的方式是把 JSON 序列化为字符串,然后用正则表达式匹配并替换敏感值。
// 方案一:正则字符串替换 — 简单但不精准
function maskByRegex(json) {
let str = typeof json === 'string' ? json : JSON.stringify(json);
// 手机号:138****5678
str = str.replace(/"(\w*[pP]hone\w*)"\s*:\s*"(1[3-9]\d{9})"/g,
(match, key, phone) => `"${key}":"${phone.slice(0,3)}****${phone.slice(-4)}"`);
// 身份证:110101****1234
str = str.replace(/"(1[0-9]{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx])"/g,
(match, id) => `"${id.slice(0,6)}****${id.slice(-4)}"`);
// 邮箱:u***@example.com
str = str.replace(/"([\w.]+)@([\w.]+\.\w+)"/g,
(match, user, domain) => `"${user[0]}***@${domain}"`);
return str;
}
// 测试
const userData = {
phone: "13812345678",
idCard: "110101199001011234",
email: "zhangsan@example.com",
name: "张三",
orders: [{ id: 1, amount: 100 }]
};
console.log(maskByRegex(userData));
// 手机号和身份证被脱敏,但 orders 中如果有 phone 字段则不会被处理
// 嵌套对象中的 phone 也可能因 JSON.stringify 格式不同而匹配失败
⚠️ 正则方案的致命缺陷:
- ❌ 无法处理嵌套对象中的同名字段(取决于
JSON.stringify的输出格式) - ❌ 数字类型的值(如
"age": 25)可能被误匹配 - ❌ 中文字符(如身份证中的
X)需要特殊处理 Unicode - ❌ 性能随 JSON 体积线性下降,因为要反复序列化和反序列化
2.2 方案二:JSONPath 递归遍脱敏(推荐通用方案)
更好的方式是递归遍历 JSON 对象,按字段名匹配规则进行脱敏。这种方式能正确处理嵌套和数组。
// 方案二:JSONPath 递归遍历 — 精准且通用
const MASK_RULES = {
// 字段名匹配规则(支持正则)
phone: { pattern: /phone|mobile|tel/i, mask: (v) => String(v).replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') },
idCard: { pattern: /idCard|idNo|identity/i, mask: (v) => String(v).replace(/(\d{6})\d{8}(\d{4})/, '$1****$2') },
email: { pattern: /email|mail/i, mask: (v) => String(v).replace(/^(.)(.*?)(@.+)$/, '$1***$3') },
bankCard: { pattern: /bankCard|cardNo/i, mask: (v) => String(v).replace(/(\d{4})\d+(\d{4})/, '$1****$2') },
name: { pattern: /^name$|realName|userName/i, mask: (v) => String(v).length <= 1 ? v : v[0] + '*'.repeat(v.length - 1) },
address: { pattern: /address|addr/i, mask: (v) => { const s = String(v); return s.length > 6 ? s.slice(0, 6) + '***' : '***'; } },
password: { pattern: /password|pwd|secret/i, mask: () => '******' },
};
function maskJSON(obj, rules = MASK_RULES) {
if (obj === null || obj === undefined) return obj;
// 处理数组
if (Array.isArray(obj)) {
return obj.map(item => maskJSON(item, rules));
}
// 处理对象
if (typeof obj === 'object') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
// 查找匹配的脱敏规则
const rule = Object.values(rules).find(r => r.pattern.test(key));
if (rule && typeof value === 'string') {
result[key] = rule.mask(value);
} else if (typeof value === 'object') {
result[key] = maskJSON(value, rules); // 递归处理嵌套对象
} else {
result[key] = value;
}
}
return result;
}
return obj;
}
// 测试 — 包含嵌套和数组的复杂 JSON
const apiResponse = {
code: 200,
data: {
user: {
name: "李四",
phone: "13987654321",
email: "lisi@company.com",
idCard: "310101199501011234",
address: "上海市浦东新区张江高科技园区",
password: "s3cretP@ss"
},
orders: [
{ id: "ORD001", amount: 299.00, receiver: { name: "李四", phone: "13987654321", address: "上海市浦东新区xxx" } },
{ id: "ORD002", amount: 599.00, receiver: { name: "王五", phone: "13611112222", address: "北京市海淀区xxx" } }
]
}
};
console.log(JSON.stringify(maskJSON(apiResponse), null, 2));
// 输出:所有层级的 phone、email、idCard、password、address 都被正确脱敏
// orders 数组中的 receiver 信息也被递归脱敏
⚠️ **警告:**字段名匹配规则要谨慎设计。过于宽泛的正则(如
/name/i)可能误匹配到非敏感字段(如fileName、gameName)。建议使用精确匹配或白名单方式。
2.3 方案三:AST 级精准脱敏(高安全场景)
在金融、医疗等合规要求极高的场景中,需要对 JSON 进行完整解析,构建抽象语法树(AST),然后按精确的路径规则进行脱敏。这种方式可以精确定位到 data.user.phone 这样的完整路径,避免字段名误匹配。
// 方案三:基于路径规则的 AST 级精准脱敏
class JSONMasker {
constructor() {
this.rules = [];
}
// 注册脱敏规则:支持精确路径和通配符
// 路径格式:data.user.phone, data.orders[*].receiver.phone
addRule(pathPattern, maskFn) {
const regex = new RegExp(
'^' + pathPattern
.replace(/\./g, '\\.')
.replace(/\[\*\]/g, '\\[\\d+\\]')
.replace(/\*/g, '[^\\.\\[\\]]+') + '$'
);
this.rules.push({ pathPattern: regex, maskFn });
}
// 递归遍历并脱敏
mask(obj, currentPath = '') {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) {
return obj.map((item, index) =>
this.mask(item, `${currentPath}[${index}]`)
);
}
if (typeof obj === 'object') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const childPath = currentPath ? `${currentPath}.${key}` : key;
// 查找匹配当前路径的规则
const matchedRule = this.rules.find(r => r.pathPattern.test(childPath));
if (matchedRule && typeof value === 'string') {
result[key] = matchedRule.maskFn(value, childPath);
} else if (typeof value === 'object') {
result[key] = this.mask(value, childPath);
} else {
result[key] = value;
}
}
return result;
}
return obj;
}
}
// 使用示例
const masker = new JSONMasker();
// 精确路径匹配 — 只脱敏 data.user 下的字段
masker.addRule('data.user.phone', (v) => v.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'));
masker.addRule('data.user.email', (v) => v.replace(/^(.)(.*?)(@.+)$/, '$1***$3'));
masker.addRule('data.user.idCard', (v) => v.replace(/(\d{6})\d{8}(\d{4})/, '$1****$2'));
masker.addRule('data.user.password', () => '******');
// 通配符匹配 — 脱敏所有 orders 中的 receiver phone
masker.addRule('data.orders[*].receiver.phone', (v) => v.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'));
masker.addRule('data.orders[*].receiver.address', (v) => v.length > 6 ? v.slice(0, 6) + '***' : '***');
const masked = masker.mask(apiResponse);
console.log(JSON.stringify(masked, null, 2));
// 精确脱敏:不会误匹配 orders 中的 id 或其他同名字段
📌 记住:AST 方案的核心优势是路径精确匹配。它不会因为字段名叫
name就脱敏所有name字段,而是只脱敏你明确指定的路径(如data.user.name),这对于复杂 API 响应的精准脱敏至关重要。
🚀 三、生产级优化与实战场景
3.1 性能优化:流式脱敏
当日志数据量达到 GB 级别时,将整个 JSON 加载到内存再处理是不现实的。我们需要流式(Streaming)处理方案。
// 流式 JSON 脱敏 — 使用 TransformStream 处理大文件
import { createReadStream, createWriteStream } from 'fs';
import { Transform } from 'stream';
class StreamingJSONMasker extends Transform {
constructor(rules, options = {}) {
super({ ...options, objectMode: false });
this.buffer = '';
this.rules = rules;
}
_transform(chunk, encoding, callback) {
this.buffer += chunk.toString();
// 尝试按行处理(JSON Lines 格式)
const lines = this.buffer.split('\n');
this.buffer = lines.pop(); // 保留不完整的最后一行
for (const line of lines) {
if (line.trim()) {
try {
const obj = JSON.parse(line);
const masked = this.maskObject(obj);
this.push(JSON.stringify(masked) + '\n');
} catch {
this.push(line + '\n'); // 非 JSON 行原样输出
}
}
}
callback();
}
_flush(callback) {
if (this.buffer.trim()) {
try {
const obj = JSON.parse(this.buffer);
this.push(JSON.stringify(this.maskObject(obj)));
} catch {
this.push(this.buffer);
}
}
callback();
}
maskObject(obj) {
// 复用方案二的递归脱敏逻辑
return maskJSON(obj, this.rules);
}
}
// 使用:处理 10GB 日志文件,内存占用 < 100MB
const inputPath = '/var/log/api-access.log'; // JSON Lines 格式
const outputPath = '/var/log/api-access-masked.log';
createReadStream(inputPath)
.pipe(new StreamingJSONMasker(MASK_RULES))
.pipe(createWriteStream(outputPath))
.on('finish', () => console.log('脱敏完成'));
3.2 实战场景:API 日志脱敏中间件
最常见的需求是在 Express/Koa 中间件层自动脱敏请求和响应中的敏感数据,再写入日志。
// Express 日志脱敏中间件
import express from 'express';
function createLoggingMiddleware(logger) {
const masker = new JSONMasker();
// 注册常见脱敏规则
masker.addRule('body.phone', maskPhone);
masker.addRule('body.idCard', maskIdCard);
masker.addRule('body.password', () => '******');
masker.addRule('body.creditCard', maskBankCard);
masker.addRule('response.data.users[*].phone', maskPhone);
masker.addRule('response.data.users[*].email', maskEmail);
return (req, res, next) => {
const startTime = Date.now();
const originalJson = res.json.bind(res);
// 拦截 res.json 以捕获响应体
res.json = function(body) {
const duration = Date.now() - startTime;
// 脱敏后写入日志
logger.info({
method: req.method,
path: req.path,
status: res.statusCode,
duration: `${duration}ms`,
request: masker.mask({ body: req.body, query: req.query }),
response: masker.mask({ data: body }),
});
return originalJson(body);
};
next();
};
}
// 使用
const app = express();
app.use(express.json());
app.use(createLoggingMiddleware(console));
3.3 性能基准测试
对三种方案在不同数据规模下的性能进行实测(Node.js 22,Apple M2,测试 1000 次取平均):
| JSON 大小 | 正则替换 | JSONPath 递归 | AST 精准脱敏 |
|---|---|---|---|
| 1 KB | 0.02 ms | 0.05 ms | 0.08 ms |
| 10 KB | 0.15 ms | 0.3 ms | 0.6 ms |
| 100 KB | 1.8 ms | 3.2 ms | 5.5 ms |
| 1 MB | 22 ms | 35 ms | 58 ms |
| 10 MB | 280 ms | 380 ms | 620 ms |
⚡ **关键结论:**对于 99% 的 API 日志场景(JSON < 100KB),JSONPath 递归方案的性能完全够用(< 1ms),且精准度远高于正则方案。只有在处理 GB 级日志文件时,才需要考虑流式处理。
3.4 脱敏规则的单元测试
脱敏规则一旦写错,要么泄露敏感数据,要么破坏业务数据的可读性。必须为每条脱敏规则编写单元测试,验证脱敏效果和边界情况。
// 脱敏规则的单元测试 — 用 Vitest 验证脱敏正确性
import { describe, it, expect } from 'vitest';
import { maskJSON } from './masker';
describe('JSON 数据脱敏规则', () => {
it('手机号脱敏:保留前3后4位', () => {
const input = { phone: '13812345678' };
expect(maskJSON(input).phone).toBe('138****5678');
});
it('身份证脱敏:保留前6后4位', () => {
const input = { idCard: '110101199001011234' };
expect(maskJSON(input).idCard).toBe('110101****1234');
});
it('邮箱脱敏:保留首字母和域名', () => {
const input = { email: 'zhangsan@example.com' };
expect(maskJSON(input).email).toBe('z***@example.com');
});
it('嵌套对象中的敏感字段被正确脱敏', () => {
const input = { data: { user: { phone: '13999998888' } } };
expect(maskJSON(input).data.user.phone).toBe('139****8888');
});
it('数组中的敏感字段被正确脱敏', () => {
const input = { users: [{ phone: '13800001111' }, { phone: '13900002222' }] };
expect(maskJSON(input).users[0].phone).toBe('138****1111');
expect(maskJSON(input).users[1].phone).toBe('139****2222');
});
it('非敏感字段保持不变', () => {
const input = { orderId: 'ORD-001', amount: 299.00, status: 'paid' };
const output = maskJSON(input);
expect(output.orderId).toBe('ORD-001');
expect(output.amount).toBe(299.00);
});
it('null 和 undefined 值不报错', () => {
expect(maskJSON(null)).toBeNull();
expect(maskJSON(undefined)).toBeUndefined();
});
});
3.5 自定义脱敏函数与格式保留
在某些场景下,我们需要保留数据的格式特征以便调试。例如脱敏后的手机号仍然是 11 位数字,脱敏后的邮箱仍然是合法格式。
// 高级脱敏函数 — 支持格式保留和自定义掩码字符
const advancedMasks = {
// 保留格式的手机号脱敏
phone: (value, { maskChar = '*', keepPrefix = 3, keepSuffix = 4 } = {}) => {
const str = String(value);
const masked = str.slice(keepPrefix, -keepSuffix).replace(/./g, maskChar);
return str.slice(0, keepPrefix) + masked + str.slice(-keepSuffix);
},
// 保留域名的邮箱脱敏
email: (value, { maskChar = '*' } = {}) => {
const [user, domain] = String(value).split('@');
if (!domain) return value;
return user[0] + maskChar.repeat(Math.max(user.length - 1, 3)) + '@' + domain;
},
// 保留省市区的地址脱敏
address: (value, { keepLength = 6, maskChar = '*' } = {}) => {
const str = String(value);
return str.length > keepLength ? str.slice(0, keepLength) + maskChar.repeat(3) : maskChar.repeat(3);
},
// 银行卡号分段显示
bankCard: (value, { maskChar = '*' } = {}) => {
const str = String(value).replace(/\s/g, '');
const last4 = str.slice(-4);
const masked = str.slice(0, -4).replace(/./g, maskChar);
// 每4位加空格
return (masked + last4).replace(/(.{4})/g, '$1 ').trim();
},
// 通用:保留前N后M位
generic: (value, { prefix = 3, suffix = 4, maskChar = '*' } = {}) => {
const str = String(value);
if (str.length <= prefix + suffix) return maskChar.repeat(str.length);
return str.slice(0, prefix) + maskChar.repeat(str.length - prefix - suffix) + str.slice(-suffix);
}
};
// 使用示例
console.log(advancedMasks.phone('13812345678')); // 138****5678
console.log(advancedMasks.email('zhangsan@gmail.com')); // z******@gmail.com
console.log(advancedMasks.bankCard('6222021234567890')); // **** **** **** 7890
console.log(advancedMasks.address('北京市朝阳区望京街道xxx号')); // 北京市朝阳区***
💡 四、最佳实践与避坑指南
✅ 推荐做法
- ✅ 使用白名单而非黑名单 — 明确指定哪些路径需要脱敏,而非试图匹配所有"看起来敏感"的字段
- ✅ 保留原始数据的不可变性 — 脱敏函数返回新对象,不修改原始数据
- ✅ 在 CI/CD 中集成脱敏测试 — 用单元测试验证脱敏规则的正确性
- ✅ 使用 JSON Lines 格式处理大文件 — 逐行解析避免内存溢出
- ✅ 记录脱敏操作的审计日志 — 谁在什么时间对什么数据执行了脱敏
❌ 避免做法
- ❌ 不要在前端进行数据脱敏 — 脱敏是后端职责,前端数据已被客户端控制
- ❌ 不要用
eval()或new Function()解析 JSON — 这是 XSS 注入的温床 - ❌ 不要脱敏后缓存到未加密的 Redis — 脱敏数据仍然包含部分 PII
- ❌ 不要对所有
name字段无差别脱敏 —productName、categoryName不是敏感数据 - ❌ 不要忽略数字类型的值 —
age: 25或salary: 30000也是敏感信息
⚠️ 法规合规注意事项
⚠️ **警告:**不同国家和地区的数据隐私法规对脱敏的要求不同。GDPR(欧盟)要求匿名化后不可逆;CCPA(加州)要求删除权;中国的《个人信息保护法》要求最小化处理。脱敏方案需要根据业务所在地区合规设计。
🎯 总结
JSON 数据脱敏不是一次性任务,而是需要融入开发全流程的安全基础设施。选择哪种方案取决于你的具体场景:
- 内部开发环境调试 → 正则替换够用,快速实现
- API 日志和监控系统 → JSONPath 递归方案是最佳平衡点
- 金融/医疗合规场景 → AST 精准脱敏 + 审计日志
🔑 **核心建议:**在项目初期就建立脱敏规则库,而不是等到数据泄露后才补救。将脱敏逻辑封装为可复用的中间件或工具函数,让团队中的每个开发者都能轻松调用。
相关工具推荐:
- 🔧 jsjson.com JSON 格式化工具 — 在线处理和查看 JSON 结构
- 🔧 jsjson.com JSON 压缩工具 — 压缩脱敏后的 JSON 减少传输体积
- 📚 OWASP Data Masking Guide — 安全测试参考
- 📚 Node.js crypto 模块 — 加密脱敏场景