编程技术开发和娱乐网址导航

网站首页 > 技术文章 正文

一般人不敢动系列之—基于logback的日志“规范”和“脱敏”

luoxia7 2024-08-25 07:46:52 技术文章 13 ℃ 0 评论

原创:小姐姐味道(微信公众号ID:xjjdog),欢迎分享,转载请保留出处。

在日常开发中,我们经常会使用logback打印日志,还会包含一些敏感内容。比如手机号、卡号、邮箱等,这对数据安全而言是有风险的。

但是如果让业务去处理这些问题,则需要在每个打印日志的地方,进行重复的脱敏操作,不仅繁琐影响代码风格,还会有遗漏情况。

这个时候,我们就需要考虑一个相对统一的解决方案,通过增强logback,在日志message落盘之前,统一进行检测、脱敏。

一、需求来源

我们通常的日志处理,面临的通用诉求:

1)超长日志message截取: 程序打印的日志message可能非常大,比如超过1M,这种message极大的影响系统的性能,而且通常数据价值比较低。我们应该对这种message进行截取或者直接抛弃。

2)日志格式统一: 通常情况下,生产环境的业务日志通过会按需采集、分析、存储,那么日志格式的统一对下游数据处理是非常必要的。

为了避免错误配置了日志格式,我们应该将日志格式规范,默认进行集成且限制修改。

日志格式中,通常包含一些用于数据分拣的系统信息(例如,项目名、部署集群名、IP、云平台、rack等),也包含一些运行时的MDC动态参数值,最终格式要求是一致的。

3)脱敏: 日志中存在特定规则的字符串时,比如手机号,需要对其进行脱敏处理。

二、设计核心思想

我们可以基于PatternLayoutEncoder来实现日志格式的限定,不再使用默认的pattern参数指定格式,而是固定字段格式 + 自定义字段,最终拼接成格式规范。

其中,局部可控字段,可以是系统变量、也可以MDC字段列表;固定格式部分,通常是message的头部,包含时间、IP、项目名等等。

基于logback提供的MessageConverter特性,在message打印之前,允许对“参数格式化之后的message”(formattedMessage)进行转换,最终logger打印的实际内容是converter返回的整形后的结果。

那么,我们就可以基于此特性,在convert方法中执行“超长message截取”、“内容脱敏”两个主要操作。

三、设计编码

设计理念

CommonPatternLayoutEncoder:父类为PatternLayoutEncoder,用于定义日志格式,包括固定字段部分、自定义字段部分,将系统属性、MDC属性等,进行拼接。

同时,基于logback的option特性,将动态参数传递给MessageConverter,最终拼接成一个字符串,作为pattern属性。同时converter所需要的配置参数,比如消息最大长度、正则表达式、替换策略,都需要通过Encoder声明。

ComplexMessageConverter:

message转换,只会操作logger.info(String message,Throwable ex)传递的message部分。其中,throwable栈信息不会被操作(其实也无法修改)。

Converter可以获取Encoder传递的option参数列表,并初始化相关的处理类;内部实现基于正则表达式来匹配敏感信息。

DataSetPatternLayoutEncoder(可选):

主要用于限定数据集类的日志格式,它本身不能对敏感信息进行过滤;数据格式主要为了便于数据分析。

主要代码

下面是CommonPatternLayoutEncoder.java的主要代码,详细参见注释。

package ch.qos.logback.classic.encoder;
  
import ch.qos.logback.classic.PolicyEnum;
import ch.qos.logback.classic.Utils;
  
import java.text.MessageFormat;
  
import static ch.qos.logback.classic.Utils.DOMAIN_DELIMITER;
import static ch.qos.logback.classic.Utils.FIELD_DELIMITER;
  
