背景

最近在公司的 Java 项目中看到很多这样的代码:

java public void maskData(List list) { ... for (ReservationDto dto : list) { for (String fieldName : fields) { try { Field field = ReservationDto.class.getDeclaredField(fieldName); field.setAccessible(true); field.set(dto, **); } catch (Exception e) { } } } ... }

有人可能立即识别到一个坏味道,有一个空的 catch 块。对于这样的代码,我并不感到惊讶,因为 Java 工程的形象在我心目中早就崩塌了:《后端工程圣殿形象的崩塌以及重建》。

我这次对该 Java 工程的改造,并不在这个空的异常捕获块,而是识别到该工程有一个明显的需求,对于某些字段,需要用星号*打码。目前该工程有很多自行实现的打码方法,造成了很多代码重复。并且需要打印日志时主动调用这样的打码方法,违反了单一职责原则,即在打印日志时,还需要关注打码逻辑。

打码需求


当大量数据被日志记录下来后,对于其中的敏感数据进行打码非常重要。一般做得好的公司,会有专门的团队对项目进行渗透测试,如果发现日志中有敏感数据泄露,是不会允许项目上线的。

Logback


我看了下项目,日志部分使用了 Logback。在 Java 的生态世界里,它算是最常用的日志框架了,也是其前驱者 Log4j 的替代者。它比 Log4j 的性能更好,也提供了更多的配置和在存档旧日志文件中具有更多的灵活性。

目前项目中的 logback 配置如下:

xml

uniheart
<springProperty scope=context name=appName source=spring.application.name/>

<appender name=STDOUT class=ch.qos.logback.core.ConsoleAppender>
    <layout class=ch.qos.logback.classic.PatternLayout>
        <pattern>[TraceNo=%X{x-request-id}] %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n
        </pattern>
    </layout>
</appender>

<root level=INFO>
    <appender-ref ref=STDOUT />
</root>


重新实现打码

本文将使用另外一种方式实现日志中的敏感数据打码,以解决打码逻辑分散在代码各处的问题,即将打码逻辑放在单独的文件里,并允许通过配置的方式去做扩展。

这里通过一个实际的例子展示打码前后的效果,但是可以应用在更多的地方。

FeignClient 请求日志中的敏感数据

这个实际的例子就是 FeignClient 请求日志。由于微服务架构的广泛应用,因此存在服务间相互调用的情况,一般通过 FeignClient 来调用上游服务。但是在联调过程中,往往需要记录下实际的请求日志。在实际项目中,如同打码逻辑一样,这种请求日志也是分散在各个调用处,并且对于一个请求,会打印多行日志分别记录请求头、入参、调用的端点地址等等,这样并不高效,在《将 FeignClient 的请求记录成 cURL 格式》详细介绍了这一点,并给出了一个解决方案,即在 FeignConfig 文件中一处实现请求日志的打印,并且将请求打印成一个 cURL 命令,从而方便重放和沟通。

将 FeignClient 的请求记录成 cURL 格式》虽然解决了日志打印问题,但是实际的服务接口调用,往往需要认证,而认证往往需要密钥,这种密钥就是敏感数据,不应该明文显示。比如实际日志中会看到:

shell [TraceNo=521F2E813AD8491AA0075CCCF5DAD682] 2021-08-04 10:26:56.750 [http-nio-8081-exec-1] INFO c.l.c.r.p.infrastructure.rpc.common.FeignConfig - cURL to replay for 521F2E813AD8491AA0075CCCF5DAD682: curl --location --request POST http://k8s-default-ingressl-0de2febdbe-1784506518.cn-northwest-1.elb.amazonaws.com.cn/auth/webtoken --header Content-Length: 152 --header Content-Type: application/json --header x-request-id: 521F2E813AD8491AA0075CCCF5DAD682

--data-raw { clientid:devinterface, clientsecret:1234567890, granttype:client_credentials, scope:member }

其实,这里的 client_secret 明文显示在日志中,就会有很大风险。

PatternLayout


