/**
* 禁止フィールド名の検出と除去。
*
* 型の allowlist だけでは、インデックスシグネチャやスプレッドで
* 禁止キーが混入しうるため、書く直前にも名前で弾く。
* 値が自由記述かどうかではなくキー名で判定する(内容は予測できない)。
*/
/**
* 個人情報・自由記述(docs/logging-design.md §4)。
*/
export const FORBIDDEN_PII_FIELDS = [
"facility_name",
"address",
"phone",
"email",
"manager_name",
"charger_name",
"inspector_name",
"customer_name",
"notes",
"comment",
"memo",
"note",
"detail",
] as const;
/**
* 認証情報。§4 は認証未着手の時点で書かれたため漏れていた。
* 個人情報と違い、漏れると「読まれる」だけでなく「なりすまされる」ので分けて明示する。
*
* ★`dwatch_session` は指示書I 段階4 で追加した★(cookie 名が決まったため)。
* 名前の正は `lib/auth/cookie.ts` の `SESSION_COOKIE_NAME`。
* cookie 名を変えるときは、この配列も同時に直すこと。
*/
export const FORBIDDEN_CREDENTIAL_FIELDS = [
"cookie",
"set-cookie",
"authorization",
"token",
"session",
"secret",
"password",
"api_key",
"dwatch_session",
] as const;
export const FORBIDDEN_LOG_FIELDS = [
...FORBIDDEN_PII_FIELDS,
...FORBIDDEN_CREDENTIAL_FIELDS,
] as const;
export type ForbiddenLogField = (typeof FORBIDDEN_LOG_FIELDS)[number];
const FORBIDDEN_SET = new Set<string>(FORBIDDEN_LOG_FIELDS);
/**
* キー名の判定は大文字小文字を無視する。
* HTTP ヘッダは `Authorization` / `Set-Cookie` のように大文字で来るため、
* 完全一致だと素通りしてしまう。
*/
function isForbiddenKey(key: string): boolean {
return FORBIDDEN_SET.has(key.toLowerCase());
}
export interface ForbiddenFieldsPolicyResult<T> {
/** 出力してよい形にしたペイロード */
payload: T;
/** 検出したキー名。ログ本文には出さず件数のみ使う */
leaked: string[];
}
export interface ForbiddenErrorFieldsPolicyResult {
/** 出力してよい形にした例外オブジェクト */
err: unknown;
leaked: string[];
}
/**
* 禁止キーを再帰的に探して名前を返す。副作用なし。
* 検出と除去を分けているのは、環境によって挙動を変える判断を
* 呼び出し側(enforceForbiddenFieldsPolicy)に閉じ込めるため。
*/
export function findForbiddenFields(payload: unknown): string[] {
const found: string[] = [];
walk(payload, found);
return found;
}
/**
* 例外オブジェクトの**列挙可能な独自プロパティ**を検査する。
*
* Prisma のエラーは `meta` のような独自プロパティを持ち、そこに値が入りえるため
* payload と同じ検査をかける必要がある。
* `message` / `stack` は非列挙なので対象外(文字列であり、デバッグに必要なので出力は維持する)。
*/
export function findForbiddenFieldsInError(err: unknown): string[] {
const found: string[] = [];
if (err === null || typeof err !== "object") return found;
for (const [key, value] of Object.entries(err)) {
if (isForbiddenKey(key) && !found.includes(key)) {
found.push(key);
}
walk(value, found);
}
return found;
}
/**
* 禁止キーを取り除いた複製を返す。元のオブジェクトは変更しない。
* Error や Date は複製せずそのまま通す(pino の err シリアライザに壊れた形で渡さないため)。
*/
export function stripForbiddenFields<T>(payload: T): T {
return strip(payload) as T;
}
/**
* 例外オブジェクトから禁止キーを除いた複製を返す。
*
* プロトタイプを引き継ぐのは、pino の err シリアライザが出す `type` が
* コンストラクタ名由来で、素の `Error` に詰め替えると
* `PrismaClientKnownRequestError` のような型名が失われるため。
* `message` / `stack` は非列挙のまま記述子ごと写して出力を維持する。
*/
export function stripForbiddenFieldsFromError(err: unknown): unknown {
if (err === null || typeof err !== "object") return err;
const source = err as Record<string, unknown>;
const clone = Object.create(Object.getPrototypeOf(source)) as Record<
string,
unknown
>;
for (const key of Object.getOwnPropertyNames(source)) {
if (isForbiddenKey(key)) continue;
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor) continue;
if ("value" in descriptor) {
Object.defineProperty(clone, key, {
...descriptor,
value: strip(descriptor.value),
});
continue;
}
// アクセサはレシーバ(this)に紐づく。V8 の Error.stack がこれで、
// 記述子をそのまま写すと複製側で undefined になるため、値を読んで写す
Object.defineProperty(clone, key, {
value: strip(readAccessor(source, key)),
writable: true,
enumerable: descriptor.enumerable,
configurable: descriptor.configurable,
});
}
return clone;
}
/**
* 環境に応じた扱いを決める。
*
* 非本番では例外を投げて開発中に気づかせる。
* 本番では投げずにキーを取り除く。ログ呼び出しは catch 節の内側から行われるため、
* ここで例外を投げると元のエラー処理を壊し、ログの機構がリクエストを 500 にしてしまう。
* 「ログの失敗でアプリを落とさない」を優先する。
*/
export function enforceForbiddenFieldsPolicy<T>(
payload: T,
isProduction: boolean
): ForbiddenFieldsPolicyResult<T> {
const leaked = findForbiddenFields(payload);
if (leaked.length === 0) return { payload, leaked };
if (!isProduction) {
throw new Error(
`ログに禁止フィールドを含められません: ${leaked.join(", ")}`
);
}
return { payload: stripForbiddenFields(payload), leaked };
}
/**
* err に対する同じ方針。分岐の理由は enforceForbiddenFieldsPolicy と同じ。
*/
export function enforceForbiddenErrorFieldsPolicy(
err: unknown,
isProduction: boolean
): ForbiddenErrorFieldsPolicyResult {
const leaked = findForbiddenFieldsInError(err);
if (leaked.length === 0) return { err, leaked };
if (!isProduction) {
throw new Error(
`ログの err に禁止フィールドを含められません: ${leaked.join(", ")}`
);
}
return { err: stripForbiddenFieldsFromError(err), leaked };
}
/** getter が投げてもログ処理を止めない(ログの失敗でアプリを落とさない) */
function readAccessor(source: Record<string, unknown>, key: string): unknown {
try {
return source[key];
} catch {
return undefined;
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object") return false;
const proto = Object.getPrototypeOf(value) as object | null;
return proto === Object.prototype || proto === null;
}
function walk(value: unknown, found: string[]): void {
if (Array.isArray(value)) {
for (const item of value) walk(item, found);
return;
}
if (!isPlainObject(value)) return;
for (const [key, child] of Object.entries(value)) {
if (isForbiddenKey(key) && !found.includes(key)) {
found.push(key);
}
walk(child, found);
}
}
function strip(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => strip(item));
}
if (!isPlainObject(value)) return value;
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
if (isForbiddenKey(key)) continue;
out[key] = strip(child);
}
return out;
}