つれづれの正規表現 (第一巻)

掲示板を見ていて正規表現が使いこなせないという声が意外に多かった。 これはそういった声に応えるために書いたものである。 しかしながら、網羅的なあるいは正確さを期したリファレンスや 即実用に供することの出来るような内容では屋上階を重ねるようなものであると 思い正規表現の解説文としては破格のアプローチをとることにした。 まず、正規表現そのものをハッキングの対象として 実用以上の、パズル解きのような知的好奇心を満足させる手段として 扱うことにした。 むろん、断るまでもないことと思うが、ハッキングと言うのは 不正アクセスやシステムへの不正侵入と いう意味ではなく、こつこつと研究すると言った意味で使っている。 読み進まれれば容易に理解が出来ると思うが、 『何もそこまでしなくても良かろう』というレベルまで扱っている。 もとよりこのテキストは実用に供するものとして意図しては書かれてはいない。 この点についてははじめに断っておく。

次に、特定の問題解決に使われるという状況も踏まえてこのテキストは 正規表現とは何等のかかわりもないことがらについてもコンテンツとして 含んでいる。これも読めばわかるが RFC などに記載のあることがらの解説も している。これは一つに正規表現を扱うための人工的で問題意識のもてない 題材では最後まで読者を引っ張っていくことに無理があると判断したためである。 私の書いたものに共通の属性だが、 例によって正規表現とは関係のない UNIX オペレーティングシステムの 知識についても言及している。 もしも、正規表現を UNIX の文化から切り離して語ったら正規表現を 理解するのは難しいと思われたからである。 しかしながら、そのような知識は、他のオペレーティングシステムを使っている ユーザにとっても参考になると思われる。実際問題として web hosting サービ ス等を利用する機会は多く、そういったサービスを利用してサイトを作る際には UNIX の知識があれば助かることも多い。

最後に、このテキストを読まれる際には何等かの正規表現のリファレンスなどを てもとに置いて読み進まれることを希望する。繰り返すが、このテキストには 正規表現の一覧表などはない。また、このテキストで扱う 正規表現は Vine Linux 1.1 にインストールされている perl 5.00404 の 正規表現である。スクリプトはすべてこの perl で動作を確認している。 日本語化された jperl などを使用されている方は正規表現の 解釈に於いて動作が違う場合もあるので注意されたい。

正規表現の習得のために

ある国の王様が幾何学者を呼び出して、幾何学を学ぶ上でどのようにしたら 良いかを聞いたところ、幾何学者が『幾何学に王道無し』と答えたと言う話があ る。誰しも、小学校以来何度も聞かされる話である。幾何学が単純に学問だったり あるいは王様が中国の皇帝だったりするバリエーションはあるものの 何かを習得するにあたってはある程度の経験と年季が必要で、 地道に努力する以外にはないという如何にもトリヴィアルな教訓話に うんざりしている人も多いだろう。もちろん、あなたがいくらうんざりしようが、 この話は真理を含んでいる。実際に、経験と年季と努力はありとあらゆる分野で 成功をおさめるために必要とされることがらである。 正規表現をマスターするにもまったく同じことが言える。 しかしながら、この逸話のすべてが真理であるとも言えない。 何事にも無駄な苦労を回避する知識があり、 それを知っていれば 結果的には王道を突き進んだことと同じになるからだ。

さて正規表現を習得する最初のコツは、シェルのコマンドラインから 正規表現を与えるようなコマンドで使う場合、最初のうちは正規表現を コマンドラインから与えないようにすると言うことである。 MS-DOS の貧弱なコマンドインタプリタですらシェル用のメタキャラクタ というものがある。正規表現をコマンドラインから与える場合には シェルのメタキャラクタと干渉しあって正規表現そのものの理解をするのに 倍以上の苦労を強いられることが多い。正規表現に集中するためには どんなに短い正規表現でもあってもファイルに納めてからコマンドに渡すように した方が無難である。

正規表現を書くときには少しずつ複雑なものを書くようにしていった方が良い。 多少関係の無いものにマッチするぐらいの雑なものに手を入れて 必要十分なものにのみマッチするように書き換えていく。 これが一番の方法である。すぐにあなたは最初から複雑なものを書けるように なるだろうが、いきなり複雑なものを書くとうまくマッチしなかった場合に 今度は色々はずしていかなければいけなくなるだろう。

実際に使う前にテスト用のデータを用意してそれでテストしてみること。 特に正規表現を用いて既存のデータを書き換える場合には このテスト作業は大切である。一括書き換えという状況になることが 多いので、書き換えに失敗したときの被害も甚大である。 逆にある意味において、与えられた正規表現を適切にテストできるデータが 用意できるというのも正規表現を書く能力の一つであると言える。

手もとの問題が解決できるような正規表現が書けたとしても、別の書き方が ないかどうかは考えるようにする習慣をつけること。一つの問題に対して、 出来れば 3 通りぐらいは正規表現を考えること。 時間の余裕の許すときにそのような遊び(あるいはハック)を 試みられないようでは正規表現のマスターには、遊びながらやったときに比べて 時間がかかることを指摘しておく。 何かの仕事をさせるためのコンピュータであり、所詮は道具に過ぎない。 しかし、 義務的に道具を使っている人が、遊びながら道具を使っている人にしばしば 能率的に勝てないことが多いのも一つの真理である。 最初に書いたが、これからはじめるのは、正規表現を使った一種の ばかげた遊びである。おそらく、何通りかの正規表現を考えるという遊び心を 持てない人はこのテキストを最後まで読み通すことは出来ないだろう。

サーバのアクセスログを見る

web hosting サービスの httpd のアクセスログから 特定のファイルへのアクセスを記録したログを抜き出すことを考える。 これは特別なことではなくて、自分のページへどの程度のアクセスが あったかどうか知るためにサーバのアクセスログを検査するのは 当然の権利であるといえる。事実サーバのアクセスログへのいかなるアクセスも ユーザの権利として認めると明示的にうたっているサービスもある。 これはプライバシーとのからみでわりと複雑な問題をはらんでいるが 基本的にサーバの管理者のポリシーによって対応は違うようである。 ある web hosting サービスではアクセスログのパーミッションを落しているし、 アクセスログへのアクセスとは別の問題として サーバ資源の管理の問題(すなわち、CGI の濫用)から アクセスログへのアクセスが発覚した場合になんらかの文句を言って来る場合も あるかもしれない。 しかし、これが自分のパソコンの httpd サーバのアクセスログなら 誰に気兼ねすること無くアクセスログを検査することも出来るし、 そういった事情ならば、これは必要なことである。 事実、アクセスログを通読する等と言ったことは一般の人間に出来ることではな いので必要な部分のみを抽出するのが普通であるし、 web サーバの管理者はそのようにしている。

さらに『サーバのアクセスログを見る』というタイトルを見て、にんまりした人に 説明するのは難しいが、httpd に限らずサーバプログラムのログの走査を要領良 く行うと言うことはサーバ管理者にとっては基本的で重要な作業である。 冗談ではなくてセキュリティ対策業務の根幹であり基本なのだ。 システムに侵入されて管理者権限を奪取されたという状況(そういう場合であっ てもログに残った記録のいくつかには意味のあるものもある。)ならともかく、 管理者権限をとられるまえにログにその予兆が記録されていると言うこともある。 セキュリティの考え方にも色々あるが、予兆が発見された段階で攻撃を受けてい るサービスを一定期間停止するということでシステムの安全性を確保できること も本当にある。ファイアウォール(などと一口で言うこと自体が愚かなことなの だが)など様々な対策にもまして、ログの検査がものを言うときだってあるのだ。

なお以下特に断わりのない限り、使用する httpd は apache-1.3.3-1 である。 インストールされた rpm パッケージのマニュアルの最初のページ (つまり "It Worked!" と書いてあるページ)を見ると Red Hat Linux の ロゴマークがある。

httpd のログファイル

正規表現のテキストであるのにもかかわらず脱線してしまった。 ともかく技術的な話に戻ろう。 本来はこれは perl というよりは正規表現の使える egrep コマンド (システムによっては grep コマンドですら正規表現が使える。 むしろ、現在では grep コマンドで正規表現の使える場合の方が多い。) の正規表現の話になるが、話が晦渋になるので perl の話に限定する。 httpd のアクセスログは特に形式が決まっているわけではない。 httpd のインストールの際に設定を変えれば好きなように設定できる。 にもかかわらず、デフォルトの設定があって大体は次のような 形式になっていることが多い。

localhost - - [08/Jan/2000:06:46:01 +0900] "GET /search.html HTTP/1.0" 200 3756
localhost - - [08/Jan/2000:06:46:01 +0900] "GET /icons/vinelogo.gif HTTP/1.0" 404 212
207.0.229.23 - - [08/Jan/2000:06:49:01 +0900] "GET / HTTP/1.0" 403 198
207.0.229.23 - - [08/Jan/2000:06:49:17 +0900] "GET /cgi-bin/ HTTP/1.0" 403 206