可以通过配置打码规则来对所有的由 Logback 产生的日志输出进行打码,从而集中打码逻辑。要实现打码逻辑集中化,我们需要实现自定义的 ch.qos.logback.classic.PatternLayout,从而用自定义的 Layout 来扩展每一个 Logback appender。具体地说,在这里我们实现一个 MaskingPatternLayout 类作为 PatternLayout 的一个实现,它支持的打码规则配置,是以正则表达式代表的打码模式。

实现的 MaskingPatternLayout 如下:

java package com.uniheart;

import ch.qos.logback.classic.PatternLayout; import ch.qos.logback.classic.spi.ILoggingEvent;

import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.IntStream;

public class MaskingPatternLayout extends PatternLayout {

private Pattern multilinePattern;
private List<String> maskPatterns = new ArrayList<>();

// 对 xml 配置文件中定义的每一项,都分别调用
public void addMaskPattern(String maskPattern) {
    maskPatterns.add(maskPattern);
    multilinePattern = Pattern.compile(maskPatterns.stream().collect(Collectors.joining(|)), Pattern.MULTILINE);
}

@Override
public String doLayout(ILoggingEvent event) {
    return maskMessage(super.doLayout(event));
}

private String maskMessage(String message) {
    if (multilinePattern == null) {
        return message;
    }
    StringBuilder sb = new StringBuilder(message);
    Matcher matcher = multilinePattern.matcher(sb);
    while (matcher.find()) {
        IntStream.rangeClosed(1, matcher.groupCount()).forEach(group -> {
            if (matcher.group(group) != null) {
                // 用星号替换每一个匹配到的字符
                IntStream.range(matcher.start(group), matcher.end(group)).forEach(i -> sb.setCharAt(i, *));
            }
        });
    }
    return sb.toString();
}

}

PattternLayout.doLayout() 的实现专门负责对应用中每一条日志信息被配置好的模式匹配到的数据进行打码。

Logback 的配置文件(文件名定义在应用的配置里的 logging.config.classpath 项)中的 maskPattern 数组构成了多行的模式。但是遗憾的是 Logback 引擎本身不支持构造器注入,从而需要对配置的每一项,分别调用 addMaskPattern() 方法。所以每次添加新的正则表达式模式到配置文件后,就需要重新编译。

打码配置


总的来说,我们需要使用正则表达式来进行敏感数据的打码。对于上面提到的 FeignClient 的日志,我们需要将 clientsecret 打码,这可以使用如下的正则表达式:

java client
secrets:s(.*?)


现在把它加入到 logback xml 配置文件中的 maskPattern 标签下,从而之前的配置文件变成了这样:

xml

uniheart
<springProperty scope=context name=appName source=spring.application.name/>

<appender name=mask class=ch.qos.logback.core.ConsoleAppender>
    <encoder class=ch.qos.logback.core.encoder.LayoutWrappingEncoder>
        <layout class=com.uniheart.MaskingPatternLayout>
            <maskPattern>client_secrets*:s*(.*?)</maskPattern>
            <pattern>[TraceNo=%X{x-request-id}] %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n
            </pattern>
        </layout>
    </encoder>
</appender>>

<root level=INFO>
    <appender-ref ref=mask />
</root>

执行效果


再次运行应用,就会看到先前的日志变成了这样:

shell [TraceNo=521F2E813AD8491AA0075CCCF5DAD682] 2021-08-04 10:26:56.750 [http-nio-8081-exec-1] INFO c.l.c.r.p.infrastructure.rpc.common.FeignConfig - cURL to replay for 521F2E813AD8491AA0075CCCF5DAD682: curl --location --request POST http://k8s-default-ingressl-0de2febdbe-1784506518.cn-northwest-1.elb.amazonaws.com.cn/auth/webtoken --header Content-Length: 152 --header Content-Type: application/json --header x-request-id: 521F2E813AD8491AA0075CCCF5DAD682

--data-raw { clientid:devinterface, clientsecret:****, granttype:client_credentials, scope:member }

image.png

通过这种方式,日志打印者不需要再关注打码逻辑,从而可以在统一的 logback 配置文件里来定义打码规则。

总结


在应用日志中,通过使用 Logback 的 PatternLayout 的特性来对敏感数据打码,可以集中在配置文件里使用正则表达式定义打码规则,减少了日志打印者的心智负担,有更好的扩展性,还避免了重复代码。