后端日志里直接打印用户手机号、身份证号、银行卡号、Token、密码甚至聊天内容,这在很多系统里几乎成了默认操作。开发环境无所谓,一旦上了生产,这些日志经过ELK、Splunk、阿里云日志服务流转,会被运维、测试、客服、外包甚至第三方风控团队看到。更麻烦的是,日志往往一存就是半年甚至更久,任何一次内部泄露或者数据库拖库,都可能把日志里的明文数据变成合规事故。脱敏不是把日志藏起来,而是让该排查问题的人还能排查,但不该看到明文的人永远看不到。
先界定清楚哪些字段必须脱敏不是所有日志字段都需要处理。真正要脱敏的是个人身份信息、账户凭证、金融数据、位置信息、通信内容以及业务自定义的敏感扩展字段。手机号通常保留前3后4,中间四位打星;身份证号保留前3后4或者前6后4,中间全部掩码;银行卡号一般只显示后四位,其余全部掩码;邮箱保留首字符和@后域名;密码和Token直接整体替换为固定占位符,不要做部分保留,因为密码哈希和Token的局部泄露依然可能被利用。姓名如果是两个字,保留姓,名字掩码;三个字及以上保留首尾,中间掩码。地址类信息通常只保留省市区,街道门牌全部掩码。IP地址在多数合规场景下也属于个人信息,建议保留前两段,后两段掩码,或者直接哈希。
最直接的方案:封装统一的脱敏工具类不要在每个打印日志的地方手写substring和replace,那样一定会漏。正确做法是在项目里封装一个MaskUtil或者SensitiveDataUtils,把手机号、身份证、银行卡、邮箱、地址、IP等常见类型全部做成静态方法。每个方法内部先做null和空串判断,再做长度判断,长度不符合规则时直接返回一个安全的默认掩码值,避免因为异常数据抛出空指针导致业务中断。工具类里还可以提供一个通用方法,根据字段名称自动匹配脱敏策略,比如字段名包含phone、mobile就按手机号处理,包含idCard、identity就按身份证处理,包含password、token、secret就整体替换。这样即使开发人员偷懒,也能通过字段名兜底。
public class MaskUtil {
public static String maskPhone(String phone) {
if (phone == null || phone.length() < 7) return "*";
return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1$2");
}
public static String maskIdCard(String idCard) {
if (idCard == null || idCard.length() < 8) return "*";
return idCard.replaceAll("(\\w{6})\\w+(\\w{4})", "$1$2");
}
public static String maskBankCard(String bankCard) {
if (bankCard == null || bankCard.length() < 8) return "*";
return " " + bankCard.substring(bankCard.length() - 4);
}
public static String maskAll(String value) {
return "*";
}
}
日志框架层面做全局拦截
工具类只能解决开发人员主动调用的问题,但很多日志是框架自动打印的,比如MyBatis的SQL日志、HttpClient的请求响应日志、Dubbo的参数日志。这些日志如果直接打印入参和返回值,里面的敏感字段依然会明文出现。针对这种情况,需要在日志框架层面做统一过滤。Logback和Log4j2都支持自定义Converter或者Filter,可以在日志输出前对message做正则匹配和替换。比如在logback.xml里注册一个自定义的MessageConverter,在convert方法里对手机号、身份证号、Token等模式做全局正则替换。这种方式的好处是对业务代码零侵入,坏处是正则匹配会消耗一定性能,而且复杂嵌套对象里的字段可能匹配不到。
public class SensitiveMessageConverter extends MessageConverter {
private static final Pattern PHONE = Pattern.compile("(1[3-9]\\d)\\d{4}(\\d{4})");
private static final Pattern ID_CARD = Pattern.compile("(\\d{6})\\d{8}(\\d{4})");
@Override
public String convert(ILoggingEvent event) {
String message = super.convert(event);
message = PHONE.matcher(message).replaceAll("$1$2");
message = ID_CARD.matcher(message).replaceAll("$1$2");
return message;
}
}
序列化阶段脱敏比日志阶段更可靠
很多系统打印对象时直接调用toString或者JSON序列化,这时候如果对象里的敏感字段没有处理,日志框架的正则可能因为字段名和字段值分离而失效。更稳妥的做法是在JSON序列化阶段就做脱敏。Jackson和Fastjson都支持自定义注解和序列化器。比如用Jackson的@JsonSerialize注解,在敏感字段上指定一个自定义的SensitiveSerializer,序列化时自动根据字段类型做掩码。这样不管是日志打印、接口返回还是消息队列传输,只要走JSON序列化,敏感字段天然就是脱敏后的值。缺点是需要在每个实体类的敏感字段上加注解,老项目改造工作量较大,但可以结合代码扫描工具批量识别。
@JsonSerialize(using = PhoneMaskSerializer.class) private String phone; public class PhoneMaskSerializer extends JsonSerializerAOP拦截Service入参和返回值{ @Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeString(MaskUtil.maskPhone(value)); } }
对于不想改实体类、又不想依赖日志框架正则的项目,AOP是一个折中方案。在Service层做一个切面,对方法入参和返回值做递归遍历,遇到String类型的字段就根据字段名判断是否需要脱敏,遇到集合和对象就继续递归。这种方式可以覆盖大部分业务日志场景,因为业务日志通常就是在Service方法入口和出口打印的。但AOP方案对性能有一定影响,尤其是大对象递归遍历时,需要控制递归深度和字段数量。另外AOP无法拦截框架内部日志,比如MyBatis打印的SQL参数,所以它更适合作为补充手段。
结构化日志里的字段级脱敏现在很多系统使用JSON格式输出日志,方便ELK检索。这种情况下,脱敏的粒度应该从整条消息细化到具体字段。比如一条日志原本是{"userId":1001,"phone":"13812345678","orderNo":"ORD20250101001"},脱敏后应该变成{"userId":1001,"phone":"1385678","orderNo":"ORD20250101001"}。实现方式可以在MDC里放入敏感字段时先脱敏,或者在日志输出前用JSON解析器遍历所有key,对命中的key做value掩码。这种方式比正则替换更精准,不会误伤非敏感字段,而且保留了日志的结构化特性,检索时依然可以按字段查询。
动态脱敏与静态脱敏的区别后端日志脱敏属于动态脱敏,也就是数据在流转过程中实时处理,原始数据不落盘。与之相对的是静态脱敏,通常用在数据导出、测试环境同步、离线分析等场景。动态脱敏的难点在于性能损耗和规则维护,静态脱敏的难点在于脱敏后的数据要保持业务关联性。生产环境日志脱敏必须采用动态方案,不能先把明文日志写进文件再事后清洗,那样等于先泄露再补救。如果日志已经明文落盘,需要立即启动清理流程,并且评估泄露范围。
脱敏不是哈希,也不是加密很多人把脱敏和加密混为一谈。加密是可逆的,有密钥就能还原,适合存储和传输场景;哈希是不可逆的,适合密码存储和完整性校验;脱敏是不可逆的,但保留部分原始特征,适合展示和日志场景。日志里不要放加密后的数据,因为加密串本身虽然不可读,但占空间大,而且一旦密钥泄露,所有历史日志全部沦陷。日志里也不要放哈希值,因为哈希值无法帮助排查问题,开发人员看到一串SHA256根本不知道原始手机号是什么。脱敏后的数据既能保留格式特征,又无法还原完整明文,才是日志场景的最优解。
处理日志中的堆栈信息和异常消息敏感数据不只出现在正常的业务日志里,异常堆栈里同样可能携带。比如一个SQL异常,异常消息里可能包含完整的SQL语句和参数值,参数值里就有手机号、身份证号。再比如远程调用异常,异常消息里可能包含完整的请求URL和请求体。针对异常日志,需要在全局异常处理器里对异常消息做脱敏后再记录,同时对于框架抛出的底层异常,可以在日志框架的Filter里对Throwable的message做同样的正则替换。堆栈信息本身一般不含敏感数据,但堆栈里的局部变量名和方法名可能泄露业务逻辑,这个属于信息泄露范畴,不是脱敏的核心目标。
多线程和异步日志的脱敏时机日志脱敏必须在日志内容进入队列之前完成,不能在消费端处理。因为Logback和Log4j2的异步日志会把事件放入队列,如果消费端才脱敏,那么队列里已经存在明文数据,内存dump或者队列溢出时依然可能泄露。正确做法是在业务代码里调用日志方法之前就完成脱敏,或者在日志框架的Encoder阶段处理,确保进入队列的已经是脱敏后的字符串。对于MDC里的敏感字段,在put的时候就要脱敏,不要在输出的时候才处理。
脱敏规则的配置化管理硬编码的脱敏规则难以维护,尤其是敏感字段的类型和掩码格式可能随着业务和合规要求变化。建议把脱敏规则做成配置化,比如在配置中心或者数据库里维护一个字段名到脱敏策略的映射表,支持正则匹配字段名,支持自定义掩码字符和保留位数。这样新增一个敏感字段类型时,不需要改代码,只需要改配置。同时配置化也便于审计,哪些字段被脱敏了、用什么策略脱敏的,一目了然。对于多租户系统,还可以按租户配置不同的脱敏策略,比如某些租户要求手机号完全掩码,某些租户要求保留后四位。
验证脱敏效果不能只靠人眼很多团队做完脱敏后,只在本地打印几条日志肉眼看一下,觉得没问题就上线了。但生产环境的日志量巨大,字段类型复杂,人眼根本看不过来。需要建立自动化的验证机制,比如在测试环境跑一批包含敏感数据的用例,然后扫描日志文件,用正则检查是否还存在明文手机号、身份证号、银行卡号。如果扫描到明文,直接让测试用例失败。这种验证可以集成到CI流程里,每次代码合并前自动执行。另外还可以在日志采集端加一道防线,用实时流处理对日志做敏感数据检测,发现明文就告警,这样即使代码有遗漏,也能第一时间发现。
脱敏与日志检索的平衡脱敏之后,开发人员排查问题时可能会遇到困难,比如想根据完整手机号搜索日志,但日志里只有掩码后的手机号。解决这个问题的方法是在日志里同时记录一个脱敏后的检索键,比如手机号的前3后4拼接一个哈希值,或者用确定性加密生成一个可检索的伪值。这样既保护了明文,又保留了检索能力。但要注意,这个检索键本身不能通过暴力枚举还原出明文,所以哈希时要加盐,盐值要妥善保管。对于需要精确查询的场景,可以在日志系统里单独建一个受限访问的索引,只有特定角色才能查询明文,但这个方案需要日志系统支持字段级权限控制。
老项目的脱敏改造顺序对于已经运行多年的老系统,一次性全量改造风险很大。建议按照数据敏感等级和日志暴露面来排优先级。先处理登录、注册、支付、个人中心这些核心链路里的日志,因为这些地方的日志最密集、敏感字段最多。然后处理框架自动打印的日志,比如SQL日志和RPC日志,这部分往往被忽略但泄露面很大。最后再处理边缘业务和内部管理系统的日志。改造过程中要保持日志格式基本不变,避免因为脱敏导致日志解析规则失效。每改完一个模块,就跑一遍自动化验证,确认没有明文残留再进入下一个模块。
日志脱敏本质上是一个工程问题,不是一个技术难题。难的不是写一个正则表达式把手机号中间四位替换掉,而是让整个团队意识到这件事的重要性,并且在日常开发中持续遵守。工具类、框架拦截、序列化注解、AOP切面这些手段可以叠加使用,但最核心的还是开发人员脑子里要有根弦:任何一行日志,都可能被不该看到的人看到。
