セキュリティ 2026年10月3日

個人情報マスクの正規表現に日本語の書き方20通りを流したら、13通りがそのまま残った。半角の電話番号も末尾4桁が見えていた話

XECIN PIIPythonJavaScript技術検証

先週、問い合わせフォームの受付処理まわりで「ログに何を残しているか」を棚卸しする機会がありました。

受付のログに入力内容が残ると、氏名やメールアドレスがログの保管期間いっぱい、閲覧権限を持つ人の目に触れる場所に置かれ続けます。後から消そうとしても、ログは転送先やバックアップにも複製されているので、きれいに消し切るのは簡単ではありません。書き込む前に止めるのが一番安い、というのが私の考えです。

そこで最初に思いついたのは、LP で検索キーワード(utm_term)を計測へ送る前に通している maskPII という関数を、受付ログにもそのまま通すことでした。すでに本番で動いている関数を流用するのは、運用を考えると筋がいいはずです。

ただ、その関数が日本語の入力でどう振る舞うかを、私は一度もまとまった形で確かめたことがありませんでした。正直なところ、作ったときに試したのは半角の「090-1234-5678」と半角のメールアドレスくらいだったと思います。

流用する前に、ダミーの個人情報を20通り、個人情報ではない値を6種類用意して、社内のローカル環境(Node.js 22.14 と Python 3.13.5)で流してみました。以下はそのときの記録です。値はすべて架空のものです。

流用しようとしていたマスク関数

関数そのものは単純で、メールアドレス・9桁以上の数字の並び(電話番号のつもり)・郵便番号・クレジットカード番号の形を、順に置き換えるだけです。

// LP で utm_term を計測に送る前に通していたマスク(抜粋)
function maskPII(value) {
  let masked = String(value);
  masked = masked.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[PII_EMAIL]");
  masked = masked.replace(/\b\d{9,}\b/g, "[PII_PHONE]");
  masked = masked.replace(/〒?\d{3}-?\d{4}\b/g, "[PII_ZIP]");
  masked = masked.replace(/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g, "[PII_CC]");
  return masked;
}

// 実行結果:
// maskPII("090-1234-5678")              => "[PII_ZIP]-5678"
// maskPII("03-1234-5678")               => "03-1[PII_ZIP]"
// maskPII("090-1234-5678")   => "090-1234-5678"(そのまま)
// maskPII("taro.yamada@example.jp")     => "taro.yamada@example.jp"(そのまま)
// maskPII("550e8400-e29b-41d4-a716-446655440000")
//                                        => "550e8400-e29b-41d4-a716-[PII_PHONE]"

用意した入力は、電話番号12通り・メールアドレス4通り・郵便番号2通り・氏名と住所が1つずつです。電話番号は、フォームに実際に入ってきそうな書き方を思いつく限り並べました。全角数字、IME で「ー」(長音符)を打ってしまったもの、記号の「−」(マイナス)や「‐」(ハイフン)を使ったもの、空白区切り、括弧、+81 から始まる形などです。

最初の結果: 20通り中13通りがそのまま残った

結果を一覧にすると、こうなりました。「正規化を足した版」は、後半で説明する書き直し後の結果です。

入力の書き方例maskPII(JavaScript)同じ式を Python に移した場合正規化を足した版
携帯・半角ハイフン090-1234-5678[PII_ZIP]-5678(末尾4桁が残る)同じ[TEL]
固定・半角ハイフン03-1234-567803-1[PII_ZIP](先頭が残る)同じ[TEL]
+81 から始まる形+81-90-1234-5678+81-90-1[PII_ZIP](先頭が残る)同じ[TEL]
半角数字・区切りなし09012345678[PII_PHONE]同じ[TEL]
空白区切り・括弧(2通り)090 1234 5678 / 03(1234)5678そのまま残るそのまま残る[TEL]
半角数字+長音符・マイナス・ハイフン記号(3通り)090ー1234ー5678 ほかそのまま残るそのまま残る[TEL]
全角数字+全角ハイフン・長音符(2通り)090-1234-5678 ほかそのまま残るそのまま残る[TEL]
全角数字・区切りなし09012345678そのまま残る[PII_PHONE][TEL]
メール・半角(+付きを含む2通り)taro.yamada@example.jp ほか[PII_EMAIL]同じ[EMAIL]
メール・全角@/全角英数(2通り)taro.yamada@example.jp ほかそのまま残るそのまま残る[EMAIL]
郵便番号・半角〒100-0001[PII_ZIP]同じ〒[ZIP]
郵便番号・全角〒100-0001そのまま残るそのまま残る〒[ZIP]
氏名・住所(2通り)山田 太郎 / 東京都千代田区千代田1-1そのまま残るそのまま残るそのまま残る
そのまま残った数20通り中13(ほかに一部が残ったもの3)12(ほかに一部が残ったもの3)2