例えば、上のログデータの 1 行目に注目すると、 左から順に、リモートホスト、 リモートログインネーム、リモートユーザネーム、 アクセス時刻、リクエスト、ステータスコード、転送バイト数 といったデータが一行に記載されている。 これらの意味を簡単に説明すると次の表の様になる。

httpd のログの各エントリの説明
リモートホスト 多くの場合 DNS による逆引きを行わない設定にし てあることが多いので そのまま IP アドレスになっていることが多い。ただし、/etc/hosts など から簡単にホスト名がわかる場合などは上のようにホスト名が記載されるこ ともある。
リモートログインネーム httpd がクライアントのチェックをするように build されていて、なおか つ、クライアント側で identd が有効になっている場合にクライアントマシ ンのログインネームが記載される。しかし、 多くの場合には identd が走っていないことの方が多いので不明の『-』がつ くのが圧倒的。
リモートユーザネーム これはドキュメントがパスワード保護になっている場合に ユーザネームが記載される。
アクセス時刻 多くのログの形式もこれに準じる。
リクエスト 正確にはリクエストヘッダの一行目の内容である。 これについては RFC か、ソケットクッキングブックの第一巻を参考にされ たい。
ステータス 200 だの 403 だのといったステータスコードが入る。
転送バイト数 ただし、ヘッダー部のサイズは含まない。

さらに詳細な記録をとることも出来るが、とりあえず正規表現の話に移ることに する。 ちなみに、ログファイルの場所だが私のパソコンで動作している Vine Linux 1.1 の場合、デフォルトは /var/log/httpd の下にある。Vine Linux の場合 一般ユーザがログの検査をすることが出来ない。

[root@vine httpd]# ls -alF
total 208
drwxr-xr-x   2 root     root         1024 Feb  6 04:02 ./
drwxr-xr-x   5 root     root         1024 Feb 22 00:55 ../
-rw-------   1 root     root         5776 Feb 22 23:28 access_log
-rw-------   1 root     root         3129 Feb  5 18:54 access_log.1
-rw-------   1 root     root         2753 Jan 31 03:24 access_log.2
-rw-------   1 root     root       102687 Jan 22 21:47 access_log.3
-rw-------   1 root     root        12969 Feb 22 20:14 error_log
-rw-------   1 root     root         5749 Feb  6 04:02 error_log.1
-rw-------   1 root     root         7006 Jan 31 04:02 error_log.2
-rw-------   1 root     root        64339 Jan 23 04:02 error_log.3

一般にログファイルは /var の下に置くのが習慣である。/var というのは 時々刻々変化するファイルを納めるための場所という意味あいがあるらしい。 他のログファイルも /var/log の下にある。基本的に管理者がどう思うかで変わる 性質のものなので何とも言えないが一応の目安として覚えておいて損はない。

各フィールドに分解する

最初に各フィールドを切り出すことを考える。 こんなことをして何が嬉しいのだろうかと思うかも知れないが、 ファイルごとのアクセスランキングを作る場合にはこれは必要になる。 生ログを整理すればそれだけで自分のサイトのコンテンツの内容の アップにつながり、興味のある人にとってはアクセスカウントを向上させる のにも役立つ。要するに、これは視聴率調査だ。もっとも、私に関する限り 自分のサイトは閑古鳥が鳴くぐらいの方が好きだが…。

正規表現を避けるとしたら、最初に次のような方法を思い付くだろう。

#!/usr/bin/perl

while( <> )
{
  ($rhost, $rlog, $ruser, $time1, $time2, $method, $doc, $ver, $stat, $byte)
    = split;

  $time = "$time1 $time2";

  print <<"records";
HOST: $rhost
RLOG: $rlog
RUSER: $ruser
TIME: $time
METHOD: $method
DOC: $doc
HTTP: $ver
STATUS: $stat
BYTES: $byte
-------------------------
records
}

問題は単純に split では時刻のフィールドとリクエストのフィールドが空白を 含むために面倒になると言うことである。もちろん、リクエストのフィールドに 関してはメソッドとドキュメントなどを切り離してくれるので便利と言えば 便利ではある。しかし、もしも、httpdUser-Agent を記録していたらちょっ と事態は悲惨である。 ただし、実用上はこれで全然問題がない。 しかし、最初にも書いたように実用上の問題だけならそもそもこんなスクリプト を書くこと自体必要ないのだ。analog というログ解析ソフトを使えば良いだけである。

そこで正規表現を使って分解することを考えてみる。時刻とリクエストヘッダの 一行目の部分以外には空白は現れないので、次のようにすれば良いだけだ。

#!/usr/bin/perl

while( <> )
{
  chop;

  if( /([^\s]+) ([^\s]+) ([^\s]+) \[([^\]]+)\] \"([^\"]+)\" ([^\s]+) ([^\s]+)/ ){
    ($rhost, $rlog, $ruser, $time, $req, $stat, $byte)
    = ($1, $2, $3, $4, $5, $6, $7);

    print <<"records";
HOST: $rhost
RLOG: $rlog
RUSER: $ruser
TIME: $time
REQ: $req
STATUS: $stat
BYTES: $byte
-------------------------
records
  }
}

出力の例は次の通りである。

-------------------------
HOST: 207.0.229.23
RLOG: -
RUSER: -
TIME: 08/Jan/2000:06:49:01 +0900
REQ: GET / HTTP/1.0
STATUS: 403
BYTES: 198
-------------------------
HOST: 207.0.229.23
RLOG: -
RUSER: -
TIME: 08/Jan/2000:06:49:17 +0900
REQ: GET /cgi-bin/ HTTP/1.0
STATUS: 403
BYTES: 206
-------------------------

前の場合には余計なゴミが出力についていたが正規表現を使って取り出せば リクエストの部分も一気に取り出せる。

では使っている正規表現を説明する。最初のホスト名、ログイン名、ユーザ名 だが、これらは空白を含まないので 『空白文字以外の文字の一回以上の繰り返し』で、表現できる。ゆえに、

([^\s]+)

という表現を用いた。 括弧はマッチした部分を取り出すためである。 さて、ところが、perl の場合 \S で空白文字以外の文字を表すので実際には

(\S+)

で十分である。さらに、『直前の表現の一回以上の繰り返し』に プラス記号のクリーネ閉包演算子を用いたが、アスタリスクの 『直前の表現のゼロ回以上の繰り返し』をあらわすクリーネ閉包演算子を使うの ならば

(\S\S*)

となる。grep の中にはプラス記号のクリーネ閉包演算子を認識しないものもあ るのでその場合には上のような表現をとるより他にない。大部分の人が Linux や FreeBSD を使っていて、Linux も FreeBSD も GNU の grep を使っているか ら当り前のようにプラス記号のクリーネ閉包演算子が使えるものと思っているか も知れないが、一昔前の UNIX の標準コマンドの grep だとベンダによっては認 識しないものもあった。 これはあまりにも深刻な問題なので、別にセクションを設けて説明する。 もっとも、perl スクリプトで正規表現を使う人はあまり気にする必要はない。

次に時刻のフィールドが来る。頭が痛いのはこのフィールドは空白文字を含むと 言うところにある。ただし、このフィールドはブラケットで括られており、 使われている文字は数字、アルファベット、コロン、スラッシュ、空白だけであ る。つまりブラケットの中にはブラケット以外の文字が入っている。 それを表すのが

\[([^\]]+)\]

である。ブラケット自体は正規表現のメタキャラクタなので、バックスラッシュ でエスケープして『文字通りに』認識させる必要がある。そして、

[^\]]+

] 以外の文字一文字以上を表す。次のリクエストヘッダの 1 行目も同様で、 ブラケットがダブルクォートになっただけである。だから同じように

\"([^\"]+)\"

となっている。上の場合でもそうだが、マッチした部分を取り出すための括弧の 位置を工夫すると一気にブラケットやらダブルクォートやらのゴミを取り除ける。 残りのステータスと転送バイト数は空白文字以外の連続と言うことで

([^\s]+)

で処理している。

余計なバックスラッシュ

基本的にメタキャラクタにバックスラッシュをつけるとメタキャラクタの意味が なくなり『文字通り』解釈がされることになる。 問題はどの文字にバックスラッシュが必要かと言うことである。 しかし、perl の正規表現で嬉しいのはメタキャラクタでない特殊文字に バックスラッシュをつけても特別な意味を持たないということころにある。 これは『何にバックスラッシュをつけないといけないか?』ということを覚えな くてもすむと言うことである。だから、実用的には怪しそうなものにはバックスラッシュを つけてしまえば動くスクリプトは書けてしまうのである。 だから私も普段は実にルーズにバックスラッシュをつけている。 ただし、このテキストは趣味を追求するテキストなので 『はぶける場合には省いてしまう』というところまで考えてみたい。 実は、前に例としてあげたスクリプトにはバックスラッシュをつけなくても良い 文字にもバックスラッシュをつけている。 無駄なバックスラッシュを省くと正規表現の部分は次のようになる。