/**
 * 适用于基于File的Appender
 * <p>
 * 限定我司日志规范,增加有关敏感信息的过滤。
 * 可以通过regex指定需要匹配和过滤的表达式,对于符合表达式的字符串,则采用policy进行处理。
 * 1)replace:替换,将字符串替换为facade,比如:18611001100 > 186****1100
 * 2) drop:抛弃整条日志
 * 3)erase:擦除字符串,全部替换成等长度的"****",18611001100 > ***********
 * <p>
 * depth:正则匹配深度,默认为12,即匹配成功次数达到此值以后终止匹配,主要考虑是性能。如果一个超长的日志,我们不应该全部替换,否则可能引入性能问题。
 * maxLength:单条message的最大长度(不计算throwable),超长则截取,并在message尾部追加终止符。
 * <p>
 * 考虑到扩展性,用户仍然可以直接配置pattern,此时regex、policy、depth等option则不生效。但是maxLength会一致生效。
 * 格式样例:
 * %d{yyyy-MM-dd/HH:mm:ss.SSS}|IP_OR_HOSTNAME|REQUEST_ID|REQUEST_SEQ|^_^|
 * SYS_K1:%property{SYS_K1}|SYS_K2:%property{SYS_K2}|MDC_K1:%X{MDC_K1:--}|MDC_K2:%X{MDC_K2:--}|^_^|
 * [%t] %-5level %logger{50} %line - %m{o1,o2,o3,o4}%n
 * 格式中domain1是必选,而且限定无法扩展
 * domain2根据配置文件指定的system properties和mdcKeys动态拼接,K-V结构,便于解析;可以为空。
 * domain3是常规message部分,其中%m携带options,此后Converter可以获取这些参数。
 **/  
public class CommonPatternLayoutEncoder extends PatternLayoutEncoder {
  
  
    protected static final String PATTERN_D1 = "%d'{'yyyy-MM-dd/HH:mm:ss.SSS'}'|{0}|%X'{'requestId:--'}'|%X'{'requestSeq:--'}'";
    protected static final String PATTERN_D2_S1 = "{0}:%property'{'{1}'}'";
    protected static final String PATTERN_D2_S2 = "{0}:%X'{'{1}:--'}'";
    protected static final String PATTERN_D3_S1 = "[%t] %-5level %logger{50} %line - ";
    //0:message最大长度(超出则截取),1:正则表达式,2:policy,3:查找深度(超过深度后停止正则匹配)
    protected static final String PATTERN_D3_S2 = "%m'{'{0},{1},{2},{3}'}'%n";
  
    protected String mdcKeys;//来自MDC的key,多个key用逗号分隔。
  
    protected String regex = "-";//匹配的正则表达式,如果此值为null或者"-",那么policy、deep参数都将无效
  
    protected int maxLength = 2048;//单条消息的最大长度,主要是message
  
    protected String policy = "replace";//如果匹配成功,字符串的策略。
  
    protected int depth = 128;
  
    protected boolean useDefaultRegex = true;
  
    protected static final String DEFAULT_REGEX = "'((?<\\d)1[3-9]\\d{9}(?!\\d))'";//手机号,11位数字,并且前后位不再是数字。
    //系统参数,如果未指定,则使用default;
    protected String systemProperties;
  
    protected static final String DEFAULT_SYSTEM_PROPERTIES = "project,profiles,cloudPlatform,clusterName";
  
    @Override  
    public void start() {
        if (getPattern() == null) {
            StringBuilder sb = new StringBuilder();
            String d1 = MessageFormat.format(PATTERN_D1, Utils.getHostName());
            sb.append(d1);
            sb.append(FIELD_DELIMITER)
                    .append(DOMAIN_DELIMITER)
                    .append(FIELD_DELIMITER);
            //拼装系统参数,如果当前数据视图不存在,则先set一个默认值
            if (systemProperties == null || systemProperties.isEmpty()) {
                systemProperties = DEFAULT_SYSTEM_PROPERTIES;
            }
            //系统参数
            String[] properties = systemProperties.split(",");
            for (String property : properties) {
                String value = Utils.getSystemProperty(property);
                if (value == null) {
                    System.setProperty(property, "-");//初始化
                }
                sb.append(MessageFormat.format(PATTERN_D2_S1, property, property))
                        .append(FIELD_DELIMITER);
            }
  
            //拼接MDC参数
            if (mdcKeys != null) {
                String[] keys = mdcKeys.split(",");
                for (String key : keys) {
                    sb.append(MessageFormat.format(PATTERN_D2_S2, key, key));
                    sb.append(FIELD_DELIMITER);
                }
                sb.append(DOMAIN_DELIMITER)
                        .append(FIELD_DELIMITER);
            }
            sb.append(PATTERN_D3_S1);
  
            if (PolicyEnum.codeOf(policy) == null) {
                policy = "-";
            }
  
            if (maxLength < 0 || maxLength > 10240) {
                maxLength = 2048;
            }
  
            //如果设定了自定义regex,则优先生效;否则使用默认
            if (!regex.equalsIgnoreCase("-")) {
                useDefaultRegex = false;
            }
            if (useDefaultRegex) {
                regex = DEFAULT_REGEX;
            }
  
            sb.append(MessageFormat.format(PATTERN_D3_S2, String.valueOf(maxLength), regex, policy, String.valueOf(depth)));
            setPattern(sb.toString());
        }
        super.start();
    }
  
