個人情報マスキングの一部伏字関数を氏名に使い回すと、audit_logでも姓が丸ごと露出する
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
16進識別子用に両端4文字(短い値は先頭2文字)を残して中間だけ伏字にするmaskIdentifierを日本語の氏名に使い回すと、2〜4文字の氏名では残す側がほぼ全体を覆うため、姓や氏名そのものが伏字にならず露出する。
結論
16進IDやUUIDのような識別子を想定して書いた「両端を残して中間だけ伏字にする」マスク関数を、日本語の氏名にそのまま使い回すと、短い氏名では残す側がほぼ全体を占めてしまい、マスクとして機能しない。 システム管理者向けの監査画面で、2〜4文字の日本語氏名の姓(や氏名全体)がそのまま表示されていた。さらに監査ログのJSON全文表示では、人名・連絡先系のキーがそもそも伏字対象のキー一覧に入っておらず、別の経路でも同じ種類の漏れが起きていた。直し方は、人名専用に「文字も長さも一切残さない」マスク関数を新設して表示名の変換先を切り替え、JSON側は人名・連絡先キーを検出する正規表現を足して再帰的に全伏字にすること。
症状
システム管理者向けの memberships(ユーザー×クラス所属)一覧は、生徒・職員の表示名をマスクしてから返す設計になっていた。実際のマスク関数はこれだった。
// mask.ts:26-33(修正前)
/** 識別子の中間部を伏せる: "AA:BB:CC:DD:EE:FF" → "AA:…:FF" / "0123456789abcdef" → "0123…cdef"。 */
export function maskIdentifier(value: string): string {
if (value.length <= 8) {
// 短い値は先頭 2 文字だけ残す (全伏せだと突合不能、全表示だと識別子のまま)。
return `${value.slice(0, 2)}${"•".repeat(Math.max(1, value.length - 2))}`;
}
return `${value.slice(0, 4)}…${value.slice(-4)}`;
}
このコメントが想定している入力は mac / client / session / token / device_id のような16進・UUID系の識別子で、8文字以下の短い値でも先頭2文字だけ残せば十分に伏字として機能する。ところが、この関数が呼ばれていたのはそうした識別子だけではなかった。
// membership-list.ts:163-170(修正前)
return {
// 生の displayName / userId はここで落とし、マスク済みのみ返す (モジュール doc「PII」)。
rows: rows.map(({ displayName, userId, ...rest }) => ({
...rest,
userDisplayMasked: maskIdentifier(displayName),
userIdMasked: maskIdentifier(userId),
})),
生徒・職員の displayName(日本語の氏名)にも同じ maskIdentifier が使われていた。日本語の氏名はJavaScriptの文字列長としては1文字=1としてカウントされるため、4文字の氏名なら8文字以下の分岐に入り、先頭2文字(たいていは姓)だけがそのまま残る。2文字の氏名ならほぼ全体が残る。モジュール内のコメント自身が、このときの出力例を「例: “田中••”」と書いていた。
原因
maskIdentifier は「両端を残して中間だけ伏字にする」という設計そのものは識別子に対しては正しい。16進文字列やUUIDは中間部分だけ見えなくすれば十分に匿名化になり、かつ両端が残っていれば画面上で同一の値かどうかの突合にも使える。問題は、この関数が「対象の長さに対して残す文字数が固定」である点にあり、識別子では十分に長い文字列が前提だったのに対し、日本語の氏名では2〜4文字という短さがその前提を壊した。
もう一つの漏れは別の経路にあった。監査ログのJSONを画面表示用に変換する maskJsonForDisplay は、キー名のパターンで伏字対象を判定していたが、そのパターンは識別子系のキーしか見ていなかった。
// mask.ts(修正前)
const SENSITIVE_KEY_RE = /(mac|client|session|token|secret|credential|device_id|uid|user_id)/;
display_name や full_name、phone、email のような人名・連絡先系のキーはこのパターンにマッチしないため、maskJsonForDisplay の既定の分岐(文字列を120文字で切り詰めるだけ)に落ちる。120文字未満の氏名やメールアドレスは切り詰めが発生しないため、audit_log.diff のようにJSON全文を表示する画面ではverbatimのまま表示されていた。設計時に「識別子キーだけ伏字にすればよい」という前提で止まっており、「人名・連絡先もPIIである」という前提が別のキー一覧として足されていなかった。
直し方
既存の maskIdentifier は識別子の正規用途のまま変更せず、人名専用の関数を新設して呼び出し先を切り替えた。
// mask.ts:45-52(修正後)
/**
* 人名 (生徒/保護者/教職員の displayName 等) の表示マスク。`maskIdentifier` は 16進識別子用で
* 短い日本語氏名だと姓 (や 2 文字氏名の全体) が露出する (ISSUE-2) ため、人名は文字も長さも
* 一切残さず固定の伏字にする。行の突合はマスク済み userId 側で行う。
*/
export function maskPersonName(value: string | null | undefined): string {
return value ? "••" : "";
}
// membership-list.ts:168(修正後)
userDisplayMasked: maskPersonName(displayName),
JSON表示側は、人名・連絡先系のキーを検出するための正規表現を新設し、該当すれば再帰的に全伏字にする経路へ分岐させた。
// mask.ts:17-28(修正後)
const NAME_KEY_RE =
/(display_?name|full_?name|first_?name|last_?name|family_?name|student_?name|parent_?name|guardian_?name|user_?name|nick_?name|kana|furigana|ruby|^name$|^names$|^students?$|^members?$|^attendees?$|roster|e_?mail|phone|^tel$|mobile|address|^addr|zip|postal|birth)/;
このキー一覧には schoolName や className のような非PIIのキーは意図的に含めていない。監査の実用性(どの学校・どのクラスの事象かは読めたままにする)を保ちつつ、人名・連絡先だけを狙って伏字にする設計にしてある。
再発防止
このマスク関数が「両端を残す」という設計だったこと自体は、識別子という入力を前提にすれば合理的だった。問題が起きたのは、その関数を別の種類の入力(日本語氏名)に使い回した時点で、関数名や型だけでは「この入力に対して安全か」を判断できなかったことにある。value: string という引数の型は、16進識別子も日本語氏名も同じように受け取れてしまう。マスク関数を増やすときは、対象の文字列が「十分に長いことが前提の識別子」なのか「短くなり得る人間が読む値」なのかで関数を分け、呼び出し側の型やキー名で機械的に誤用を防げる形にしておく必要がある。
よくある質問
Q1なぜ識別子用のマスク関数を氏名にも使ってしまったのですか?
システム管理者向けの一覧画面で、もともとmaskIdentifierはmac/token/session/user_idなど16進・UUID系の識別子を両端だけ残して中間を伏字にする目的で作られていた。これをmembership一覧のdisplayNameにもそのまま適用したため、識別子では機能していた「両端を残す」設計が、短い日本語氏名では残す側がほぼ全体を占めてしまい、マスクとして機能しなくなった。
Q2具体的にどれくらい露出していたのですか?
maskIdentifierは8文字以下の値だと先頭2文字だけ残し、残りを文字数分の•で埋める実装だった。日本語の氏名は1文字が2バイトでもJavaScriptの文字列長では1文字として数えられるため、4文字の氏名なら先頭2文字(たいていは姓)がそのまま残り、2文字の氏名ならほぼ全体が残る形になっていた。
Q3表示名だけでなく、監査ログのJSON表示でも漏れがあったのはなぜですか?
jsonbを画面表示用に変換するmaskJsonForDisplayは、mac/token/session/user_idなど識別子系のキー名だけを伏字対象にしており、display_name/full_name/phone/emailのような人名・連絡先系のキーは対象に含まれていなかった。そのため audit_log.diff のようにJSON全文を出す経路では、120文字未満の氏名やメールアドレスがそのままverbatimで表示されていた。
Q4直し方は既存のmaskIdentifierを修正したのですか?
いいえ。maskIdentifierはmac/uuid/token等の正規用途のために両端残しのままとし、人名専用のmaskPersonName(文字も長さも一切残さない固定の••)を新設して表示名の変換先を切り替えた。JSON表示側はNAME_KEY_REという人名・連絡先キーのパターンを追加し、該当キー配下を再帰的に全伏字にするredactPiiを通すようにした。
確認した環境
- Next.js ^16.0.0 / drizzle-orm ^0.45.2
- 2026-06-12 にシステム管理者向け監査画面(UIUX-03)のOpus検証で発覚
この記事の根拠
- TypeScriptファイル 26〜33行目コミット 4c23299
- TypeScriptファイル 163〜170行目コミット 4c23299
- TypeScriptファイル 17〜28行目コミット 85e8563
- TypeScriptファイル 45〜52行目コミット 85e8563
- TypeScriptファイル 164〜170行目コミット 85e8563
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。