日志脱敏

2023-06-12T14:21:02+08:00 | 7分钟阅读 | 更新于 2023-06-18T14:22:02+08:00

@

学习目标

学完本章你应该能够:

  1. 说清为什么 zap.Hooks 做不了脱敏,以及"自定义 zapcore.Core“才是正确入口。
  2. 装饰器模式包装原生 Core,在日志写入前拦截并改写所有 Field。
  3. 设计"Key 名匹配 + 值正则匹配"双重脱敏规则,覆盖显式敏感字段与隐式敏感值。
  4. 讲清脱敏实现里的并发安全性能要点,避免高并发下数据竞争。
  5. 判断何时需要"深度脱敏”,并权衡反射带来的性能损耗是否可接受。

前置知识

  • Go 基础、接口与装饰器模式
  • Uber Zap 日志库的基本用法(zapcore.Corezap.Field
  • 一点正则(regexp)与反射(reflect)常识

本章你会动手做的事

  1. 写一个 DesensitizeCore,把 zap.String("mobile", "13800138000") 自动变成 138****8000
  2. 故意"手贱"直接改原 Field 切片,在并发下跑出数据竞争,再改回"先拷贝再改",体会区别。
  3. LogConfig 里加一个 Desensitize 开关,验证脱敏可一键关闭/开启。

敏感字段自动脱敏(Zap 自定义 Core 方案)

类比:日志脱敏就像快递面单上的手机号打码——原本 13800138000 完整印在单子上,谁捡到都能看到;脱敏后变成 138****8000,人能认出是"谁的号段"、却拿不到完整号码。生产日志里满屏的手机号、身份证、token,就是一张张没打码的面单,合规和安全的底线要求我们必须在"打印"这最后一刻遮住它们。

1、方案选型与核心原理

Zap 原生 zap.Hooks 只能获取日志元信息(级别、消息、调用栈),无法拦截和修改结构化 Fields,因此生产级脱敏必须通过自定义 zapcore.Core 实现

核心思路:

  • 用装饰器模式包装原生 Core,在日志真正写入前拦截所有 Field
  • 采用Key 名匹配 + 值正则匹配双重脱敏规则,覆盖显式敏感字段和隐式敏感值
  • 默认仅处理顶层 String 字段,避免递归反射损耗性能;深度脱敏作为可选项
  • 先拷贝 Field 切片再修改,彻底避免并发写冲突

脱敏发生在日志"真正落盘"之前,整条链路如下:业务调用 logger.Info(...) 产生的 Entry 与 Fields 先经过我们包装的 DesensitizeCore,改写完敏感值后再交给底层真正的 Core 写出。

flowchart LR
    A["业务代码
logger.Info(msg, fields)"] --> B["DesensitizeCore.Write"] B --> C{"脱敏开关?
enable=true"} C -->|是| D["doDesensitize
改写敏感 Field"] C -->|否| E[原样透传] D --> F["底层 Core 写出
文件/控制台"] E --> F

2、预定义脱敏规则与工具函数

先统一脱敏规则、预编译正则,保证全公司格式一致,同时最大化性能。

import (
    "regexp"
    "strings"
    "go.uber.org/zap/zapcore"
)

// 预编译正则:全局只编译一次,避免性能损耗
var (
    // 手机号正则(11位、1开头)
    mobileRegex = regexp.MustCompile(`^1[3-9]\d{9}$`)
    // 18位身份证号
    idCardRegex = regexp.MustCompile(`^\d{17}[\dXx]$`)
    // 银行卡号(16-19位数字)
    bankCardRegex = regexp.MustCompile(`^\d{16,19}$`)
    // 邮箱正则
    emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
)

// 敏感Key集合:O(1)匹配,命中即全脱敏
var sensitiveKeyMap = map[string]struct{}{
    "password":     {},
    "pwd":          {},
    "token":        {},
    "access_token": {},
    "secret":       {},
    "secret_key":   {},
    "authorization":{},
    "cookie":       {},
    "mobile":       {},
    "phone":        {},
    "id_card":      {},
    "idcard":       {},
    "bank_card":    {},
    "email":        {},
    "id_no":        {},
}

// 脱敏处理函数
func desensitizeValue(key, value string) string {
    keyLower := strings.ToLower(key)

    // 1. Key命中敏感集合:按类型脱敏
    if _, ok := sensitiveKeyMap[keyLower]; ok {
        switch {
        case mobileRegex.MatchString(value):
            return value[:3] + "****" + value[7:]
        case idCardRegex.MatchString(value):
            return value[:6] + "********" + value[14:]
        case bankCardRegex.MatchString(value):
            return value[:4] + "********" + value[len(value)-4:]
        case emailRegex.MatchString(value):
            parts := strings.Split(value, "@")
            if len(parts) == 2 {
                prefix := parts[0]
                if len(prefix) <= 1 {
                    return "*@" + parts[1]
                }
                return prefix[:1] + "****@" + parts[1]
            }
        }
        // 密码、Token等非结构化敏感值:全掩码
        return "***"
    }

    // 2. Key未命中:值正则兜底匹配(防止漏脱敏)
    switch {
    case mobileRegex.MatchString(value):
        return value[:3] + "****" + value[7:]
    case idCardRegex.MatchString(value):
        return value[:6] + "********" + value[14:]
    }

    return value
}

⚠️ 新手必踩的坑:value[:7] 这类切片在值太短时直接 panic。上面的正则已经保证了手机号 11 位、身份证 18 位,所以 value[7:] 是安全的;但如果你要脱敏的字段长度不确定,务必先判断 len(value),否则线上一个超短字符串就能把日志协程打挂。

3、自定义脱敏 Core 完整实现

脱敏 Core 通过装饰器模式套在原生 Core 外面。它复用 zapcore.Core 接口,所以底层完全无感知。判定逻辑是"Key 命中优先,值正则兜底",流程如下:

flowchart TD
    A[拿到 Field] --> B{是 String 类型?}
    B -->|否| Z[原样保留]
    B -->|是| C{Key 在敏感集合?}
    C -->|是| D[按类型脱敏
手机/身份证/银行卡/邮箱/全掩码] C -->|否| E{值匹配手机/身份证正则?} E -->|是| F[对值兜底脱敏] E -->|否| Z D --> G[返回脱敏后 Field] F --> G
// DesensitizeCore 脱敏Core装饰器
type DesensitizeCore struct {
    zapcore.Core
    enable bool // 脱敏开关
}

// With 继承字段时保持包装
func (c *DesensitizeCore) With(fields []zapcore.Field) zapcore.Core {
    if !c.enable {
        return c.Core.With(fields)
    }
    // 先脱敏再传给底层Core
    desensitizedFields := c.doDesensitize(fields)
    return &DesensitizeCore{
        Core:   c.Core.With(desensitizedFields),
        enable: c.enable,
    }
}

// Check 委托给底层Core判断
func (c *DesensitizeCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
    if c.Enabled(entry.Level) {
        return ce.AddCore(entry, c)
    }
    return ce
}

// Write 核心:写入前执行脱敏
func (c *DesensitizeCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
    if !c.enable {
        return c.Core.Write(entry, fields)
    }

    // 必须拷贝切片,禁止修改原切片,避免并发数据竞争
    desensitizedFields := c.doDesensitize(fields)
    return c.Core.Write(entry, desensitizedFields)
}

// 执行脱敏:仅处理String类型顶层字段,兼顾性能与合规
func (c *DesensitizeCore) doDesensitize(fields []zapcore.Field) []zapcore.Field {
    if len(fields) == 0 {
        return fields
    }

    // 拷贝切片,不污染原数据
    newFields := make([]zapcore.Field, len(fields))
    copy(newFields, fields)

    for i := range newFields {
        field := &newFields[i]
        // 仅处理String类型字段,跳过数字、布尔等
        if field.Type == zapcore.StringType {
            field.String = desensitizeValue(field.Key, field.String)
        }
    }
    return newFields
}

4、集成到现有日志构建链路

在原 buildMultiCores 函数中,对每个 Core 统一包装一层脱敏装饰器,通过配置开关控制。

// LogConfig 新增脱敏开关
type LogConfig struct {
    // ... 原有字段保持不变
    Desensitize bool // 是否开启敏感字段脱敏
}

func buildMultiCores(cfg *LogConfig, encoder zapcore.Encoder, atomicLevel zap.AtomicLevel) ([]zapcore.Core, error) {
    var cores []zapcore.Core

    // 1. 主日志Core(原有逻辑不变)
    mainSyncer, _ := buildRotateSyncer(...)
    mainLevelEnabler := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
        return atomicLevel.Enabled(l) && l < zap.ErrorLevel
    })
    mainCore := zapcore.NewCore(encoder, mainSyncer, mainLevelEnabler)
    samplingMainCore := zapcore.NewSamplerWithOptions(mainCore, time.Second, 100, 100)

    // 包装脱敏Core
    if cfg.Desensitize {
        samplingMainCore = &DesensitizeCore{Core: samplingMainCore, enable: true}
    }
    cores = append(cores, samplingMainCore)

    // 2. 错误日志Core(原有逻辑不变)
    errorSyncer, _ := buildRotateSyncer(...)
    errorLevelEnabler := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
        return l >= zap.ErrorLevel
    })
    errorCore := zapcore.NewCore(encoder, errorSyncer, errorLevelEnabler)

    // 错误日志同样脱敏
    if cfg.Desensitize {
        errorCore = &DesensitizeCore{Core: errorCore, enable: true}
    }
    cores = append(cores, errorCore)

    // 3. 控制台Core
    if cfg.Console {
        consoleCore := zapcore.NewCore(encoder, zapcore.AddSync(os.Stdout), atomicLevel)
        if cfg.Desensitize {
            consoleCore = &DesensitizeCore{Core: consoleCore, enable: true}
        }
        cores = append(cores, consoleCore)
    }

    return cores, nil
}

