必要最低限で覚えるLinuxテキスト処理超入門

# はじめに
Linuxにはテキスト処理を行うコマンドがたくさんあります。コマンドには多彩は書式やオプションが用意されており、複雑な処理ができるものが多くあります。

ただ、初歩的な書式やオプションだけでも **パイプで複数連結すればなんとかなる** ことが多いので、基本的な書式だけでも無意識に使えるようにすると効率が桁違いです。

最初から複雑な書式を使いこなすのは難しく、すぐに忘れてしまします。チートシートを片手に多彩なオプションで悩む時間も無駄です。

:::note info
**パイプでつなげればなんとかなる**
* grep １回ですべて検索しようとしない
  パイプでつなげればなんとかなる
* sed １回ですべて置換しようとしない
  パイプでつなげればなんとかなる
* awk １回でなんでもかんでもやろうとしない
  他コマンドをパイプでつなげればなんとかなる。
:::

このエントリで記載している各コマンドの使い方は、ごく基本的な書式と初歩的なオプションに限定しています。

説明文は文献をあたっていないので、いいかげんなものが含まれている可能性があります。ごめんちょ。


# 処理対象のテキストファイル

Splunkのチュートリアルデータを利用してテキスト処理のためのコマンドを解説します。
あ、いや、Splunkの活用法と違うってのはわかってるんですが。今回はすいません、データだけ利用させてください。

```
curl https://docs.splunk.com/images/Tutorial/tutorialdata.zip -o ~/tmp/tutorialdata.zip
unzip ~/tmp/tutorialdata.zip -d ~/tmp/sample
cd ~/tmp/sample
```


# テキスト処理コマンドいろいろ

テキスト処理のコマンドは、標準入力→処理→標準出力が多い。なので、パイプで連結できるのです。

## cat
**cat（きゃっと）：conCATenate**
ファイルか標準入力を連結して標準出力に出力する
→ ファイルの中身を出力

```:基本書式
cat <ファイル名>
cat <STDIN>
```

```:基本の使い方
$ cat www1/secure.log
```


## grep 
**grep（ぐれっぷ）：Global Regular Expression Print**
ファイルか標準入力から正規表現に一致する行を標準出力に出力する
→ 入力されてきた行に対して、フィルタして出力

正規表現はregex（Regular Expression）と表記されることが多くあります。

複数ファイルがある場合は、先にgrepで検索すると、先頭にファイル名がつくので `cat 全ファイル | grep xxx` としてもよいです。

```:基本書式
grep <正規表現> <ファイル>
grep <正規表現> <STDIN>
```

```:基本の使い方
$ grep sudo: */secure.log
$ cat */secure.log | grep sudo:
```

**よく使うオプション**

|オプション|略称|意味|
|:--:|:--:|:--:|
|`-v`|inVert|指定条件以外を検索|
|`-i`|Ignore case|大文字/小文字を無視|
|`-r`|Recursive|ディレクトリを再帰的に検索|
|`-E`|Extended|拡張正規表現を利用|

拡張正規表現は `(ABC|XYZ)` などが利用できる書式のことで `egrep` コマンドでも同じ効果が得られます。

```:オプションを利用した書式
# sudo: という文字列を「含まない」行を抽出
$ cat */secure.log | grep -v sudo:

# ssh という文字列の大文字/小文字を無視する
$ cat */secure.log | grep -i ssh

# カレントディレクトリ配下の全ファイルに対して検索する
$ grep ssh * -ri
```

**その他の書式**

以下の「ステータスコード200」は、想定しない場所も抽出してしまう可能性があるので、完璧ではないですが漏れは無いかと。

```:ログを抽出する
# ステータスコード200以外を抽出
$ cat */access.log | egrep -v " 200 [1-9][0-9]* "

# さらに特定のUAを除外する
$ cat */access.log | egrep -v " 200 [1-9][0-9]* " | grep -v Mozilla | grep -v Opera

# さらに特定のUAを除外する
$ cat */access.log | egrep -v " 200 [1-9][0-9]* " | grep -v Mozilla | grep -v Opera

# さらにPOSTリクエストのみに限定する
$ cat */access.log | egrep -v " 200 [1-9][0-9]* " | grep -v Mozilla | grep -v Opera | grep POST
```

## sed
**sed（せっど）：Stream EDitor**
ファイルか標準入力に対して条件文を利用して変換する
→ 入力されてきた行に対して、条件式で置換して出力

だいたい正規表現で置換する用途で利用します。

