生成AIへ送る文章をブラウザ内でマスキング・復元する仕組み
はじめに
前回の記事では、AIに事業案の検討から設計、実装、公開までを任せた結果、生成AI向けのマスキングサービス「MaskedForm」が公開された経緯を紹介しました。
前回の記事:
MaskedFormは、生成AIへ文章を送る前に、メールアドレスやIPアドレスなどの情報候補を次のようなトークンへ置き換えるWebツールです。
tanaka@example.com
↓
[EMAIL_01]
AIから返された回答に既知のトークンが含まれていれば、同じブラウザタブ内で元の値へ戻せます。
[EMAIL_01]へ連絡してください。
↓
tanaka@example.comへ連絡してください。
今回の記事では、MaskedFormの中核となる次の仕組みを解説します。
- なぜマスキングをブラウザ内で行うのか
- 検出ルールをどのように定義しているか
- 同じ値へ同じトークンを割り当てる方法
- 複数の検出結果が重なった場合の処理
- AI回答から元の値へ戻す方法
- 未知のトークンを変更しない理由
- 匿名利用集計と入力データの境界
- この方式で防げないこと
現在のMaskedFormは、以下から試せます。
MaskedFormの処理フロー
MaskedFormの基本的な処理は、次の流れです。
利用者が文章を入力
↓
ブラウザ内で対象候補を検出
↓
検出位置と値を一覧化
↓
値ごとに安定したトークンを割り当てる
↓
マスク済み文章を生成
↓
利用者が生成AIへ手動で貼り付ける
↓
AI回答をMaskedFormへ貼り付ける
↓
既知のトークンだけを元の値へ戻す
MaskedForm自身は、マスク済み文章を生成AIへ送信しません。
利用者が結果をコピーし、利用する生成AIサービスへ自分で貼り付けます。
つまり、MaskedFormが担当する範囲は次の部分です。
原文
↓
マスク済み文章
↓
AI回答に含まれるトークンの復元
生成AIとの通信そのものは、MaskedFormの処理範囲外です。
なぜブラウザ内で処理するのか
マスキングサービスを作る場合、サーバーへ文章を送信して処理する構成も考えられます。
ブラウザ
↓ 原文を送信
サーバー
↓ マスキング
ブラウザへ結果を返却
しかし、この構成では、保護したい原文をいったんMaskedFormのサーバーへ送ることになります。
例えば次のような文章を扱う場合、サービス運営側がその内容を受信することになります。
顧客の株式会社青空で障害が発生しています。
担当者はtanaka@example.comです。
対象サーバーは192.168.10.24です。
これは、「外部へ送る前に情報を減らす」というサービスの目的と相性がよくありません。
そこでMaskedFormでは、次のデータをReactの状態として現在のブラウザタブ内で処理します。
- 入力した原文
- マスク済み文章
- AIから貼り付けた回答
- トークンと元の値の対応表
- 利用者が追加した任意の登録語句
これらをMaskedFormのアプリケーションデータベースへ保存しません。
ブラウザを閉じたり、ページを再読み込みしたりすると、現在の対応表も失われます。
利便性よりも、元データを保持しないことを優先した設計です。
検出対象
2026年7月時点の公開ベータでは、次の情報候補を検出します。
| 種別 | トークン例 |
|---|---|
| AWSアクセスキーID候補 | [AWS_KEY_01] |
| メールアドレス | [EMAIL_01] |
| IPv4アドレス | [IP_01] |
| 日本の電話番号候補 | [PHONE_01] |
| カード番号候補 | [CARD_01] |
| 利用者が追加した語句 | [CUSTOM_01] |
検出ルールは、TypeScript上で次のような形式で定義しています。
type Rule = {
type: string;
label: string;
pattern: RegExp;
};
const RULES: Rule[] = [
{
type: "AWS_KEY",
label: "AWSアクセスキー",
pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
},
{
type: "EMAIL",
label: "メールアドレス",
pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
},
{
type: "IP",
label: "IPv4アドレス",
pattern:
/\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g,
},
{
type: "PHONE",
label: "電話番号",
pattern:
/(?<!\d)(?:0\d{1,4}[-ー- ]?\d{1,4}[-ー- ]?\d{3,4})(?!\d)/g,
},
{
type: "CARD",
label: "カード番号候補",
pattern: /\b(?:\d[ -]*?){13,19}\b/g,
},
];
単純な正規表現だけで判定しているわけではありません。
電話番号候補やカード番号候補には、追加の検証を行っています。
電話番号候補の追加判定
電話番号らしい文字列を正規表現だけで検出すると、電話番号ではない長い数値まで拾う可能性があります。
そのため、区切り記号を取り除いた後の数字が10桁または11桁であることを確認します。
function digitsOnly(value: string) {
return value.replace(/\D/g, "");
}
function isJapanesePhoneCandidate(value: string) {
const digits = digitsOnly(value);
return digits.length === 10 || digits.length === 11;
}
例えば、次の値は候補として扱います。
03-1234-5678
090 1234 5678
0120ー123ー456
一方、次のような12桁以上の文字列は除外します。
012345678901
01234-5678-9012
ただし、桁数が正しいことは、その番号が実在することを意味しません。
MaskedFormが行っているのは、あくまで「日本の電話番号らしい候補」の検出です。
カード番号候補にはLuhnチェックを使う
13桁から19桁の数列をすべてカード番号として扱うと、注文番号や管理番号などを大量に誤検出する可能性があります。
そこで、カード番号候補にはLuhnアルゴリズムによる検査を追加しています。
function passesLuhn(value: string) {
const digits = value.replace(/\D/g, "");
if (digits.length < 13 || digits.length > 19) {
return false;
}
if (new Set(digits).size === 1) {
return false;
}
let sum = 0;
let doubleDigit = false;
for (let index = digits.length - 1; index >= 0; index -= 1) {
let digit = Number(digits[index]);
if (doubleDigit) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
doubleDigit = !doubleDigit;
}
return sum % 10 === 0;
}
また、次のように同じ数字だけが繰り返される値は除外します。
0000 0000 0000 0000
1111 1111 1111 1111
ただし、Luhnチェックを通過したからといって、実際に有効なカード番号であることを保証するものではありません。
逆に、業務上の管理番号が偶然Luhnチェックを通る可能性もあります。
このため、画面上では「カード番号」ではなく「カード番号候補」と表現しています。
任意の顧客名や案件名を登録できるようにする
メールアドレスやIPv4アドレスは、比較的規則的な形式を持っています。
一方、次のような情報を汎用的な正規表現だけで判定するのは困難です。
- 顧客名
- 案件名
- 製品名
- 社内システム名
- 担当者名
- 非公開のホスト名
そのため、MaskedFormでは利用者が任意の語句を登録できます。
例えば、次の語句を登録したとします。
株式会社青空
青空案件
Project Falcon
入力文章に一致する語句があれば、次のように置き換えます。
株式会社青空
↓
[CUSTOM_01]
登録語句を正規表現へ変換する際は、特殊文字をエスケープします。
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
これにより、例えば次のような記号を含む語句も文字列として扱えます。
Project A+
customer[01]
system.example
長い登録語句を優先する
任意語句の検出では、部分一致の重複が問題になります。
例えば、次の2語を登録したとします。
青空
株式会社青空
入力文章が次の場合、
株式会社青空の青空案件
短い語句から処理すると、株式会社青空の内部にある青空だけが先に検出される可能性があります。
そこで、登録語句を文字数の長い順へ並べます。
customTerms
.map((term) => term.trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
これにより、同じ位置から始まる候補では、より長い語句を優先できます。
株式会社青空
↓
[CUSTOM_01]
青空
↓
[CUSTOM_02]
検出結果は位置情報として保持する
検出結果は、元の値だけでなく、文章内の開始位置と終了位置を持ちます。
export type Finding = {
type: string;
value: string;
replacement: string;
start: number;
end: number;
};
例えば、次の文章があるとします。
連絡先はtanaka@example.comです。
メールアドレスの検出結果は、概念的には次のようになります。
{
type: "EMAIL",
value: "tanaka@example.com",
replacement: "[EMAIL_01]",
start: 4,
end: 22
}
位置情報を持つことで、元の文章を何度も正規表現で置換せずに、左から順番に新しい文章を構築できます。
重複する検出結果を除外する
同じ部分が複数のルールに一致する場合があります。
例えば、メールアドレス全体を任意語句として登録していた場合、その文字列は次の2つに一致します。
CUSTOMEMAIL
contact@example.com
両方を採用すると、同じ範囲を二重に置換することになります。
そこで、候補を次の優先順位で並べます。
- 開始位置が前のもの
- 同じ開始位置なら終了位置が後ろのもの
- 範囲も同じなら種別の優先順位が高いもの
const TYPE_ORDER = [
"CUSTOM",
"AWS_KEY",
"EMAIL",
"IP",
"PHONE",
"CARD",
];
実装は次のようになっています。
const selected = raw
.sort(
(a, b) =>
a.start - b.start ||
b.end - a.end ||
TYPE_ORDER.indexOf(a.type) - TYPE_ORDER.indexOf(b.type),
)
.filter(
(item, index, all) =>
!all
.slice(0, index)
.some(
(other) =>
item.start < other.end &&
item.end > other.start,
),
);
すでに採用された範囲と重なる候補は除外します。
その結果、任意登録された語句と組み込みルールが同じ範囲に一致した場合は、CUSTOMを優先します。
同じ値には同じトークンを割り当てる
MaskedFormでは、同じ値が複数回登場した場合、同じトークンを割り当てます。
入力例:
tanaka@example.comへ連絡してください。
返信先もtanaka@example.comです。
出力例:
[EMAIL_01]へ連絡してください。
返信先も[EMAIL_01]です。
1回目を[EMAIL_01]、2回目を[EMAIL_02]にしてしまうと、同じメールアドレスであるという関係が失われます。
そこで、種別と値を組み合わせた識別子を作り、すでに割り当てたトークンを再利用します。
const counters = new Map<string, number>();
const identities = new Map<string, string>();
const identity = `${item.type}:${item.value}`;
let replacement = identities.get(identity);
if (!replacement) {
const next = (counters.get(item.type) ?? 0) + 1;
counters.set(item.type, next);
replacement =
`[${item.type}_${String(next).padStart(2, "0")}]`;
identities.set(identity, replacement);
}
例えば、次の値には別々の識別子が作られます。
EMAIL:tanaka@example.com
EMAIL:suzuki@example.com
IP:192.168.10.24
その結果、次のような対応になります。
tanaka@example.com → [EMAIL_01]
suzuki@example.com → [EMAIL_02]
192.168.10.24 → [IP_01]
番号は種別ごとに管理します。
そのため、EMAIL_01とIP_01は同時に存在できます。
入力順が変わらなければ結果も安定する
生成AIへ送る文章では、同じ対象が常に同じトークンになることが重要です。
例えば、任意語句の登録順が次のように異なっても、
["顧客A", "顧客B"]
["顧客B", "顧客A"]
文章内の登場順が同じなら、結果も同じになるようにしています。
入力:
顧客Aと顧客B、再び顧客A
結果:
[CUSTOM_01]と[CUSTOM_02]、再び[CUSTOM_01]
登録配列の順番ではなく、文章内の検出位置を基準にトークンを割り当てるためです。
このような決定性は、テストでも確認しています。
test("produces deterministic output regardless of custom-term input order", () => {
const source = "顧客Aと顧客B、再び顧客A";
const first = applyMask(
source,
detect(source, ["顧客A", "顧客B"]),
);
const second = applyMask(
source,
detect(source, ["顧客B", "顧客A"]),
);
assert.equal(
first,
"[CUSTOM_01]と[CUSTOM_02]、再び[CUSTOM_01]",
);
assert.equal(second, first);
});
マスク済み文章を組み立てる
検出結果が確定したら、文章を左から順番に構築します。
export function applyMask(
text: string,
findings: Finding[],
) {
let cursor = 0;
let result = "";
findings.forEach((finding) => {
result +=
text.slice(cursor, finding.start) +
finding.replacement;
cursor = finding.end;
});
return result + text.slice(cursor);
}
例えば、次の文章があるとします。
担当者はtanaka@example.com、接続先は192.168.1.10です。
検出結果は概念的に次の2件です。
tanaka@example.com
192.168.1.10
処理は次の順番になります。
「担当者は」
+
「[EMAIL_01]」
+
「、接続先は」
+
「[IP_01]」
+
「です。」
最終結果:
担当者は[EMAIL_01]、接続先は[IP_01]です。
この方法では、置換後の文字数が変わっても、後続の位置情報に影響しません。
元の文章に対する位置情報を使い、1回だけ左から構築しているためです。
AI回答から元の値へ戻す
次に、AI回答内のトークンを元の値へ戻します。
まず、マスキング時の検出結果から対応表を作ります。
const replacements = new Map<string, string>();
findings.forEach((finding) => {
replacements.set(
finding.replacement,
finding.value,
);
});
対応表は、例えば次のようになります。
[EMAIL_01] → tanaka@example.com
[IP_01] → 192.168.1.10
そのうえで、対応する形式のトークンを検索します。
const TOKEN_PATTERN =
/\[(?:CUSTOM|AWS_KEY|EMAIL|IP|PHONE|CARD)_\d{2,}\]/g;
実際の復元処理は次のようになります。
export function restoreMask(
text: string,
findings: Finding[],
) {
const replacements = new Map<string, string>();
findings.forEach((finding) => {
replacements.set(
finding.replacement,
finding.value,
);
});
let restoredCount = 0;
const restoredText = text.replace(
/\[(?:CUSTOM|AWS_KEY|EMAIL|IP|PHONE|CARD)_\d{2,}\]/g,
(token) => {
const original = replacements.get(token);
if (original === undefined) {
return token;
}
restoredCount += 1;
return original;
},
);
return {
restoredText,
restoredCount,
};
}
既知のトークンだけを復元し、復元できた件数も返します。
未知のトークンをそのまま残す
AIが回答内で、元の文章には存在しなかったトークンを生成することがあります。
例えば、MaskedFormが保持している対応表が次だけだったとします。
[EMAIL_01] → tanaka@example.com
AI回答:
[EMAIL_01]へ連絡し、[EMAIL_02]にも共有してください。
この場合、[EMAIL_02]の元の値は分かりません。
そのため、結果は次のようになります。
tanaka@example.comへ連絡し、[EMAIL_02]にも共有してください。
未知のトークンを削除したり、推測で別の値に置き換えたりしません。
これは、復元処理を安全側に倒すためです。
次のような不正形式や未対応形式も、そのまま保持します。
[EMAIL_1]
[EMAIL_XX]
[UNKNOWN_01]
[EMAIL_999]
対応表に存在する正しいトークンだけを復元します。
対応表はブラウザタブ内だけに存在する
トークンの復元には、次のような対応表が必要です。
[EMAIL_01] → tanaka@example.com
[IP_01] → 192.168.1.10
[CUSTOM_01] → 株式会社青空
MaskedFormでは、この対応表をサーバーへ保存しません。
Reactの状態として、現在のブラウザタブ内にだけ存在します。
この設計には、次の制約があります。
- ページを再読み込みすると復元できなくなる
- 別の端末では復元できない
- 別のタブへ自動的に対応表を共有しない
- ブラウザを閉じると対応表が失われる
- 過去の文章を後から復元する履歴機能はない
一見すると不便ですが、対応表を永続保存する場合は、保存先の安全性、暗号化、アクセス制御、削除、バックアップなどを考える必要があります。
公開ベータでは、その複雑さを避け、タブ内の一時処理に限定しました。
匿名利用集計は別経路で送信する
MaskedFormでは、原文や置換表は送信しませんが、すべての通信が存在しないわけではありません。
サービスの利用状況を確認するため、次のような操作名と区分を集計APIへ送信します。
{
"event": "masked_copy",
"variant": "default"
}
主なイベントは次のようなものです。
- ツール画面を表示した
- マスク済み文章をコピーした
- 復元済み文章をコピーした
- 価格ページを表示した
- 価格案に関心を示した
- 用途別ガイドからツールを開いた
- 固定選択式アンケートへ回答した
ここで重要なのは、APIが任意のJSONを受け付けないことです。
受け付けるイベント名と区分の組み合わせを、許可リストとして固定しています。
export const EVENT_VARIANTS = {
tool_view: ["default"],
masked_copy: ["default"],
restored_copy: ["default"],
pricing_view: ["default"],
price_interest: [
"personal_2980",
"team_4980",
],
guide_tool_open: [
"logs",
"personal-data",
"aws-keys",
],
} as const;
例えば、次のような任意文字列は受け付けません。
{
"event": "masked_copy",
"variant": "tanaka@example.com"
}
次のように余分な項目が含まれている場合も拒否します。
{
"event": "masked_copy",
"variant": "default",
"originalText": "顧客情報を含む文章"
}
APIは、キーがeventとvariantの2つだけであることを確認します。
const keys = Object.keys(record).sort();
if (
keys.length !== 2 ||
keys[0] !== "event" ||
keys[1] !== "variant"
) {
return { ok: false };
}
さらに、リクエスト本文を256バイト以下に制限しています。
これにより、誤実装によって長い入力本文がイベントAPIへ送られるリスクを減らしています。
集計データから分からないこと
MaskedFormの集計データベースには、次の情報を保存しません。
- 原文
- マスク済み文章
- AI回答
- 置換表
- 登録語句
- 氏名
- メールアドレス
- IPアドレス
- Cookie
- セッションID
- 端末ID
- User-Agent
- 個別イベントの時刻
保存するのは、基本的に次の4項目です。
| 項目 | 内容 |
|---|---|
event_date |
日付 |
event_name |
許可済みの操作名 |
variant |
許可済みの区分 |
count |
合計回数 |
そのため、次のことは分かりません。
- ユニーク利用者数
- 同じ利用者が再訪したか
- 利用者単位のマスク完了率
- 利用者単位のコンバージョン率
- 個人や組織ごとの利用履歴
- 7日後や30日後の継続率
例えば、マスク済みコピーが100回記録されていても、100人が1回ずつ使ったのか、1人が100回使ったのかは区別できません。
この点は、プライバシーを優先したことによる計測上の制約です。
テストで確認している境界
マスキング処理では、正常系だけでなく、誤検出や境界条件もテストしています。
主なテスト項目は次のとおりです。
メールアドレス
first.last+tag@example.co.jp
user@example.com
不完全な形式は除外します。
user@example
@example.com
IPv4アドレス
境界値を受け付けます。
0.0.0.0
255.255.255.255
不正なオクテットは除外します。
256.1.1.1
999.999.999.999
AWSアクセスキーID候補
次の形式を検出します。
AKIAIOSFODNN7EXAMPLE
ASIAIOSFODNN7EXAMPLE
小文字や桁数不正は除外します。
カード番号候補
Luhnチェックを通過する候補だけを採用します。
登録語句の重複
長い語句を優先します。
同一値の再登場
同じトークンを再利用します。
復元処理
既知のトークンだけを復元し、未知のものは保持します。
この方式で防げないこと
MaskedFormは、機密情報の完全検出を目的としたDLP製品ではありません。
現在の方式では、例えば次のような情報を自動検出できない場合があります。
- 未登録の人名
- 住所
- 顧客固有の管理番号
- 社内だけで意味を持つ略称
- ホスト名
- データベース名
- AWSシークレットアクセスキー
- APIトークン
- JWT
- 文脈上のみ機密となる文章
- 文章全体から推測可能な個人情報
- 複数の情報を組み合わせることで特定できる情報
また、メールアドレスや電話番号らしい文字列を検出しても、それが本当に機密情報かどうかは判断できません。
反対に、公開情報であっても形式が一致すれば検出します。
そのため、MaskedFormの役割は次の範囲です。
規則と利用者の登録語句に基づいて候補を置換し、生成AIへ送信する前の目視確認を補助する
マスキング後の文章は、必ず送信前に確認する必要があります。
「匿名化」ではなく「マスキング」と呼ぶ理由
MaskedFormでは、処理結果を「匿名化」とは呼ばず、「マスキング」または「タグへの置換」と表現しています。
例えば、次の文章をマスクしたとします。
[CUSTOM_01]の担当者である[EMAIL_01]から、
[IP_01]の障害について連絡がありました。
固有値は除かれていますが、文章の内容や他の情報から対象を推測できる可能性があります。
また、トークンと元の値の対応表があれば、元へ戻せます。
したがって、この処理だけで法令上の匿名加工情報や匿名化に該当するとは限りません。
MaskedFormは、法令適合性を判定するものでもありません。
この境界を曖昧にしないため、「匿名化」ではなく「マスキング」という表現を使っています。
現在のアーキテクチャ
2026年7月時点の構成は次のとおりです。
ブラウザ
├─ React UI
├─ 検出処理
├─ トークン割り当て
├─ マスキング
├─ 対応表の一時保持
└─ AI回答の復元
サーバー側
├─ Webアプリ配信
├─ 許可リスト型の操作イベントAPI
└─ 日別・操作別の集計データ
使用している主な要素は次のとおりです。
- Vinext / React / Next.js
- ChatGPT Sitesのホスティング環境
- Cloudflare Worker互換の実行環境
- Drizzle ORM
- Cloudflare D1互換の集計データ保存
- TypeScript
- Node.js標準テストランナー
- GitHub Actions
マスキング処理の中でOpenAI APIは呼び出していません。
今後の改善候補
現在の公開ベータで需要が確認できた場合、次の改善を検討しています。
- 検出ルールの追加
- 誤検出を個別に解除する操作
- マスク対象の手動追加
- トークン名の編集
- より分かりやすい差分表示
- ブラウザ拡張による貼り付け前処理
- ドメインごとの有効・無効設定
- 端末内に保存するカスタムルール
- 対応サイトの仕様変更時に安全側へ停止する処理
- シークレット値の追加検出
- より多くのテストケース
ただし、機能を追加するほど、誤検出や過剰な権限、対応表の保存方法など、新しい問題も増えます。
そのため、まず無料Web版の利用状況を確認し、実際に求められる機能から優先する予定です。
まとめ
MaskedFormでは、生成AIへ送る文章を次の考え方で処理しています。
- 元の文章はブラウザ内で処理する
- 検出結果は開始位置と終了位置を持つ
- 重複する検出候補は優先順位で整理する
- 同じ値には同じトークンを割り当てる
- 文章内の登場順に基づいて決定的に処理する
- AI回答では既知のトークンだけを復元する
- 未知のトークンは変更しない
- 対応表をサーバーへ保存しない
- 操作計測は許可済みのイベント名と区分だけに限定する
- 完全検出や法令上の匿名化は保証しない
技術的には比較的単純な正規表現と文字列処理が中心です。
しかし、サービスとして公開するには、検出精度だけでなく、次のような境界を明確にする必要がありました。
- 何を外部へ送らないのか
- 何を集計するのか
- 何を保存しないのか
- 何を復元するのか
- 復元できない場合にどうするのか
- 何を保証しないのか
MaskedFormは現在、無料公開ベータとして利用できます。
なお、MaskedFormはDLP製品ではなく、すべての機密情報を検出するものではありません。マスキング後の文章は、生成AIへ送信する前に必ず確認してください。
次回は、AIがGitHub Issue、Pull Request、テスト、公開判定、ロールバック手順まで含めて、MaskedFormの開発をどのように進めたかを紹介します。