5、进阶:结构化对象深度脱敏

如果需要对 zap.Any("user", user) 传入的结构体 / Map 做深度递归脱敏,可基于反射扩展 doDesensitize 方法,但需注意:

  • 反射会带来 3~5 倍性能损耗,高 QPS 服务不推荐开启
  • 建议仅对明确标注 log:"desensitize" tag 的字段做脱敏
  • 更优实践:业务层在打印前手动处理敏感字段,日志层仅做兜底校验

6、脱敏核心注意事项

  • 并发安全:绝对禁止直接修改传入的 Field 切片,必须拷贝后再修改,否则高并发下会出现数据竞争、日志乱码。
  • 性能优先:正则预编译、Key 用 Map 查找、仅处理 String 字段,三者缺一不可,避免脱敏成为性能瓶颈。
  • 白名单机制trace_idservicespan_id 等排障核心字段禁止加入脱敏规则,必须保障链路可追踪。
  • 规则统一:全公司统一敏感 Key 集合与脱敏格式,避免日志平台检索、告警规则失效。

⚠️ 新手必踩的坑:直接改原切片破坏并发安全Write / With 收到的 fields 切片可能被多处复用,若原地修改 field.String,高并发下另一个正在写同一条日志的协程会读到被你改掉的值,甚至触发 data race 检测直接崩溃。一定要 make + copy 出一份新的再改。