```:基本書式
# 正規表現で置換
sed 's/<正規表現>/<置換後文字列>/' <ファイル>
sed 's/<正規表現>/<置換後文字列>/' <STDIN>

# 正規表現で置換（１行中に出現する複数の正規表現の全てを置換する）
sed 's/<正規表現>/<置換後文字列>/g' <ファイル>
sed 's/<正規表現>/<置換後文字列>/g' <STDIN>

# 正規表現の大文字/小文字を判定しない
sed 's/<正規表現>/<置換後文字列>/gi' <ファイル>
sed 's/<正規表現>/<置換後文字列>/gi' <STDIN>
```

```:基本の使い方
$ cat */secure.log | grep sudo: | sed 's/;..*//'
```

**よく使うオプション**

|オプション|略称|意味|
|:--:|:--:|:--:|
|`-E`|Extended|拡張正規表現|

`-i`でファイル指定して上書き、`-e`で複数条件の指定などありますが、代替手段でなんとかなるので覚えなくてよいです。


**その他の書式**

正規表現パターンを `\(` と `\)` で囲むことでグループ化して、置換文字列として参照させることができます。
「マッチさせたうえで、抽出後にその文字をそのまま残したい」というときに利用します。

```:グループ化と後方参照
$ cat */secure.log | grep sudo: | sed 's/..*sudo: \(..*\) ;/\1/'
```

一部の文字はエスケープが必要です。

```:特殊文字のエスケープ
$ cat */secure.log | grep sudo: | sed 's/..*\/home\/\(..*\) ;/\1/'
```

セパレータは任意に変更できます。

```:セパレータの変更
$ cat */secure.log | grep sudo: | sed 's:..*/home/\(..*\) ;:\1:'

# 拡張正規表現のオプションだとグループの()がエスケープ不要になる
$ cat */secure.log | grep sudo: | sed -E 's:..*/home/(..*) ;:\1:'
```


## cut
**cut（かっと）：CUT**
入力されていた行に対して、文字数やデリミタなどの条件式で抽出して出力

```:基本書式
cut -d<delimiter> -f<field> <STDIN>
```

```:基本の使い方
$ cat */secure.log | grep sudo: | cut -d: -f4

# デリミタを空白とする場合はエスケープする
$ cat */secure.log | grep sudo: | sed 's/;..*//' | cut -d\  -f6,8
```

**よく使うオプション**

|オプション|略称|意味|
|:--:|:--:|:--:|
|`-d`|delimiter|デリミタ指定（複数文字NG）|
|`-f`|field|抽出するフィールドを指定|


## sort
**sort（そーと）：SORT**
入力されてきた行をソートして出力

```:基本書式
sort <ファイル名>
sort <STDIN>
```

```:基本の使い方
$ cat */secure.log | grep sudo: | sed 's/;..*//' | awk '{print $6,$8}' | sort
```

**よく使うオプション**

|オプション|略称|意味|
|:--:|:--:|:--:|
|`-n`|number|数値として判定|
|`-r`|reverse|逆順（降順）に表示|


## uniq
**uniq（ゆにーく）：UNIQue**
前後の行が重複している場合に行をまとめる

```:基本書式
uniq <ファイル名>
uniq <STDIN>
```

```:基本の使い方
$ cat */secure.log | grep sudo: | sed 's/;..*//' | awk '{print $6,$8}' | sort | uniq
```

**よく使うオプション**

|オプション|略称|意味|
|:--:|:--:|:--:|
|`-c`|count|重複回数を表示|


```
$ grep sudo: secure.log | awk -F\; '{print $3}' | sort | uniq -c
```

```
$ grep sudo: secure.log | awk -F\; '{print $3}' | sort | uniq -c | sort -nr
```


## awk
**awk（おーく）：Aho Weinberger Kernighan**
テキストファイル処理に特化したプログラミング言語
→ 入力されてきた行に対して、いろいろ処理して表示する

```:基本書式
awk <code> <ファイル>
awk <code> <STDIN>
```

```:基本の使い方
# print $n で、n番目のフィールドを抜き出します
$ cat */secure.log | grep sudo: | sed 's/;..*//' | awk '{print $6,$8}'
```

`awk`は使いこなせれば、sort/uniqなどをまとめて処理できるようになりますが、個人的には `print` で使うだけです。
`$NF`で最後のフィールド、`$(NF-1)`で最後から１番目、という使い方ができます。


**よく使うオプション**

