ログのPII漏洩を防止する: TypeScriptの型推論とランタイムの境界
テックリード @ 株式会社カケハシ
医療SaaSの共通基盤を開発。TypeScriptと関数型プログラミングで堅牢なシステム設計を実践。
ログにPIIが混入するとなぜ致命的か
サーバサイド開発において、ログにPIIが混入する事故は珍しくありません。ユーザーのメールアドレスや氏名がログに書き込まれ、それがDatadogやNew Relicなどの外部SaaSに送信されてしまう。多くの開発チームにとっては気をつけるべきことではあるものの、直ちにビジネスへ影響するような問題になるとは限りません。
しかし、医療システムや金融システムのように厳格なデータ保護要件を持つドメインでは話が変わります。個人情報保護法やGDPRでは個人データの取り扱いに厳しい制約があり、ログ基盤として国外にデータセンターを持つSaaSを利用している場合、ログへのPII混入は個人データの国外移転に該当しうるわけです。医療情報を扱う場合は厚生労働省の「医療情報システムの安全管理に関するガイドライン」や総務省・経済産業省の関連ガイドラインによるさらに厳しい制約も加わります。いずれにせよ、単なるバグではなくインシデントです。
厄介なのは、QAプロセスでログの中身が見落とされやすいことです。品質管理ではUIの使いやすさやAPIレスポンスの正しさに意識が向きがちで、「ロガーに何が渡されているか」を毎リリースで検証するチームは少ないのではないでしょうか。リリースのたびに全ログ出力をチェックするのは現実的ではありませんし、人手のチェックリストではスケールしません。
だからこそ仕組みで防ぐ必要があります。TypeScriptで型を使えば防げそうな気がしますが、実はそう簡単ではありません。
構造的部分型がPIIを透過させる
TypeScriptは構造的部分型を採用しています。型の互換性を名前ではなく構造で判定するため、ある型が求めるプロパティをすべて持っていれば、余分なプロパティがあっても互換とみなされます。
以下のプレイグラウンドで実際に試してみてください。logUserActionはLogPayload(idとroleだけ)を受け付けるので安全に見えますが、Userをそのまま渡しても型エラーになりません。「実行」するとemailがそのまま出力されることが確認できます。
type User = Readonly<{
id: string;
role: string;
email: string; // PII
}>;
type LogPayload = Readonly<{
id: string;
role: string;
}>;
function logUserAction(action: string, payload: LogPayload): void {
console.log(JSON.stringify({ action, ...payload }));
}
const user: User = { id: "1", role: "admin", email: "[email protected]" };
// 型エラーにならない!
logUserAction("login", user);オブジェクトリテラルを直接代入する場合に限り余剰プロパティチェックが例外的に働きますが、変数経由やスプレッド構文では適用されません。スプレッド構文内でも余剰プロパティチェックを効かせてほしいという要望はありますが、2026年3月時点では未対応です。
Branded Typeでも防げない
構造的部分型による漏洩を型レベルで防げないか、Branded Typeを使ったアプローチを考えてみます。Sensitive<string>というintersection型でブランドを付け、Conditional Typesで再帰的に検出してneverに推論することで型エラーを起こす戦略です。
以下のプレイグラウンドでは、safeLog(user)が型エラーになることを「問題」タブで確認できます。
declare const sensitiveTag: unique symbol;
type Sensitive<T> = T & { readonly [sensitiveTag]: true };
type RejectSensitive<T> = {
[K in keyof T]: T[K] extends { readonly [sensitiveTag]: unknown }
? never
: T[K] extends object
? T[K] & RejectSensitive<T[K]>
: T[K];
};
function safeLog<T extends Record<string, unknown>>(data: T & RejectSensitive<T>): void {
console.log(JSON.stringify(data));
}
const user = { id: "1", role: "admin", email: "[email protected]" as Sensitive<string> };
// emailがSensitive<string>なのでneverに推論され、型エラーになる
safeLog(user);
safeLog({ ...user });しかし、Sensitive<string>はstring & { readonly [sensitiveTag]: true }というintersection型であり、stringのサブタイプです。つまり、string型の変数に代入した時点でブランドが消え、型チェックをすり抜けます。以下のプレイグラウンドで確認できます。safeLog(user) のコメントアウトを外して「問題」タブの変化を見てみてください。
declare const sensitiveTag: unique symbol;
type Sensitive<T> = T & { readonly [sensitiveTag]: true };
type RejectSensitive<T> = {
[K in keyof T]: T[K] extends { readonly [sensitiveTag]: unknown }
? never
: T[K] extends object
? T[K] & RejectSensitive<T[K]>
: T[K];
};
function safeLog<T extends Record<string, unknown>>(data: T & RejectSensitive<T>): void {
console.log(JSON.stringify(data));
}
const user = { id: "1", role: "admin", email: "[email protected]" as Sensitive<string> };
// ↓ これは型エラーになる(コメントアウトを外して確認)
// safeLog(user);
// ↓ string変数に代入するとブランドが消え、型エラーにならない
const email: string = user.email;
safeLog({ id: user.id, role: user.role, email });これはBranded Typeの原理的な限界です。intersection型で付けたブランドはスーパータイプへの代入で消失するので、型レベルでの検出は必ずバイパスできてしまいます。
そもそもBranded Typeはコンパイル時だけの概念で、ランタイムには何も残りません。JSON.stringifyの出力に影響を与えることはなく、値は素のstringとしてそのままシリアライズされます。
これらの限界は個別のテクニックの問題ではなく、TypeScriptの設計思想に起因しています。TypeScript Design GoalsのNon-goal #3に「Apply a sound or “provably correct” type system. Instead, strike a balance between correctness and productivity.」と明記されている通り、TypeScriptは意図的に健全(sound)な型システムを目指していません。型検査が通ったコードがランタイムで型通りに動く保証はないのです。
型レベルの防御は「ないよりはまし」ですが、すり抜けるパターンが構造的に存在し、すり抜けたときにランタイムでは何も守ってくれない以上、主防御にはなりえません。
値をシリアライズ不能にする: Sensitive型
ここまで見てきた通り、型レベルの防御はコンパイル時のみでランタイムには何も残りません。
そこで、値そのものをシリアライズ不能にしてしまうアプローチを考えます。ScalaではCirisのSecret型として既に確立されているパターンです。
Sensitive.of()は値を関数クロージャに閉じ込めます。外から見えるのはunwrap、toJSON、toStringという関数プロパティだけであり、値自体はクロージャの中に隠されています。toJSON()を定義しているためJSON.stringifyでシリアライズされた場合は"[REDACTED]"が返り、toString()も同様にオーバーライドしているためテンプレートリテラル経由でも値は漏れません。[Symbol.for("nodejs.util.inspect.custom")]はNode.jsのconsole.logが内部で使うutil.inspectのフックで、これを定義しておくとconsole.logでも[REDACTED]と表示されます。実行して確認してみてください。
type Sensitive<T> = Readonly<{
unwrap(): T;
toJSON(): string;
toString(): string;
}>;
const Sensitive = {
of: <T>(value: T): Sensitive<T> => ({
unwrap() { return value; },
toJSON() { return "[REDACTED]"; },
toString() { return "[REDACTED]"; },
[Symbol.for("nodejs.util.inspect.custom")]() { return "[REDACTED]"; },
}),
};
type User = Readonly<{
id: string;
role: string;
email: Sensitive<string>;
}>;
const user: User = {
id: "1",
role: "admin",
email: Sensitive.of("[email protected]"),
};
// スプレッドしてもemailは漏れない
console.log("stringify:", JSON.stringify({ ...user }));
// テンプレートリテラルでもtoString()が呼ばれる
console.log(`template: User email is ${user.email}`);
// unwrapすれば値を取り出せる
console.log("unwrap:", user.email.unwrap());Node.jsのconsole.logは内部でutil.inspectを使っており、util.inspectはオブジェクトに[Symbol.for("nodejs.util.inspect.custom")]メソッドがあればその戻り値を表示します。以下のプレイグラウンドではNode.jsのutil.inspectをシミュレートして、このシンボルが定義されている場合と定義されていない場合の出力の違いを確認できます。
type Sensitive<T> = Readonly<{
unwrap(): T;
toJSON(): string;
toString(): string;
}>;
const inspectSymbol = Symbol.for("nodejs.util.inspect.custom");
// util.inspect.customなし
const withoutInspect = {
unwrap() { return "[email protected]"; },
toJSON() { return "[REDACTED]"; },
toString() { return "[REDACTED]"; },
};
// util.inspect.customあり
const withInspect = {
unwrap() { return "[email protected]"; },
toJSON() { return "[REDACTED]"; },
toString() { return "[REDACTED]"; },
[inspectSymbol]() { return "[REDACTED]"; },
};
// Node.jsのutil.inspectの挙動をシミュレート
function simulateUtilInspect(obj: Record<string | symbol, unknown>): string {
const customInspect = obj[inspectSymbol];
if (typeof customInspect === "function") {
return customInspect.call(obj) as string;
}
// util.inspect.customがない場合、プロパティが列挙される
const keys = Object.keys(obj);
const entries = keys.map(k => `${k}: [Function]`);
return `{ ${entries.join(", ")} }`;
}
console.log("customなし:", simulateUtilInspect(withoutInspect));
// => { unwrap: [Function], toJSON: [Function], toString: [Function] }
console.log("customあり:", simulateUtilInspect(withInspect));
// => [REDACTED]このアプローチのミソは、防御がデータ構造自体に組み込まれている点です。Sensitive型なら入口(ドメインモデル定義)で一度ラップすれば、出口で何もしなくても漏れません。
Branded Typeとは違ってランタイムに実体を持つため、型検査をすり抜けてもシリアライズ時に値が漏れることはありません。ログ出力では"[REDACTED]"と表示されるので、「意図的にマスクされている」ことが明確に伝わりますし、障害調査で空オブジェクトと誤認されることもありません。
Sensitive型の限界
ただし、Sensitive型にも限界があります。
まず、unwrap()で取り出した値は素の文字列に戻るため、取り出し後の扱いは開発者の責任です。
// メール送信のためにunwrapした値を、うっかりログに含めてしまう
const rawEmail = user.email.unwrap();
logger.info({ action: "send_email", to: rawEmail }); // 漏れる
Sensitive型が守るのは「ラップされた状態の値」であり、「unwrapされた後の値」ではありません。この弱点を補うには、unwrap()の呼び出し箇所をESLintカスタムルールで制限し、PIIを取り出せるコンテキストを限定するといった運用上の工夫を組み合わせる必要があります。
また、関数をプロパティに持つオブジェクトはstructuredCloneできません。Worker間のメッセージパッシングやpostMessageなど、構造化複製アルゴリズムに依存する処理ではSensitive型を含むオブジェクトをそのまま渡せないので、事前にunwrapするか受け側で再ラップする必要があります。
それから、ドメインモデル定義時にすべてのPIIフィールドをSensitive.of()でラップする必要がある以上、漏れなく適用することが前提になります。ラップし忘れたフィールドは素の値のままログに出てしまいます。この問題に対してはPino redactが安全網として機能します(後述)。
パフォーマンス面では、PIIフィールドごとに関数オブジェクトが生成されます。通常のAPIサーバではまず問題になりませんが、大量のオブジェクトを生成するバッチ処理等では留意が必要です。
ドメインモデルへの組み込み
Sensitive型を使うと、PIIフィールドをドメインモデルの定義段階で明示できます。
// PIIフィールドが型から読み取れる
type Patient = Readonly<{
id: string;
department: string;
name: Sensitive<string>;
diagnosis: Sensitive<string>;
insuranceNumber: Sensitive<string>;
}>;
// 値が必要な場面では明示的にunwrapする
function formatInsuranceClaim(patient: Patient) {
return {
patientName: patient.name.unwrap(),
diagnosis: patient.diagnosis.unwrap(),
insuranceNo: patient.insuranceNumber.unwrap(),
};
}
unwrap()の呼び出しはコードレビューで目立つため、「ここでPIIを取り出しているが、この用途は妥当か?」という判断を促す効果もあります。
Zodスキーマとの連携
外部入力のバリデーションにZodを使っている場合、transformでパース時にSensitive型へ自動ラップできます。
import { z } from "zod";
const sensitiveString = z.string().transform(Sensitive.of);
const PatientSchema = z.object({
id: z.string(),
department: z.string(),
name: sensitiveString,
diagnosis: sensitiveString,
insuranceNumber: sensitiveString,
});
type Patient = Readonly<z.output<typeof PatientSchema>>;
z.outputで推論される型はname: Sensitive<string>のようになり、手動でSensitive型を定義する必要がなくなります。パース結果を受け取った時点でPIIフィールドはすでにクロージャに閉じ込められているため、ラップし忘れの余地がありません。
const raw = await request.json();
const patient: Patient = PatientSchema.parse(raw);
// パース直後からSensitive型で保護されている
logger.info(patient);
// => {"id":"1","department":"cardiology","name":"[REDACTED]","diagnosis":"[REDACTED]","insuranceNumber":"[REDACTED]"}
Zodのスキーマ定義がバリデーションとPII保護の両方を担うため、ドメインモデルの入口が一箇所に集約されます。
Pino redactとの併用
Sensitive型は便利ですが、全てのPIIフィールドにSensitive.of()を付け忘れなく適用できるかという問題は残ります。ここでPinoのredactオプションがフォールバックとして使えます。
import pino from "pino";
const logger = pino({
redact: {
paths: ["email", "*.email", "password", "*.password"],
censor: "[REDACTED]",
},
});
Pino redactはブラックリスト方式で、既知のセンシティブなフィールド名を指定してマスキングします。内部実装のfast-redactは**.emailのような任意深度の再帰パターンをサポートしないので、ネストの深い構造では防御が不完全になりえます。新しいセンシティブフィールドが追加されたときにredactリストの更新を忘れるリスクもつきまといます。
なので、Pino redactは主防御ではなく安全網です。Sensitive型で値をクロージャに閉じ込めるのが主防御で、Pino redactはSensitive型の適用漏れがあったときに、フィールド名が既知であればキャッチしてくれるフォールバックという立ち位置です。
まとめ
TypeScriptの構造的部分型は、余分なプロパティを持つオブジェクトを互換とみなします。Branded TypeとConditional Typesを組み合わせれば型レベルで検出できるように見えますが、intersection型のブランドはstring変数への代入で消失するので、バイパスが常に可能です。そもそもランタイムには何も残りません。
この限界を踏まえて、本記事ではSensitive型とPino redactの二層構成を提案しました。Sensitive型はPIIを関数クロージャに閉じ込め、JSON.stringifyや構造化ロガー経由ではtoJSON()により"[REDACTED]"として出力されます。防御がデータ構造自体に組み込まれているので、入口(フィールド定義)で一度ラップすれば出口での対応は要りません。Zodのtransformと組み合わせれば、パース時に自動でラップされるためラップし忘れの余地もなくなります。そのうえで、Pino redactが既知のセンシティブフィールド名をブラックリスト方式でマスキングし、Sensitive型の適用漏れに対する安全網になります。
「型安全な開発」という言葉で安心してしまわず、型にできることとできないことを正確に理解した上で、ランタイムでの防御を設計していくのが大事ではないでしょうか。
参考文献
- TypeScript: Type Compatibility — 構造的部分型と余剰プロパティチェックの仕様
- Suggestion: perform excess property checks when spreading an inline object literal (TypeScript Issue #39998) — スプレッド構文で余剰プロパティチェックを効かせる要望(未対応)
- TypeScript Design Goals — TypeScriptが意図的にSound型システムではないことの公式見解
- Pino Redaction — センシティブフィールドの自動マスキング
- fast-redact — Pino redactの内部実装。パスパターンの制約
- Ciris - Secret — Scalaにおけるセンシティブ値のラッパー型。同様のパターンの先行事例
- Zod - transform — パース時の値変換。Sensitive型との連携に利用