Java日志脱敏——基于logback MessageConverter实现
背景简介
日志脱敏 是常见的安全需求,最近公司也需要将这一块内容进行推进。看了一圈网上的案例,很少有既轻量又好用的轮子可以让我直接使用。我一直是反对过度设计的,而同样我认为轮子就应该是可以让人拿去直接用的。所以我准备分享两篇博客分别实现两种日志脱敏方案。
方案分析
-
logback MessageConverter + 正则匹配
本篇博客主要介绍此方法
- 优势
- 侵入性低、工作量极少, 只需要修改xml配置文件,适合老项目
- 劣势
- 效率低,会对每一行日志都进行正则匹配检查,效率受日志长度影响,日志越长效率越低,影响日志吞吐量
- 因基于正则匹配 存在错杀风险,部分内容难以准确识别
- 优势
-
fastjson Filter + 注解 + 工具类
下一篇博客介绍
传送门:Java日志脱敏(二)- 优势
- 性能损耗低、效率高、扩展性强,精准脱敏,适合QPS较高日志吞吐量较大的项目。
- 劣势
- 侵入性较高,需对所有可能的情况进行脱敏判断
- 存在漏杀风险,全靠开发控制
- 优势
其实还有一种方案,基于 工具类+配置模式
优势是 工作量低(比注解模式低,比正则匹配模式高),灵活度高,性能也好。但是只适合那些新项目,如果是老项目大家命名不规范,就很难推动整改了。此处不进行扩展。详见:项目日志脱敏
logback MessageConverter + 正则匹配
流程图解
代码案例
正则匹配日志脱敏工具类
此工具类主要用于实现依据配置的正则匹配规则集,进行依次匹配。并提取敏感文本对其执行对应的脱敏策略。大家拿去用可以不做修改
package com.zhibo.log.format;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @Author: Zhibo.lv
* @Description: 正则匹配日志脱敏工具类
**/
@Component
public class LogSensitiveUtils {
// 脱敏日志最大长度,超出此长度的日志放弃脱敏,直接返回
private static Integer SENSITIVE_LOG_MAX_LENGTH = 10000;
/**
* 日志脱敏 获取规则集进行依次匹配
* @param content 明文日志文本
* @return 脱敏后的日志文本
*/
public static String filterSensitive(String content) {
try {
if (StringUtils.isNotBlank(content) && content.length() < SENSITIVE_LOG_MAX_LENGTH) {
for (Map.Entry<String, List<Pattern>> entry : LogSensitiveConstants.SENSITIVE_SEQUENCE.entrySet()) {
content = filter(content, entry.getKey(), entry.getValue());
}
}
return content;
} catch (Exception e) {
return content;
}
}
/**
*
* @param content 需脱敏字符串
* @param type 文本类型,依据类型可以做不同的脱敏方式
* @param patterns 该方式下需匹配的正则
* @return
*
*/
public static String filter(String content, String type, List<Pattern> patterns) {
for (Pattern pattern : patterns) {
Matcher matcher = pattern.matcher(content);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
matcher.appendReplacement(sb, Matcher.quoteReplacement(baseSensitive(matcher.group(), type)));
}
matcher.appendTail(sb);
content = sb.toString();
}
return content;
}
/**
* 依据正则抓去的文本执行对应的脱敏策略
* @param str 待脱敏的字符串
* @return
*/
private static String baseSensitive(String str, String type) {
if (StringUtils.isBlank(str)) {
return StringUtils.EMPTY;
}
//通过工厂获取对应类型的脱敏类执行脱敏方法
return SensitiveStrategyBuiltInUtil.getStrategy(type).des(str);
}
}
正则匹配日志脱敏常量
此工具类主要是进行配置需要脱敏的文本的正则。需要大家依据业务调整或新增
package com.zhibo.log.format;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Pattern;
/**
* @Author: Zhibo
* @Description: 正则匹配日志脱敏常量
**/
public class LogSensitiveConstants {
/**
* 过滤先后顺序:身份证 -> 手机号
* 顺序原因:避免部分业务需求出现可能同时满足多个正则规则的文本,大家可以优先提取更长的、更复杂的文本。后处理简单的
*/
public static final Map<String,List<Pattern>> SENSITIVE_SEQUENCE = new TreeMap<String, List<Pattern>>();
/**
* 手机号匹配规则集,支持配置多个正则规则
*/
public static final List<Pattern> SENSITIVE_PHONE_KEY = new ArrayList<Pattern>(1);
/**
* 身份证号码匹配规则集,支持配置多个正则规则
*/
public static final List<Pattern> SENSITIVE_ID_NO_KEY = new ArrayList<Pattern>(1);
/**
* 手机号正则匹配,11位1开头数字
* 瞻前顾后:校验符合要求的文本前后均不能为数字 避免误匹配
*/
public static final String PHONE_REGEX = "(?<!\\d)[1][3-9][0-9]{9}(?!\\d)";
/**
* 身份证号正则匹配 18位数版本
* 15位数的身份证号码暂不考虑,如果需要自行新增下方正则加入 SENSITIVE_ID_NO_KEY 中
* (?<!\d)([1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3})(?!\d)
* 瞻前顾后:校验符合要求的文本前后均不能为数字 避免误匹配
*/
public static final String ID_NO_REGEX = "(?<!\\d)([1-9]\\d{5}[1-9]\\d{3}((0\\d)|(1[0-2]))(([0|1|2]\\d)|3[0-1])\\d{3}([0-9]|X))(?!\\d)";
static {
SENSITIVE_ID_NO_KEY.add(Pattern.compile(ID_NO_REGEX));
SENSITIVE_PHONE_KEY.add(Pattern.compile(PHONE_REGEX));
}
// 脱敏替代字符
public static final char STAR = '*';
// 手机号类型脱敏替代字符
public static final String PHONE_MASK = "****";
/** 手机号码脱敏策略 */
public static final String STRATEGY_PHONE = "strategyPhone";
/** 身份证号码脱敏策略 */
public static final String STRATEGY_ID_NO = "strategyIdNo";
static {
//将每一个规则集绑定一个对应的类型
SENSITIVE_SEQUENCE.put(STRATEGY_ID_NO, SENSITIVE_ID_NO_KEY);
SENSITIVE_SEQUENCE.put(STRATEGY_PHONE, SENSITIVE_PHONE_KEY);
}
private LogSensitiveConstants() {
}
}
脱敏策略代码
定义文本脱敏接口 IStrategy
package com.zhibo.log.sensitive.api;
/**
* @Author: Zhibo
* @Description: 脱敏策略
*/
public interface IStrategy {
/**
* 脱敏
* @param original 原始内容
* @return 脱敏后的字符串
*/
String des(final Object original);
}
文本脱敏抽象类,进行通用实现 AbstractStringStrategy
package com.zhibo.log.sensitive.core.strategory;
import com.zhibo.log.sensitive.api.IStrategy;
import com.zhibo.log.format.LogSensitiveConstants;
import java.security.MessageDigest;
/**
* @Author: zhibo
* @Description: 抽象字符串策略,
* 支持在脱敏后的文本后面追加明文的MD5加密串,方便研发进行日志查询使用
*/
public abstract class AbstractStringStrategy implements IStrategy {
/**
* 获取掩码之前的长度
* @param original 原始
* @param chars 字符串
* @return 结果
*/
protected abstract int getBeforeMaskLen(Object original, char[] chars);
/**
* 获取掩码之后的长度
* @param original 原始
* @param chars 字符串
* @return 结果
*/
protected abstract int getAfterMaskLen(Object original, char[] chars);
/**
* 针对固定长度的加密直接返回脱敏字符串,避免StringBuilder循环拼接
* @return 脱敏字符串
* 如返回null 则通过 {@link AbstractStringStrategy#getBeforeMaskLen(Object, char[])} 与 {@link AbstractStringStrategy#getAfterMaskLen(Object, char[])}
* 进行截取字符串
*/
protected String getMask(){
return null;
}
/**
* 是否需要拼接MD5密文方便日志查询。
* @return false : 不拼接(默认)
* true : 拼接密文 用于日志查询 格式 [MD5]
*/
protected Boolean addMD5(){
return false;
}
@Override
public String des(Object original) {
if(original == null) {
return null;
}
String strValue = original.toString();
char[] chars = strValue.toCharArray();
int beforeMaskLen = getBeforeMaskLen(original, chars);
int afterMaskLen = getAfterMaskLen(original, chars);
//范围纠正
int maxLen = chars.length;
beforeMaskLen = Math.min(beforeMaskLen, maxLen);
afterMaskLen = Math.min(afterMaskLen, maxLen);
StringBuilder stringBuilder = new StringBuilder();
//获取明文前缀
if(beforeMaskLen > 0) {
stringBuilder.append(chars, 0, beforeMaskLen);
}
//获取脱敏字符串
String mask = getMask();
if (null == mask){//如未指定脱敏字符串则按规则循环拼接
// 中间使用掩码
for(int i = beforeMaskLen; i < chars.length - afterMaskLen; i++) {
stringBuilder.append(LogSensitiveConstants.STAR);
}
}else {
stringBuilder.append(mask);
}
//获取明文后缀
if(afterMaskLen > 0) {
stringBuilder.append(chars, chars.length - afterMaskLen, afterMaskLen);
}
if (addMD5()){
addMD5(strValue,stringBuilder);
}
return stringBuilder.toString();
}
// MD5加密
private void addMD5(String originalString,StringBuilder stringBuilder) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(originalString.getBytes());
byte[] digest = md.digest();
stringBuilder.append("[");
for (byte b : digest) {
stringBuilder.append(String.format("%02x", b));
}
stringBuilder.append("]");
} catch (Exception e) {
e.printStackTrace();
}
}
}
身份证脱敏策略实现 StrategyIdNo
package com.zhibo.log.sensitive.core.strategory;
/**
* @Author: Zhibo
* @Description: 身份证号脱敏
* 脱敏规则:保留前6 后4 位,其它由星号替换
*/
public class StrategyIdNo extends AbstractStringStrategy {
@Override
protected int getBeforeMaskLen(Object original, char[] chars) {
return 6;
}
@Override
protected int getAfterMaskLen(Object original, char[] chars) {
return 4;
}
}
手机号码脱敏策略实现 StrategyPhone
package com.zhibo.log.sensitive.core.strategory;
import com.zhibo.log.format.LogSensitiveConstants;
/**
* @Author: zhibo
* @Description: 手机号脱敏
* 脱敏规则:186****8567[MD5]
*/
public class StrategyPhone extends AbstractStringStrategy {
@Override
protected int getBeforeMaskLen(Object original, char[] chars) {
return 3;
}
@Override
protected int getAfterMaskLen(Object original, char[] chars) {
return 4;
}
@Override
protected String getMask() {
return LogSensitiveConstants.PHONE_MASK;
}
@Override
protected Boolean addMD5(){
return true;
}
}
logback 消息转换器实现
最关键的方法来啦
package com.zhibo.log.format;
import ch.qos.logback.classic.pattern.MessageConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
/**
* @Author: Zhibo
* @Description: 日志脱敏转换器
**/
public class SensitiveConverter extends MessageConverter {
@Override
public String convert(ILoggingEvent event){
// 获取原始日志
String requestLogMsg = super.convert(event);
// 执行日志脱敏
return LogSensitiveUtils.filterSensitive(requestLogMsg);
}
public SensitiveConverter() {
}
}
自此我们的工具包也就完成了,业务系统需要使用此工具只需要修改resources目录下的logback.xml配置。
<!-- 新增或修改原有消息转换器为SensitiveConverter -->
<conversionRule conversionWord="msgToo" converterClass="com.zhibo.log.format.SensitiveConverter" />
并将文件输出日志的消息内容替换为指定消息转换器的 conversionWord
脱敏效果展示
有请提示
注:此方法对日志吞吐量存在影响,由于正则需要循环匹配整个日志文本,所以正则规则越多,日志文本越长,耗时越长。如您的应用程序对日志吞吐量要求较高且存在大量超长日志文本请压测后使用。
如配置了logback的异步打印,且设置了允许日志丢弃,在压测中可能出现因线程池与等待队列均被占满而导致日志丢失情况。下面是我的问题复盘:
logback日志异步打印配置如下
<appender name="ASYNC-FILE" class="ch.qos.logback.classic.AsyncAppender">
<neverBlock>true</neverBlock><!-- 非阻塞方式运行 如队列满就开始丢弃日志 -->
<queueSize>1024</queueSize><!-- 等待队列大小 -->
<discardingThreshold>0</discardingThreshold><!-- 日志队列深度,配置0 队列满后丢弃最老的日志 -->
<appender-ref ref="FILE"/>
</appender>
以上配置 为logback线程池工作配置,默认线程池 线程数为 10个,最大队列长度为1024个。
意味着如果日志产生的速度超过10个线程工作处理日志的速度,则无法处理的日志会被写入BlockingQueue 队列,当队列满了之后就会导致日志丢失的情况。
继续阅读:Java日志脱敏(二)——fastjson Filter + 注解 + 工具类实现