🚀

Cloudflare Snippets を使ったDLPの実装サンプル クレジットカード番号を塗りつぶす

2024/11/29に公開

前回の記事で、Cloudflare WAFが備えているDLP機能の使い方を説明しました。
https://zenn.dev/kameoncloud/articles/cf6e87bd094fa5
このDLP機能は主に以下2つの理由で通信をブロックさせず、Logを出力して管理者にアラートを出すのみとなっています。

1.通信遅延への配慮
すべての通信においてAPIコールのResponseをリアルタイムで行い、様々なセンシティブインフォーメーションのチェックを行うことはかなりの通信遅延を引き起こします。このためDLP機能は非同期で通信を検査しLogを出力します。

2.誤検知への配慮
MIMEType等を活用して高い確率で検知可能なファイル種別単位のブロックと異なり、文字列を対象としたDLPはどうしても誤検知が増えてしまいます。例えばクレジットカード番号であればある程度のアルゴリズム(後述します)により判別はつくものの100%ではありませんし、CVVを対象とした場合3桁の数字全てが対象となってしまいます。免許書番号やパスポート番号も同様の問題をはらみます。これらを精緻に判別するためにはお客様毎のビジネスロジック(流れるデータの組み合わせなどにより判別の可能性を高める)仕組みが必要です。

Cloudflare Snippets

https://zenn.dev/kameoncloud/articles/89baddeed1bd1d
CloudflareにはSnippetsというWAFの中でJavaScriptを実行させる機能が備わっており、そこに詳細のロジックを設定してRequest/Responseの通信をドロップする、書き換える、追加の値を付与する、などが可能となっています。

これを活用して、クレジットカード番号の漏洩を防ぐサンプルを作成しました。

サンプル

https://zenn.dev/kameoncloud/articles/cf6e87bd094fa5
まずはこの記事の内容をもとにAMEX番号を漏洩させてしまうオリジンを構築します。Cloudflare側のDLP設定は不要でDNS設定のみを行っておきます。

次に以下の記事をもとに一度Snippetsを動作させてテストしておきます。
https://zenn.dev/kameoncloud/articles/89baddeed1bd1d
動作が確認できれば次にコードを以下に置換します。

export default {
    async fetch(request) {
        // オリジンからレスポンスを非同期で取得
        const response = await fetch(request);

        // レスポンスのボディをJSONとして読み取る
        const responseBody = await response.json();

        // デバッグ情報を格納するためのオブジェクトを作成
        const debugInfo = {
            action: 'no action', // 置き換えが行われたかどうか
        };

        // Luhnアルゴリズムを使用して有効なクレジットカード番号かどうかを確認する関数
        const isLuhnValid = (cardNumber) => {
            let sum = 0;
            let shouldDouble = false;

            // 右から左に向かって各桁を処理
            for (let i = cardNumber.length - 1; i >= 0; i--) {
                let digit = parseInt(cardNumber.charAt(i));

                if (shouldDouble) {
                    digit *= 2;
                    if (digit > 9) {
                        digit -= 9;
                    }
                }

                sum += digit;
                shouldDouble = !shouldDouble;
            }

            // Luhnアルゴリズムが通るかどうかをチェック
            return sum % 10 === 0;
        };

        // 再帰的にオブジェクトのすべてのフィールドをチェックし、15桁の数字を置き換える関数
        const replaceAMEXValues = (obj) => {
            // オブジェクトが配列の場合
            if (Array.isArray(obj)) {
                return obj.map(item => replaceAMEXValues(item));
            }

            // オブジェクトの場合
            if (obj !== null && typeof obj === 'object') {
                const updatedObj = {};
                for (const key in obj) {
                    if (obj.hasOwnProperty(key)) {
                        updatedObj[key] = replaceAMEXValues(obj[key]);
                    }
                }
                return updatedObj;
            }

            // 15桁の数字の文字列であれば、Luhnアルゴリズムでチェック
            if (typeof obj === 'string' && /^\d{15}$/.test(obj)) {
                // Luhnチェックを通過する場合のみ置き換え
                if (isLuhnValid(obj)) {
                    debugInfo.action = 'replaced'; // 置き換えが行われたことを記録
                    return 'XXXXXXXXXXXXXXXX'; // 置き換え
                }
            }

            // それ以外の場合はそのまま返す
            return obj;
        };

        // レスポンスのボディ内のすべての値を置き換え
        const updatedResponseBody = replaceAMEXValues(responseBody);

        // デバッグ情報をレスポンスボディに追加
        updatedResponseBody.debugInfo = debugInfo;

        // 更新されたボディを使って新しいレスポンスを作成
        return new Response(JSON.stringify(updatedResponseBody), {
            status: response.status,
            headers: response.headers,
        });
    },
};

出力が以下のように塗りつぶされていれば成功です。

{"AMEX":"XXXXXXXXXXXXXXXX","debugInfo":{"action":"replaced"}}

コード解説とLuhnチェック

        // オリジンからレスポンスを非同期で取得
        const response = await fetch(request);

        // レスポンスのボディをJSONとして読み取る
        const responseBody = await response.json();

の部分でオリジンからResponseを取得しています。
その後以下の部分でJSON構造を解析し、15桁の数字が格納されていた場合塗りつぶしを実行しています。

 // 再帰的にオブジェクトのすべてのフィールドをチェックし、15桁の数字を置き換える関数
        const replaceAMEXValues = (obj) => {
            // オブジェクトが配列の場合
            if (Array.isArray(obj)) {
                return obj.map(item => replaceAMEXValues(item));
            }

            // オブジェクトの場合
            if (obj !== null && typeof obj === 'object') {
                const updatedObj = {};
                for (const key in obj) {
                    if (obj.hasOwnProperty(key)) {
                        updatedObj[key] = replaceAMEXValues(obj[key]);
                    }
                }
                return updatedObj;
            }

            // 15桁の数字の文字列であれば、Luhnアルゴリズムでチェック
            if (typeof obj === 'string' && /^\d{15}$/.test(obj)) {
                // Luhnチェックを通過する場合のみ置き換え
                if (isLuhnValid(obj)) {
                    debugInfo.action = 'replaced'; // 置き換えが行われたことを記録
                    return 'XXXXXXXXXXXXXXXX'; // 置き換え
                }
            }

            // それ以外の場合はそのまま返す
            return obj;
        };

以下の部分でただの数字の羅列なのかクレジットカード番号なのか、の判別を行っています。

                if (isLuhnValid(obj)) {
---
        // Luhnアルゴリズムを使用して有効なクレジットカード番号かどうかを確認する関数
        const isLuhnValid = (cardNumber) => {
            let sum = 0;
            let shouldDouble = false;

            // 右から左に向かって各桁を処理
            for (let i = cardNumber.length - 1; i >= 0; i--) {
                let digit = parseInt(cardNumber.charAt(i));

                if (shouldDouble) {
                    digit *= 2;
                    if (digit > 9) {
                        digit -= 9;
                    }
                }

                sum += digit;
                shouldDouble = !shouldDouble;
            }

            // Luhnアルゴリズムが通るかどうかをチェック
            return sum % 10 === 0;
        };

クレジットカード番号は桁数の違いはあれど、主要ブランドは全てある一定の法則に従っています。その法則がLuhnチェックと言われるもので、このチェックを通る文字列のみがクレジットカード番号となります。ただし、クレジットカード番号以外の数字もこのチェックを通るケースはありますので、100%ではありません。

メールアドレスなどその他センシティブ情報の塗りつぶしも同様のSnippetsを活用した方法で作ることが可能です。

Discussion