いちばん想定外だったのは、一番ふつうの書き方である「090-1234-5678」でした。

電話番号のルールは「区切りのない9桁以上の数字」しか見ていないので、ハイフン入りの番号には反応しません。代わりに、後ろに控えている郵便番号のルール(3桁、ハイフンは任意、4桁)が先頭の「090-1234」を郵便番号として置き換え、末尾の「-5678」がそのまま残ります。固定電話の「03-1234-5678」では逆に後ろ寄りが置き換わって「03-1」が残りました。ラベルも [PII_ZIP] なので、ログを読んだ人は「郵便番号が入っていたのだな」と誤解します。半角の番号なら効いている、という私の思い込みは、ここで崩れました。

全角や長音符の書き方が全部素通りするのは、式を見れば当然です。\d も - も @ も半角の文字しか指していません。ただ、問い合わせフォームに全角で電話番号を書く人は珍しくないですし、スマートフォンの入力で「ー」が混ざることもあります。utm_term に入る検索キーワードも、全角を含むことがあります。

個人情報ではない値も、6種類中5種類を壊していた

逆方向も確かめました。ログに残っていないと困る値が、マスクで壊れないかどうかです。

残したい値例maskPII を通した結果正規化を足した版
リクエストID(UUID)550e8400-e29b-41d4-a716-446655440000末尾12桁が [PII_PHONE] にそのまま
エポックミリ秒1790996410123全体が [PII_PHONE] にそのまま
受付番号ORD-2026-1003-0042ORD-2[PII_ZIP]-0042そのまま
予算(円)128000001[PII_ZIP]そのまま
インボイスの登録番号T1234567890123T123456[PII_ZIP]そのまま
ISO 形式の時刻2026-10-03T12:34:56.789+09:00そのままそのまま

運用を考えると、こちらのほうが痛いかもしれません。障害の問い合わせを受けたとき、私たちはまずリクエストIDでログをたどります。そのIDの末尾が [PII_PHONE] に化けていたら、前後のログをつなげられません。郵便番号のルールは後ろの区切りしか見ていないので、受付番号や金額の途中からでも「3桁+4桁」を拾って食い込みます。

個人情報を漏らすマスクと、追跡に必要な値を壊すマスク。同じ関数が両方をやっていました。

Python に移したら、結果が変わった

ログを後から集計したり加工したりする処理は、Python で書くことも多いです。そこで同じ正規表現を Python にもそのまま移して流してみたところ、表の4列目のとおり、全角数字の区切りなしの番号だけ結果が変わりました。

import re


def mask_pii(value: str) -> str:
    masked = re.sub(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "[PII_EMAIL]", value)
    masked = re.sub(r"\b\d{9,}\b", "[PII_PHONE]", masked)
    masked = re.sub(r"〒?\d{3}-?\d{4}\b", "[PII_ZIP]", masked)
    return masked


print(mask_pii("09012345678"))      # [PII_PHONE]  ← JavaScript ではそのまま残った
print(mask_pii("090-1234-5678"))  # そのまま   ← 区切りの「-」は - ではない

# \w で書いたメール用の式だと、Python は漢字やかなも \w に数える
email_w = r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+"
print(re.sub(email_w, "[EMAIL]", "連絡はtaro.yamada@example.jpまで"))
# 実行結果: [EMAIL]   ← 「連絡は」「まで」ごと消えた(JavaScript では 連絡は[EMAIL]まで)

理由は単純で、\d が何を数字とみなすかが言語で違うんですよね。JavaScript の \d は u フラグを付けても半角の 0〜9 だけです。Python の re は、文字列のパターンなら \d が全角数字を含む Unicode の数字全般に当たります。ドキュメントにはちゃんと書いてあって、半角だけにしたければ re.ASCII を付けます。\w も同じで、Python では漢字やかなが \w に入るので、メールの前後の日本語まで巻き込んで消えました。

マスクとしては「消えすぎ」の方向なので安全側ではあります。ただ、ログの文脈が消えるので、後で読む人は何の行だったのか分からなくなります。同じ式をコピーしても、言語が変わると振る舞いが変わる。式だけでなく、テストケースも一緒に持ち運ばないといけない、というのが今回の教訓の一つです。

正規化を挟んだ版: 漏れは2通りまで減った

書き直しの方針は3つです。先に Unicode の NFKC 正規化で全角英数・全角@・全角ハイフンを半角に寄せる。NFKC では変わらない長音符(ー)・マイナス記号(−)・ハイフン記号(‐)は、数字に挟まれているときだけ「-」に寄せる。電話番号と郵便番号の式には、前後に数字やハイフンが続いていないことの条件を付ける。