/([^\s]+) ([^\s]+) ([^\s]+) \[([^]]+)\] "([^"]+)" ([^\s]+) ([^\s]+)/

変更のある点は

\[([^]]+)\] "([^"]+)"

の箇所である。まずダブルクォートは正規表現のメタキャラクタでないのは 明らかなので一切バックスラッシュをつける必要がない。ここは議論の余地はな いだろう。 ここで問題になるのは最初の文字クラス指定の中である。

[^]]

カレット(つまり山印記号)は文字クラスの補集合をとる。文字クラスを指定する 以上そのなかが空ということは考えられないので最低でも一文字はないといけな い。そう考えれば、文字通りの ] を表すためにバックスラッシュを使っても使 わなくても選択の余地はないはずである。だから、カレットの直後の ] は文字 クラスを閉じるためのメタキャラクタではなくて文字通りの ] を表していると 解釈されるのである。

さらに次のスクリプトを試してみてもらいたい。

#!/usr/bin/perl

while( <> ){
  print if /^[]]+$/;
}

データは次のようなものが良いだろう。

abcd]aaaa
bbbb[ddddd
[[[[[[[
]]]]]]]]]]]
aaaa]]]]]]]]]]]bbbb
aaa]]]bbb]]]]ccc]]]]

なお上のスクリプトで正規表現の最初にカレットが最後にドルマークが ついているが、これは位置を指定する正規表現である。 ことに、この状況で使われるカレットは 文字クラスのブラケットのなかで使われるカレットとは意味が異なる。 位置を指定するためのカレットは行また文字列の先頭を表す。 だから、

^abcd

は行頭にある abcd にのみマッチする。 同様に位置を示すためのドルマークは行または文字列の末尾を表しており

abcd$

というのは行末にある abcd にのみマッチする。従って、

^abcd$

というのは純粋に abcd のみ含む行または文字列を表すことになる。

脱線したが上のスクリプトに上のデータファイルを読ませたら結果はどうなるか? もしも、上の解釈の通りなら正規表現

^[]]+$

] の一回以上の繰り返しだけからなる行を表す。本当にその行だけが 表示されただろうか?あっていなければ結果は全然違うことになる。 あるいはエラーが出るかも知れない。逆に

#!/usr/bin/perl

while( <> ){
  print if /^[]+$/;
}

というスクリプトならどうだろうか? 実行してみればすぐにわかる。 結果は

[ageha@vine re]$ perl t.pl t
/^[]+$/: unmatched [] in regexp at t.pl line 2.
[ageha@vine re]$ 

となる。つまり、[] で空な文字クラスを表そうにも出来ないようになっている のである。

文字クラスの中の特殊文字

まだ『余計なバックスラッシュ』の話はつづくが、話を続ける前に文字クラスに ついてもうちょっと考えてみる。 ちょっとした謎々だが、次のようなデータファイルがある。これは 前のテスト用のスクリプトのデータファイルをちょっといじったものである。

abab]aaaa
aaa[bbbb
[[[[[[[
aaaaa[[[[[[[bbbbbbb
aaaa[][][][][]bbbbbbbb
]]]]]]]]]]]
aaaa]]]]]]]]]]]bbbb

では謎々の第一問である。次のスクリプトは何を表示するか?

#!/usr/bin/perl

while( <> ){
  print if /^[]ab]+$/;
}

正規表現

^[]ab]+$

は正しい表現である。これは ab] のいずれかの文字の一回以上の繰り 返しのみからなる行にマッチする。よって上の場合には

[ageha@vine re]$ perl t2.pl t
abab]aaaa
]]]]]]]]]]]
aaaa]]]]]]]]]]]bbbb
[ageha@vine re]$ 

となる。では第二問。

#!/usr/bin/perl

while( <> ){
  print if /^[][ab]+$/;
}

これは ab[] のいずれかからなる一回以上の繰り返しにマッチする。 だから、全部にマッチするはずである。では、次はどうだろう?

#!/usr/bin/perl

while( <> ){
  print if /^[a][b]+$/;
}

これは最初が a で 残りが b の一回以上の繰り返しにマッチする。例えば、 abbbbbbbbbbbbb なんていうのはマッチするパターンである。 書き換えれば次と同じである。

^ab+$

それでは最後に

#!/usr/bin/perl

while( <> ){
  print if /^[[]ab]+$/;
}

である。これは何にマッチするだろうか? なんとこれは

[ab]
[ab]]]]]]]]]]]]]]]

にマッチする。つまり、正規表現

^[[]ab]+$

はノーマルな書き方をすれば

^\[ab\]+$

と同じになるのである。これは最初に文字クラスの指定

[[]

があったとみなし、その後の対応が失われた ] は必然的にメタキャラクタの解 釈が出来ないので文字通りに解釈している。 実際問題として正規表現

^]+$

はエラーを起こさず、例えば

]]]]]]]]]]]

などにマッチする。対応するブラケットがないのでもはやメタキャラクタとして 解釈してもナンセンスで通常の文字通りの解釈をしていているわけである。 一方で

^[+$

はエラーである。文字クラスを閉じるための ] がないとして認識される。

この手の悪乗り遊戯をもっと続ける。今度のデータファイルは

---------------
-a--ab-----bbbbbb
abbbbbbababababa
a^^^^^^^bbbbb^^^^^
aaaaaa^^^^^a^^^^^
^^^^^^^^^^^^^^
^^^^^^^^a^^^^^^aaa

である。まず、正規表現

^[a-b]+$

は何にマッチするだろうか? これは簡単でハイフンは範囲指定のためだとみな されて

abbbbbbababababa

にのみマッチする。もしも、ハイフンに文字通りの意味を持たせたければ

^[a\-b]+$

とバックスラッシュを使えば良い。 では

^[-ab]+$

はどうか? 今度は先頭にハイフンが来ているので範囲指定とはみなされず 文字通りの解釈がされる。むろん、

^[ab-]+$

も同様である。 それでは次はどうか?

^[^ab]+$

今度はマッチするのは

---------------
^^^^^^^^^^^^^^

になる。これは a ないしは b 以外の文字一文字以上の繰り返しというパターン になるからである。ハイフンのときと、同じように

^[a^b]+$

で考えてみよう。予想通り、カレットの意味は失われて文字通りの解釈がされる。 つまりマッチするのは

abbbbbbababababa
a^^^^^^^bbbbb^^^^^
aaaaaa^^^^^a^^^^^
^^^^^^^^^^^^^^
^^^^^^^^a^^^^^^aaa

だけである。

^[ab^]+$

これも上と同じ結果になる。やはりカレットの意味が失われる。 それではカレットだけからなる行にマッチさせるにはどうしたら良いだろうか? 誰しも考える

^[^]+$

これはエラーである。一番良いのはバックスラッシュを用いて

^[\^]+$

とすることである。ないしは上のデータファイルの場合なら、 ありえもしないブラケットを使って

^[]^]+$

とすることも出来る。ただし、上の場合には

^^^^^^^^^^]]]]]]]]]

なんていうのもマッチしてしまう。 逆にカレットを含まない場合だと

^[^^]+$

となる。つまり、カレットは文字クラスの先頭にあるときだけにメタキャラクタ の意味を持つのである。

以上のところまで飲み込めたら ハイフンとカレットを混ぜてもあまり難しくないことがわかるだろう。

^[^a-b]+$
^[^-ab]+$

なんていうのはそれぞれ何に対応するのかはここまでくれば容易に言える。

かなり仕様のぎりぎりの線で遊んでみたが個人的には日常のスクリプトを 書く際にはあまりこのようなことまでやるのはすすめない。 ただし、バックスラッシュがうるさい場合 (キャメルブックでは『バックスラッシュ中毒』なんていう表現が使われている。) にはハイフンやカレットの位置を移動することで 逆にスクリプトを見やすくすることが出来る。

余計なバックスラッシュ(続き)

話がアクセスログからだいぶ遠ざかってしまった。しかし、ついでなので ここで Force 264 で配布している『荒し対策掲示板』の Ver. 3.1A のスクリプ トを眺めてみよう。これは俗にカール掲示板ともよばれ人気の高い掲示板である。

この掲示板を使った人ならわかるかと思うが、投稿の際に投稿内容に URI が含 まれている際にそれをリンクするように出来る機能が備わっている。 つまり、単純に http://localhost/cgi-bin/che.cgi と書き込んだものを投稿す ると html のレベルで

<a href="http://localhost/cgi-bin/che.cgi">http://localhost/cgi-bin/che.cgi</a>

などと変換してくれる。これはとても便利な機能で、掲示板が資源的に価値のあ るリンク集に早変わりしてくれる。URI というのは貴重な資源なのだ。