    public String getMdcKeys() {
        return mdcKeys;
    }
  
    public void setMdcKeys(String mdcKeys) {
        this.mdcKeys = mdcKeys;
    }
  
    public String getRegex() {
        return regex;
    }
  
    public void setRegex(String regex) {
        this.regex = regex;
    }
  
    public int getMaxLength() {
        return maxLength;
    }
  
    public void setMaxLength(int maxLength) {
        this.maxLength = maxLength;
    }
  
    public String getPolicy() {
        return policy;
    }
  
    public void setPolicy(String policy) {
        this.policy = policy;
    }
  
    public int getDepth() {
        return depth;
    }
  
    public void setDepth(int depth) {
        this.depth = depth;
    }
  
    public Boolean getUseDefaultRegex() {
        return useDefaultRegex;
    }
  
    public boolean isUseDefaultRegex() {
        return useDefaultRegex;
    }
  
    public void setUseDefaultRegex(boolean useDefaultRegex) {
        this.useDefaultRegex = useDefaultRegex;
    }
  
    @Override  
    public String getPattern() {
        return super.getPattern();
    }
  
    @Override  
    public void setPattern(String pattern) {
        super.setPattern(pattern);
    }
  
    public String getSystemProperties() {
        return systemProperties;
    }
  
    public void setSystemProperties(String systemProperties) {
        this.systemProperties = systemProperties;
    }
}

代码介绍

下面简单介绍一下上面的代码。

MDC参数声明格式为:%X{key},如果上下文中key不存在,则打印"";我们通过使用:-来声明其默认值。比如,%X{key:--}表示,如果key不存在则将打印“-”。

根据logback的规定,option参数列表需要声明在某个字段中,并配合<conversionRule>才能生效,以本文为例,我们主要对message进行整形。所以option参数声明在%m上,其格式为:%m{o1,o2...},多个option之间以,分割。o1,o2的字面值,可以在Converter中获取。简单来说,你需要将参数传递给Converter时,这些参数必须以option方式声明在某个字段上,否则没法做。

特别注意,如果option参数中包含{、}时,必须将option参数使用''包括。比如%m{2048,'\\d{11}','replace','128'},为了便于理解,建议所有的option参数都使用''逐个包含。

此外,如果你对日志格式中,还需要使用系统参数(System Property),可以使用%property{key}来声明。比如,

MessageFormat.format("展示一下'{'{0}'}'格式化的效果。","hello")

输出>>

展示一下{hello}格式化效果。

还有一些比较重要的参数。

useDefaultRegex

是否使用默认表达式,即手机号数字(连续11位数字,且后位不再跟进数字)。

regex

我们也允许用户自定义表达式。此时需要将useDefaultRegex设定为false才能生效。

maxLength

默认值为2048,即message的最大长度超过此值后将会被截取,可配置。

policy

对于regex匹配成功的字符串,如何处理。(处理规则,参见下文ComplexMessageConverter)

A)drop 直接抛弃,将message重置为一个“终止符号”。比如:

我的手机号为18611001100

将会被整形为:

><

B)replace 替换,将敏感信息除去前三、后四位字符之外的其他字符用“*”替换,也是默认策略。比如:

我的手机号为18611001100

将会被整形为

我的手机号为186****1100

C)erase:参数,将匹配成功的字符串,全部替换为等长度的“*”,比如:

我的手机号为18611001100

将会被整形为:

我的手机号为***********

depth