|オプション|略称|意味|
|:--:|:--:|:--:|
|`-F`|Field|デリミタ指定（複数文字OK）|

デリミタ（=delimiter/de,limiter）は「区切り文字」のことで、セパレータということもあります。デフォルトはスペースです。



## nl
**nl（えぬえる）：Number of Line**
入力されてきた行に対して行番号を添えて出力

```:基本書式
nl <ファイル名>
nl <STDIN>
```

```:基本の使い方
$ cat */secure.log | grep sudo: | sed 's/;..*//' | awk '{print $6,$8}' | sort | uniq | nl
```


## head
**head（へっど）：HEAD**
入力されてきた行の「先頭」から指定した分だけ出力

```:基本書式
head <ファイル名>
head <STDIN>
```

```:基本の使い方
$ cat */secure.log | grep sudo: | sed 's/;..*//' | awk '{print $6,$8}' | sort | uniq -c | sort -nr | head
```

**よく使うオプション**

|オプション|略称|意味|
|:--:|:--:|:--:|
|`-n`|number|表示させる行数を指定|

```
$ head access.log -n 30
```


## tail
**tail（ている）：TAIL**
入力されてきた行の「末尾」から指定した分だけ出力

```:基本書式
tail <ファイル名>
tail <STDIN>
```

```:基本の使い方
$ cat */secure.log | grep sudo: | sed 's/;..*//' | awk '{print $6,$8}' | sort | uniq -c | sort -n | tail
```

**よく使うオプション**

|オプション|略称|意味|
|:--:|:--:|:--:|
|`-n`|number|表示させる行数を指定|
|`-f`|follow|入力が続く限り更新して表示|

```
$ tail access.log -n 30
```

読み込み対象ファイルが継続的に追記されるようなファイルの場合は `-f` で逐次更新することもできます。
```
$ tail -f /var/log/nginx/access.log
$ tail -f /var/log/nginx/access.log | grep POST
```


# ログ検索のサンプル問題

## セキュアログ調査

Linuxシステムのセキュアログと思われる `*/secure.log` について、それぞれ次のログ調査を実施してください。

```
$ head */secure.log -n 2
==> mailsv/secure.log <==
Thu Apr 13 2023 13:34:34 mailsv1 sshd[4351]: Failed password for invalid user guest from 86.212.199.60 port 3771 ssh2
Thu Apr 13 2023 13:34:34 mailsv1 sshd[2716]: Failed password for invalid user postgres from 86.212.199.60 port 4093 ssh2

==> www1/secure.log <==
Thu Apr 13 2023 13:34:30 www1 sshd[4747]: Failed password for invalid user jabber from 118.142.68.222 port 3187 ssh2
Thu Apr 13 2023 13:34:30 www1 sshd[4111]: Failed password for invalid user db2 from 118.142.68.222 port 4150 ssh2

==> www2/secure.log <==
Thu Apr 13 2023 13:34:33 www2 sshd[1319]: Failed password for invalid user varnish from 209.160.24.63 port 3459 ssh2
Thu Apr 13 2023 13:34:33 www2 sshd[1390]: Failed password for invalid user icinga from 209.160.24.63 port 2678 ssh2

==> www3/secure.log <==
Thu Apr 13 2023 13:34:31 www3 sshd[3163]: Failed password for invalid user sysadmin from 202.91.242.117 port 1994 ssh2
Thu Apr 13 2023 13:34:31 www3 sshd[3301]: Failed password for games from 202.91.242.117 port 3741 ssh2
```

<details><summary>すべての secure.log からSSHのパスワード認証に<b>成功</b>した<b>アカウント名</b>と、その<b>数</b>を調査する</summary>

```
grep sshd */secure.log | grep Accepted \
| awk '{print $11}' \
| sort | uniq -c
```
</details>


<details><summary>すべての secure.log からSSHのパスワード認証に<b>成功</b>した<b>アカウント名</b>と<b>接続元IPアドレス</b>と、その<b>数</b>を調査する</summary>

```
grep sshd */secure.log | grep Accepted \
| awk '{print $11,$13}' \
| sort | uniq -c
```
</details>


<details><summary>すべての secure.log からSSHのパスワード認証に<b>失敗</b>した<b>アカウント名</b>と、その<b>数</b>を調査する</summary>

これだと `invalid` というユーザ名が多いように見えてしまう。
```
grep sshd */secure.log | grep Failed \
| awk '{print $11}' \
| sort | uniq -c | sort -n
```