長音符をむやみに置き換えると「サーバー」が「サ-バ-」になってログの日本語が壊れるので、数字に挟まれたときだけに絞りました。前後の条件は、UUID や受付番号の途中を拾わないためのものです。

// 数字に挟まれた長音符・マイナス記号・ハイフン記号などを「-」に寄せる
const DASHES = /(?<=\d)[‐-―−ーー](?=\d)/g;

function maskForLog(line) {
  return line
    .normalize("NFKC") // 全角英数・全角@・全角ハイフンを半角に寄せる
    .replace(DASHES, "-")
    .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g, "[EMAIL]")
    .replace(/(?<![\d-])(?:\+81[-\s]?|0)\d{1,4}[-\s()]{0,2}\d{1,4}[-\s()]{0,2}\d{3,4}(?![\d-])/g, "[TEL]")
    .replace(/(?<![\d-])\d{3}-\d{4}(?![\d-])/g, "[ZIP]");
}

// 改善の余地あり:
// 行全体を NFKC で書き換えるので、ログの原文が変わる。
//   maskForLog("①サーバー移行の件 ㈱テスト 第2期 見積番号Q0012345678")
//   => "1サーバー移行の件 (株)テスト 第2期 見積番号Q[TEL]"
// 「①」は「1」、「㈱」は「(株)」になり、先頭が 0 の社内番号は電話番号として消える。

同じ26件を流し直すと、JavaScript と Python のどちらでも、そのまま残ったのは氏名と住所の2通りだけになり、壊した値は0になりました。Python 版は同じ式を re に移しただけですが、正規化を先に通すので全角数字の扱いの差も出ません。

処理コストも測っておきました。同じ26件を繰り返して10万行にし、正規化なしの素朴な式と比べると、Node.js では 33ミリ秒が 60ミリ秒に、Python では 220ミリ秒が 240ミリ秒になりました。問い合わせフォームの流量なら誤差です。

ただ、コードの注釈に書いたとおり、行全体を書き換えてしまうのは気持ちのいいものではありません。ここは好みが分かれるところですが、私はログの原文が変わることより漏れのほうが怖いので、今はこの形を許容しています。

氏名と住所は、出さないことでしか守れなかった

正規化をどれだけ工夫しても、「山田 太郎」や「東京都千代田区千代田1-1」は形で見分けられません。問い合わせ本文のような自由記述になると、どこに何が書かれているか分かりません。式を足して追いかけるのは、たぶん終わらない作業です。

なので受付ログは、マスクで消すのをやめて、出す項目を決め打ちにしました。

// 受付ログに出すのは、ここに書いた項目だけ
function toLogEntry(requestId, body) {
  return {
    level: "info",
    msg: "contact received",
    requestId,
    inquiryType: body.inquiryType,
    messageLength: body.message?.length ?? 0,
    hasBudget: body.budget != null,
  };
}

// 本文ごと出していた行:   846 バイト(氏名・メール・電話・住所・本文188字を含む)
// toLogEntry で出した行:  161 バイト

ダミーの問い合わせ1件で比べると、1行が 846 バイトから 161 バイトへ、約81% 減りました。ログの取り込み量で課金される仕組みなら、そのまま費用に効きます。問い合わせの件数が少ないうちは金額としては小さいのですが、マスクのための正規表現を保守し続ける手間と比べると、項目を絞るほうが安くつくと考えています。

マネージドのマスキング機能を後段に足す選択肢もあります。ただ、日本の電話番号のこれだけの書き方をどこまで拾うかは、結局自分たちで確かめないと分かりません。私は「アプリが書かない」を既定にして、マネージドの機能は入れるとしても保険の位置づけにするのがよいと考えています。

一方で、utm_term のように中身が自由記述で、項目を絞りようがない値もあります。そこは正規化を足した版で消すしかありません。LP 側の maskPII にも同じ穴があるので、正規化版に寄せる準備を進めています。

おわりに

今回いちばん効いたのは、正規表現の書き方よりも、20通りの入力を並べて流したこと自体でした。作った人が思いつく入力だけで試している限り、「半角なら効いている」という思い込みには気づけません。

今後は、マスクの式と今回の26件のケースを1か所にまとめて、JavaScript と Python の両方で同じケースを回すテストにしたいと考えています。全角や長音符の書き方は、フォームのログやサポートの問い合わせで見つけるたびにケースへ足していくつもりです。

行全体を NFKC で書き換えてしまう点は、マスクする範囲だけを正規化して置き換える形にできないか、もう少し試してみます。