あけましておめでとうございます!
駅伝企画 第四区走者の みにせら (minisera) です。
普段は顧客体験チーム(CRE)でサーバーサイドエンジニアをやっています。
上ちょ(@psnzbss) から受け取ったタスキを持って走り抜けます!よろしければ前記事もどうぞ。 blog.smartbank.co.jp
この記事ではクレジットカード番号(PAN: Primary Account Number)がシステム内に紛れ込むのを検出・防止する仕組みについてお話しします。「カード番号っぽい文字列」を見つけ出すアルゴリズムをGoで実装し、大量の誤検知と格闘した経験から、段階的にリリースしていく中で得られた知見を共有できればと思います。
なぜクレジットカード番号の検出が必要なのか
僕たちはワンバンクという家計簿プリペイドカードサービスを提供しています。このような決済サービスを提供する上で、PCI DSS(Payment Card Industry Data Security Standard)への準拠は必須です。PCI DSSではクレジットカード番号の保護だけでなく、カード会員データの管理やパスワードポリシーなど多岐にわたる要件が定められています。
その中でもクレジットカード番号の不適切な保存や漏洩は厳しく制限されており、準拠のためには検出機構が必要となります。
PCI DSS準拠の観点に加え、一般的なセキュリティ対策としてもクレジットカード番号の検出は重要です。
決済サービスを提供していなくても以下のようなリスクが考えられます。
- ログへの混入リスク: クレジットカード番号がログに記録されると、ログを閲覧できる開発者や運用担当者が意図せずカード情報にアクセスしてしまいます
- 外部サービスへの漏洩: 分析ツールやエラー監視サービス(Sentry、Datadogなど)にリクエストボディが送信される場合、クレジットカード番号が外部に流出する可能性があります
「ウチは決済サービスではないし、クレジットカード番号を入力されることもないし大丈夫だろう」と思われる方もいるかもしれません。
しかし、想定外の経路からシステムに入り込んでくるケースがあるのです。
想定されるリスク
僕たちのサービスでは、以下のような経路でクレジットカード番号が紛れ込むリスクがありました。
- レシート読み取りのOCRした文字列・撮影画像: レシートをカメラで撮影してOCR処理する機能があるのですが、レシートにカード番号が印字されているケースがあります。最近は下4桁のみ表示のレシートが多いですが、稀に古い端末や一部の加盟店では全桁印字されていることも。
- 決済メモ欄へのユーザー入力: ユーザーが自由にメモを入力できる欄があり、「カード番号: 4323-XXXX-XXXX-9999」のように書いてしまうケースがあります。
これらのデータがログに保存されてしまうと、PCI DSS違反となる可能性があります。そこでAPIリクエストの段階でクレジットカード番号らしき文字列を検出し、適切に処理する仕組みが必要になりました。
クレジットカード番号検出の技術解説
検出ロジックは以下の4段階で構成されています。
入力テキスト
↓
① 正規表現による候補抽出
↓
② 正規化(全角→半角、区切り文字除去)
↓
③ Luhnアルゴリズムによる検証
↓
④ カードブランドのチェック
↓
検出結果
それぞれの段階について詳しく見ていきましょう。
1. 正規表現による候補抽出
最初のステップは「カード番号っぽい文字列」を網羅的に拾い上げることです。この段階では誤検知が多くてもOK。後段のフィルタで絞り込むので、まずは取りこぼしを防ぐことを優先します。
クレジットカード番号の要件を整理すると:
- 桁数: 13〜19桁(カードブランドによって異なる)
- 区切り文字: 半角/全角スペース、各種ハイフン(
‐,–,—,-)を許容 - 全角数字: OCR結果などで全角数字が含まれる場合がある
これらを考慮した正規表現パターンがこちらです。
var panCandidateRe = regexp.MustCompile(`(?:[0-90-9][\\s \\-‐-–—_]{0,3}){12,18}[0-90-9]`)
ポイントは、区切り文字を最大3文字まで許容している点です。「4012 XXXX XXXX 1881」のようなスペース区切りはもちろん、「4012—XXXX—XXXX—1881」のようなダッシュ区切りもマッチします。OCRの結果次第では区切り文字が連続するケースがあるので3文字まで許容しています。
2. 正規化
候補として抽出された文字列を、計算可能な純粋な数字列に変換します。
func normalize(s string) string { // 1. Unicode正規化(NFKC):全角→半角、互換分解/合成 s = norm.NFKC.String(s) // 2. 数字以外を除去 var buf strings.Builder for _, r := range s { if r >= '0' && r <= '9' { buf.WriteRune(r) } } digits := buf.String() // 3. 桁数フィルタ:13〜19桁以外は破棄 if len(digits) < 13 || len(digits) > 19 { return "" } return digits }
Unicode正規化(NFKC)を使うことで、全角数字「1234」を半角「1234」に統一できます。これにより、OCR結果で全角数字が混入していても後段のLuhnやブランドチェックで正しく処理できます。
3. Luhnアルゴリズム
Luhnアルゴリズムは、クレジットカード番号の妥当性を検証するためのチェックサムです。
このアルゴリズムの目的は誤入力の検知です。1桁間違えたカード番号はLuhnチェックを通過しないので、単純な入力ミスを検出できます。つまり、クレジットカード番号に見えるけど実際は違う数字列の誤検知を防ぐことができます。
func luhnValid(digits string) bool { sum := 0 double := false for i := len(digits) - 1; i >= 0; i-- { n := int(digits[i] - '0') if double { n *= 2 if n > 9 { n -= 9 } } sum += n double = !double } return sum%10 == 0 }
アルゴリズムの手順を簡単に説明すると:
- 右端から左に向かって処理
- 偶数番目(右から2番目、4番目...)の数字を2倍する
- 2倍した結果が9を超えたら9を引く
- すべての数字を合計
- 合計が10で割り切れればOK
例えば「79927398713」の場合:
元の数字: 7 9 9 2 7 3 9 8 7 1 3 2倍処理: 7 18 9 4 7 6 9 16 7 2 3 9引く: 7 9 9 4 7 6 9 7 7 2 3 合計: 70 → 70 % 10 = 0 → 有効
4. カードブランドのチェック
最後にカードブランドごとのプレフィックス(BIN/IIN)と有効桁数を照合します。BIN(Bank Identification Number)/ IIN(Issuer Identification Number)はカード番号の先頭6〜8桁で、カード発行会社やブランドを識別するための番号です。
| ブランド | 先頭桁 | 有効桁数 |
|---|---|---|
| Visa | 4 | 13, 16, 19桁 |
| Mastercard | 51-55, 2221-2720 | 16桁 |
| American Express | 34, 37 | 15桁 |
| JCB | 3528-3589 | 16桁 |
| Discover | 6011, 65, 644-649 | 16, 19桁 |
| Diners Club | 300-305, 36, 38-39 | 14, 16桁 |
*BIN/IINの表については主要レンジのみ表記します
これらの4段階を通過した文字列のみが「クレジットカード番号」として検出されます。
誤検知との格闘
さてここからが本当の戦いです。上記のアルゴリズムを実装してリリースしてみたところ...大量の誤検知が発生しました。
Phase 1: 正規表現だけでは誤検知だらけ
最初のリリースでは、検知したクレジットカード番号の候補をログに出力するだけにしていました。(いきなりリクエストを拒否すると、誤検知で正常なリクエストまで弾いてしまう恐れがあったためです)
ログを分析してみると、以下のような誤検知パターンが見つかりました。
| カテゴリー | 特徴 | 具体例 |
|---|---|---|
| 16進数ハッシュの一部 | RailsのURL署名やトラッキングIDから偶然数字のみが抽出 | 4967083572277(元: --6d737843f49670835722...) |
| JANコード(バーコード) | レシートOCR結果の商品バーコード。「49」「45」で始まりVisaと誤認 | 4966988786636 |
| インボイス登録番号 | T + 13桁。前処理でTが除去されると誤検知 | T8200001031520 → 8200001031520 |
| UUIDの一部 | ハイフン区切りのUUID後半部分が連結 | 4779872528281(元: ...4779-8725-28281acb...) |
共通しているのは、「先頭が4で始まる」「13桁または16桁」「偶然Luhnチェックを通過」という条件を満たしてしまう点です。Visaのプレフィックスが4なので殆どがVisaと判定されてしまいました。
Phase 2: バリデーションルールの厳格化
誤検知を減らすため、以下の対策を実施しました。
1. URL署名のスキップ
URLの場合、RailsのURL署名に含まれがちな eyJ(Base64エンコードされたJSONの先頭)が存在すれば除外します。
なお、これらAPI群はユーザーが通常操作で意図的にリクエストボディを変更できるものではないためこの例外を許容しました。
if strings.Contains(url, "eyJ") { return // URL署名と判断してスキップ }
2. 正規表現の厳格化
区切り位置を厳密にチェックするようにしました。カード番号の一般的な区切りパターンは決まっています。
- 16桁:
4-4-4-4(例: 4012-XXXX-XXXX-1881) - 15桁:
4-6-5(例: 3782-XXXXXX-10005) - 14桁:
4-6-4(例: 3056-XXXXXX-5904)
これ以外の区切りパターンはクレジットカード番号ではないと判断します。
3. Luhnチェックとブランドチェックを適用
Luhnチェックとブランドチェックを適用し、メジャーなブランドのメジャーな桁数のみを検知対象としました。
この結果、検知件数は大幅に減少しました。しかし、まだ誤検知がゼロにはなりません...。
以下のようなケースで誤検知が発生しました。
- デバイス登録API: UUIDが偶然一致
- 認証トークン発行API: ランダム文字列が偶然Visa形式と一致
Phase 3: 全カードブランドでリクエスト拒否
最終フェーズでは、以下の対策を実施しました。
スキップ対象パスの導入
「ユーザーがクレジットカード番号を入力し得ないAPIパス」をスキップ対象として設定しました。
var skipPaths = []string{ "/devices", // デバイス登録 "/auth_token", // 認証トークン発行 // ... }
これらのパスでは、リクエストボディにカード番号が含まれる可能性が構造的にありません。システム内部で生成されるUUIDやトークンが偶然クレジットカード番号のパターンと一致しても、それは誤検知であると判断できます。
結果
- Visa、Mastercard、AmEx、JCB、Discover、Diners Clubすべてのブランドを検知・拒否
- スキップ対象パスにより誤検知を回避
- PCI DSSコンプライアンスの強化を実現
学んだこと
- 正規表現だけでは不十分。Luhnチェック + ブランドチェックの組み合わせが必須
- 誤検知パターンの分析が重要。どんな文字列が誤検知されるのかを理解することで、効果的な除外ルールを作れる
- 段階的なアプローチが有効。いきなりリクエストを拒否せず、まずログを収集して傾向を把握する
運用の工夫
クレジットカード番号検出機能をリリースする際、僕たちが採用した段階的リリース戦略を紹介します。
1: ログ出力のみでリリース
最初のリリースでは、バリデーション(リクエスト拒否)は行わず、検知結果をログに出力するだけにしました。
[PAN_SCAN] candidate: 451212******7890 len=16 luhn=true brand=Visa path=/memos
ポイントは、クレジットカード番号らしき文字列をそのまま出力しないこと。最初6桁(BIN)と末尾4桁だけを残し、中間をマスクしています。これにより、PCI DSSに抵触せずに調査が可能です。
2: ログを分析して誤検知を特定
2週間ほどログを蓄積し、以下の観点で分析しました。
- どのパスで検知が発生しているか
- どのようなパターンで誤検知が発生しているか
この分析により、誤検知パターンを特定し、除外ルールを追加しました。
3: バリデーションを有効化
誤検知がゼロまで減ったことを確認してから、実際のリクエスト拒否を有効化しました。
まとめ
この記事では、クレジットカード番号検出システムの実装と運用について解説しました。
技術的なポイント
- 正規表現 → 正規化 → Luhnチェック → ブランドチェックの4段階で検出
- Luhnアルゴリズムは誤入力検知のためのチェックサム
- カードブランドごとにプレフィックスと有効桁数が異なる
運用のポイント
- 最初はログ出力のみでリリースし、誤検知を分析
- スキップ対象パスを設定して誤検知を回避
- 段階的にバリデーションを強化
クレジットカード番号の検出は「完璧」を求めると際限がありません。誤検知ゼロを目指しつつも、ビジネス上の優先度とバランスを取りながら、継続的に改善していくことが大切です。
この記事がサービスのセキュリティ向上やPCI DSS準拠の参考になれば幸いです。
明日は駅伝企画第五走者の ohbarye にタスキを渡したいと思います!
参考資料
- 日本カード情報セキュリティ協議会(JCDSC) - 国内のPCI DSS普及促進団体
- PCI DSSとは|JCDSC - PCI DSSの日本語解説
- Luhn algorithm - Wikipedia
- Luhnアルゴリズムとは - Stripe
- Payment card number - Wikipedia