匹配深度,即message中,最多匹配成功的次数,超过之后将会终止匹配,主要考虑性能,默认值为128。假如message中有200个手机号,那么匹配和替换到128个之后,将会终止操作,剩余的手机号将不会再替换。

mdcKeys

指定pattern拼接时,需要植入的mdc参数列表,比如mdcKeys="name,address",那么在pattern中将会包含:

name:%X{name:--}|address:%X{address:--}

其实大家主要关注的是option部分,Encoder的主要作用就是拼接一个pattern大概样例:

%d{yyyy-MM-dd/HH:mm:ss.SSS}|IP_OR_HOSTNAME|REQUEST_ID|REQUEST_SEQ|^_^|
    SYS_K1:%property{SYS_K1}|SYS_K2:%property{SYS_K2}|MDC_K1:%X{MDC_K1:--}|MDC_K
%X{MDC_K2:--}|^_^|
    [%t] %-5level %logger{50} %line - %m{2048,'(\\d{11})','replace',128}

格式中,domain1是必选,而且限定无法扩展 。

domain2根据配置文件指定的system properties和mdcKeys动态拼接,K-V结构,便于解析;可以为空。

domain3是常规message部分,其中%m携带options,此后Converter可以获取这些参数。

日志格式转换器

package ch.qos.logback.classic.pattern;
  
import ch.qos.logback.classic.PolicyEnum;
import ch.qos.logback.classic.spi.ILoggingEvent;
  
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
  
/**
 * <p>
 * 日志格式转换器,会为每个appender创建一个实例,所以在配置层面需要考虑兼容。
 * 主要目的是,根据配置的regex来匹配message,对于匹配成功的字符串进行替换操作,并返回修正后的message。
 **/  
public class ComplexMessageConverter extends MessageConverter {
  
    protected String regex = "-";
    protected int depth = 0;
    protected String policy = "-";
    protected int maxLength = 2048;
    private ReplaceMatcher replaceMatcher = null;
  
    @Override  
    public void start() {
        List<String> options = getOptionList();
        //如果存在参数选项,则提取
        if (options != null && options.size() == 4) {
            maxLength = Integer.valueOf(options.get(0));
            regex = options.get(1);
            policy = options.get(2);
            depth = Integer.valueOf(options.get(3));
  
            if ((regex != null && !regex.equals("-"))
                    && (PolicyEnum.codeOf(policy) != null)
                    && depth > 0) {
                replaceMatcher = new ReplaceMatcher();
            }
        }
        super.start();
    }
  
    @Override  
    public String convert(ILoggingEvent event) {
        String source = event.getFormattedMessage();
        if (source == null || source.isEmpty()) {
            return source;
        }
        //复杂处理的原因:尽量少的字符串转换、空间重建、字符移动。共享一个builder
        if (source.length() > maxLength || replaceMatcher != null) {
            StringBuilder sb = null;
            //如果超长截取
            if (source.length() > maxLength) {
                sb = new StringBuilder(maxLength + 6);
                sb.append(source.substring(0, maxLength))
                        .append("???");//后面增加三个终止符
            }
            //如果启动了matcher
            if (replaceMatcher != null) {
                //如果没有超过maxLength
                if (sb == null) {
                    sb = new StringBuilder(source);
                }
                return replaceMatcher.execute(sb, policy);
            }
  
            return sb.toString();
        }
  
        return source;
    }
  
    class ReplaceMatcher {
        Pattern pattern;
  
        ReplaceMatcher() {
            pattern = Pattern.compile(regex);
        }
  
        String execute(StringBuilder source, String policy) {
  
            Matcher matcher = pattern.matcher(source);
  
            int i = 0;
            while (matcher.find() && (i < depth)) {
                i++;
                int start = matcher.start();
                int end = matcher.end();
                if (start < 0 || end < 0) {
                    break;
                }
                String group = matcher.group();
                switch (policy) {
                    case "drop":
                        return "??";//只要匹配,立即返回
                    case "replace":
                        source.replace(start, end, facade(group, true));
                        break;
                    case "erase":
                    default:
                        source.replace(start, end, facade(group, false));
                        break;
  
                }
            }
            return source.toString();
        }
  
    }
  
