実例で学ぶワンライナーの作り方(ログ内のIPアドレスに簡単な匿名化を施す)

# はじめに

アクセスログを解析のために誰かに渡したり、参考資料として提示したいとき、IPアドレスをそのまま出してしまうとまずい場面、よくありますよね。

アクセスログの中のIPアドレスを、[RFC5737](https://tools.ietf.org/html/rfc5737)で規定されている例示用のアドレス[^example-ip]に変換して、簡単な匿名化を実現するワンライナーを作ってみたので紹介します。

[^example-ip]: RFC5737では、192.0.2.0/24、198.51.100.0/24、203.0.113.0/24の3つの範囲のアドレスがドキュメンテーション用としてリザーブされています。

ワンライナーはこんな感じです。ここでは、Apacheのアクセスログ(```/var/log/httpd/access_log```)を対象にしましたが、ワンライナー中2箇所に登場するファイル名を変更すれば、どんなファイルでも変換できます。

なお、このワンライナーはCentOS7上で動作確認しました。MacなどBSD系のOSを使用している方は、5行目のsedのオプションを```-re```ではなく```-Ee```にすれば動くはずです。

```bash:アクセスログのIPアドレスに簡単な匿名化を行うワンライナー
grep -Eo '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' /var/log/httpd/access_log \
  | sort -u \
  | shuf \
  | paste - <(echo 203.0.113.{,,,}{1..254} | tr ' ' "\n") \
  | sed -re '/^\t.+/d;/^.+\t$/d;s|([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)\t(.+)|sed -re "s@\1\\.\2\\.\3\\.\4@\5@g" \||;1i cat /var/log/httpd/access_log |' \
  | xargs \
  | sed -e 's/|$//' \
  | bash
```

このワンライナーの中では7種類のコマンドと、2種類のbash拡張機能を利用しています。

* grep
* sort
* shuf
* paste
* tr
* sed(置換、削除、コマンドの連続実行)
* xargs
* ブレース展開(bashの拡張機能)
* プロセス置換(bashの拡張機能)

このワンライナーを作る過程を紹介します。

# 考え方
以下のように、ログファイルに対してIPアドレスを変換するsedコマンドを、対象となるIPアドレスの数だけ自動生成して実行することでIPアドレスの変換を実現します[^sed]。

[^sed]: sedの置換コマンド```s```で置換前パターンと置換後パターンの区切り文字は通常```/```が使われますが、実際には ```s``` の次に記述された文字が区切り文字になります。置換前後のパターンに ```/``` が含まれる場合などは、```|```など他の文字に変更すると、```/```をエスケープしなくても良くなります。ここではピリオドをエスケープするためのバックスラッシュと見間違いやすいので、```@```を区切り文字にしています。

```bash
sed -re 's@192\.168\.1\.1@203.0.113.1@g'
```

こんなコマンドを出力して実行すれば、IPアドレスの置換が実現できます。簡単ですね。

```bash
cat ログファイル | \
  sed -re 's@置換前パターン1@置換後IPアドレス1@g' | \
  sed -re 's@置換前パターン2@置換後IPアドレス2@g' | \
    ・・・
```

このコマンドをどのようにして自動的に作るかがポイントです。今回は、大きく以下の3つのステップにわけて作っていきます。

1. ログファイルから置換対象のIPアドレスを抽出する
1. 置換後のIPアドレスを列挙する
1. 置換前後のIPアドレスの対応表を作る
1. 対応表からsedコマンドを自動生成する

# 実践編

では実際にワンライナーを作っていきましょう。

まず、上記ステップ3の以下のようなIPアドレス変換用の対応表を作ります。2つのIPアドレスの間はタブ文字です。

```bash
# 置換前のIPアドレス <タブ文字> 置換後のIPアドレス
192.168.1.1    203.0.113.1
192.168.2.5    203.0.113.2
192.168.11.3   203.0.113.3
....
```

## 1. ログファイルから置換対象のIPアドレスを抽出する

まず、grepコマンドでログファイル(```/var/log/httpd/access_log```)から変換対象のIPアドレスを抽出[^ip-regexp]して重複を取り除きます。また、匿名化が目的なので、念のため shuf コマンドでシャッフルしておきます。

[^ip-regexp]: この正規表現だと```999.999.999.999```のようなIPv4のIPアドレスとしてあり得ない文字列も抽出されてしまいますが、面倒なのでこのままにしておきます。

```bash
grep -Eo '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' /var/log/httpd/access_log \
  | sort -u \
  | shuf
```

これで、IPアドレス変換表の左側の列ができあがります。

## 2.置換後のIPアドレスを列挙する

次に、置換後のIPアドレスのリストを作ります。変換後のIPアドレスは ```203.0.113.1```〜```203.0.113.254``` の範囲を使用します。

```bash
echo 203.0.113.{1..254} | tr ' ' "\n"
```

bashのブレース展開で ```203.0.113.1 203.0.113.2 203.0.113.3 ... 203.0.113.254``` といった、スペース区切りのIPアドレスリストをつくり、trコマンドでスペースを改行に変換することで、1行につき1つのIPアドレスのリストが得られます。

抽出したIPアドレスが254種類以上ある場合、これでは足らないのでもう一工夫します。

```bash
echo 203.0.113.{,,,}{1..254} | tr ' ' "\n"
```

ブレース展開の```{1..254}```の手前に```{,,,}```を追加しました。これで、1〜254の数列を4回繰り返し出力します。これで、約1000種類のIPアドレスに対応できます。中括弧の中のカンマを増やせば、いくらでも対応できます。

ここは少し変わったことをしているので解説します。まず、bashのブレース展開は、中括弧が複数あるとその組合せをすべて出してくれます。

```bash
$ echo {a,b,c}{1..3}
a1 a2 a3 b1 b2 b3 c1 c2 c3
```

最初の中括弧の中を```a,b,c```から```a,a,a```に書き換えるとこんな感じになります。

```bash
$ echo {a,a,a}{1..3}
a1 a2 a3 a1 a2 a3 a1 a2 a3
```

さらに```a```を空文字にするとこのようになります。

```bash
$ echo {,,}{1..3}
1 2 3 1 2 3 1 2 3
```
つまり、```{1..3}```の展開結果である```1 2 3```を3つの空文字と組み合わせた結果、```1 2 3```が3回繰り返えして出力されます。

これと同じ理屈で、```echo 203.0.113.{,,,}{1..254}```からは ```203.0.113.1```〜```203.0.113.254``` のリストを4回繰り返したものが得られます。

置換後はIPアドレスの対応が1対1ではなくなりますが、匿名化が目的なのであまり気にしないことにします。

## 3.置換前後のIPアドレスの対応表を作る

これで変換リストの右側もできあがりました。この2つの出力をpasteコマンドで結合します。一時ファイルに保存してから処理するのが分かり易いですが、今回はワンライナーで実現するため、プロセス置換を使用します。以下のようなコマンドです。

```bash
1列目を出力コマンド | paste - <(2列目を出力するコマンド) | sed -re '/^\t.+/d'
```

paste コマンドは引数で結合するファイルを指定しますが、ファイル名に`-`(ハイフン)を指定すると、標準入力から読み込みます。ただ、コマンドの出力結果を標準出力で渡せるのは1つだけなので、もうひとつはbashの拡張機能であるプロセス置換を使います。

プロセス置換は、ファイル名を指定すべき所に「```<(コマンド)```」と記述すると、コマンドの標準出力をファイルと見なして読み込ませてくれます。上の例では、1列目の内容は標準出力をパイプ経由で、2列目の内容はプロセス置換で、それぞれpasteコマンドに渡しています。

このようにプロセス置換を使うと一時ファイルを作らずとも、あるコマンドの出力を別のコマンドに渡すことができます[^another-paste]。

[^another-paste]: この例では片方をパイプで渡していましたが、つぎのようにして両方をプロセス置換で渡すこともできます。```paste <(1列目を出力コマンド) <(2列目を出力するコマンド)```

なお、パイプのあとのsedコマンドは、片方の列しか出力されなかった行を削除するためのものです。pasteコマンドの出力は、列がタブ文字で区切られているため、タブ文字で始まる行(つまり1列目が出力されていない行)を、sedのdコマンドで削除しています[^another]。

[^another]: 別解として、sedの部分を ```grep -Ev '^\t'``` と書いても良いです。grepの`-v`オプションを使って、(タブで始まる行)「**以外**」を検索して出力します。

プロセス置換の部分に先ほど作成したコマンドを埋め込むと、以下のようになります。これで、IPアドレスの対応表をつくることができました。

```bash
grep -Eo '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' /var/log/httpd/access_log \
  | sort -u \
  | shuf \
  | paste - <(echo 203.0.113.{,,,}{1..254} | tr ' ' "\n") \
  | sed -re '/^\t.+/d;/^.+\t$/d'
```

## 4.対応表からsedコマンドを自動生成する

### 1組のIPアドレスを置換するsedの生成

ここまでで作った対応表を元に、実際に置換するためのsedコマンドを生成します。

たとえば、以下のような対応表のレコードから、、、

```
192.168.1.1    203.0.113.1
```

冒頭で説明したような、以下のようなsedコマンドを生成します。一番最後に```|```(パイプ)記号をつけているのは、生成したsedコマンドをパイプで連結して動かせるようにするためです。

```bash
sed -re 's@192\.168\.1\.1@203.0.113.1@g' |
```

このsedコマンドをsedコマンドで生成します。(笑)

```bash
sed -re 's|([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)\t(.+)|sed -re "s@\1\\.\2\\.\3\\.\4@\5@g" \||'
```

ただの置換コマンドですが、エスケープが多くてややこしいので、置換前と置換後のパターンを抜き出しました。

```:置換前文字列の正規表現
([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)\t(.+)
```

これは以下の入力にマッチさせるものです。

```
192.168.1.1    203.0.113.1
```

1カラム目の方は4つの数字に分解してマッチさせています。それぞれ置換時の後方参照用に```()```で括っています。

```:置換後文字列
sed -re "s@\1\\.\2\\.\3\\.\4@\5@g\" \|
```

置換後のパターンでは、 ```\1```〜```\4```が置換前IPアドレスの4つのオクテット、```\5```が置換後IPアドレスを表します。置換前だけオクテット毎にバラしたのは、正規表現のパターンを作るとき、オクテットを区切るピリオドの部分をマッチさせる正規表現```\\.```を作るためです。

### sedコマンドをパイプで組み合わせる

ここまでで、以下のようなテキストが生成できました。

```bash
sed -re 's@置換前パターン1@置換後IPアドレス1@g' |
sed -re 's@置換前パターン2@置換後IPアドレス2@g' |
sed -re 's@置換前パターン3@置換後IPアドレス3@g' |
・・・
```

これを、以下のように変換すれば、bashで実行できるコマンドができあがります。

```bash
cat ログファイル名 | sed -re 's@置換前パターン1@置換後IPアドレス1@g' | sed -re 's@置換前パターン2@置換後IPアドレス2@g' | sed -re 's@置換前パターン3@置換後IPアドレス3@g' | ・・・
```

まず、以下のsedの```i```コマンドで1行目にcatコマンドとパイプ記号を追加します。

```bash
sed -e '1i cat /var/log/httpd/access_log |'
```

これら全体を xargs コマンドに渡して、1行に連結します。xargsコマンドは、複数行の入力をスペース区切りに連結し、引数で渡されたコマンドの引数として実行してくれるものです。

```:args.txt
aaa
bbb
ccc
```

このようなファイルをxargsに渡すと、、、

```bash
$ cat args.txt | xargs echo
```

以下のようにargs.txtの中身をechoコマンドに渡したことと同じことになります。

```bash
echo aaa bbb ccc
```

xargs コマンドは、引数を省略すると入力を echo コマンドに渡しますので、複数行をスペース区切りで1行に連結することができます。


sedのコマンドの自動生成以降と組み合わせると、以下のようになります。sedでは、```;```で区切ると同一行に対して複数のコマンドを実行できるので、```i```コマンドによる1行目の追加処理もまとめてしまいました。

また、最後のsedは、出力の一番最後のパイプ記号を削除するためのものです。

```bash
sed -re 's|([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)\t(.+)|sed -re "s@\1\\.\2\\.\3\\.\4@\5@g" \||;1i cat /var/log/httpd/access_log |' \
  | xargs \
  | sed -e 's/|$//'
```

これでようやくすべての処理が書けました。

# 完成

前半部分と後半部分をパイプで繋げると、冒頭で紹介したワンライナーになります。

```bash
grep -Eo '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' /var/log/httpd/access_log \
  | sort -u \
  | shuf \
  | paste - <(echo 203.0.113.{,,,}{1..254} | tr ' ' "\n") \
  | sed -re '/^\t.+/d;/^.+\t$/d;s|([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)\t(.+)|sed -re "s@\1\\.\2\\.\3\\.\4@\5@g" \||;1i cat /var/log/httpd/access_log |' \
  | xargs \
  | sed -e 's/|$//'
```

行区切りを無くすとこんな感じ。呪文のようです。(笑)

```
grep -Eo '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' /var/log/httpd/access_log
| sort -u | shuf | paste - <(echo 203.0.113.{,,,}{1..254} | tr ' ' "\n") | sed -re 
'/^\t.+/d;/^.+\t$/d;s|([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)\t(.+)|sed -re "s@\1\\.\2
\\.\3\\.\4@\5@g" \||;1i cat /var/log/httpd/access_log |' | xargs | sed -e 's/|$//'
```

# （おまけ）別解

１つのIPアドレスを置換するsedの生成処理を、readコマンドで分かりやすく書けないかと試行錯誤したあとを紹介します。動くのですが、whileループを回してしまっているので、遅くなってしまっています。

ただ、本編では使わなかったwhileループ、サブシェル、ヒアストリングといった要素が出てくるので、こちらも紹介しておきます。

最初に紹介したワンライナーの5行目に対応するのが、こちらのワンライナーの5〜8行目です。

```bash
grep -Eo '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' /var/log/httpd/access_log \
  | sort -u \
  | shuf \
  | paste - <(echo 203.0.113.{,,,}{1..254} | tr ' ' "\n") \
  | sed -re '/^\t.+/d;/^.+\t$/d' \
  | (while read BF AF;do BF=`sed -re 's|\.|\\\\.|g' <<<"$BF"`; echo "sed -re 's@$BF@$AF@g' |"; done; ) \
  | sed -e '1i cat /var/log/httpd/access_log |' \
  | xargs \
  | sed -e 's/|$//' \
  | bash
```