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

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

* http://logback.qos.ch/manual/layouts_ja.html#cwOptions ← クレジットカード番号をマスクする例

[Javaの正規表現](https://docs.oracle.com/javase/jp/8/docs/api/java/util/regex/Pattern.html)の機能や、キャプチャした部分文字列の参照を利用すれば、上の例より難しい条件でのマスクもできる。その例と実際のコードを記す。

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

```text:ログの例
2020-11-14T09:30:52.774+09:00 [main] INFO com.example.Main - UserID: 35f44b06a3cf8dab8355eb8ba5844c73, Token: b9656056c799ab9ba19cebe12b49992b, ResourceID: 945c4f63c61f1bc7ba632fe0ce25aa0d
```

```xml: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` を次のように書き換える。

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

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

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

`%replace` が丸括弧および2つの引数をとるのは、 [`String#replaceAll()`](https://docs.oracle.com/javase/jp/8/docs/api/java/lang/String.html#replaceAll-java.lang.String-java.lang.String-) のレシーバーおよび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](https://github.com/logstash/logstash-logback-encoder)でJSON形式にする場合
（Logstash内にもマスク処理の設定があるみたいだが、そちらはまだ調べられていない）

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

```json:設定（一部）
{
	"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****'}"
}
```

```json:ログ
{"timestamp":"2020-11-14T11:31:38.259+09:00","thread":"main","level":"INFO","logger":"com.example.Main","message":"UserID: c610e22e634ed2ff9f1bb27afc81e638, Token: ****, ResourceID: de343ea6405a8c559043c3e3e84f9bcd"}
```

## （付録）実験コード
今回の実験に使用したコードは以下の通り。

```xml: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>
```

```xml: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>
```

```java: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);
	}
}
```