**解決方法その１**
ログを見ると以下のことがわかる。
* ユーザ名が存在するときのログは、最後のフィールドから数えて５番目がユーザ名
* ユーザ名が存在しないときのログも、最後のフィールドから数えて５番目がユーザ名 
```
Mon Apr 17 2023 13:34:34 mailsv1 sshd[5586]: Failed password for mail from 142.233.200.21 port 3967 ssh2
Mon Apr 17 2023 13:34:34 mailsv1 sshd[3093]: Failed password for invalid user administrator from 142.233.200.21 port 3229 ssh2
```

awkの`$NF`を利用することで最後から〇番目というフィルタができる。
```
grep sshd */secure.log | grep Failed \
| awk '{print $(NF-5)}' \
| sort | uniq -c | sort -n
```


**解決方法その２**
ログを見ると以下のことがわかる。
* ユーザ名が存在するときのログは`Failed password for XXX from`
* ユーザ名が存在しないときのログは`Failed password for invalid user XXX from`
```
Mon Apr 17 2023 13:34:34 mailsv1 sshd[5586]: Failed password for mail from 142.233.200.21 port 3967 ssh2
Mon Apr 17 2023 13:34:34 mailsv1 sshd[3093]: Failed password for invalid user administrator from 142.233.200.21 port 3229 ssh2
```

`Failed password for`のあとに`invalid user `が０回以上存在するとき、を条件に含めるようにすることでユーザ名だけを抽出できる。
```
grep sshd */secure.log | grep Failed \
| sed 's/.*Failed password for \(invalid user \)*\(.*\) from.*/\2/' \
| sort | uniq -c | sort -n
```
</details>


<details><summary>すべての secure.log からSSHのパスワード認証に<b>失敗</b>した<b>アカウント名</b>と<b>接続元IPアドレス</b>と、その<b>数</b>を調査する</summary>

前述の解決方法その１の手法をもとに、IPアドレスが最後から３番目なので以下のように抽出できる
```
grep sshd */secure.log | grep Failed \
| awk '{print $(NF-5),$(NF-3)}' \
| sort | uniq -c | sort -n
```

前述の解決方法その１の手法をもとに、IPアドレスの前後の文字からグループ化することで以下のように抽出できる
```
grep sshd */secure.log | grep Failed \
| sed 's/.*Failed password for \(invalid user \)*\(.*\) from \(.*\) port.*/\2 \3/' \
| sort | uniq -c | sort -n
```
</details>


## アクセスログ調査

Webサーバのアクセスログと思われる `www[1-3]/access.log` について、それぞれ次のログ調査を実施してください。

```
$ head www[1-3]/access.log -n 2
==> www1/access.log <==
209.160.24.63 - - [13/Apr/2023:18:22:16] "GET /product.screen?productId=WC-SH-A02&JSESSIONID=SD0SL6FF7ADFF4953 HTTP 1.1" 200 3878 "http://www.google.com" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.5 (KHTML, like Gecko) Chrome/19.0.1084.46 Safari/536.5" 349
209.160.24.63 - - [13/Apr/2023:18:22:16] "GET /oldlink?itemId=EST-6&JSESSIONID=SD0SL6FF7ADFF4953 HTTP 1.1" 200 1748 "http://www.buttercupgames.com/oldlink?itemId=EST-6" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.5 (KHTML, like Gecko) Chrome/19.0.1084.46 Safari/536.5" 731

==> www2/access.log <==
199.15.234.66 - - [13/Apr/2023:18:24:31] "GET /cart.do?action=view&itemId=EST-6&productId=SC-MG-G10&JSESSIONID=SD5SL9FF2ADFF4958 HTTP 1.1" 200 3033 "http://www.google.com" "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.2.28) Gecko/20120306 YFF3 Firefox/3.6.28 ( .NET CLR 3.5.30729; .NET4.0C)" 177
175.44.24.82 - - [13/Apr/2023:18:44:36] "GET /category.screen?categoryId=SHOOTER&JSESSIONID=SD7SL9FF5ADFF5066 HTTP 1.1" 200 2334 "http://www.google.com" "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; BOIE9;ENUS)" 546

==> www3/access.log <==
74.125.19.106 - - [13/Apr/2023:18:27:48] "GET /product.screen?productId=FS-SG-G03&JSESSIONID=SD10SL4FF4ADFF4976 HTTP 1.1" 200 3770 "http://www.buttercupgames.com/category.screen?categoryId=STRATEGY" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.5 (KHTML, like Gecko) Chrome/19.0.1084.46 Safari/536.5" 667
74.125.19.106 - - [13/Apr/2023:18:27:50] "POST /cart.do?action=addtocart&itemId=EST-26&productId=FS-SG-G03&JSESSIONID=SD10SL4FF4ADFF4976 HTTP 1.1" 200 293 "http://www.buttercupgames.com/product.screen?productId=FS-SG-G03" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.5 (KHTML, like Gecko) Chrome/19.0.1084.46 Safari/536.5" 100
```


