Logbackでログメッセージ内の秘密情報をマスク


概要

マスク対象を正規表現で抽出できることが条件だが、Logbackの設定において %replace で文字列を置換できる。

Javaの正規表現の機能や、キャプチャした部分文字列の参照を利用すれば、上の例より難しい条件でのマスクもできる。その例と実際のコードを記す。

以下のログには、ユーザーID・トークン・リソースIDがどれも同じ形式(十六進32桁)で出力されている。このうちトークンのみをマスクしたい。

ログの例
2020-11-14T09:30:52.774+09:00 [main] INFO com.example.Main - UserID: 35f44b06a3cf8dab8355eb8ba5844c73, Token: b9656056c799ab9ba19cebe12b49992b, ResourceID: 945c4f63c61f1bc7ba632fe0ce25aa0d
Logbackの設定
<configuration debug="true">
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%date{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} [%thread] %level %logger - %message%n</pattern>
        </encoder>
    </appender>

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

方法

正規表現でトークン部分を抽出するなら、この例では「直前に Token と書かれていること」を利用できそう。(あらゆる状況で厳密に判定しようとすると正規表現だけでは無理)

%message を次のように書き換える。

%replace(%message){'((?i:token).{0,10}?)\b\p{XDigit}{32}\b','$1****'}

するとログは以下の通りになる。トークン部分のみが **** となり、他は変化していない。

2020-11-14T09:43:31.724+09:00 [main] INFO com.example.Main - UserID: 5457645aaa75b97eb9e2c7b0aec79ca6, Token: ****, ResourceID: c194b0155ac7ece290092c1ee2a73948

%replace が丸括弧および2つの引数をとるのは、 String#replaceAll() のレシーバーおよび2引数と同じと考えていい(と思う)。

もう少し正規表現を頑張れば、「トークンの前後4桁ずつは残す」といったこともできる。

(補足)正規表現の詳細

  • 「十六進32桁」
    • 十六進数に使われる文字は \p{XDigit} で表せる( [0-9A-Fa-f] と同じ)
    • 32回繰り返すことを表すため、パターンの後ろに {32} を付ける
    • 32桁より長い場合を排除するため、前後に \b (単語境界)を付ける
      • ただし _ が単語の一部扱いなので、これで区切っている場合は使えない
      • より詳細に境界を定めたいなら、否定[先後]読みを利用できる
    • 以上より、正規表現は \b\p{XDigit}{32}\b となる
  • 「直前に Token と書かれていること」
    • 戦略としては、マスク対象より前の文字列はキャプチャしておき置換文字列内で参照する
      • そのためにキャプチャ対象を () で囲う
      • 参照時は $n と書く(※ n はキャプチャグループの番号)
    • Token token TOKEN などのバリエーションに対応できるよう、 (?i:) で一時的に大文字小文字を無視する
    • Token の後に何文字か入るのを許容する
      • .* だと最長一致のため、今回の例では正しくマスクできない
      • .*? もよくない、例えば token validation for user 0123... is failed とか
      • というわけで字数制限はつけた方が安全(長さは要検討、最長・最短は任意)
    • 以上より、正規表現は ((?i:token).{0,10}?) となる

LogstashでJSON形式にする場合

(Logstash内にもマスク処理の設定があるみたいだが、そちらはまだ調べられていない)

Logback内の設定をJSONで書くため、バックスラッシュをエスケープする必要がある。(さもないと、 \b はバックスペース、 \p は不正なエスケープと認識される)

設定(一部)
{
    "timestamp": "%date{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}",
    "thread": "%thread",
    "level": "%level",
    "logger": "%logger",
    "message": "%replace(%message){'((?i:token).{0,10}?)\\b\\p{XDigit}{32}\\b','$1****'}"
}
ログ
{"timestamp":"2020-11-14T11:31:38.259+09:00","thread":"main","level":"INFO","logger":"com.example.Main","message":"UserID: c610e22e634ed2ff9f1bb27afc81e638, Token: ****, ResourceID: de343ea6405a8c559043c3e3e84f9bcd"}

(付録)実験コード

今回の実験に使用したコードは以下の通り。

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>logback-sample</artifactId>
    <version>1.0-SNAPSHOT</version>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>8</source>
                    <target>8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.2.3</version>
        </dependency>
        <dependency>
            <groupId>net.logstash.logback</groupId>
            <artifactId>logstash-logback-encoder</artifactId>
            <version>6.4</version>
        </dependency>
    </dependencies>
</project>
src/main/resources/logback.xml
<configuration debug="true">
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%date{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} [%thread] %level %logger - %replace(%message){'((?i:token).{0,10}?)\b\p{XDigit}{32}\b','$1****'}%n</pattern>
        </encoder>
    </appender>

    <appender name="STDOUT_JSON" class="ch.qos.logback.core.ConsoleAppender">
        <!-- https://github.com/logstash/logstash-logback-encoder -->
        <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
            <providers>
                <pattern>
                    <pattern>
                        {
                        "timestamp": "%date{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}",
                        "thread": "%thread",
                        "level": "%level",
                        "logger": "%logger",
                        "message": "%replace(%message){'((?i:token).{0,10}?)\\b\\p{XDigit}{32}\\b','$1****'}"
                        }
                    </pattern>
                </pattern>
            </providers>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDOUT" />
        <appender-ref ref="STDOUT_JSON" />
    </root>
</configuration>
src/main/java/com/example/Main.java
package com.example;

public class Main {
    private static final org.slf4j.Logger log =
            org.slf4j.LoggerFactory.getLogger(Main.class);

    public static void main(String[] args) {
        log.info("UserID: {}, Token: {}, ResourceID: {}", hex(), hex(), hex());
    }

    private static String hex() {
        return new java.util.Random().ints(16, 0, 256)
                .mapToObj(x -> String.format("%02x", x))
                .reduce("", (a, b) -> a + b);
    }
}