さて、この機能を実現しているのは次のコードである。

    if ($link eq "1") {
      $value =~ s/(https?|ftp|news):\/\/([\w|\!\#\$\%\&\'\(\)\=\-\^\`\\\|\@\~\[\{\]\}\;\+\:\*\,\.\?\/]+)/<a href=\"$1:\/\/$2\">$1:\/\/$2<\/a>/g;
    }

ごらんの通りバックスラッシュの嵐が吹き荒れている。 とにかくこれを何とかしたい。 まず、URI だと必然的にスラッシュが多くなる。これは

s/   /   /g;

なんていうコードを書く関係上起こるのである。URI に限らずパス名の処理なん かでもスラッシュが出て来る。 perl の場合には s の直後の文字を変えることができる。 例えば、aaabbb に置き換えるのには

s#aaa#bbb#g;

でもよい。だから、上の部分はまず

$value =~ s#(https?|ftp|news)://([\w|\!\#\$\%\&\'\(\)\=\-\^\`\\\|\@\~\[\{\]\}\;\+\:\*\,\.\?/]+)#<a href=\"$1://$2\">$1://$2</a>#g;

と書き換えられる。 とにかく URI や UNIX のパスなどスラッシュが出る局面は多いので、 これは覚えておいて絶対に損はない。

次に良く見ると文字クラスの指定の中の記号類にバックスラッシュが使われてい ることがわかる。正規表現のメタキャラクタとは関係のないものにもバックスラッ シュが施されている。これは、コードを書いた人のポリシーの問題である。 だから、これをだけを以ってどうこう言えることではない。実際に 何にバックスラッシュをつけて何につけないかを考えるのは煩わしいことである。 つけることによって誤動作が減るならそれは見識と言うものである。 だからこれはコーディングのうまい下手とは無関係な問題であると言える。

とにかく、コロンやセミコロン、カンマ、イコールなどはどう考えても 正規表現のメタキャラクタではないのでそれらのバックスラッシュはとっても 問題ない。その方針で書き換えると次のようになる。

  s#(https?|ftp|news)://([\w|!\#\$%&'\(\)=\-\^`\\\|@~\[\{\]\};\+:\*,\.\?/]+)#<a href="$1://$2">$1://$2</a>#g;

メタキャラクタのうちでも文字クラスの中ではメタキャラクタとしての意味を 失うものがある。アスタリスク、プラス記号、ピリオド、疑問符、ブレース、 ドルマーク、かっこなどの記号はそうである。 これらは文字クラスの中ではバックスラッシュをつけるには及ばない。 これを考えて書き直すと、次のようになる。 ただし、ドルマークは変数展開される恐れがあるので続く文字 によってはバックスラッシュで保護した方が良い場合もある。

  s#(https?|ftp|news)://([\w|!\#\$%&'()=\-\^`\\@~\[{\]};+:*,.?/]+)#<a href="$1://$2">$1://$2</a>#g;

文字クラスの中でブラケットをそのまま使うには文字クラスとしての解釈が不要 な位置に持っていけば良いし、カレットやハイフンも同様に処理できる。 そこで、次のようにする。

  s#(https?|ftp|news)://([][\w|!\#\$%&'()=^`\\@~{};+:*,.?/-]+)#<a href="$1://$2">$1://$2</a>#g;

結局バックスラッシュが本当に必要なのは \w\#\$ とバックスラッシュ自身だ けである。なお \w というのは英数字とアンダースコアをあわせた文字クラスに 一致する。つまり、

[a-zA-Z0-9_]

と同じである。さて、これを次のようなスクリプトにした。

#!/usr/bin/perl

while( <> ){
  s#(https?|ftp|news)://([][\w|!\#\$%&'()=^`\\@~{};+:*,.?/-]+)#<a href="$1://$2">$1://$2</a>#g;
  print;
}

また、次のようなデータファイルを用意してみた。

http://localhost/cgi-bin/che.cgi
http://bahnhof.virtualave.net/cgi-bin/che.cgi
http://www.ecc.u-tokyo.ac.jp/cgi-bin/hoge.cgi?name=foo&email=root@localhost
http://[][][][]{}{}{}{}/@@@@@?????$$$$$/,,,,..../*****.cgi

これで実験してみると

<a href="http://localhost/cgi-bin/che.cgi">http://localhost/cgi-bin/che.cgi</a><br>
<a href="http://bahnhof.virtualave.net/cgi-bin/che.cgi">http://bahnhof.virtualave.net/cgi-bin/che.cgi</a><br>
<a href="http://www.ecc.u-tokyo.ac.jp/cgi-bin/hoge.cgi?name=foo&email=root@localhost">http://www.ecc.u-tokyo.ac.jp/cgi-bin/hoge.cgi?name=foo&email=root@localhost</a><br>
<a href="http://[][][][]{}{}{}{}/@@@@@?????$$$$$/,,,,..../*****.cgi">http://[][][][]{}{}{}{}/@@@@@?????$$$$$/,,,,..../*****.cgi</a><br>

となる。 さらにアスキーコード表を見ると文字コード 0x21 から 0x7e までが記号類とア ルファベットそれから数字なので次のようにするともっと簡単になる。

#!/usr/bin/perl

while( <> ){
  s#(https?|ftp|news)://([\x21-\x7e]+)#<a href="$1://$2">$1://$2</a>#g;
  print;
}

これは文字コードを直接に指定して文字クラスを定義している。 こちらははるかに短くなったが、ダブルクォート、不等号、アンダースコアなど も扱ってしまう。なお、オリジナルのカール掲示板は \w を含んでいるので この中にアンダースコアは含まれている。 ただ、カール掲示板のコードにせよ、上のコードにせよ、

http://[][][][]{}{}{}{}/

などというあり得もしない URI も扱ってしまうところが難である。 これはあとで何とかしてみたい。

話は変わるが、上の最後のスクリプトの中の

(https?|ftp|news)

という部分は https? または ftp または news にマッチする。さらに、 https?http または https にマッチするので、上のスクリプトを わかりやすいように書き換えると

#!/usr/bin/perl

while( <> ){
  s#http://([\x21-\x7e]+)#<a href="http://$1">http://$1</a>#g;
  s#https://([\x21-\x7e]+)#<a href="https://$1">https://$1</a>#g;
  s#ftp://([\x21-\x7e]+)#<a href="ftp://$1">ftp://$1</a>#g;
  s#news://([\x21-\x7e]+)#<a href="news://$1">news://$1</a>#g;
  print;
}

となる。実用的にはどちらでもパフォーマンスに関係ないと言い切って良い。 むしろ、最後の列挙型の方がスピードとメモリの消費では優れているはずである。 つまり、凝った正規表現が必ずしもパフォーマンス上の良い結果を 出すとは限らないのである。むしろ悪い結果を出す方が多い。

範囲指定

どんどんと当初のアクセスログの検査の話からずれていくが、文字クラスの 範囲指定の際の注意事項を考えよう。例えば、アルファベット小文字 の集合を表すのに

[a-z]

なんて書くこともあるが、これを仮に

[z-a]

としたらどうなるだろうか? 答えは簡単で単純にエラーが出るだけである。

[ageha@vine re]$ perl t.pl t
/^[z-a]+$/: invalid [] range in regexp at t.pl line 2.
[ageha@vine re]$ 

つまり範囲指定は ascii コード順になっていないといけないと言うことである。 あと、数字を表すのには通常

[0-9]

とする。間違ってもこれを

[1-0]

などとやってはいけない。数字の中で一番 ascii コードが若いのは 0 である。 ただし、次は正確に数字だけにマッチする

[1-90]

これは決して 1 から 90 までにマッチするわけではない。

クリーネ閉包演算子と最長一致の原則

さて、アクセスログの話に戻ろう。前のスクリプトを見て時刻の部分を 取り出すのには

\[(.*)\]

でも良いのではないかと思う人もいるだろう。スクリプトにすると次のような感 じになる。

#!/usr/bin/perl

while( <> )
{
  s/.*\[(.*)\].*/$1/;
  print;
}

実際問題としてこれは『だいたい』うまく動く。時刻の部分だけを切り出してくれる。 しかし、次のアクセスログはうまく処理できない。

localhost - - [23/Feb/2000:04:41:13 +0900] "GET /tripod/archive/aaa].html HTTP/1.0" 200 858

結果は次のようになる。

[ageha@vine re]$ perl l3.pl access_log
23/Feb/2000:04:41:13 +0900] "GET /tripod/archive/aaa
[ageha@vine re]$ 

『そんな馬鹿なことが』と思うかも知れないが、UNIX ではスラッシュ以外は全 部ファイル名の文字として使用できる。 次はどうだろうか? 次のようにすればちゃんとマッチするかも知れない。

#!/usr/bin/perl

while( <> )
{
  s/.* \[(.*)\] .*/$1/;
  print;
}

時刻フィールドの前後には空白が入っているからである。しかし、こんなログも 事実存在する。

localhost - - [23/Feb/2000:04:41:13 +0900] "GET /tripod/archive/aaa].html HTTP/1.0" 200 858
localhost - - [23/Feb/2000:04:55:03 +0900] "GET /tripod/archive/bbb] .html HTTP/1.0" 404 213
localhost - - [23/Feb/2000:04:55:19 +0900] "GET /tripod/archive/bbb]%20.html HTTP/1.0" 200 858

このログが嘘だと思うのならログの最後の行を注目して欲しい。最初は bbb] .html は 404 を返しているが、次の bbb]%20.html は 200 である。 つまり、ファイル名に空白を含んだファイルも事実作成できるし、空白を %20 などのような表記を使えば URI で指定できるのである。だから、原理的には このようなログも存在するし、事実存在している。そして、このようなログを 処理させると

