正規表現の落とし穴 7 選:catastrophic backtracking から Unicode の罠まで

約9分

正規表現は強力ですが、誤った書き方をするとパフォーマンスが致命的に落ちたり、想定外のマッチを返したりします。本記事では本番環境で実際にトラブルを起こしやすい 7 つのパターンを、ケーススタディと修正例の両方で整理します。

1. Catastrophic backtracking(指数的バックトラック)

最も悪名高い罠。一見シンプルな正規表現が、特定の入力に対して指数的な時間を消費します。

^(a+)+$

入力 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab" (a が 31 個 + b)に対して、上の正規表現は 2³¹ ≈ 21 億回の試行を行います。マッチ自体は失敗しますが、CPU を 100% 食ったまま数秒〜数分かかる。

原因

(a+)+ のような 重複した量指定子は、a のグループ化の組み合わせがすべて試されます。aaa(aaa), (aa)(a), (a)(aa), (a)(a)(a) の 4 通りに分けられる、という冗長性が爆発の元。

対策

  • (a+)+a+ に書き直す(同等で爆発しない)
  • 一般化すると、重複した量指定子を排除する:(\w+\s*)+\w+(\s+\w+)* のように
  • atomic group (?>...) や possessive quantifier *+ ++ を使う(対応エンジン限定)
^(?>a+)+$    # atomic group。バックトラックを禁止

2. ^ $ の multiline モード違い

/^foo/      # 単一行モード:文字列の先頭のみマッチ
/^foo/m     # 複数行モード:各行の先頭でマッチ

ログをパースしていて、/^ERROR/ で「エラー行を全部抽出」したつもりが、m フラグを忘れて最初の行しか取れない事故。逆に、JSON のように改行を含む 1 つの値を扱うときに m を付けて、想定外の位置でマッチが切れる事故もあります。

対策

  • 「行ごと」の処理なら明示的に \n で split してから個別マッチをかける
  • m フラグを使うときは、^ $ が改行ごとに当たることを意識
  • 文字列全体の先頭・末尾に確実に当てたいなら \A \z を使う(PCRE / Ruby など対応エンジン)

3. \b の Unicode 不対応

JavaScript では伝統的に \b(単語境界)は ASCII 文字でのみ動作しました。

'日本語hello'.match(/hello/); // ✓ マッチ
'日本語hello'.match(/日本語/); // ✗ マッチしない(旧挙動)

ECMAScript 2018 で u フラグ + v フラグの対応が進みましたが、Unicode property 対応の \b は未だに普通の \b ではない

// 'v' flag + Unicode property を使う
const re = /(?<=P{L}|^)日本語(?=P{L}|$)/u;

対策

  • 日本語を含むテキストで「単語の境界」を扱うなら \b ではなく (?<=\P{L}) (?=\P{L}) を明示
  • そもそも日本語に「単語の境界」は語境的に難しい。形態素解析を使うべきか検討
  • Python 3 の re.UNICODE フラグや、Java の (?U) フラグなど、エンジンごとに名前が違うので確認

4. Lookbehind の対応差

(?<=USDs)d+    # "USD " の後ろの数字

Lookbehind(後読み)はエンジンによって長さ制限があることに注意:

エンジン可変長 lookbehind
JavaScript✓(ES2018 から)
Python re✗(固定長のみ)
Python regex
PCRE✗(固定長のみ)
Go regexplookbehind 自体なし
Java固定長のみ

「テストでは Python regex モジュールで通ったのに、本番の Python 標準 re で例外」「Node でテストしたのが Go の正規表現エンジンで通らなかった」は典型的な事故。

対策

  • 可変長 lookbehind を使う前に、デプロイ環境のエンジンを確認
  • どうしても固定長で書けないなら、lookbehind を使わない方向に書き換える(マッチ後に部分文字列を取り出す)

5. 文字クラス内の -] の扱い

[a-z-]    # a-z と "-"
[a-z]    # 'a', '-', 'z'
[a-]      # 'a' と '-'(- が末尾なら literal)
[]z]      # ✗ 多くのエンジンで構文エラー
[]]z]    # ']' と 'z'

文字クラスに - を入れるなら最初か最後に置く。] を入れるならエスケープする。これを誤ると:

  • 意図しない範囲展開([A-Z\-0-9] のつもりが [A-Z\\-0-9]- がメタ扱いになる)
  • 構文エラー

対策

  • -[abc-] のように末尾に置く、または \- でエスケープ
  • ]\] でエスケープ
  • 文字クラスにメタ文字を入れたい場合、文字クラスでは多くのメタ文字が literal になる([.+*?]. + * ? の 4 文字に literal マッチ)が、- ] \ ^(先頭の場合)は注意が必要

6. エンジン依存:PCRE / POSIX / RE2 の差

「同じ正規表現がエンジンを変えると動かない」事象。

  • PCRE 系(Perl、Python re、Java、JavaScript の多く):lookbehind、lookahead、後方参照、再帰、キャプチャグループに名前を付ける構文
  • POSIX:互換性は高いが拡張機能なし
  • RE2(Go の標準、Cloudflare 等):バックトラックを使わないので catastrophic backtracking が起きないが、lookbehind と後方参照に未対応

「ローカルで Python の re.match が通ったから OK」と思って Go のサーバに移植したら通らない、というのが頻発します。

対策

  • デプロイ先のエンジンを最初に確認
  • バックトラックを使わない RE2 系で動かすことが決まっているなら、最初から RE2 互換の subset で書く
  • Cloudflare Workers の WAF ルールなど、特殊なエンジンを使うサービスは仕様を確認

7. ReDoS(Regular Expression Denial of Service)

ユーザー入力をそのまま正規表現で処理する場合、入力を細工することで catastrophic backtracking を意図的に起こせる攻撃。

例:メール検証によく使われる ^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$ のような pattern に、a@a.aaaaaaaaaaaaaaaaaaaaaaaaaaaaa! のような入力を渡すと、エンジンによっては数秒固まります。

対策

  • ユーザー入力を扱う正規表現は、必ずタイムアウトを設定できる環境で動かす(Node.js の vm モジュール、Python の regex ライブラリの timeout パラメータなど)
  • そもそも RE2 系(バックトラックなし)のエンジンを使う
  • ReDoS 検出ツール(ReScuesafe-regex)でパターンを事前検査
  • メール検証なら正規表現でなく parse() 系のライブラリ(email-validator など)を使う

チェックリスト

実装時に踏みやすいポイント:

  1. 量指定子の入れ子((a+)+ パターン)を避ける
  2. multiline モードの ^ $ の挙動を確認する
  3. \b を非 ASCII テキストで使うときは \P{L} 系の代替を検討
  4. lookbehind を使う前にエンジンの可変長対応を確認
  5. 文字クラスの - ] の位置に注意
  6. デプロイ先のエンジン(PCRE / RE2 / POSIX)を最初に確認
  7. ユーザー入力を渡す正規表現はタイムアウト設定 or RE2 系エンジンで実行

正規表現テスターで、書いたパターンの挙動を確認できます。

まとめ

正規表現の落とし穴は「書き手の意図と、エンジンが実行する戦略が一致していない」ことから発生します。catastrophic backtracking は最も極端な例ですが、エンジン差や Unicode 対応の差も同じ系統の問題。書く前にデプロイ先のエンジンと、入力データの言語特性(ASCII なのか Unicode なのか)を確認することで、ほとんどの罠は事前に避けられます。