今天一早刚到工位,就被安全合规的老大拉进了一个紧急小会,脸色极其难看。原来是昨晚安全审计扫描了生产环境的 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 收集层做异步正则脱敏更好?