23/Feb/2000:04:41:13 +0900
23/Feb/2000:04:55:03 +0900] "GET /tripod/archive/bbb
23/Feb/2000:04:55:19 +0900

などというへまをしてくれる。これは正規表現の性質で条件を満たす 最長のパターンにマッチするというものである。 だからこのような特殊な場合にしか問題は起こらないが問題解決の解としては 上のでは不適当だと言うことがわかるだろう。

一方最初のように各フィールドを取り出すスクリプトだと依然としてうまく動く

#!/usr/bin/perl

while( <> )
{
  chop;

  if( /([^\s]+) ([^\s]+) ([^\s]+) \[(.*)\] "(.*)" ([^\s]+) ([^\s]+)/ ){
    ($rhost, $rlog, $ruser, $time, $req, $stat, $byte)
    = ($1, $2, $3, $4, $5, $6, $7);

    print <<"records";
HOST: $rhost
RLOG: $rlog
RUSER: $ruser
TIME: $time
REQ: $req
STATUS: $stat
BYTES: $byte
-------------------------
records
  }
}

これはステータスと転送バイト数のパターンマッチがあるためである。だから、 時刻だけを取り出すにしてもマッチするパターンは厳密にした方がゴミを拾わず にすむのでほかのフィールドの記述もしっかり書いた方が良いのである。 この場合には正規表現の部分を

/([^\s]+) ([^\s]+) ([^\s]+) \[(.*)\] "(.*)" (.*) (.*)/

としてもまだちゃんと動く。

アクセス時刻を取り出す

最初から正攻法でアクセスログを取り出すことも出来る。 ログの時刻のフィールドを見れば、時刻フィールドを表す部分はもっと緻密に

\[\d+/\w+/\d+:\d+:\d+:\d+ \+\d+\]

と表すことが出来る。これで直接抜き出せば良い。なお、上の正規表現では スラッシュをバックスラッシュでエスケープしなかった。これは次のような 使い方をすれば良いからである。

#!/usr/bin/perl

while( <> )
{
  s#.*\[(\d+/\w+/\d+:\d+:\d+:\d+ \+\d+)\].*#$1#;
  print;
}

これなら正確にアクセス時刻のみ取り出せる。 しかし、これで本当に正確だろうか? apache のマニュアルには次のように書いてある。

date = [day/month/year:hour:minute:second zone]
day = 2*digit
month = 3*letter
year = 4*digit
hour = 2*digit
minute = 2*digit
second = 2*digit
zone = (`+' | `-') 4*digit

例えば、最初の正規表現のままだと次のようなばかげたパターンも拾ってしまう。

[1000000/hogehoge/4000000000:13:76:98 +55555555555]

したがって正しくは次のようでなければならない。

\[\d\d/\w\w\w/\d\d\d\d:\d\d:\d\d:\d\d [+-]\d\d\d\d\]

これをスクリプトの形にすると

#!/usr/bin/perl

while( <> )
{
  if( s#.*\[(\d\d/\w\w\w/\d\d\d\d:\d\d:\d\d:\d\d [+-]\d\d\d\d)\].*#$1# ){
     print;
   }
}

となり真面目な時刻フォーマットのもののみ相手にするようになる。 逆に、真面目なフォーマットかそうでないかを判断することでログファイルが 改竄されたかどうかを判断できる。侵入者が常に改竄でへまをするということも 考えられないが一応の手がかりにはなり得る。 少なくとも真面目でないフォーマットを含んだログファイルは誰かの改竄による もので侵入の可能性を疑ってみる根拠にはなり得る。

それはともかくとして、話を前にすすめることにする。 上の場合だと \d だとかがやたらに目について、ちょっとうっとうしい。 そのために反復回数の指定が出来るようになっている。それを 使って書き換えると

\[\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\]

となる。少しだけ短くなった。パフォーマンスはどの程度かわからないが 回数がちょうどの指定なのでそんなに悪くはなっていないはずである。 上の場合

\d{4}

\d\d\d\d

と等価である。 さらに次のようにしてももっと正規表現を短くできる。 なお、はっきり言うと次の正規表現よりも直前の正規表現の方が 優れているように思われる。データのフォーマットがわかりやすいからだ。

\[\d{2}/\w{3}/\d{4}(:\d{2}){3} [+-]\d{4}\]

時刻フィールドを取り出すためのスクリプトは次のようになる。

#!/usr/bin/perl

while( <> )
{
  if( s#.*\[(\d{2}/\w{3}/\d{4}(:\d{2}){3} [+-]\d{4})\].*#$1# ){
     print;
   }
}

ここで注意したいのは括弧が入れ子になっている場合には外側の括弧から 番号が振られていくので時刻全体を取り出すには相変わらず $1 でいいというこ とだ。これはあとでより詳しく検討してみる。

さて、前に UNIX ではスラッシュ以外の任意の文字がファイル名に使えると言っ た。上の場合にはスラッシュが含まれているので時刻フィールドと全く同じ ファイル名はあり得ない。しかし、ちょっとした実験をしてみよう。 次のようなファイルを人工的に作ってみた。

localhost - - [23/Feb/2000:04:56:24 +0900] "GET /tripod/archive/bbb] .html HTTP/1.0" 404 213
localhost - - [23/Feb/2000:05:23:43 +0900] "GET /tripod/archive/[23/Feb/2000:04:56:24 +0900].html HTTP/1.0" 404 213

このログファイルを相手に上のスクリプトをかけてみると次のようになる。

23/Feb/2000:04:56:24 +0900
23/Feb/2000:04:56:24 +0900

つまり、後ろの方のファイル名にある部分が抜き出されているわけで、上の通り では失敗している。実際にはファイル名に [23/Feb/2000:04:56:24 +0900].html などというものが現れる気遣いはそんなにいらないが (しかし、[23 というディレクトリのしたに Feb というディレクトリを作り、 さらにその下に 2000:04:56:24 +0900].html というファイルを作成すればその 心配は現実のものになるし、実際に簡単に作成できる)、 もしも User-Agent も記録をとっ ているのならこれは可能性としてあり得る。 解決方法の一つとしては

#!/usr/bin/perl

while( <> )
{
  if( s#[^]]*\[(\d{2}/\w{3}/\d{4}(:\d{2}){3} [+-]\d{4})\].*#$1# ){
     print;
   }
}

のようにするとよい。RFC を見ればわかるがホスト名にはブラケットは使えない。 さらに、ログイン名もユーザ名にも使えない。

同様にステータスと転送バイト数ももっと緻密に表現できる。 ステータスは 3 桁の数字なので大雑把には

\d{3}

でよい。また転送バイト数も

\d+

で表現できる。 いい加減ここまで読み進んだ人なら、 07654 なんて記録は残らないので、もっと厳密には

[1-9]\d*

でないといけないと思うだろう? しかし、実はこれでも不正確である。サーバが 304 を返した場合には ログは次のようになっている。

localhost - - [08/Jan/2000:06:55:23 +0900] "GET /icons/text.gif HTTP/1.0" 304 -
localhost - - [08/Jan/2000:06:55:23 +0900] "GET /icons/blank.gif HTTP/1.0" 304 -
localhost - - [08/Jan/2000:06:55:23 +0900] "GET /icons/back.gif HTTP/1.0" 304 -

だから転送バイト数のフィールドは、より正確には

[1-9-]\d*

でないといけない。 するといままでのところをまとめるとログファイルのフォーマットは

\S+ \S+ \S+ \[\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\] ".*" \d{3} [1-9-]\d*

となることがわかるだろう。なお、上の正規表現の

\w{3}

という部分だが、もっと正確にするのなら

[A-Z][a-z]{2}

の方が良い。もちろん、

(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)

のほうがより正確である。ただし、時刻はサーバが返すものなので サーバプログラム自体が改竄されていなければ最初のままでも良いかも知れない。

その他のフィールド

ステータスコード、リモートホスト、ログイン名、ユーザ名、 リクエストなどがあるが、 扱い出すと到底収まりがつきそうにないので別の機会に譲ることにする。 IP アドレス一つとっても

\d+\.\d+\.\d+\.\d+

で良いのかといった問題がある。このままだと

11111111.2222222222.33.444444

なんてふざけたものもマッチしてしまう。パケットから読み出された IP アドレ スならこういったふざけた値が入る心配はない。 おそらく最初の案で問題ないだろう。 ただし、実際あり得る IP アドレスが入っていたとしてもそのホストから送られたパケットかどうかは 怪しい場合もある。たまにクラッカーがソースアドレスを偽造したパケットを 送ってポートスキャンをする場合もある。 深刻な問題が生じるのはリクエストヘッダの HTTP_X_FORWARDED_FOR などから 読み出した IP アドレスである。この場合にはありていに言えば何でもありであ る。上のままでは不安なら

\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}

