Pythonの正規表現で「$」って文字列末尾にマッチすると思っていませんか? 実はそうでもない……ってところから始まるいろいろを調べてみました。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
どうもHPかわさきです。
先日、編集長の一色の方から「これ、知ってる?」と「Use “\A...\z”, not “^...$” with Python regular expressions」のURLが送られてきました。簡単にいえば、正規表現で入力を検証するときに「^」と「$」というペアを使うよりも、「\A」と「\z」を使う方がいいよ、というお話です。
そういうわけで、ちょっと調べてみました。
ちょっと調べた結果を以下にまとめましょう。
特に「$」が「文字列末尾」か「文字列末尾にある改行の直前」にマッチすることと、「\Z」の振る舞いが言語(正規表現エンジン)で異なる点には注意が必要です。というわけで、少し詳しく見ていきましょう。
まずはPythonの正規表現における「$」の振る舞いを確認します。Pythonのreモジュールにある「Regular Expression Syntax」には「$」の振る舞いとして「Matches the end of the string or just before the newline at the end of the string, and in MULTILINE mode also matches before a newline」とあります。適当訳ですが、これは「$は文字列末尾もしくは文字列末尾にある改行の直前にマッチする。MULTILINEモードでは、改行の直前にもマッチする」となります(上でまとめた通りです)。
ちょっとコードで確認してみます。
import re
s = 'foo\n'
p = r'foo$'
m = re.match(p, s)
repr(m.group()) # "'foo'":repr(m[0])でも可
このコードでは、re.match関数を使って、文字列がr'foo$'というパターンにマッチするかどうかを判定しています。re.match関数は文字列の先頭から比較を行うので、「^」は使っていません。「$」は文字列末尾か文字列末尾にある改行の直前にマッチするので、実行結果に示したように'foo'という文字列にマッチしました。
と書いてしまうとそれで問題なさそうです(改行がどこにいったかはさておき)。そこで、今度はre.sub関数を使って「$」の振る舞いを確認してみましょう。
result = re.sub(r'$', '*', 'foo\n')
repr(result) # "'foo*\\n*'"
re.sub関数には単独の「$」をパターンとして、置換候補として「*」を渡しています(置換対象の文字列は先ほどと同じ'foo\n'です)。上で述べたように、「$」は文字列末尾に改行がある場合、文字列末尾(改行の直後)もしくは改行の直前にマッチするので、re.sub関数によりこの2カ所に「*」が挿入されます。
これでまずは「$」が「文字列末尾」もしくは「文字列末尾にある改行の直前」にマッチすることは分かりました。
でも、特に最初のコード例を見ても、これがどんな問題を引き起こすかはよく分かりません。そこで次のようなコードを考えてみます。ユーザーとアクションを1行の文字列としてログにすることを想定しています(あくまでもサンプルで現実性に乏しいことはご承知ください)。
import re
def make_log(user, action):
if not re.match(r'\w+$', user, flags=re.ASCII):
raise ValueError('invalid username')
return f'{user}, {action}\n'
user = 'deep_insider'
log = make_log(user, 'login')
# 何かしてログアウト
log += make_log(user, 'edit')
log += make_log(user, 'logout')
print(log)
make_log関数ではr'\w+$'という正規表現でユーザー名をチェックしています。re.match関数呼び出し時にはフラグとしてre.ASCIIを指定しているので、これはユーザー名が大文字小文字のアルファベットと数字、アンダースコアだけで構成されているかどうかを判定していることになります。
関数定義の後では、ユーザー名として'deep_insider'を指定して、make_log関数を何度か呼び出しています。そして、最後にログを表示していますが、この結果は次の通りです。
問題はなさそうですね。では、今後は文字列末尾に改行を含んだユーザー名'deep_insider\n'にしてみます。
user = 'deep_insider\n'
log = make_log(user, 'login')
# 何もせずログアウト
log += make_log(user, 'logout')
print(log)
これを実行すると次のようになりました。
ユーザー名の検証を適当な正規表現で行っていたため、1行に1つのログのつもりが、そうはならなくなってしまいました。
これはシンプルで、現実性にも乏しい例ですが、データ内の改行の有無で処理が切り替わるようなシステムがあった場合、不意な改行がよくないことを引き起こすことが考えられます(HTTPヘッダとHTTPボディは改行で区切られていますよね)。無理やりに改行を入れ込む攻撃手法のことを「CRLFインジェクション」などと呼びます(残念ながら、筆者はそこまで詳しくはないので、これ以上の言及はできないのですが)。
というわけで、Pythonでは文字列末尾を「$」でマッチさせるのがあまりよろしくないことは分かりました。
では「^」と「$」に変わるアンカーとして何が使えるかというと、Python 3.13までは「\A」と「\Z」が使えました(Python 3.14でももちろん使えます)。
実際に試してみましょう。ここではre.match関数を使うのでやっぱり「\A」は省略しましょう。
import re
s = 'foo\n'
p = r'foo\Z'
result = re.match(p, s)
print(result) # None
「\Z」は文字列末尾を意味するので、'foo'と文字列末尾の間に'\n'を含む文字列'foo\n'はパターンr'foo\Z'にはマッチしません。
じゃあ、文字列末尾は「\Z」を使えばいいじゃない? と思うかもしれません。それで話が済めば問題はないのですが、実は言語によっては「\Z」が「文字列末尾にマッチ」するわけではないのです……。というか、JavaScriptでは「\A」も「\Z」も使えません。試してみましょう。
const p = /\Afoo\Z/;
const s0 = 'foo\n';
let result = p.test(s0);
console.log(result); // false
パターン/\Afoo\Z/は文字列の先頭にfooにそこで末尾となることを意味しています(なんで「\A」を使っているかは後で)。そして、文字列'foo\n'がこのパターンにマッチするかを試していますが、その結果はfalseです。
ここで、以下のコードを試してみましょう。
const s1 = 'AfooZ';
result = p.test(s1);
console.log(result); // true
文字列が'foo\n'ではなく、'AfooZ'に変わっていることに注目してください。実行結果は次の通りです。
なんと、ヘンテコな文字列'AfooZ'が先ほどのパターンにマッチしました。つまり、「\A」は文字「A」をバックスラッシュでエスケープしている、つまり単なる「A」を意味しているということです。「\Z」も同様です。そのため、文字列'AfooZ'がこのパターンにマッチしたということです。
実際にはパターンを/\Afoo\Z/uのようにuフラグ付きにすることで、このように存在しないエスケープシーケンスを記述するとSyntaxErrorが発生します。
また、後出しですが、JavaScriptでは「$」は文字列末尾にマッチして、末尾が改行の場合にその直前にマッチするようなことはなさそうです。そういう意味では「\Z」がなくても問題はないといえます(ただし、「\Z」がないことで、他の言語と同じ正規表現を使えないという問題はありますが)。
JavaScriptには「\Z」がないことは分かりました。この他にも「\Z」でも「文字列末尾に改行があれば、その直前にもマッチする」言語もあります。コードを出すのが面倒になってきたので、「regular expressions 101」を見てもらいましょう(このサイト、便利なので覚えておきましょう)。
詳しい説明は省略しますが、左側のペーンでは「Java 8」が選択されています。中央のペーンでは正規表現として「cat\Z」を、マッチ対象の文字列として「cat\n」を入力しています(「\n」ではなく改行マークになっていますね)。Java 8では「\Z」が文字列末尾の改行の直前にマッチするので、その結果、ここでは文字列の「cat」の背景色がうすい水色になり、右側のペーンでは「MATCH INFORMATION」にこのマッチについての情報が表示されています。
このように、「\Z」の振る舞いは言語によってさまざまです。そのため、文字列末尾にマッチする正規表現に「\Z」を含めても、それを他の言語に持っていったときに使えるかどうかは分かりません。
そこでPython 3.14でサポートが追加された「\z」の出番です。以下の画像はmake_log関数で「$」ではなく「\z」を使うようにして、改行を含むユーザー名を渡してみたところです。
不正なユーザー名なので例外が発生しました。これはそのまま処理を続けてログを壊すよりも安全な挙動といえます。
「\z」は多くの環境で「文字列末尾にマッチする」振る舞いをするようです(ただし、JavaScriptにはありません)。「What’s new in Python 3.14」にも「Support \z as a synonym for \Z in regular expressions. It is interpreted unambiguously in many other regular expression engines, unlike \Z, which has subtly different behavior.」とあります。日本語にテキトー訳すると「\Zの同義語として\zをサポート。他の多くの正規表現エンジンで\zは一意に解釈されている(それとは異なり、\Zは微妙に異なる振る舞いをする)」となるでしょう。
まとめると次のようになります。
こうした理由から、Pythonで文字列の先頭から末尾を対象に入力を検証するときには、Python 3.13までの「\A」と「\Z」ではなく、「\A」と「\z」を使うのがよさそうということになります。他にもre.fullmatch関数を使うなどのやり方はあるはずです。でも、正規表現を使うのであれば、「$」は使わないようにするのが正解といえそうです。
ちょっと調べ始めたときには、そんなに書くことないんじゃない? と思ったら大間違いでした。しかも、セキュリティ面での考慮が必要だし、上で出したサンプルコードでは不十分じゃないかという気もするのですが、許してください。そうだ。MULTILINEモードについて言及できていないのがアレですが、そこも許してください(笑)。
こんな難しいネタを振ってきた一色さんには感謝です(なぜかここでは「さん」付けだ。ま、私信みたいなものなので)。
Copyright© Digital Advantage Corp. All Rights Reserved.