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

原创:小姐姐味道(微信公众号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属性等,进行拼接。

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

ComplexMessageConverter:

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

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

DataSetPatternLayoutEncoder(可选):

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

主要代码

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
复制代码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}来声明。比如,

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

输出>>

1
复制代码展示一下{hello}格式化效果。

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

useDefaultRegex

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

regex

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

maxLength

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

policy

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

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

1
复制代码我的手机号为18611001100

将会被整形为:

1
复制代码><

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

1
复制代码我的手机号为18611001100

将会被整形为

1
复制代码我的手机号为186****1100

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

1
复制代码我的手机号为18611001100

将会被整形为:

1
复制代码我的手机号为***********

depth

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

mdcKeys

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

1
复制代码name:%X{name:--}|address:%X{address:--}

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

1
2
复制代码%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
1
2
复制代码%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可以获取这些参数。

日志格式转换器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
复制代码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设计时注意代码兼容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码<?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,欢迎添加好友,​进一步交流。​

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%