自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 为什么 zap.Hooks 做不了字段级脱敏?自定义 zapcore.Core 相比 Hook 强在哪?
  2. “Key 名匹配 + 值正则匹配"双重规则分别解决什么问题?只保留一种会有什么漏脱敏的隐患?
  3. doDesensitize 里为什么要 make + copy 出新的切片,而不是直接改原 fields
  4. 为什么默认只处理顶层 String 字段?深度脱敏(反射递归结构体)的代价是什么?
  5. 为什么 trace_idspan_id 这类字段不能进脱敏规则?把这俩脱敏了会有什么后果?

动手练习(建议真做一遍)

  1. DesensitizeCore 接进你的 Zap logger,打印 zap.String("mobile", "13800138000")zap.String("token", "abc.def.ghi"),确认前者变 138****8000、后者变 ***
  2. 写一个并发测试(go test -race),在 Write 里直接改原切片(去掉 copy),观察 -race 是否报数据竞争;再改回拷贝写法,确认竞争消失。
  3. LogConfigDesensitize 开关,分别用 true / false 启动,对比同一行日志的输出差异,验证脱敏可一键开关。

本章小结

  • 入口选对是关键:Zap 字段级脱敏只能走自定义 zapcore.Corezap.Hooks 拿不到 Field,做不了。
  • 装饰器模式无痛接入:把 DesensitizeCore 套在原生 Core 外,底层完全无感知;用 enable 开关即可一键开关。
  • 双重规则兜底:Key 名匹配覆盖"显式敏感字段”,值正则匹配兜底"隐式敏感值",两者配合避免漏脱敏。
  • 并发与性能是底线:必须拷贝切片再改、正则预编译、Key 用 Map、只处理顶层 String,脱敏不能成为瓶颈也不能引发 data race。
  • 深度脱敏按需开启,反射有 3~5 倍损耗;更优做法是在业务层先处理敏感字段,日志层只做兜底。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!