と回数指定すれば良い。この

\d{1,3}

というのは

(\d|\d\d|\d\d\d)

と等価である。リクエストにしても

".*"

でとりあえずすませているが、これでは如何にもたよりない。

".* HTTP/\d\.\d"

ぐらいにした方がまだいいかもしれない。当然、リクエストメソッドの 問題も生じる。

"(GET|POST|HEAD) .* HTTP/\d\.\d"

ではまずいかも知れない。HTTP/1.1 だと TRACE などがあるからだ。 ただここはクライアントが好きなものを送れるので、クライアントが私家版の ユーザエージェントを使った場合には、そのログを見逃す恐れがある。 次のログは perl で書いた特別なクライアントを使って得たもので、 現実に存在している。

localhost - - [24/Feb/2000:03:01:27 +0900] "GET /tripod/archive/re1.html HTTP/1.0" 200 63708
localhost - - [24/Feb/2000:03:32:03 +0900] "HOGEHOGE http://localhost/tripod/archive/ HTTP/7.1" 501 236

あまりにチェックを厳しくして、こう言ったログを見逃すと言うのも 逆にまずいように思われる。 やはり、多少甘くても

".*"

で我慢するよりほかになさそうだ。 ちなみに上の場合でも転送バイト数は 236 byte ある。これはヘッダを 除いたサイズだから何かが返って来ている。で、返って来たのは次のような ものだった。

HTTP/1.1 501 Method Not Implemented
Date: Wed, 23 Feb 2000 18:32:03 GMT
Server: Apache/1.3.3 (Unix)  (Red Hat/Linux)
Allow: GET, HEAD, OPTIONS, TRACE
Connection: close
Transfer-Encoding: chunked
Content-Type: text/html

e0 
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<HTML><HEAD>
<TITLE>501 Method Not Implemented</TITLE>
</HEAD><BODY>
<H1>Method Not Implemented</H1>
HOGEHOGE to /tripod/archive/index.html not supported.<P>
</BODY></HTML>

0

関係のない話だが、上の返って来たものを 良く見るとエラーを通知する html の前後に e0 だとか 0 だとかいったゴミが ついている。あるいはこれはこのバージョンの apache にある潜在的な バグの存在を意味しているのかも知れない。

Referer と User-Agent

Apache をデフォルトでインストールすると以上説明したような形式の ログを生成することが多い。しかし、中には管理者がカスタマイズしてログの フォーマットを変えていたり、FreeBSD の packages に入っている apache の ように最初からより多くの情報を残すようになっているものもある。 もしも、あなたのパソコンに apache が動いているのなら 設定ファイルの httpd.conf を見て欲しい。次のような部分があるはずだ。

# The following directives define some format nicknames for use with
# a CustomLog directive (see below).

LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %b" common
LogFormat "%{Referer}i -> %U" referer
LogFormat "%{User-agent}i" agent

# The location of the access logfile (Common Logfile Format).
# If this does not start with /, ServerRoot is prepended to it.

CustomLog logs/access_log common

この部分でログファイルのフォーマットを決めている。 今まで説明して来たログファイルのフォーマットは common といわれるものであ る。もしも、RefererUser-Agent も記録にとりたいのなら combined という形式を選択すれば良い。 すなわち設定ファイルを

#CustomLog logs/access_log common
CustomLog logs/access_log combined

などとすればいい。この形式のログファイルは次の通りになる。

localhost - - [25/Feb/2000:01:34:19 +0900] "GET /tripod/perl/ HTTP/1.0" 200 2045 "-" "Mozilla/4.08 [Vine-ja] (X11; I; Linux 2.0.36 i586)"
localhost - - [25/Feb/2000:01:34:22 +0900] "GET /tripod/perl/perlipc.html HTTP/1.0" 200 66741 "http://vine.my.domain/tripod/perl/" "Mozilla/4.08 [Vine-ja] (X11; I; Linux 2.0.36 i586)"
localhost - - [25/Feb/2000:01:34:48 +0900] "GET /tripod/perl/Socket.html HTTP/1.0" 200 7916 "http://vine.my.domain/tripod/perl/" "Mozilla/4.08 [Vine-ja] (X11; I; Linux 2.0.36 i586)"
localhost - - [25/Feb/2000:01:34:56 +0900] "GET /tripod/perl/IOSocket.html HTTP/1.0" 200 10920 "http://vine.my.domain/tripod/perl/" "Mozilla/4.08 [Vine-ja] (X11; I; Linux 2.0.36 i586)"
localhost - - [25/Feb/2000:01:35:01 +0900] "GET /tripod/perl/perlmisc.html HTTP/1.0" 200 817 "http://vine.my.domain/tripod/perl/" "Mozilla/4.08 [Vine-ja] (X11; I; Linux 2.0.36 i586)"
localhost - - [25/Feb/2000:01:35:02 +0900] "GET /tripod/perl/whoami.html HTTP/1.0" 200 2443 "http://vine.my.domain/tripod/perl/perlmisc.html" "Mozilla/4.08 [Vine-ja] (X11; I; Linux 2.0.36 i586)"

一番最初のレコードを見るとわかるが直接 URI を指定したり、 ブックマークからジャンプしたりすると Referer は該当なしの『-』になる。 この形式のログファイルを表現するための正規表現は

\S+ \S+ \S+ \[\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\] ".*" \d{3} [1-9-]\d* ".*" ".*"

となるだろう。人によっては Referer

"http://.*"

ぐらいの方が良いのではないかと思うかも知れないが、http:// から始まるか否か といった問題を議論する以前に、Referer はユーザエージェントの側で いくらでも変更できるものであることを思い出した方が良い。 私家版のユーザエージェントを使えば Referer に何をいれても良いことになる。 RFC1945 の規定はちょとわかりにくいが、ユーザエージェントが出来るのは Referer を送るか送らないかである。古くなってしまってあり得もしない URI や ミスタイプした URI は送ってもやむなしとされているが、最初から、意図的に URI でないものは送るなと書いてある。さらに、URI であったら送っても 問題ないかというとそれもいけないようだ。 送れるのは実際に訪れたドキュメント等の URI である。 そうは言っても、私家版のユーザエージェントが RFC の規格を守る保証など どこにもない。 ユーザが送って来るもの、 ユーザが干渉し得るデータなどは一切信用してはいけない。 これはセキュリティを考える上での基本である。 そういうことなら規格外の Referer が送られた場合にこれを記録したログを 見逃してもいけない。こういう観点からわざと正規表現のマッチする範囲を 甘くしておいた方がよい。実際に何でも入れられる余地のあるデータは やはり

".*"

となると仮定した方が無難である。 さらに、

"[^"]*"

ではどうだろうかという考えもあるかも知れないが、これもいけない。 URI にダブルクォートを入れるなど簡単に出来る。 次のログはやはり perl で書いた特殊なユーザエージェントによって 得られたもので、Referer

http://hogehoge" .co.jp/

を指定したものである。

localhost - - [25/Feb/2000:02:36:09 +0900] "GET http://localhost/tripod/archive/ HTTP/1.0" 200 858 "http://hogehoge" .co.jp/" "Mozilla/4.0 (compatible; MSIE 4.01; Windows 98)"

この場合ならまだ最長一致の原則があるので、

".*"

を使えば正確に Referer を切り出せる。しかし、そのあとに、やはり ユーザエージェントが勝手に設定できる User-Agent が来ているので 絶望的な状態になる。 試しに、いま、

#!/usr/bin/perl

while( <> )
{
  chop;

  if( m#(\S+) (\S+) (\S+) \[(\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\] "(.*)" (\d{3}) (\d+|-) "(.*)" "(.*)"# ){
    ($rhost, $rlog, $ruser, $time, $req, $stat, $byte, $referer, $agent)
    = ($1, $2, $3, $4, $5, $6, $7, $8, $9);

    print <<"records";
HOST: $rhost
RLOG: $rlog
RUSER: $ruser
TIME: $time
REQ: $req
STATUS: $stat
BYTES: $byte
REFERER: $referer
USER-AGENT: $agent
-------------------------
records
  }
}

というスクリプトでログを走査してみると次のようになる。

HOST: localhost
RLOG: -
RUSER: -
TIME: 25/Feb/2000:02:36:09 +0900
REQ: GET http://localhost/tripod/archive/ HTTP/1.0
STATUS: 200
BYTES: 858
REFERER: http://hogehoge" .co.jp/
USER-AGENT: Mozilla/4.0 (compatible; MSIE 4.01; Windows 98)
-------------------------

最長一致の原則があるためにたまたまこの場合にはうまくいった。 では、Referer

string1" "string2" "string3

User-Agent に

string4" "string5" "string6