<details><summary>アクセスのピークは<b>何時ごろ</b>か？</summary>

**調査から除外する日時を判断する**
調査対象は複数日にわたったログであり、最初の日時以前のログと最後の日時以降のログは存在しないため、その日付以外のログを対象とする。
（現実世界であれば曜日による増減を考慮する必要があるが、ここでは考慮しないことにする）

以下の結果から、2023/4/14～2023/4/19のログを調査対象とする。
```
$ cat www[1-3]/access.log | awk '{print $4}' | sort | head -n 1
[13/Apr/2023:18:22:16]

$ cat www[1-3]/access.log | awk '{print $4}' | sort | tail -n 1
[20/Apr/2023:18:22:16]
```

以下のように、`egrep -v '(13|20)/Apr'`とすることで除外できることが確認できた。
```
$ cat www[1-3]/access.log | awk '{print $4}' | egrep -v '(13|20)/Apr' | sort | head -n 1
[14/Apr/2023:00:00:19]

$ cat www[1-3]/access.log | awk '{print $4}' | egrep -v '(13|20)/Apr' | sort | tail -n 1
[19/Apr/2023:23:58:49]
```

時間帯の順にアクセス数量を表示
```
cat www[1-3]/access.log | awk '{print $4}' \
| egrep -v '(13|20)/Apr' \
| awk -F: '{print $2}' \
| sort | uniq -c
```

アクセス数量の順に時間帯を表示
```
cat www[1-3]/access.log | awk '{print $4}' \
| egrep -v '(13|20)/Apr' \
| awk -F: '{print $2}' \
| sort | uniq -c \
| sort -n
```

タイムゾーンは不明だが、午前３時がアクセスのピークの模様
</details>


# 正規表現

このエントリでは、様々な正規表現を使ってテキストを操作するものではないので、正規表現自体の解説は本筋ではありません。（として逃げます。）

正規表現の解説記事は世の中にわんさかあるので、ここではよく利用するパターンを記載するだけにしています。

**正規表現をチェックできるサイト**

https://regex101.com/

https://rubular.com/

https://regexper.com/

**よく利用する正規表現**

正規表現/拡張正規表現がごちゃまぜに書いてあるので `grep` で利用できなかったら `egrep` で利用するなど、適宜工夫してください。

|表記|意味|
|:--:|:--:|
|`^`|先頭|
|`$`|末尾|
|`.`|なにかの文字|
|`[ab]`|`a`もしくは`b`|
|`[a-z]`|`a`から`z`までの英字|
|`[-az]`|`-`もしくは`a`もしくは`z`|
|`[^]`|以外|
|`()`|グループ|
|`(abc\|xyz)`|`abc`もしくは`xyz`|
|`\d`|数字|
|`\w`|英字|
|`\s`|スペース（空白、タブ、改行）|
|`*`|０以上の繰り返し|
|`+`|１以上の繰り返し|
|`{n}`|直前のn回繰り返し|
|`{n,m}`|直前のn～m回の繰り返し|
|`{n,}`|直前のn回以上の繰り返し|
|`{,m}`|直前のm回以下の繰り返し|

コンフィグファイルで必要な部分を抜き出すのに
* 空行（`^$`）
* 先頭に0個以上のスペースがあって、そのあとが`#`

になっていない行を表示する。とかはよくやりますよね。
```
cat <config> | egrep -v "^($|\s*#)"

# 文字数は多いけど、個人的にはこっちのほうが好き
cat <config> | egrep -v "(^$|^\s*#)"
``` 


# さいごに

とある勉強会の用途でこの資料を作成しました。

コマンドの活用方法に唯一解は存在しません。

最初は基本のコマンドだけで、初歩のオプションだけで、自然に手が動くようになることが最初の一歩でいいのです。そこから「必要と感じたら」少しずつアレンジしていけばいいし、「それ、違うよ」と言われたらその時にちゃんと調べて使い方をなおせばいいのです。
と私は考えます。