今天一早刚到工位,就被安全合规的老大拉进了一个紧急小会,脸色极其难看。原来是昨晚安全审计扫描了生产环境的 Kibana 日志,发现里面密密麻麻全是明文的用户手机号、身份证和银行卡号。原因是有新人直接 console.log(req.body) 把原始请求全打出来了。这要是被拖库,直接就是重大安全事故。
很多后端研发平时图方便,直接把敏感的 PII(个人身份信息)数据以明文形式打印落盘。这不仅严重违反 GDPR 和国内的数据安全法,而且一旦泄露,开发和架构师都得直接背锅。
与其指望靠 Code Review 去人肉堵漏,不如干脆在日志传输的最前端(Logger 格式化管线)做一套全局拦截脱敏。这里分享我们在 Node.js 服务中,基于 winston 深度定制的正则敏感数据脱敏 Formatter 实现,开箱即用,绝无硬编码占位符:
import winston from 'winston';
// 定义敏感信息的正则匹配规则const MASK_PATTERNS = { // 手机号:匹配 1 开头的 11 位数字,脱敏中间 4 位 phone: /(?:^|\D)(1[3-9]\d{9})(?:\D|$)/g, // 身份证:匹配 18 位数字(最后一位可能为 X),脱敏中间 8 位 idCard: /(?:^|\D)([1-9]\d{5}[19|20]\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dX])(?:\D|$)/gi, // 银行卡号:匹配 16 到 19 位连续数字,只保留前 6 后 4 bankCard: /(?:^|\D)([1-9]\d{15,18})(?:\D|$)/g};
/** * 递归遍历对象,对所有字符串字段以及 log 消息文本进行脱敏替换 */function maskSensitiveInfo(value: any): any { if (value === null || value === undefined) { return value; }
if (typeof value === 'string') { let masked = value;
// 1. 脱敏手机号 masked = masked.replace(MASK_PATTERNS.phone, (match, p1) => { const maskedPhone = p1.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); return match.replace(p1, maskedPhone); });
// 2. 脱敏身份证 masked = masked.replace(MASK_PATTERNS.idCard, (match, p1) => { const maskedId = p1.replace(/^(\d{6})\d{8}(\d{4}|\d{3}[\dX])$/i, '$1********$2'); return match.replace(p1, maskedId); });
// 3. 脱敏银行卡号 masked = masked.replace(MASK_PATTERNS.bankCard, (match, p1) => { const len = p1.length; const maskedCard = p1.substring(0, 6) + '*'.repeat(len - 10) + p1.substring(len - 4); return match.replace(p1, maskedCard); });
return masked; }
if (Array.isArray(value)) { return value.map(item => maskSensitiveInfo(item)); }
if (typeof value === 'object') { const maskedObj: Record<string, any> = {}; for (const key of Object.keys(value)) { // 过滤键名也包含敏感特征的特定对象属性(可选) maskedObj[key] = maskSensitiveInfo(value[key]); } return maskedObj; }
return value;}
// 自定义 winston 脱敏格式化插件const maskFormat = winston.format((info) => { // 复制一份,防止污染或改变原始的请求上下文对象 const newInfo = JSON.parse(JSON.stringify(info));
// 递归脱敏日志对象中的所有字段,包括 message newInfo.message = maskSensitiveInfo(newInfo.message);
// 同样对 meta 数据等附属属性进行脱敏 for (const key of Object.keys(newInfo)) { if (key !== 'level' && key !== 'timestamp' && key !== 'message') { newInfo[key] = maskSensitiveInfo(newInfo[key]); } }
return newInfo;});
// 构建 Winston Logger 实例export const logger = winston.createLogger({ level: 'info', format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), maskFormat(), // 注入脱敏管线 winston.format.json() ), transports: [ new winston.transports.Console() ]});把这个脱敏管线挂上去之后,我们在业务层就算继续踩坑,直接打印像 logger.info("创建账户成功,卡号是 6222021001123456789,预留手机 13812345678"); 这样的黑盒数据,落盘出来的日志文件也会乖乖变成遮掩后的状态,心智负担瞬间减轻了大半。
需要注意的是,正则回溯在高并发场景下存在严重的 CPU 性能瓶颈。如果打印的日志体量极其庞大,直接在 Node 线程里同步做正则替换可能会导致服务吞吐量崩掉。
大伙对此有没有做过基准测试?是在服务端代码最前端脱敏比较划算,还是统一把原日志丢到 Logstash / Vector 收集层做异步正则脱敏更好?