とセットして送るとどうなるだろうか? まずログは

localhost - - [25/Feb/2000:03:17:02 +0900] "GET http://localhost/tripod/archive/ HTTP/1.0" 200 858 "string1" "string2" "string3" "string4" "string5" "string6"

となる。さっきのスクリプトで走査すると

HOST: localhost
RLOG: -
RUSER: -
TIME: 25/Feb/2000:03:17:02 +0900
REQ: GET http://localhost/tripod/archive/ HTTP/1.0
STATUS: 200
BYTES: 858
REFERER: string1" "string2" "string3" "string4" "string5
USER-AGENT: string6
-------------------------

となる。完全に切り分けに失敗している。 もはやこうなると記録されてしまったログからは正確にフィールドを 得ることが出来ない。 逆にいうと記録する際には、このようなデータは必要なデータの最後に まわすべきだと言うことがわかるはずだ。もしも、RefererUser-Agent を リモートホストより前に記録してしまったなら、データとして信頼の高い リモートホストも得られなくなってしまう恐れがある。 有無を言わさない信頼の置ける固定したデータからとっていけば 信頼の置けないデータによって、それらのデータが汚染される恐れもなくなる。

ログフォーマットのカスタマイズ

これは正規表現とは関係ないが脱線のついでなので書いておくことにする。

掲示板などを設置している人で結構神経質に HTTP ヘッダを記録している人がいる。 これはサーバのログの情報では足りないからであろう。 他にも、ページごとのデータをとるために 1 ドット四方の透明な 画像を表示させる CGI プログラムを仕込んで統計データをとっている人もいる。 ある意味 CGI プログラムでそのようなデータをとるというのは伝統的な 確立された手法なのでそれを行っているだろうが、自分でサーバを 立てている人間までがそれを真似ることはない。 CGI プログラムが記録できるものはサーバでも記録できる。 当り前と言えば当り前のはなしである。

例えば、よくあるパターンとしてプロクシサーバ経由でアクセスして来た場合に プロクシサーバがクライアントの IP アドレスをリクエストヘッダに 記録していることがある。 この記録を残しておけば、プロクシサーバを 経由されたとしても訪問者の接続時のパソコンの IP アドレスがわかる場合がある。 ある種のプロクシサーバのこのような振舞を利用して実 IP アドレスを 記録しようと試みる掲示板スクリプトも事実存在する。 その一例が、前に紹介したカール掲示板のスクリプトである。 このスクリプトはサーバがセットした環境変数のいくつかを 走査してその手がかりを探そうとする。例えば、 HTTP_SP_HOST, HTTP_CLIENT_IP, HTTP_FROM, HTTP_VIA, HTTP_X_FORWARDED_FOR, HTTP_FORWARDED などを走査する。

いずれ、インターネットも専用線が中心の時代になるだろうし、そうなったら 掲示板スクリプトをサーバに設置するどころか、自分のパソコンに httpd を設置して自宅に web サーバをおいて掲示板に設置する 世の中になるだろう。そういった時代になってまで、CGI のレベルでそのような データをとる必要はない。設定すれば httpd がとってくれる。 例えば、HTTP_X_FORWARDED_FOR のデータを記録したければ httpd.conf のログファイルフォーマットを

LogFormat "%h %l %u %t \"%r\" %>s %b \"%{X-Forwarded-For}i\" \"%{Referer}i\" \"%{User-Agent}i\"" myformat

などとすればよい。そのようにして、実際に記録したものが次のログである。

localhost - - [25/Feb/2000:04:36:40 +0900] "GET /tripod/archive/re1.html HTTP/1.0" 304 - "-" "-" "Mozilla/4.08 [Vine-ja] (X11; I; Linux 2.0.36 i586)"
localhost - - [25/Feb/2000:04:38:31 +0900] "GET http://localhost/tripod/archive/ HTTP/1.0" 200 858 "123.123.234.45" "http://www.yahoo.co.jp/" "Mozilla/4.0 (compatible; MSIE 5.0; Windows 98; DigExt)"

2 番目のデータはやはり特殊なエージェントを使って生成した記録である。 なお X-Forwarded-For の IP アドレスはまったくでたらめである。 それでも、記録自体は実際の値である。 ただし、あまり調子に乗ってログをとっているとマシンに負荷がかかり サーバダウンにつながることもあるので注意した方が良い。 それでも、個人単位での web サーバなら一日にそんなに多くのアクセスが あるとも思えないので、ある程度のマシンなら大丈夫だとは思う。

ログ表示プログラムを作る

いい加減退屈だったかも知れない。 最後に、今までの議論のまとめを兼ねてログを閲覧する CGI プログラムを書いてみることにする。 各フィールドに色々な条件を設定して表示するような CGI プログラムである。 実用上の問題から言えば別にこんな CGI プログラムは必要ではない。 grep が使えればそれですむだけの話である。

仕様は次のようにする。単純に CGI プログラムにアクセスしたときには 検索条件の入力画面になる。 それから、検索条件は基本的に正規表現を入力することにする。 例えば、che.cgi を含むようなファイル名へのアクセスを検索したければ

.*che\.cgi.*

と入力しないといけない。 不便であると言えば不便だし、便利だと言えば便利ではある。 出来れば条件を解釈するモードを何通りか用意して che.cgi といれれば、それを含むような正規表現に翻訳するように するモードを用意するというのも一つのアイディアではある。 入力しなかったところはそのフィールドのあらゆるパターンに マッチするような正規表現をいれておくことにする。 これもデフォルトを適切に決めることでもっと使いやすくなるかも知れない。

スクリプト

さてこれを行うスクリプトは次のようにした。

#!/usr/bin/perl

# スクリプトの URI
my $script_url = 'http://localhost/cgi-bin/vl.cgi';
# アクセスログファイルの位置
my $logfile    = '/home/ageha/labo/re/access_log.3';

read(STDIN, $buffer, $ENV{'CONTENT_LENGTH'});
@pairs = split('&', $buffer);

foreach $pair ( @pairs ){
  ($name, $value) = split('=', $pair);
  $value =~ tr/+/ /;
  $value =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg;

  $FORM{$name} = $value;
}

if( $FORM{'action'} eq 'grep' ){
  search();
}else{
  html();
}

exit;

sub search {
  my ($regex);

  $FORM{'hostexpr'} = '\S+' if( $FORM{'hostexpr'} eq '' );
  $FORM{'yearexpr'} = '\d{4}' if( $FORM{'yearexpr'} eq '' );
  $FORM{'monexpr'} = '\w{3}' if( $FORM{'monexpr'} eq '' );
  $FORM{'dayexpr'} = '\d{2}' if( $FORM{'dayexpr'} eq '' );
  $FORM{'hourexpr'} = '\d{2}' if( $FORM{'hourexpr'} eq '' );
  $FORM{'minexpr'} = '\d{2}' if( $FORM{'minexpr'} eq '' );
  $FORM{'secexpr'} = '\d{2}' if( $FORM{'secexpr'} eq '' );

  $FORM{'methodexpr'} = '\S+' if( $FORM{'methodexpr'} eq '' );
  $FORM{'docexpr'} = '.*' if( $FORM{'docexpr'} eq '' );
  $FORM{'statusexpr'} = '\d{3}' if( $FORM{'statusexpr'} eq '' );

  $regex = "^$FORM{'hostexpr'} \\S+ \\S+ \\[$FORM{'dayexpr'}/$FORM{'monexpr'}/$FORM{'yearexpr'}:$FORM{'hourexpr'}:$FORM{'minexpr'}:$FORM{'secexpr'} [+-]\\d{4}\\] \"$FORM{'methodexpr'} $FORM{'docexpr'} .*\" $FORM{'statusexpr'} (\\d+|-)\$";

  open( LOG, $logfile ) || xdie("ログファイル $logfile がオープンできません");

  print "Content-type: text/plain\n\n$regex\n\n";
  while( <LOG> ){
    print if /$regex/;
  }
  close( LOG );
  exit;
}   
    
sub html {
  print <<"HTML";
Content-type: text/html

<html>
<head>
<META HTTP-EQUIV="Content-type" CONTENT="text/html; charset=EUC-JP">
<title>View Log</title>
</head>
<body bgcolor="#ffffff" text="#000000" link="blue">
<table border=0 width=100% cellpadding=2 cellspacing=3>
<tr><td bgcolor=#2b7a9b><font color=#ffffff size=+3>
アクセスログ検索
</font></td></tr></table>
<p>
検索には正規表現を使います。例えば che.cgi を含む文字列の場合には
<pre>
.*che\\.cgi.*
</pre>
などといった表記を使うので注意して下さい。
<form method="POST" action="$script_url">
<input type="hidden" name="action" value="grep">
<table border="0">
<tr>
<td>リモートホスト</td>
<td><input type="text" name="hostexpr" size="80"></td>
</tr>
<tr>
<td>アクセス時刻</td>
<td></td>
</tr>
<tr>
<td></td>
<td>
年 <input type="text" name="yearexpr" size="6">
月 <input type="text" name="monexpr" size="6">
日 <input type="text" name="dayexpr" size="6">
時 <input type="text" name="hourexpr" size="6">
分 <input type="text" name="minexpr" size="6">
秒 <input type="text" name="secexpr" size="6"><br>
</td>
</tr>
<tr>
<td>メソッド</td>
<td><input type="text" name="methodexpr" size="10"></td>
</tr>
<tr>
<td>ファイル名</td>
<td><input type="text" name="docexpr" size="80"></td>
</tr>
<tr>
<td>ステータス</td>
<td><input type="text" name="statusexpr" size="6"></td>
</tr>
</table>
<br>
<center>
<input type="submit" value=" 検索開始 ">
<input type="reset"  value=" リセット ">
</center>
</form>
</body>
</html>
HTML
}

