Newer
Older
client_dwatch / lib / logger / redact.ts
/**
 * 禁止フィールド名の検出と除去。
 *
 * 型の 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;
}