    /**
     * 混淆,但是不能改变字符串的长度
     *
     * @param source
     * @param included
     * @return 
     */  
    public static String facade(String source, boolean included) {
        int length = source.length();
        StringBuilder sb = new StringBuilder();
        //长度超过11的,保留前三、后四,中间全部*替换
        //低于11位或者included=false,全部*替换
        if (length >= 11) {
            if (included) {
                sb.append(source.substring(0, 3));
            } else {
                sb.append("***");
            }
            sb.append(repeat('*', length - 7));
            if (included) {
                sb.append(source.substring(length - 4));
            } else {
                sb.append(repeat('*', 4));
            }
        } else {
            sb.append(repeat('*', length));
        }
  
        return sb.toString();
    }
  
    private static String repeat(char t, int times) {
        char[] r = new char[times];
        for (int i = 0; i < times; i++) {
            r[i] = t;
        }
        return new String(r);
    }
}

这个类,主要是从CommonPatternLayoutEncoder声明的options(即regix、maxLength、policy、depth)初始化一个Matcher,针对message进行匹配和替换。正则比较消耗CPU。我门还要避免在message处理过程中,新建太多的字符串,否则会大量消耗内存;在处理时,尽可能确保主message只有一个,replace时不改变message的长度,可以避免因为重建String导致一些空间浪费。

之所以Converter能够发挥作用,离不开<conversionRule>,参看下文的配置样例。不过还需要注意,每个Appender都会根据<conversionRule>创建一个Converter实例,所以Converter设计时注意代码兼容。

<?xml version="1.0" encoding="UTF-8"?>  
<configuration>  
  
    ...
  
    <conversionRule conversionWord="m" converterClass="ch.qos.logback.classic.pattern.ComplexMessageConverter"/>  
  
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">  
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">  
            <level>INFO</level>  
        </filter>  
        <file>你的日志文件名</file>  
        <Append>true</Append>  
        <prudent>false</prudent>  
        <encoder class="ch.qos.logback.classic.encoder.CommonPatternLayoutEncoder">  
            <useDefaultRegex>true</useDefaultRegex>  
            <policy>replace</policy>  
            <maxLength>2048</maxLength>  
        </encoder>  
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">  
            <FileNamePattern>你的日志名.%d{yyyy-MM-dd}.%i</FileNamePattern>  
            <maxFileSize>64MB</maxFileSize>  
            <maxHistory>7</maxHistory>  
            <totalSizeCap>6GB</totalSizeCap>  
        </rollingPolicy>  
    </appender>  
  
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">  
        <encoder class="ch.qos.logback.classic.encoder.ConsolePatternLayoutEncoder"/>  
    </appender>  
  
    ...
</configuration>  

注意<conversionRule>节点中的conversionWord='m',其中m就是对应pattern中的%m,可以从%m获取options列表。

因为CommonPatternLayoutEncoder中已经限定了pattern的格式,所以我们在logback.xml中也不需要再显示的声明pattern参数。基于此,可以限定业务日志的格式保持统一。当然,如果有特殊情况需要自定义,仍然可以使用<pattern>来声明以覆盖默认格式。

作者简介:小姐姐味道 (xjjdog),一个不允许程序员走弯路的公众号。聚焦基础架构和Linux。十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道。我的个人微信xjjdog0,欢迎添加好友,进一步交流。

后台回复“加群”,带你进入高手如云交流群



推荐阅读:


一图解千愁,jvm内存从来没有这么简单过!


实力解剖一枚挖矿脚本,风骚操作亮瞎双眼


又一P1故障,锅比脸圆


传统企业的人才们,先别忙着跳“互联网”!


面试官很牛,逼我尿遁


又一批长事务,P0故障谁来背锅?


一天有24个小时?别开玩笑了!


《程序人生》杀机!


可怕的“浏览器指纹”,让你在互联网上,无处可藏


2w字长文,让你瞬间拥有「调用链」开发经验


996的乐趣,你是无法想象的


作为高级Java,你应该了解的Linux知识(非广告)


必看!java后端,亮剑诛仙(最全知识点)


学完这100多技术,能当架构师么?(非广告)


Linux上,最常用的一批命令解析(10年精选)


数百篇「原创」文章,助你完成技术「体系化」



本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表