sub xdie {
  my ($msg) = @_;
  print "Content-type: text/plain\n\n$msg\n";
}

先頭のイタンプリタの場所は適切に決める。また、スクリプトの置いてある場所 とログファイルの場所も必要に応じて書き換えること。

ちょっとコードを説明する。

read(STDIN, $buffer, $ENV{'CONTENT_LENGTH'});

POST メソッドで送られたデータを得るにはこのようにする。 これはある意味で常套句みたいなものなのであまり説明はいらないだろう。

@pairs = split('&', $buffer);

foreach $pair ( @pairs ){
  ($name, $value) = split('=', $pair);
  $value =~ tr/+/ /;
  $value =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg;

  $FORM{$name} = $value;
}

まず最初に送られて来たデータをわける。送られて来たデータは

action=aaaaa&hoge=hogehoge&foo=var

等のようになっているので、これをまずばらす。ばらすと各々は name=value の形式になっているので今度はそれをばらす。 今回は扱うのは大部分が US ascii だが、正規表現のための記号類が %5B などといった形式になっているので、これもデコードする。 これは普通の掲示板でも全く同じことをしている。 ある意味やはり常套句である。

さらに最初の検索入力画面で action という変数に grep という値を セットして送るようになっているので、これをみて動作を変える。 それをしているのが、

if( $FORM{'action'} eq 'grep' ){
  search();
}else{
  html();
}

exit;

の部分である。こうすれば、一つのスクリプトで検索条件の入力画面と ログの走査を同時に行える。むろん、動作ごとにスクリプトをわけても良いし、 検索条件の入力画面は静的な html にして、スクリプトではフォームデータの デコードと検索だけを行うようにしても問題ない。 そのようにすれば、サブルーチンの html というのは単純に html を吐いている だけなのでスクリプトから取り去っても良い。

検索の本体は

sub search {
  my ($regex);

  $FORM{'hostexpr'} = '\S+' if( $FORM{'hostexpr'} eq '' );
  $FORM{'yearexpr'} = '\d{4}' if( $FORM{'yearexpr'} eq '' );
  $FORM{'monexpr'} = '\w{3}' if( $FORM{'monexpr'} eq '' );
  $FORM{'dayexpr'} = '\d{2}' if( $FORM{'dayexpr'} eq '' );
  $FORM{'hourexpr'} = '\d{2}' if( $FORM{'hourexpr'} eq '' );
  $FORM{'minexpr'} = '\d{2}' if( $FORM{'minexpr'} eq '' );
  $FORM{'secexpr'} = '\d{2}' if( $FORM{'secexpr'} eq '' );

  $FORM{'methodexpr'} = '\S+' if( $FORM{'methodexpr'} eq '' );
  $FORM{'docexpr'} = '.*' if( $FORM{'docexpr'} eq '' );
  $FORM{'statusexpr'} = '\d{3}' if( $FORM{'statusexpr'} eq '' );

  $regex = "^$FORM{'hostexpr'} \\S+ \\S+ \\[$FORM{'dayexpr'}/$FORM{'monexpr'}/$FORM{'yearexpr'}:$FORM{'hourexpr'}:$FORM{'minexpr'}:$FORM{'secexpr'} [+-]\\d{4}\\] \"$FORM{'methodexpr'} $FORM{'docexpr'} .*\" $FORM{'statusexpr'} (\\d+|-)\$";

  open( LOG, $logfile ) || xdie("ログファイル $logfile がオープンできません");

  print "Content-type: text/plain\n\n$regex\n\n";
  while( <LOG> ){
    print if /$regex/;
  }
  close( LOG );
  exit;
}   

で行っている。最初の

  $FORM{'hostexpr'} = '\S+' if( $FORM{'hostexpr'} eq '' );
  $FORM{'yearexpr'} = '\d{4}' if( $FORM{'yearexpr'} eq '' );
  $FORM{'monexpr'} = '\w{3}' if( $FORM{'monexpr'} eq '' );
  $FORM{'dayexpr'} = '\d{2}' if( $FORM{'dayexpr'} eq '' );
  $FORM{'hourexpr'} = '\d{2}' if( $FORM{'hourexpr'} eq '' );
  $FORM{'minexpr'} = '\d{2}' if( $FORM{'minexpr'} eq '' );
  $FORM{'secexpr'} = '\d{2}' if( $FORM{'secexpr'} eq '' );

  $FORM{'methodexpr'} = '\S+' if( $FORM{'methodexpr'} eq '' );
  $FORM{'docexpr'} = '.*' if( $FORM{'docexpr'} eq '' );
  $FORM{'statusexpr'} = '\d{3}' if( $FORM{'statusexpr'} eq '' );

という部分では検索条件が入力されなかった場合に指定する正規表現を 代入しているだけである。 次に、

  $regex = "^$FORM{'hostexpr'} \\S+ \\S+ \\[$FORM{'dayexpr'}/$FORM{'monexpr'}/$FORM{'yearexpr'}:$FORM{'hourexpr'}:$FORM{'minexpr'}:$FORM{'secexpr'} [+-]\\d{4}\\] \"$FORM{'methodexpr'} $FORM{'docexpr'} .*\" $FORM{'statusexpr'} (\\d+|-)\$";

でそれらを組み合わせて、ログのフォーマットを表している。 これが検索のための正規表現を組み立てているところである。 なお、ここでバックスラッシュが沢山出て来ているが、これは正規表現とは 関係ない。ダブルクォートやバックスラッシュ自身を表すためのものである。 もしも、これがうっとうしければ、例えば、連結演算子とシングルクォートを 使って記述するとか、一般引用符

qq# #

などを使うとかの方法がある。正規表現があまりに長くなってコードが見づらい 場合にもこのような工夫をしてコードを見やすくしても構わない。 パフォーマンス以上に、コードが見やすくなるかどうかというのが問題になる 場合だってある。

  open( LOG, $logfile ) || xdie("ログファイル $logfile がオープンできません");

  print "Content-type: text/plain\n\n$regex\n\n";
  while( <LOG> ){
    print if /$regex/;
  }
  close( LOG );
  exit;

あとはログファイルをオープンしてファイルを走査しているだけである。 一応、デバッグの便宜のためにどんな正規表現になっているかが 一行目に表示される。出力は単純なプレインテキストにした。

もしも、特別に検索条件を指定しなければ、正規表現は

^\S+ \S+ \S+ \[\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\] "\S+ .* .*" \d{3} (\d+|-)$

となるので、ログのすべての行を表示することになる。 また、リモートホストのところに

207\..*\.23

を指定しただけの結果は次の通りである。

^207\..*\.23 \S+ \S+ \[\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\] "\S+ .* .*" \d{3} (\d+|-)$

207.0.229.23 - - [08/Jan/2000:06:49:01 +0900] "GET / HTTP/1.0" 403 198
207.0.229.23 - - [08/Jan/2000:06:49:17 +0900] "GET /cgi-bin/ HTTP/1.0" 403 206
207.0.229.23 - - [13/Jan/2000:23:09:49 +0900] "GET / HTTP/1.0" 403 198
207.0.229.23 - - [13/Jan/2000:23:09:56 +0900] "GET /icons/ HTTP/1.0" 403 204
207.0.229.23 - - [13/Jan/2000:23:10:02 +0900] "GET /icons/ HTTP/1.0" 403 204
207.0.229.23 - - [13/Jan/2000:23:10:15 +0900] "GET /cgi-bin/linux.cgi HTTP/1.0" 403 215
207.0.229.23 - - [13/Jan/2000:23:10:29 +0900] "GET /tripod/ HTTP/1.0" 403 205
207.0.229.23 - - [13/Jan/2000:23:11:21 +0900] "GET / HTTP/1.0" 403 198

自分のパソコンに httpd が動いている人はこのスクリプトで色々と遊んでみる と良いかも知れない。 例えば、このスクリプトは RefererUser-Agent を記録したような ログファイルには対応していない。 そのようなログファイルに対応させたりするのも面白いかも知れない。 web hostig サービスのサーバで行う場合には 一応管理ポリシーを確認してからにした方が無難である。

$Id: re1.html,v 1.1 2000/05/06 11:33:05 ageha Exp $