From b859935bbb89a5ee5f54163315f0f73b62817a4f Mon Sep 17 00:00:00 2001 From: kohta ito Date: Tue, 1 Sep 2026 19:19:04 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20structured=20logging=20=E3=81=AE?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=80=A7=E3=82=92=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 + agents/lint-rules.md | 3 + agents/logging.md | 75 ++++++ lint-rules/coding-style.ts | 4 +- lint-rules/rules/no-sensitive-logging.ts | 204 +++++++++++++++ lint-rules/run-tests.ts | 80 +++++- packages/api/src/lib/logger.test.ts | 147 ++++++++++- packages/api/src/lib/logger.ts | 51 ++-- packages/api/src/lib/middleware.test.ts | 128 ++++++++++ packages/api/src/lib/middleware.ts | 5 +- packages/api/src/lib/redact.test.ts | 239 ++++++++++++++++++ packages/api/src/lib/redact.ts | 212 ++++++++++++++++ packages/api/src/lib/wrap.ts | 13 +- .../client/src/app/(authenticated)/actions.ts | 54 ++-- .../client/src/app/(authenticated)/page.tsx | 2 +- packages/client/src/app/_lib/logger.test.ts | 149 ++++++++++- packages/client/src/app/_lib/logger.ts | 50 ++-- packages/client/src/app/_lib/redact.test.ts | 239 ++++++++++++++++++ packages/client/src/app/_lib/redact.ts | 212 ++++++++++++++++ packages/task/src/lib/logger.test.ts | 160 ++++++++++++ packages/task/src/lib/logger.ts | 50 ++-- packages/task/src/lib/redact.test.ts | 239 ++++++++++++++++++ packages/task/src/lib/redact.ts | 212 ++++++++++++++++ vite.config.ts | 22 +- 24 files changed, 2428 insertions(+), 126 deletions(-) create mode 100644 agents/logging.md create mode 100644 lint-rules/rules/no-sensitive-logging.ts create mode 100644 packages/api/src/lib/middleware.test.ts create mode 100644 packages/api/src/lib/redact.test.ts create mode 100644 packages/api/src/lib/redact.ts create mode 100644 packages/client/src/app/_lib/redact.test.ts create mode 100644 packages/client/src/app/_lib/redact.ts create mode 100644 packages/task/src/lib/logger.test.ts create mode 100644 packages/task/src/lib/redact.test.ts create mode 100644 packages/task/src/lib/redact.ts diff --git a/AGENTS.md b/AGENTS.md index 1f30887..7ca1af1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,10 @@ - 実装完了の条件: 変更したパッケージに対応する CI ワークフロー(`.github/workflows/ci-{api,client,shared,task}.yml`)に書かれているコマンドをローカルで実行し、build/lint/test がエラーなく通ること。CI と異なるコマンドを実行して「通った」と判断しない - 特定の作業をするときだけ必要な詳細ガイドラインは `agents/` 配下に置き、この AGENTS.md からは「いつ読むか」を1行で指す +## Logging Guidelines + +- `logger.*` を呼ぶとき、logger / redact 実装や access log / error handler を変更するときは `agents/logging.md` を読む(console の単一入口、sensitive field の redaction、error の安全な serialize、requestId の引き回し) + ## Monorepo Guidelines - 複数パッケージ(api/bin/client/shared)の実装がたまたま似ていても、それだけを理由に共通化しない(ルートに tsconfig.base.json を作って extends させる、logger/fetcher のような実装コードを shared に抽出する、など) diff --git a/agents/lint-rules.md b/agents/lint-rules.md index 35f0351..ebb1ebe 100644 --- a/agents/lint-rules.md +++ b/agents/lint-rules.md @@ -8,6 +8,9 @@ - `coding-style/no-process-env-outside-config` → `process.env` の参照を config ファイル(`config.ts` / `config.server.ts` 等)に集約する(テストファイルは対象外) - `coding-style/enforce-zod-entrypoint` → zod の entrypoint をパッケージごとに強制する(client は `zod/mini`、api は `zod`) +- `coding-style/no-sensitive-logging` → `logger.*` へ credential / request payload を渡すことを禁止する(テストファイルは意図的な leakage fixture を持つため対象外。詳細は `agents/logging.md`) + +builtin ルールでは `no-console` を `packages/{api,client,task}/src` に対して error にし、logger 実装本体とテストだけ `vite.config.ts` の override で除外している(structured log の単一入口を保つため)。 ルールを追加・変更したら、違反例・準拠例を `lint-rules/run-tests.ts` に追加し、`npm run test-lint-rules` を通すこと(CI では `ci-lint-rules.yml` が実行する)。 diff --git a/agents/logging.md b/agents/logging.md new file mode 100644 index 0000000..1e793f5 --- /dev/null +++ b/agents/logging.md @@ -0,0 +1,75 @@ +# Logging Guidelines + +ログ出力を伴うコード(`logger.*` の呼び出し、logger / redact 実装の変更、access log や error handler の変更)を書くときに参照する。 + +目的は、ログを observability の共通基盤にしつつ credential / PII / request payload が accidental に流出しないようにすること。 + +## 単一入口 + +- application code は `console.*` を直接呼ばず、パッケージごとの logger を経由する + - api: `packages/api/src/lib/logger.ts` + - client: `packages/client/src/app/_lib/logger.ts` + - task: `packages/task/src/lib/logger.ts` +- `console` を直接呼んでよいのは上記 logger 実装本体と、その出力を spy/assert するテストだけ。`vite.config.ts` の `no-console` override で機械的に強制している +- dev 専用 CLI(`packages/bin`)は structured log の対象外(人間が読むための出力なので `console` を使う) + +## 共通フィールド + +`logger.log / error / debug` はいずれも同じ shape を受け取る。 + +| field | 用途 | +| --- | --- | +| `level` | logger が付与する(`INFO` / `ERROR` / `DEBUG`) | +| `label` | ログの発生箇所を識別する短い名前(`access` / `handleError` 等) | +| `requestId` | access log と application log を突き合わせるための ID | +| `body` | 人間が読むメッセージ(文字列のみ) | +| `meta` | 構造化された付加情報。`redact()` を通る | +| `error` | 例外。`serializeError()` で限定 shape になる | + +### requestId + +- api では `accessLogMiddleware` が `randomUUID()` で発行し、hono の `c.set('requestId', ...)` に載せる +- application log 側は `c.get('requestId')` を `logger.*` の `requestId` に渡す。これで 1 リクエストの access log と application log が同じ ID で追える +- client / task は横断して追跡したい単位(api から受け取った ID、1 実行の ID 等)を呼び出し側が明示的に渡す + +## redaction + +`redact()`(`redact.ts`)は 2 つの防御を併用する。key 名だけに頼ると `Headers` や provider response のような「丸ごとのオブジェクト」から漏れるため。 + +1. **key 名による redaction** — 小文字化 + 区切り文字除去で正規化した key 名が `authorization` / `cookie`(`set-cookie` を含む) / `password` / `secret` / `token`(`idToken` / `accessToken` / `refreshToken` / `sessionToken` を含む) / `credential` / `apikey` / `privatekey` / `sessionid` に部分一致したら値を `[REDACTED]` にする +2. **plain object 以外は展開しない** — object literal / `Object.create(null)` / 配列 / プリミティブ以外(`Headers` / `Request` / `Response` / `Map` / class instance / 関数)は中身を見ずに `[UNSUPPORTED]` にする + +加えて、循環参照は `[CIRCULAR]`、深すぎるネストは `[DEPTH_LIMIT]`、長すぎる配列は末尾を件数へ畳み、ログ量を有界にする。 + +redaction は最後の防波堤であって、設計上の免罪符ではない。**request / response / headers / body を丸ごと `meta` へ渡さず、必要な field だけを抜き出して渡す**。 + +```ts +// NG: 何が載るか呼び出し側で分からない +logger.log({ body: 'req', meta: { headers: c.req.raw.headers, payload: await c.req.json() } }) + +// OK: 必要な field だけを明示する +logger.log({ label: 'access', requestId, body: `${c.req.method} ${c.req.path}`, meta: { status: c.res.status } }) +``` + +## error の serialize + +- `Error` を `{ ...error }` で spread しない。external SDK の error は request / credential を enumerable property に詰めてくることがある +- `logger.*` の `error` に渡された値は `serializeError()` が `name` / `message` / `stack` / `cause` だけの shape へ変換する。任意の property は読まない +- `Error` ではない値(文字列、`{ code, message }` 形式の SDK error 等)も `name` / `message` へ narrowing する +- `cause` チェーンは有界の深さまで辿る(循環していても停止する) +- stack trace は行数上限で切り詰める + +## 静的検査 + +`coding-style/no-sensitive-logging`(`lint-rules/rules/no-sensitive-logging.ts`)が `logger.log / error / debug` の第 1 引数を検査し、以下を error にする。 + +- sensitive な key 名を持つ property(`{ authorization: ... }` / `{ 'set-cookie': ... }` 等) +- sensitive な名前の変数・プロパティ参照(`{ sessionToken }` / `{ value: provider.accessToken }` 等) +- request / response を丸ごと渡す property(`c.req.raw.headers` / `req.body` / `await c.req.json()` / `headers()` / `cookies()` 等) +- 第 1 引数オブジェクト内での spread(何が載るか静的に追えないため) + +テストファイル(`*.test.ts` / `test/` 配下)は対象外。redaction が効いていることを検証するために、意図的な leakage fixture を logger へ渡す必要があるため。 + +## パッケージごとの重複について + +`logger.ts` / `redact.ts` は api / client / task がそれぞれ自己完結して持ち、`shared` へは切り出さない。実行コンテキスト(Node ESM バックエンド / Next.js フロントエンド / CLI)が異なり、今の実装が似ているのは偶然であるため(AGENTS.md の Monorepo Guidelines)。片方だけ出力先や level policy を変えたくなったときに、共通化が足枷になる。 diff --git a/lint-rules/coding-style.ts b/lint-rules/coding-style.ts index ee5dcd6..b10e7b0 100644 --- a/lint-rules/coding-style.ts +++ b/lint-rules/coding-style.ts @@ -6,6 +6,7 @@ import type { Plugin } from './types.ts' import { enforceZodEntrypointRule } from './rules/enforce-zod-entrypoint.ts' import { noProcessEnvOutsideConfigRule } from './rules/no-process-env-outside-config.ts' +import { noSensitiveLoggingRule } from './rules/no-sensitive-logging.ts' const plugin: Plugin = { meta: { @@ -13,7 +14,8 @@ const plugin: Plugin = { }, rules: { 'no-process-env-outside-config': noProcessEnvOutsideConfigRule, - 'enforce-zod-entrypoint': enforceZodEntrypointRule + 'enforce-zod-entrypoint': enforceZodEntrypointRule, + 'no-sensitive-logging': noSensitiveLoggingRule } } diff --git a/lint-rules/rules/no-sensitive-logging.ts b/lint-rules/rules/no-sensitive-logging.ts new file mode 100644 index 0000000..f74ad77 --- /dev/null +++ b/lint-rules/rules/no-sensitive-logging.ts @@ -0,0 +1,204 @@ +/** + * logger 呼び出しへ credential / request payload を渡すことを禁止するルール。 + * + * runtime 側の `redact()` は key 名と「plain object 以外は展開しない」判定で + * 防御するが、静的にも同じ pattern を落として実装時に気付けるようにする。 + * 検査対象は `logger.log/error/debug({ ... })` の第1引数オブジェクトのみ。 + * + * - sensitive な key 名(`authorization` / `cookie` / `*token` 等)を持つ property + * - sensitive な名前の変数をそのまま渡す property(`{ sessionToken }` 等) + * - request / response を丸ごと渡す property(`c.req.raw`, `req.headers`, + * `res.body`, `await c.req.json()`, `headers()`, `cookies()` 等) + * - logger 引数内での spread(何が載るか静的に追えないため) + * + * テストファイルは対象外。redaction が効いていることを検証するために + * 意図的な leakage fixture(`{ authorization: 'Bearer ...' }` 等)を + * logger へ渡す必要があるため。 + */ +import type { CallExpressionNode, ObjectExpressionNode, Rule } from '../types.ts' + +const LOGGER_OBJECT = 'logger' +const LOGGER_METHODS = new Set(['log', 'error', 'debug']) + +/** 他のルールと同じ判定式。テストファイル・テスト補助ファイルを対象外にする。 */ +const TEST_FILE = /(\.test\.(ts|tsx)$|(^|\/)test\/)/ + +/** + * 正規化(小文字化 + 区切り文字除去)した名前への部分一致で判定する。 + * runtime の `redact()` が持つ SENSITIVE_KEY_PATTERN と同じ語彙を使う。 + */ +const SENSITIVE_NAME_PATTERN = + /authorization|cookie|password|passwd|secret|token|credential|apikey|privatekey|sessionid/ + +const NAME_SEPARATOR_PATTERN = /[-_\s.]/g + +/** 丸ごと渡すと request / response の中身が載るプロパティ名。 */ +const RAW_PAYLOAD_PROPERTIES = new Set(['headers', 'rawHeaders', 'cookies', 'body', 'raw']) + +/** `c.req.json()` のように request payload を取り出すメソッド名。 */ +const REQUEST_READER_METHODS = new Set([ + 'header', + 'json', + 'text', + 'parseBody', + 'formData', + 'arrayBuffer', + 'blob' +]) + +/** next/headers の `headers()` / `cookies()` のような引数なしの取得関数。 */ +const GLOBAL_PAYLOAD_READERS = new Set(['headers', 'cookies']) + +/** ネストしたオブジェクトを辿る最大深さ(壊れた AST でも停止させるため)。 */ +const MAX_DEPTH = 8 + +const SENSITIVE_MESSAGE = + 'secret / credential をログへ渡しています。ログに載せない、または redact 済みの値だけを渡してください(agents/logging.md 参照)' +const RAW_PAYLOAD_MESSAGE = + 'request / response の headers / body を丸ごとログへ渡しています。必要な field だけを抜き出してください(agents/logging.md 参照)' +const SPREAD_MESSAGE = + 'logger へ渡すオブジェクトを spread しないでください。何がログに載るか静的に追えなくなります(agents/logging.md 参照)' + +function isSensitiveName(name: string): boolean { + return SENSITIVE_NAME_PATTERN.test(name.toLowerCase().replace(NAME_SEPARATOR_PATTERN, '')) +} + +function isLoggerCall(node: CallExpressionNode): boolean { + const callee = node.callee + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === LOGGER_OBJECT && + callee.property.type === 'Identifier' && + LOGGER_METHODS.has(callee.property.name) + ) +} + +type KeyNode = ObjectExpressionNode['properties'][number] + +function getStaticKeyName(property: KeyNode): string | null { + if (property.type !== 'Property' || property.computed) { + return null + } + if (property.key.type === 'Identifier') { + return property.key.name + } + if (property.key.type === 'Literal' && typeof property.key.value === 'string') { + return property.key.value + } + return null +} + +type ValueNode = Extract['value'] + +/** `await expr` / `expr as T` / `expr!` のようなラッパーを剥がす。 */ +function unwrap(node: ValueNode): ValueNode { + if (node.type === 'AwaitExpression') { + return unwrap(node.argument) + } + if (node.type === 'TSNonNullExpression' || node.type === 'TSAsExpression') { + return unwrap(node.expression) + } + return node +} + +function isRawPayloadExpression(node: ValueNode): boolean { + const target = unwrap(node) + if (target.type === 'MemberExpression' && !target.computed) { + return target.property.type === 'Identifier' && RAW_PAYLOAD_PROPERTIES.has(target.property.name) + } + if (target.type === 'CallExpression') { + const callee = target.callee + if (callee.type === 'Identifier' && GLOBAL_PAYLOAD_READERS.has(callee.name)) { + return true + } + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + REQUEST_READER_METHODS.has(callee.property.name) + ) + } + return false +} + +function isSensitiveExpression(node: ValueNode): boolean { + const target = unwrap(node) + if (target.type === 'Identifier') { + return isSensitiveName(target.name) + } + if ( + target.type === 'MemberExpression' && + !target.computed && + target.property.type === 'Identifier' + ) { + return isSensitiveName(target.property.name) + } + return false +} + +export const noSensitiveLoggingRule: Rule = { + meta: { + docs: { + description: 'logger へ secret / credential / request payload を渡すことを禁止する' + } + }, + create(context) { + if (TEST_FILE.test(context.filename)) { + return {} + } + function checkObject(node: ObjectExpressionNode, depth: number) { + if (depth > MAX_DEPTH) { + return + } + for (const property of node.properties) { + if (property.type === 'SpreadElement') { + context.report({ node: property, message: SPREAD_MESSAGE }) + continue + } + if (property.type !== 'Property') { + continue + } + const key = getStaticKeyName(property) + if (key !== null && isSensitiveName(key)) { + context.report({ node: property, message: SENSITIVE_MESSAGE }) + continue + } + const value = unwrap(property.value) + if (value.type === 'ObjectExpression') { + checkObject(value, depth + 1) + continue + } + if (value.type === 'ArrayExpression') { + for (const element of value.elements) { + if (element?.type === 'ObjectExpression') { + checkObject(element, depth + 1) + } + } + continue + } + if (isRawPayloadExpression(value)) { + context.report({ node: property, message: RAW_PAYLOAD_MESSAGE }) + continue + } + if (isSensitiveExpression(value)) { + context.report({ node: property, message: SENSITIVE_MESSAGE }) + } + } + } + + return { + CallExpression(node) { + if (!isLoggerCall(node)) { + return + } + const [argument] = node.arguments + if (!argument || argument.type !== 'ObjectExpression') { + return + } + checkObject(argument, 0) + } + } + } +} diff --git a/lint-rules/run-tests.ts b/lint-rules/run-tests.ts index a1bacb6..52b46e0 100644 --- a/lint-rules/run-tests.ts +++ b/lint-rules/run-tests.ts @@ -9,6 +9,7 @@ import { requireHttpExceptionResRule } from './rules/require-httpexception-res.t import { requireValidatorForParamQueryRule } from './rules/require-validator-for-param-query.ts' import { noProcessEnvOutsideConfigRule } from './rules/no-process-env-outside-config.ts' import { enforceZodEntrypointRule } from './rules/enforce-zod-entrypoint.ts' +import { noSensitiveLoggingRule } from './rules/no-sensitive-logging.ts' const handlerFile = 'packages/api/src/handlers/user/index.ts' const tester = new RuleTester() @@ -84,7 +85,10 @@ tester.run('no-process-env-outside-config', noProcessEnvOutsideConfigRule, { valid: [ // 準拠: config ファイル内の参照は許可 { code: 'export const PORT = process.env.PORT', filename: 'packages/api/src/config.ts' }, - { code: 'export const API_URI = process.env.API_URI', filename: 'packages/client/src/config.server.ts' }, + { + code: 'export const API_URI = process.env.API_URI', + filename: 'packages/client/src/config.server.ts' + }, // 対象外: テストファイル { code: 'const db = getTestDbClient(process.env)', filename: 'packages/api/src/app.test.ts' } ], @@ -124,4 +128,78 @@ tester.run('enforce-zod-entrypoint', enforceZodEntrypointRule, { ] }) +const loggerFile = 'packages/api/src/lib/middleware.ts' + +tester.run('no-sensitive-logging', noSensitiveLoggingRule, { + valid: [ + // 準拠: 必要な field だけを抜き出して渡す + { + code: "logger.log({ label: 'access', requestId, body: 'GET /api/user', meta: { status: c.res.status } })", + filename: loggerFile + }, + // 準拠: error は logger 側の serializeError で安全な shape になる + { + code: "logger.error({ label: 'handleError', body: 'failed', error })", + filename: loggerFile + }, + // 対象外: logger 以外の呼び出し + { code: 'client(url, { headers: c.req.raw.headers })', filename: loggerFile }, + // 対象外: テストファイルは redaction を検証するため意図的な leakage fixture を渡す + { + code: "logger.log({ body: 'auth', meta: { authorization: 'Bearer secret' } })", + filename: 'packages/api/src/lib/logger.test.ts' + } + ], + invalid: [ + // key 名が sensitive + { + code: "logger.log({ body: 'auth', meta: { authorization: value } })", + filename: loggerFile, + errors: 1 + }, + // shorthand の sensitive な変数 + { + code: "logger.debug({ body: 'auth', meta: { sessionToken } })", + filename: loggerFile, + errors: 1 + }, + // set-cookie / idToken / refreshToken も同じ語彙で拾う + { + code: "logger.log({ body: 'auth', meta: { headerValues: { 'set-cookie': raw, idToken: id, refreshToken: refresh } } })", + filename: loggerFile, + errors: 3 + }, + // sensitive な名前のプロパティ参照 + { + code: "logger.log({ body: 'auth', meta: { value: provider.accessToken } })", + filename: loggerFile, + errors: 1 + }, + // request headers / body / raw を丸ごと渡す + { + code: "logger.log({ body: 'req', meta: { headers: c.req.raw.headers, payload: req.body } })", + filename: loggerFile, + errors: 2 + }, + // request payload を読み出す呼び出し + { + code: "logger.debug({ body: 'req', meta: { payload: await c.req.json(), cookie: cookies() } })", + filename: loggerFile, + errors: 2 + }, + // spread は何が載るか静的に追えない + { + code: "logger.log({ body: 'req', meta: { ...req } })", + filename: loggerFile, + errors: 1 + }, + // 配列内の object も検査する + { + code: "logger.log({ body: 'auth', meta: { values: [{ accessToken }] } })", + filename: loggerFile, + errors: 1 + } + ] +}) + console.log('lint-rules: all rule tests passed') diff --git a/packages/api/src/lib/logger.test.ts b/packages/api/src/lib/logger.test.ts index c21f318..70ee944 100644 --- a/packages/api/src/lib/logger.test.ts +++ b/packages/api/src/lib/logger.test.ts @@ -1,39 +1,160 @@ import { expect, test, vi } from 'vite-plus/test' import { logger } from './logger.js' +import { REDACTED, UNSUPPORTED } from './redact.js' vi.mock('console') -test('logger.log', () => { - const logMock = vi - .spyOn(console, 'log') - .mockImplementationOnce(() => undefined) +// console の spy は同一ファイル内の test をまたいで呼び出し履歴が蓄積するため、 +// test ごとに履歴をクリアしてから使う +function spyOnConsole(method: 'log' | 'error') { + const spy = vi.spyOn(console, method).mockImplementation(() => { + return undefined + }) + spy.mockClear() + return spy +} + +test('logger.log outputs level / label / body as a structured log', () => { + const logMock = spyOnConsole('log') logger.log({ label: 'foo', body: 'bar' }) expect(logMock).toHaveBeenCalledTimes(1) expect(logMock).toHaveBeenCalledWith( JSON.stringify({ + level: 'INFO', label: 'foo', - body: 'bar', - level: 'INFO' + body: 'bar' }) ) }) -test('logger.error', () => { - const logMock = vi - .spyOn(console, 'error') - .mockImplementationOnce(() => undefined) +test('logger.log carries requestId as a top level field', () => { + const logMock = spyOnConsole('log') - logger.error({ label: 'foo', body: 'error', error: 'bar' }) + logger.log({ + label: 'access', + requestId: 'request-id-1', + body: 'GET /api/user', + meta: { status: 200 } + }) expect(logMock).toHaveBeenCalledTimes(1) expect(logMock).toHaveBeenCalledWith( JSON.stringify({ + level: 'INFO', + label: 'access', + requestId: 'request-id-1', + body: 'GET /api/user', + meta: { status: 200 } + }) + ) +}) + +test('logger.log redacts Authorization / Cookie / token in meta', () => { + const logMock = spyOnConsole('log') + + logger.log({ + label: 'auth', + body: 'verified', + meta: { + authorization: 'Bearer super-secret-token', + cookie: 'session=abcdef', + provider: { idToken: 'id-token-value', userId: 1 } + } + }) + + const [output] = logMock.mock.calls[0] + expect(output).not.toContain('super-secret-token') + expect(output).not.toContain('abcdef') + expect(output).not.toContain('id-token-value') + expect(JSON.parse(output)).toStrictEqual({ + level: 'INFO', + label: 'auth', + body: 'verified', + meta: { + authorization: REDACTED, + cookie: REDACTED, + provider: { idToken: REDACTED, userId: 1 } + } + }) +}) + +test('logger.log does not expand a Headers passed through meta', () => { + const logMock = spyOnConsole('log') + + logger.log({ + body: 'request', + meta: { + headers: new Headers({ authorization: 'Bearer super-secret-token' }) + } + }) + + const [output] = logMock.mock.calls[0] + expect(output).not.toContain('super-secret-token') + expect(JSON.parse(output)).toStrictEqual({ + level: 'INFO', + body: 'request', + meta: { headers: UNSUPPORTED } + }) +}) + +test('logger.error serializes an Error into a limited shape', () => { + const logMock = spyOnConsole('error') + + logger.error({ + label: 'handleError', + requestId: 'request-id-1', + body: 'error', + error: new Error('boom') + }) + + expect(logMock).toHaveBeenCalledTimes(1) + const [output] = logMock.mock.calls[0] + const parsed = JSON.parse(output) + expect(parsed.level).toBe('ERROR') + expect(parsed.label).toBe('handleError') + expect(parsed.requestId).toBe('request-id-1') + expect(parsed.error.name).toBe('Error') + expect(parsed.error.message).toBe('boom') + expect(typeof parsed.error.stack).toBe('string') +}) + +test('logger.error does not leak enumerable properties of an external error', () => { + const logMock = spyOnConsole('error') + + class ProviderError extends Error { + config = { headers: { authorization: 'Bearer super-secret-token' } } + refreshToken = 'refresh-token-value' + } + + logger.error({ + label: 'provider', + body: 'provider call failed', + error: new ProviderError('provider failed') + }) + + const [output] = logMock.mock.calls[0] + expect(output).not.toContain('super-secret-token') + expect(output).not.toContain('refresh-token-value') + expect(Object.keys(JSON.parse(output).error).sort()).toStrictEqual(['message', 'name', 'stack']) +}) + +test('logger.error serializes a non-Error value without spreading it', () => { + const logMock = spyOnConsole('error') + + logger.error({ + label: 'foo', + body: 'error', + error: { code: 'auth/invalid-id-token', message: 'invalid', idToken: 'id' } + }) + + expect(logMock).toHaveBeenCalledWith( + JSON.stringify({ + level: 'ERROR', label: 'foo', body: 'error', - error: 'bar', - level: 'ERROR' + error: { name: 'auth/invalid-id-token', message: 'invalid' } }) ) }) diff --git a/packages/api/src/lib/logger.ts b/packages/api/src/lib/logger.ts index 591b237..0ed3962 100644 --- a/packages/api/src/lib/logger.ts +++ b/packages/api/src/lib/logger.ts @@ -1,44 +1,57 @@ import { ENV } from '../config.js' +import { redact, serializeError } from './redact.js' -type Options = { +/** + * structured log の単一入口。application code から console を直接呼ばず、 + * 必ずこの logger を経由する(`no-console` lint で強制している)。 + * + * - `meta` は `redact()` を通し、sensitive key と plain object 以外を落とす + * - `error` は `serializeError()` で name/message/stack/cause の限定 shape にする + * - `requestId` は access log / application log を横断して追跡するための共通 field。 + * api では accessLogMiddleware が発行した値を `c.get('requestId')` から渡す + */ +type LogOptions = { label?: string + requestId?: string body: string - meta?: any + meta?: Record + error?: unknown } -type ErrorOptions = Options & { - error?: any +type Level = 'INFO' | 'ERROR' | 'DEBUG' + +function buildLog(level: Level, options: LogOptions) { + const { label, requestId, body, meta, error } = options + return { + level, + ...(label === undefined ? {} : { label }), + ...(requestId === undefined ? {} : { requestId }), + body, + ...(meta === undefined ? {} : { meta: redact(meta) }), + ...(error === undefined ? {} : { error: serializeError(error) }) + } } export const logger = { - log: (options: Options) => { - const json = { - ...options, - level: 'INFO' - } + log: (options: LogOptions) => { + const json = buildLog('INFO', options) if (ENV.local) { console.dir(json, { depth: null }) return } console.log(JSON.stringify(json)) }, - error: (options: ErrorOptions) => { - const json = { - ...options, - level: 'ERROR' - } + error: (options: LogOptions) => { + const json = buildLog('ERROR', options) if (ENV.local) { console.dir(json, { depth: null }) return } console.error(JSON.stringify(json)) }, - debug: (options: Options) => { + debug: (options: LogOptions) => { if (!ENV.production) { - const json = { - ...options, - level: 'DEBUG' - } + const json = buildLog('DEBUG', options) console.dir(json, { depth: null }) } } diff --git a/packages/api/src/lib/middleware.test.ts b/packages/api/src/lib/middleware.test.ts new file mode 100644 index 0000000..ea760d3 --- /dev/null +++ b/packages/api/src/lib/middleware.test.ts @@ -0,0 +1,128 @@ +import { Hono } from 'hono' +import { expect, test, vi } from 'vite-plus/test' +import { accessLogMiddleware, verifyAuthorizationMiddleware } from './middleware.js' +import { handleError } from './wrap.js' + +// console の spy は同一ファイル内の test をまたいで呼び出し履歴が蓄積するため、 +// test ごとに履歴をクリアしてから使う +function spyOnConsole(method: 'log' | 'error' | 'dir') { + const spy = vi.spyOn(console, method).mockImplementation(() => { + return undefined + }) + spy.mockClear() + return spy +} + +function parseLogs(calls: ReadonlyArray>) { + return calls.map(([output]) => { + return JSON.parse(`${output}`) + }) +} + +test('access log and application log share the same requestId', async () => { + const logMock = spyOnConsole('log') + const errorMock = spyOnConsole('error') + + const app = new Hono() + app.use(accessLogMiddleware()) + app.onError(handleError) + app.get('/boom', () => { + throw new Error('boom') + }) + + const res = await app.request('/boom') + expect(res.status).toBe(500) + + const [accessLog] = parseLogs(logMock.mock.calls) + const [applicationLog] = parseLogs(errorMock.mock.calls) + + expect(accessLog.label).toBe('access') + expect(applicationLog.label).toBe('handleError') + expect(typeof accessLog.requestId).toBe('string') + expect(applicationLog.requestId).toBe(accessLog.requestId) +}) + +test('token verification failure is logged with the access log requestId', async () => { + const logMock = spyOnConsole('log') + spyOnConsole('dir') + + const app = new Hono() + app.use(accessLogMiddleware()) + app.use(verifyAuthorizationMiddleware()) + app.onError(handleError) + app.get('/secure', (c) => { + return c.text('ok') + }) + + const res = await app.request('/secure', { + headers: { Authorization: 'Bearer not-a-valid-token' } + }) + expect(res.status).toBe(401) + + const logs = parseLogs(logMock.mock.calls) + const accessLog = logs.find((log) => { + return log.label === 'access' + }) + const tokenLog = logs.find((log) => { + return log.label === 'token_verification' + }) + + expect(typeof accessLog?.requestId).toBe('string') + expect(tokenLog?.requestId).toBe(accessLog?.requestId) +}) + +test('token verification failure log does not contain the presented token', async () => { + const logMock = spyOnConsole('log') + spyOnConsole('dir') + + const app = new Hono() + app.use(accessLogMiddleware()) + app.use(verifyAuthorizationMiddleware()) + app.onError(handleError) + app.get('/secure', (c) => { + return c.text('ok') + }) + + await app.request('/secure', { + headers: { + Authorization: 'Bearer super-secret-token', + Cookie: 'session=abcdef' + } + }) + + const output = logMock.mock.calls + .map(([value]) => { + return `${value}` + }) + .join('\n') + + expect(output).toContain('token_verification') + expect(output).not.toContain('super-secret-token') + expect(output).not.toContain('abcdef') +}) + +test('access log does not contain request headers', async () => { + const logMock = spyOnConsole('log') + + const app = new Hono() + app.use(accessLogMiddleware()) + app.get('/', (c) => { + return c.text('ok') + }) + + await app.request('/', { + headers: { + Authorization: 'Bearer super-secret-token', + Cookie: 'session=abcdef' + } + }) + + const [accessLog] = parseLogs(logMock.mock.calls) + + expect(accessLog.meta).toStrictEqual({ + method: 'GET', + path: '/', + status: 200, + duration: expect.any(Number) + }) +}) diff --git a/packages/api/src/lib/middleware.ts b/packages/api/src/lib/middleware.ts index e0d4898..c01048b 100644 --- a/packages/api/src/lib/middleware.ts +++ b/packages/api/src/lib/middleware.ts @@ -29,9 +29,9 @@ export function accessLogMiddleware() { await next() logger.log({ label: 'access', + requestId, body: `${c.req.method} ${c.req.path}`, meta: { - requestId, method: c.req.method, path: c.req.path, status: c.res.status, @@ -71,8 +71,9 @@ export function verifyAuthorizationMiddleware() { } catch (error) { logger.log({ label: 'token_verification', + requestId: c.get('requestId'), body: 'token verification failed', - meta: { error } + error }) throw createHttpException(401, { type: 'about:blank', diff --git a/packages/api/src/lib/redact.test.ts b/packages/api/src/lib/redact.test.ts new file mode 100644 index 0000000..c4f9fe3 --- /dev/null +++ b/packages/api/src/lib/redact.test.ts @@ -0,0 +1,239 @@ +import { expect, test } from 'vite-plus/test' +import { + CIRCULAR, + DEPTH_LIMIT, + REDACTED, + UNSUPPORTED, + isSensitiveKey, + redact, + serializeError +} from './redact.js' + +test('isSensitiveKey matches credential-ish keys regardless of case and separators', () => { + expect(isSensitiveKey('Authorization')).toBe(true) + expect(isSensitiveKey('cookie')).toBe(true) + expect(isSensitiveKey('set-cookie')).toBe(true) + expect(isSensitiveKey('Set-Cookie')).toBe(true) + expect(isSensitiveKey('password')).toBe(true) + expect(isSensitiveKey('client_secret')).toBe(true) + expect(isSensitiveKey('idToken')).toBe(true) + expect(isSensitiveKey('accessToken')).toBe(true) + expect(isSensitiveKey('refresh_token')).toBe(true) + expect(isSensitiveKey('sessionToken')).toBe(true) + expect(isSensitiveKey('x-api-key')).toBe(true) + + expect(isSensitiveKey('status')).toBe(false) + expect(isSensitiveKey('requestId')).toBe(false) + expect(isSensitiveKey('userId')).toBe(false) +}) + +test('redact masks Authorization / Cookie / token values including nested ones', () => { + const actual = redact({ + method: 'GET', + Authorization: 'Bearer super-secret-token', + cookie: 'session=abcdef', + session: { + 'set-cookie': 'session=abcdef; HttpOnly', + idToken: 'id-token-value', + accessToken: 'access-token-value', + refreshToken: 'refresh-token-value', + userId: 1 + } + }) + + expect(actual).toStrictEqual({ + method: 'GET', + Authorization: REDACTED, + cookie: REDACTED, + session: { + 'set-cookie': REDACTED, + idToken: REDACTED, + accessToken: REDACTED, + refreshToken: REDACTED, + userId: 1 + } + }) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') + expect(JSON.stringify(actual)).not.toContain('abcdef') +}) + +test('redact does not expand Headers / Request / Response', () => { + const headers = new Headers({ + authorization: 'Bearer super-secret-token', + cookie: 'session=abcdef' + }) + const request = new Request('https://example.com/api/user', { + method: 'POST', + headers, + body: JSON.stringify({ password: 'p@ssw0rd' }) + }) + + const actual = redact({ headers, request, response: new Response('body') }) + + expect(actual).toStrictEqual({ + headers: UNSUPPORTED, + request: UNSUPPORTED, + response: UNSUPPORTED + }) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') + expect(JSON.stringify(actual)).not.toContain('p@ssw0rd') +}) + +test('redact does not expand class instances or functions', () => { + class Provider { + accessToken = 'access-token-value' + } + + const actual = redact({ + provider: new Provider(), + map: new Map([['token', 'token-value']]), + fn: () => { + return 'noop' + } + }) + + expect(actual).toStrictEqual({ + provider: UNSUPPORTED, + map: UNSUPPORTED, + fn: UNSUPPORTED + }) +}) + +test('redact keeps primitives and normalizes Date / BigInt', () => { + const date = new Date('2026-09-01T00:00:00.000Z') + + expect( + redact({ + count: 1, + enabled: true, + empty: null, + date, + big: 10n + }) + ).toStrictEqual({ + count: 1, + enabled: true, + empty: null, + date: '2026-09-01T00:00:00.000Z', + big: '10' + }) +}) + +test('redact walks arrays and truncates long ones', () => { + expect(redact({ users: [{ password: 'x', name: 'a' }] })).toStrictEqual({ + users: [{ password: REDACTED, name: 'a' }] + }) + + const long = Array.from({ length: 52 }).map((_, i) => { + return i + }) + const actual = redact(long) + + expect(Array.isArray(actual) && actual.length).toBe(51) + expect(Array.isArray(actual) && actual.at(-1)).toBe('[2 more items]') +}) + +test('redact stops at circular references and depth limit', () => { + const circular: Record = { name: 'root' } + circular.self = circular + + expect(redact(circular)).toStrictEqual({ name: 'root', self: CIRCULAR }) + + expect(redact({ a: { b: { c: { d: { e: { f: { g: 'deep' } } } } } } })).toStrictEqual({ + a: { b: { c: { d: { e: { f: DEPTH_LIMIT } } } } } + }) +}) + +test('serializeError keeps only name / message / stack', () => { + const error = new Error('boom') + const actual = serializeError(error) + + expect(actual.name).toBe('Error') + expect(actual.message).toBe('boom') + expect(typeof actual.stack).toBe('string') + expect(actual.cause).toBeUndefined() +}) + +test('serializeError does not leak arbitrary enumerable properties of an error', () => { + class ProviderError extends Error { + // external SDK が request 情報を error に詰めてくるケース + config = { + headers: { authorization: 'Bearer super-secret-token' }, + body: { password: 'p@ssw0rd' } + } + accessToken = 'access-token-value' + } + const error = new ProviderError('provider failed') + error.name = 'ProviderError' + + const actual = serializeError(error) + + expect(actual.name).toBe('ProviderError') + expect(actual.message).toBe('provider failed') + expect(Object.keys(actual).sort()).toStrictEqual(['message', 'name', 'stack']) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') + expect(JSON.stringify(actual)).not.toContain('access-token-value') + expect(JSON.stringify(actual)).not.toContain('p@ssw0rd') +}) + +test('serializeError follows the cause chain within a bounded depth', () => { + const error = new Error('level0', { + cause: new Error('level1', { + cause: new Error('level2', { cause: new Error('level3') }) + }) + }) + + const actual = serializeError(error) + + expect(actual.message).toBe('level0') + expect(actual.cause?.message).toBe('level1') + expect(actual.cause?.cause?.message).toBe('level2') + expect(actual.cause?.cause?.cause?.message).toBe('level3') + expect(actual.cause?.cause?.cause?.cause).toBeUndefined() +}) + +test('serializeError stops on a circular cause chain', () => { + const error = new Error('self referencing') + error.cause = error + + expect(serializeError(error).cause?.cause?.cause?.cause).toBeUndefined() +}) + +test('serializeError narrows non-Error values to name / message', () => { + expect(serializeError('boom')).toStrictEqual({ + name: 'NonError', + message: 'boom' + }) + expect(serializeError(500)).toStrictEqual({ + name: 'NonError', + message: '500' + }) + expect(serializeError(undefined)).toStrictEqual({ + name: 'NonError', + message: 'undefined' + }) + // external SDK の plain object error。message / name(なければ code)だけを残す + expect( + serializeError({ + code: 'auth/invalid-id-token', + message: 'invalid token', + idToken: 'id-token-value', + headers: { authorization: 'Bearer super-secret-token' } + }) + ).toStrictEqual({ + name: 'auth/invalid-id-token', + message: 'invalid token' + }) +}) + +test('redact converts nested errors through serializeError', () => { + const actual = redact({ failure: new Error('nested boom') }) + + expect(actual).toStrictEqual({ + failure: { + name: 'Error', + message: 'nested boom', + stack: expect.any(String) + } + }) +}) diff --git a/packages/api/src/lib/redact.ts b/packages/api/src/lib/redact.ts new file mode 100644 index 0000000..8265dc5 --- /dev/null +++ b/packages/api/src/lib/redact.ts @@ -0,0 +1,212 @@ +/** + * ログ出力する値から credential / PII を落とすための redaction ヘルパー。 + * logger の単一入口(`lib/logger.ts`)から呼ばれる想定で、直接 console へ + * 書き出す実装は持たない。 + * + * 設計方針(agents/logging.md 参照): + * - key 名による redaction と「plain object 以外は展開しない」ホワイトリスト方式を併用する。 + * key 名だけに依存すると `Headers` / `Request` / provider response のような + * 丸ごとのオブジェクトから secret が漏れるため + * - Error は enumerable property を spread せず、name/message/stack/cause の + * 限定 shape へ変換する + * + * 各パッケージは実行コンテキストが異なるため(AGENTS.md の Monorepo Guidelines)、 + * このファイルは shared へ切り出さず api/client/task がそれぞれ自己完結して持つ。 + */ + +/** 値が sensitive な key に紐づいていたことを示すプレースホルダ。 */ +export const REDACTED = '[REDACTED]' +/** plain object / 配列 / プリミティブ以外(Headers, Request, Map, 関数等)。 */ +export const UNSUPPORTED = '[UNSUPPORTED]' +/** 循環参照を検出した位置。 */ +export const CIRCULAR = '[CIRCULAR]' +/** ネストが深すぎて打ち切った位置。 */ +export const DEPTH_LIMIT = '[DEPTH_LIMIT]' + +/** ネストの最大深さ。これを超えた位置は DEPTH_LIMIT に置き換える。 */ +const MAX_DEPTH = 6 +/** 配列の最大要素数。超過分は件数だけを残す。 */ +const MAX_ARRAY_LENGTH = 50 +/** stack trace として残す最大行数(ログ量を有界にするため)。 */ +const MAX_STACK_LINES = 20 +/** cause チェーンを辿る最大段数(cause の循環でも停止させるため)。 */ +const MAX_CAUSE_DEPTH = 3 + +/** + * 正規化(小文字化 + 区切り文字除去)した key 名に対する部分一致で判定する。 + * `token` が idToken / accessToken / refreshToken / sessionToken を、 + * `cookie` が set-cookie を、それぞれまとめて拾う。 + * 判定回数を key ごとに 1 回へ抑えるため単一の正規表現にまとめている。 + */ +const SENSITIVE_KEY_PATTERN = + /authorization|cookie|password|passwd|secret|token|credential|apikey|privatekey|sessionid/ + +const KEY_SEPARATOR_PATTERN = /[-_\s.]/g + +/** ログの key 名が redaction 対象かどうかを返す。 */ +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERN.test(key.toLowerCase().replace(KEY_SEPARATOR_PATTERN, '')) +} + +/** + * `redact()` が出力しうる値。JSON へそのまま流せる形だけを許す。 + * `redactValue` が再帰しており戻り値型を推論できないため明示する。 + */ +export type RedactedValue = + | string + | number + | boolean + | null + | undefined + | SerializedError + | RedactedValue[] + | { [key: string]: RedactedValue } + +/** Error を JSON へ安全に落とし込むための限定 shape。 */ +export type SerializedError = { + name: string + message: string + stack?: string + cause?: SerializedError +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** + * plain object(リテラル/`Object.create(null)`/`structuredClone` 由来)だけを + * 展開対象にする。`Headers` / `Request` / `Response` / `Map` / class instance は + * ここで false になり、丸ごとログへ流れることを防ぐ。 + */ +function isPlainObject(value: unknown): value is Record { + if (!isRecord(value)) { + return false + } + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function trimStack(stack: string | undefined): string | undefined { + if (!stack) { + return undefined + } + const lines = stack.split('\n') + if (lines.length <= MAX_STACK_LINES) { + return stack + } + return [ + ...lines.slice(0, MAX_STACK_LINES), + ` ... ${lines.length - MAX_STACK_LINES} more lines` + ].join('\n') +} + +/** + * external SDK が投げる「Error ではないが name/message を持つ」オブジェクトから + * 文字列フィールドだけを取り出す。任意の enumerable property は読まない。 + */ +function serializeNonError(value: unknown): SerializedError { + if (typeof value === 'string') { + return { name: 'NonError', message: value } + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return { name: 'NonError', message: `${value}` } + } + if (!isRecord(value)) { + return { name: 'NonError', message: `${typeof value}` } + } + const name = + typeof value.name === 'string' + ? value.name + : typeof value.code === 'string' + ? value.code + : 'NonError' + const message = typeof value.message === 'string' ? value.message : '[unknown]' + return { name, message } +} + +function serializeErrorWithDepth(error: unknown, depth: number): SerializedError { + if (!(error instanceof Error)) { + return serializeNonError(error) + } + const cause = + error.cause !== undefined && depth < MAX_CAUSE_DEPTH + ? serializeErrorWithDepth(error.cause, depth + 1) + : undefined + const stack = trimStack(error.stack) + return { + name: error.name, + message: error.message, + ...(stack === undefined ? {} : { stack }), + ...(cause === undefined ? {} : { cause }) + } +} + +/** + * Error / external SDK error を name/message/stack/cause の限定 shape へ変換する。 + * `{ ...error }` のような spread と違い、任意の enumerable property + * (provider が詰めた credential や request payload)を持ち出さない。 + */ +export function serializeError(error: unknown): SerializedError { + return serializeErrorWithDepth(error, 0) +} + +function redactValue(value: unknown, depth: number, seen: WeakSet): RedactedValue { + if (value === null || value === undefined) { + return value + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value + } + if (typeof value === 'bigint') { + return `${value}` + } + if (typeof value !== 'object') { + // function / symbol は構造化ログに載せる意味が無く、toString で + // クロージャの中身が漏れる余地もあるため展開しない + return UNSUPPORTED + } + if (value instanceof Error) { + return serializeError(value) + } + if (value instanceof Date) { + return value.toISOString() + } + if (seen.has(value)) { + return CIRCULAR + } + if (depth >= MAX_DEPTH) { + return DEPTH_LIMIT + } + seen.add(value) + if (Array.isArray(value)) { + const items = value.slice(0, MAX_ARRAY_LENGTH).map((item) => { + return redactValue(item, depth + 1, seen) + }) + seen.delete(value) + return value.length > MAX_ARRAY_LENGTH + ? [...items, `[${value.length - MAX_ARRAY_LENGTH} more items]`] + : items + } + if (!isPlainObject(value)) { + // Headers / Request / Response / Map / class instance など。 + // 丸ごとログへ流すと credential が混入するため中身を見ない + seen.delete(value) + return UNSUPPORTED + } + const result: Record = {} + for (const [key, entry] of Object.entries(value)) { + result[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, depth + 1, seen) + } + seen.delete(value) + return result +} + +/** + * ログの meta に載せる値から sensitive field を落とす。 + * sensitive な key は `[REDACTED]` に、plain object 以外のオブジェクトは + * `[UNSUPPORTED]` に置き換える。 + */ +export function redact(value: unknown): RedactedValue { + return redactValue(value, 0, new WeakSet()) +} diff --git a/packages/api/src/lib/wrap.ts b/packages/api/src/lib/wrap.ts index 4fca9e8..593ff51 100644 --- a/packages/api/src/lib/wrap.ts +++ b/packages/api/src/lib/wrap.ts @@ -10,12 +10,7 @@ export type ProblemDetails = { } function isProblemDetails(value: unknown): value is ProblemDetails { - return ( - typeof value === 'object' && - value !== null && - 'title' in value && - 'status' in value - ) + return typeof value === 'object' && value !== null && 'title' in value && 'status' in value } export function createHttpException>( @@ -35,8 +30,10 @@ export async function handleError(error: Error, c: Context) { if (error instanceof HTTPException) { logger.debug({ label: 'handleError', + requestId, body: error.message, - meta: { requestId, error } + meta: { status: error.status }, + error }) // res 付きの HTTPException(createHttpException 経由)はレスポンスの // 形状(拡張フィールドを含む)が確定しているため、そのまま返す。 @@ -52,8 +49,8 @@ export async function handleError(error: Error, c: Context) { } logger.error({ label: 'handleError', + requestId, body: 'Error occurred in handleError', - meta: { requestId }, error }) return c.json( diff --git a/packages/client/src/app/(authenticated)/actions.ts b/packages/client/src/app/(authenticated)/actions.ts index d69e7a2..eb396e9 100644 --- a/packages/client/src/app/(authenticated)/actions.ts +++ b/packages/client/src/app/(authenticated)/actions.ts @@ -8,35 +8,35 @@ import { logger } from '../_lib/logger' type FetchUserListResponse = APIResult, 200> -export const fetchUserList = fetcher( - async (): Promise => { - const sessionCookie = (await cookies()).get(SESSION_COOKIE_NAME)?.value - if (!sessionCookie) { - return { - ok: false, - status: 401, - body: 'not authenticated' - } +export const fetchUserList = fetcher(async (): Promise => { + const sessionCookie = (await cookies()).get(SESSION_COOKIE_NAME)?.value + if (!sessionCookie) { + return { + ok: false, + status: 401, + body: 'not authenticated' } + } - const url = new URL('/api/user', API_URI) - const res = await client(url.toString(), { - path: '/api/user', - method: 'get', - parameters: { - header: { - Authorization: `Bearer ${sessionCookie}` - } + const url = new URL('/api/user', API_URI) + const res = await client(url.toString(), { + path: '/api/user', + method: 'get', + parameters: { + header: { + Authorization: `Bearer ${sessionCookie}` } - }) - if (res.status === 200) { - return { ok: true, ...res } } - logger.debug({ - label: 'fetchUserList', - body: 'unexpected status', - meta: { status: res.status, body: res.body, url: url.toString() } - }) - return { ok: false, ...res } + }) + if (res.status === 200) { + return { ok: true, ...res } } -) + // レスポンス body を丸ごとログへ流すと provider 由来の値まで載るため、 + // status / url のみを残す(agents/logging.md 参照) + logger.debug({ + label: 'fetchUserList', + body: 'unexpected status', + meta: { status: res.status, url: url.toString() } + }) + return { ok: false, ...res } +}) diff --git a/packages/client/src/app/(authenticated)/page.tsx b/packages/client/src/app/(authenticated)/page.tsx index d83291a..69ba6da 100644 --- a/packages/client/src/app/(authenticated)/page.tsx +++ b/packages/client/src/app/(authenticated)/page.tsx @@ -3,7 +3,7 @@ import { fetchUserList } from './actions' export default async function Page() { const user = await fetchUserList() - logger.debug({ label: 'Top', body: 'render', meta: { user } }) + logger.debug({ label: 'Top', body: 'render', meta: { ok: user.ok } }) return (
diff --git a/packages/client/src/app/_lib/logger.test.ts b/packages/client/src/app/_lib/logger.test.ts index c21f318..75373dc 100644 --- a/packages/client/src/app/_lib/logger.test.ts +++ b/packages/client/src/app/_lib/logger.test.ts @@ -1,39 +1,160 @@ import { expect, test, vi } from 'vite-plus/test' -import { logger } from './logger.js' +import { logger } from './logger' +import { REDACTED, UNSUPPORTED } from './redact' vi.mock('console') -test('logger.log', () => { - const logMock = vi - .spyOn(console, 'log') - .mockImplementationOnce(() => undefined) +// console の spy は同一ファイル内の test をまたいで呼び出し履歴が蓄積するため、 +// test ごとに履歴をクリアしてから使う +function spyOnConsole(method: 'log' | 'error') { + const spy = vi.spyOn(console, method).mockImplementation(() => { + return undefined + }) + spy.mockClear() + return spy +} + +test('logger.log outputs level / label / body as a structured log', () => { + const logMock = spyOnConsole('log') logger.log({ label: 'foo', body: 'bar' }) expect(logMock).toHaveBeenCalledTimes(1) expect(logMock).toHaveBeenCalledWith( JSON.stringify({ + level: 'INFO', label: 'foo', - body: 'bar', - level: 'INFO' + body: 'bar' }) ) }) -test('logger.error', () => { - const logMock = vi - .spyOn(console, 'error') - .mockImplementationOnce(() => undefined) +test('logger.log carries requestId as a top level field', () => { + const logMock = spyOnConsole('log') - logger.error({ label: 'foo', body: 'error', error: 'bar' }) + logger.log({ + label: 'access', + requestId: 'request-id-1', + body: 'GET /api/user', + meta: { status: 200 } + }) expect(logMock).toHaveBeenCalledTimes(1) expect(logMock).toHaveBeenCalledWith( JSON.stringify({ + level: 'INFO', + label: 'access', + requestId: 'request-id-1', + body: 'GET /api/user', + meta: { status: 200 } + }) + ) +}) + +test('logger.log redacts Authorization / Cookie / token in meta', () => { + const logMock = spyOnConsole('log') + + logger.log({ + label: 'auth', + body: 'verified', + meta: { + authorization: 'Bearer super-secret-token', + cookie: 'session=abcdef', + provider: { idToken: 'id-token-value', userId: 1 } + } + }) + + const [output] = logMock.mock.calls[0] + expect(output).not.toContain('super-secret-token') + expect(output).not.toContain('abcdef') + expect(output).not.toContain('id-token-value') + expect(JSON.parse(output)).toStrictEqual({ + level: 'INFO', + label: 'auth', + body: 'verified', + meta: { + authorization: REDACTED, + cookie: REDACTED, + provider: { idToken: REDACTED, userId: 1 } + } + }) +}) + +test('logger.log does not expand a Headers passed through meta', () => { + const logMock = spyOnConsole('log') + + logger.log({ + body: 'request', + meta: { + headers: new Headers({ authorization: 'Bearer super-secret-token' }) + } + }) + + const [output] = logMock.mock.calls[0] + expect(output).not.toContain('super-secret-token') + expect(JSON.parse(output)).toStrictEqual({ + level: 'INFO', + body: 'request', + meta: { headers: UNSUPPORTED } + }) +}) + +test('logger.error serializes an Error into a limited shape', () => { + const logMock = spyOnConsole('error') + + logger.error({ + label: 'handleError', + requestId: 'request-id-1', + body: 'error', + error: new Error('boom') + }) + + expect(logMock).toHaveBeenCalledTimes(1) + const [output] = logMock.mock.calls[0] + const parsed = JSON.parse(output) + expect(parsed.level).toBe('ERROR') + expect(parsed.label).toBe('handleError') + expect(parsed.requestId).toBe('request-id-1') + expect(parsed.error.name).toBe('Error') + expect(parsed.error.message).toBe('boom') + expect(typeof parsed.error.stack).toBe('string') +}) + +test('logger.error does not leak enumerable properties of an external error', () => { + const logMock = spyOnConsole('error') + + class ProviderError extends Error { + config = { headers: { authorization: 'Bearer super-secret-token' } } + refreshToken = 'refresh-token-value' + } + + logger.error({ + label: 'provider', + body: 'provider call failed', + error: new ProviderError('provider failed') + }) + + const [output] = logMock.mock.calls[0] + expect(output).not.toContain('super-secret-token') + expect(output).not.toContain('refresh-token-value') + expect(Object.keys(JSON.parse(output).error).sort()).toStrictEqual(['message', 'name', 'stack']) +}) + +test('logger.error serializes a non-Error value without spreading it', () => { + const logMock = spyOnConsole('error') + + logger.error({ + label: 'foo', + body: 'error', + error: { code: 'auth/invalid-id-token', message: 'invalid', idToken: 'id' } + }) + + expect(logMock).toHaveBeenCalledWith( + JSON.stringify({ + level: 'ERROR', label: 'foo', body: 'error', - error: 'bar', - level: 'ERROR' + error: { name: 'auth/invalid-id-token', message: 'invalid' } }) ) }) diff --git a/packages/client/src/app/_lib/logger.ts b/packages/client/src/app/_lib/logger.ts index d3ed431..a3276cd 100644 --- a/packages/client/src/app/_lib/logger.ts +++ b/packages/client/src/app/_lib/logger.ts @@ -1,44 +1,56 @@ import { NEXT_PUBLIC_ENV } from '../../constants' +import { redact, serializeError } from './redact' -type Options = { +/** + * structured log の単一入口。application code から console を直接呼ばず、 + * 必ずこの logger を経由する(`no-console` lint で強制している)。 + * + * - `meta` は `redact()` を通し、sensitive key と plain object 以外を落とす + * - `error` は `serializeError()` で name/message/stack/cause の限定 shape にする + * - `requestId` は api の access log と突き合わせるための共通 field(呼び出し側が明示的に渡す) + */ +type LogOptions = { label?: string + requestId?: string body: string - meta?: any + meta?: Record + error?: unknown } -type ErrorOptions = Options & { - error?: any +type Level = 'INFO' | 'ERROR' | 'DEBUG' + +function buildLog(level: Level, options: LogOptions) { + const { label, requestId, body, meta, error } = options + return { + level, + ...(label === undefined ? {} : { label }), + ...(requestId === undefined ? {} : { requestId }), + body, + ...(meta === undefined ? {} : { meta: redact(meta) }), + ...(error === undefined ? {} : { error: serializeError(error) }) + } } export const logger = { - log: (options: Options) => { - const json = { - ...options, - level: 'INFO' - } + log: (options: LogOptions) => { + const json = buildLog('INFO', options) if (NEXT_PUBLIC_ENV.local) { console.dir(json, { depth: null }) return } console.log(JSON.stringify(json)) }, - error: (options: ErrorOptions) => { - const json = { - ...options, - level: 'ERROR' - } + error: (options: LogOptions) => { + const json = buildLog('ERROR', options) if (NEXT_PUBLIC_ENV.local) { console.dir(json, { depth: null }) return } console.error(JSON.stringify(json)) }, - debug: (options: Options) => { + debug: (options: LogOptions) => { if (!NEXT_PUBLIC_ENV.production) { - const json = { - ...options, - level: 'DEBUG' - } + const json = buildLog('DEBUG', options) console.dir(json, { depth: null }) } } diff --git a/packages/client/src/app/_lib/redact.test.ts b/packages/client/src/app/_lib/redact.test.ts new file mode 100644 index 0000000..e15c7ee --- /dev/null +++ b/packages/client/src/app/_lib/redact.test.ts @@ -0,0 +1,239 @@ +import { expect, test } from 'vite-plus/test' +import { + CIRCULAR, + DEPTH_LIMIT, + REDACTED, + UNSUPPORTED, + isSensitiveKey, + redact, + serializeError +} from './redact' + +test('isSensitiveKey matches credential-ish keys regardless of case and separators', () => { + expect(isSensitiveKey('Authorization')).toBe(true) + expect(isSensitiveKey('cookie')).toBe(true) + expect(isSensitiveKey('set-cookie')).toBe(true) + expect(isSensitiveKey('Set-Cookie')).toBe(true) + expect(isSensitiveKey('password')).toBe(true) + expect(isSensitiveKey('client_secret')).toBe(true) + expect(isSensitiveKey('idToken')).toBe(true) + expect(isSensitiveKey('accessToken')).toBe(true) + expect(isSensitiveKey('refresh_token')).toBe(true) + expect(isSensitiveKey('sessionToken')).toBe(true) + expect(isSensitiveKey('x-api-key')).toBe(true) + + expect(isSensitiveKey('status')).toBe(false) + expect(isSensitiveKey('requestId')).toBe(false) + expect(isSensitiveKey('userId')).toBe(false) +}) + +test('redact masks Authorization / Cookie / token values including nested ones', () => { + const actual = redact({ + method: 'GET', + Authorization: 'Bearer super-secret-token', + cookie: 'session=abcdef', + session: { + 'set-cookie': 'session=abcdef; HttpOnly', + idToken: 'id-token-value', + accessToken: 'access-token-value', + refreshToken: 'refresh-token-value', + userId: 1 + } + }) + + expect(actual).toStrictEqual({ + method: 'GET', + Authorization: REDACTED, + cookie: REDACTED, + session: { + 'set-cookie': REDACTED, + idToken: REDACTED, + accessToken: REDACTED, + refreshToken: REDACTED, + userId: 1 + } + }) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') + expect(JSON.stringify(actual)).not.toContain('abcdef') +}) + +test('redact does not expand Headers / Request / Response', () => { + const headers = new Headers({ + authorization: 'Bearer super-secret-token', + cookie: 'session=abcdef' + }) + const request = new Request('https://example.com/api/user', { + method: 'POST', + headers, + body: JSON.stringify({ password: 'p@ssw0rd' }) + }) + + const actual = redact({ headers, request, response: new Response('body') }) + + expect(actual).toStrictEqual({ + headers: UNSUPPORTED, + request: UNSUPPORTED, + response: UNSUPPORTED + }) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') + expect(JSON.stringify(actual)).not.toContain('p@ssw0rd') +}) + +test('redact does not expand class instances or functions', () => { + class Provider { + accessToken = 'access-token-value' + } + + const actual = redact({ + provider: new Provider(), + map: new Map([['token', 'token-value']]), + fn: () => { + return 'noop' + } + }) + + expect(actual).toStrictEqual({ + provider: UNSUPPORTED, + map: UNSUPPORTED, + fn: UNSUPPORTED + }) +}) + +test('redact keeps primitives and normalizes Date / BigInt', () => { + const date = new Date('2026-09-01T00:00:00.000Z') + + expect( + redact({ + count: 1, + enabled: true, + empty: null, + date, + big: BigInt(10) + }) + ).toStrictEqual({ + count: 1, + enabled: true, + empty: null, + date: '2026-09-01T00:00:00.000Z', + big: '10' + }) +}) + +test('redact walks arrays and truncates long ones', () => { + expect(redact({ users: [{ password: 'x', name: 'a' }] })).toStrictEqual({ + users: [{ password: REDACTED, name: 'a' }] + }) + + const long = Array.from({ length: 52 }).map((_, i) => { + return i + }) + const actual = redact(long) + + expect(Array.isArray(actual) && actual.length).toBe(51) + expect(Array.isArray(actual) && actual.at(-1)).toBe('[2 more items]') +}) + +test('redact stops at circular references and depth limit', () => { + const circular: Record = { name: 'root' } + circular.self = circular + + expect(redact(circular)).toStrictEqual({ name: 'root', self: CIRCULAR }) + + expect(redact({ a: { b: { c: { d: { e: { f: { g: 'deep' } } } } } } })).toStrictEqual({ + a: { b: { c: { d: { e: { f: DEPTH_LIMIT } } } } } + }) +}) + +test('serializeError keeps only name / message / stack', () => { + const error = new Error('boom') + const actual = serializeError(error) + + expect(actual.name).toBe('Error') + expect(actual.message).toBe('boom') + expect(typeof actual.stack).toBe('string') + expect(actual.cause).toBeUndefined() +}) + +test('serializeError does not leak arbitrary enumerable properties of an error', () => { + class ProviderError extends Error { + // external SDK が request 情報を error に詰めてくるケース + config = { + headers: { authorization: 'Bearer super-secret-token' }, + body: { password: 'p@ssw0rd' } + } + accessToken = 'access-token-value' + } + const error = new ProviderError('provider failed') + error.name = 'ProviderError' + + const actual = serializeError(error) + + expect(actual.name).toBe('ProviderError') + expect(actual.message).toBe('provider failed') + expect(Object.keys(actual).sort()).toStrictEqual(['message', 'name', 'stack']) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') + expect(JSON.stringify(actual)).not.toContain('access-token-value') + expect(JSON.stringify(actual)).not.toContain('p@ssw0rd') +}) + +test('serializeError follows the cause chain within a bounded depth', () => { + const error = new Error('level0', { + cause: new Error('level1', { + cause: new Error('level2', { cause: new Error('level3') }) + }) + }) + + const actual = serializeError(error) + + expect(actual.message).toBe('level0') + expect(actual.cause?.message).toBe('level1') + expect(actual.cause?.cause?.message).toBe('level2') + expect(actual.cause?.cause?.cause?.message).toBe('level3') + expect(actual.cause?.cause?.cause?.cause).toBeUndefined() +}) + +test('serializeError stops on a circular cause chain', () => { + const error = new Error('self referencing') + error.cause = error + + expect(serializeError(error).cause?.cause?.cause?.cause).toBeUndefined() +}) + +test('serializeError narrows non-Error values to name / message', () => { + expect(serializeError('boom')).toStrictEqual({ + name: 'NonError', + message: 'boom' + }) + expect(serializeError(500)).toStrictEqual({ + name: 'NonError', + message: '500' + }) + expect(serializeError(undefined)).toStrictEqual({ + name: 'NonError', + message: 'undefined' + }) + // external SDK の plain object error。message / name(なければ code)だけを残す + expect( + serializeError({ + code: 'auth/invalid-id-token', + message: 'invalid token', + idToken: 'id-token-value', + headers: { authorization: 'Bearer super-secret-token' } + }) + ).toStrictEqual({ + name: 'auth/invalid-id-token', + message: 'invalid token' + }) +}) + +test('redact converts nested errors through serializeError', () => { + const actual = redact({ failure: new Error('nested boom') }) + + expect(actual).toStrictEqual({ + failure: { + name: 'Error', + message: 'nested boom', + stack: expect.any(String) + } + }) +}) diff --git a/packages/client/src/app/_lib/redact.ts b/packages/client/src/app/_lib/redact.ts new file mode 100644 index 0000000..d383d70 --- /dev/null +++ b/packages/client/src/app/_lib/redact.ts @@ -0,0 +1,212 @@ +/** + * ログ出力する値から credential / PII を落とすための redaction ヘルパー。 + * logger の単一入口(`_lib/logger.ts`)から呼ばれる想定で、直接 console へ + * 書き出す実装は持たない。 + * + * 設計方針(agents/logging.md 参照): + * - key 名による redaction と「plain object 以外は展開しない」ホワイトリスト方式を併用する。 + * key 名だけに依存すると `Headers` / `Request` / provider response のような + * 丸ごとのオブジェクトから secret が漏れるため + * - Error は enumerable property を spread せず、name/message/stack/cause の + * 限定 shape へ変換する + * + * 各パッケージは実行コンテキストが異なるため(AGENTS.md の Monorepo Guidelines)、 + * このファイルは shared へ切り出さず api/client/task がそれぞれ自己完結して持つ。 + */ + +/** 値が sensitive な key に紐づいていたことを示すプレースホルダ。 */ +export const REDACTED = '[REDACTED]' +/** plain object / 配列 / プリミティブ以外(Headers, Request, Map, 関数等)。 */ +export const UNSUPPORTED = '[UNSUPPORTED]' +/** 循環参照を検出した位置。 */ +export const CIRCULAR = '[CIRCULAR]' +/** ネストが深すぎて打ち切った位置。 */ +export const DEPTH_LIMIT = '[DEPTH_LIMIT]' + +/** ネストの最大深さ。これを超えた位置は DEPTH_LIMIT に置き換える。 */ +const MAX_DEPTH = 6 +/** 配列の最大要素数。超過分は件数だけを残す。 */ +const MAX_ARRAY_LENGTH = 50 +/** stack trace として残す最大行数(ログ量を有界にするため)。 */ +const MAX_STACK_LINES = 20 +/** cause チェーンを辿る最大段数(cause の循環でも停止させるため)。 */ +const MAX_CAUSE_DEPTH = 3 + +/** + * 正規化(小文字化 + 区切り文字除去)した key 名に対する部分一致で判定する。 + * `token` が idToken / accessToken / refreshToken / sessionToken を、 + * `cookie` が set-cookie を、それぞれまとめて拾う。 + * 判定回数を key ごとに 1 回へ抑えるため単一の正規表現にまとめている。 + */ +const SENSITIVE_KEY_PATTERN = + /authorization|cookie|password|passwd|secret|token|credential|apikey|privatekey|sessionid/ + +const KEY_SEPARATOR_PATTERN = /[-_\s.]/g + +/** ログの key 名が redaction 対象かどうかを返す。 */ +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERN.test(key.toLowerCase().replace(KEY_SEPARATOR_PATTERN, '')) +} + +/** + * `redact()` が出力しうる値。JSON へそのまま流せる形だけを許す。 + * `redactValue` が再帰しており戻り値型を推論できないため明示する。 + */ +export type RedactedValue = + | string + | number + | boolean + | null + | undefined + | SerializedError + | RedactedValue[] + | { [key: string]: RedactedValue } + +/** Error を JSON へ安全に落とし込むための限定 shape。 */ +export type SerializedError = { + name: string + message: string + stack?: string + cause?: SerializedError +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** + * plain object(リテラル/`Object.create(null)`/`structuredClone` 由来)だけを + * 展開対象にする。`Headers` / `Request` / `Response` / `Map` / class instance は + * ここで false になり、丸ごとログへ流れることを防ぐ。 + */ +function isPlainObject(value: unknown): value is Record { + if (!isRecord(value)) { + return false + } + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function trimStack(stack: string | undefined): string | undefined { + if (!stack) { + return undefined + } + const lines = stack.split('\n') + if (lines.length <= MAX_STACK_LINES) { + return stack + } + return [ + ...lines.slice(0, MAX_STACK_LINES), + ` ... ${lines.length - MAX_STACK_LINES} more lines` + ].join('\n') +} + +/** + * external SDK が投げる「Error ではないが name/message を持つ」オブジェクトから + * 文字列フィールドだけを取り出す。任意の enumerable property は読まない。 + */ +function serializeNonError(value: unknown): SerializedError { + if (typeof value === 'string') { + return { name: 'NonError', message: value } + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return { name: 'NonError', message: `${value}` } + } + if (!isRecord(value)) { + return { name: 'NonError', message: `${typeof value}` } + } + const name = + typeof value.name === 'string' + ? value.name + : typeof value.code === 'string' + ? value.code + : 'NonError' + const message = typeof value.message === 'string' ? value.message : '[unknown]' + return { name, message } +} + +function serializeErrorWithDepth(error: unknown, depth: number): SerializedError { + if (!(error instanceof Error)) { + return serializeNonError(error) + } + const cause = + error.cause !== undefined && depth < MAX_CAUSE_DEPTH + ? serializeErrorWithDepth(error.cause, depth + 1) + : undefined + const stack = trimStack(error.stack) + return { + name: error.name, + message: error.message, + ...(stack === undefined ? {} : { stack }), + ...(cause === undefined ? {} : { cause }) + } +} + +/** + * Error / external SDK error を name/message/stack/cause の限定 shape へ変換する。 + * `{ ...error }` のような spread と違い、任意の enumerable property + * (provider が詰めた credential や request payload)を持ち出さない。 + */ +export function serializeError(error: unknown): SerializedError { + return serializeErrorWithDepth(error, 0) +} + +function redactValue(value: unknown, depth: number, seen: WeakSet): RedactedValue { + if (value === null || value === undefined) { + return value + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value + } + if (typeof value === 'bigint') { + return `${value}` + } + if (typeof value !== 'object') { + // function / symbol は構造化ログに載せる意味が無く、toString で + // クロージャの中身が漏れる余地もあるため展開しない + return UNSUPPORTED + } + if (value instanceof Error) { + return serializeError(value) + } + if (value instanceof Date) { + return value.toISOString() + } + if (seen.has(value)) { + return CIRCULAR + } + if (depth >= MAX_DEPTH) { + return DEPTH_LIMIT + } + seen.add(value) + if (Array.isArray(value)) { + const items = value.slice(0, MAX_ARRAY_LENGTH).map((item) => { + return redactValue(item, depth + 1, seen) + }) + seen.delete(value) + return value.length > MAX_ARRAY_LENGTH + ? [...items, `[${value.length - MAX_ARRAY_LENGTH} more items]`] + : items + } + if (!isPlainObject(value)) { + // Headers / Request / Response / Map / class instance など。 + // 丸ごとログへ流すと credential が混入するため中身を見ない + seen.delete(value) + return UNSUPPORTED + } + const result: Record = {} + for (const [key, entry] of Object.entries(value)) { + result[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, depth + 1, seen) + } + seen.delete(value) + return result +} + +/** + * ログの meta に載せる値から sensitive field を落とす。 + * sensitive な key は `[REDACTED]` に、plain object 以外のオブジェクトは + * `[UNSUPPORTED]` に置き換える。 + */ +export function redact(value: unknown): RedactedValue { + return redactValue(value, 0, new WeakSet()) +} diff --git a/packages/task/src/lib/logger.test.ts b/packages/task/src/lib/logger.test.ts new file mode 100644 index 0000000..70ee944 --- /dev/null +++ b/packages/task/src/lib/logger.test.ts @@ -0,0 +1,160 @@ +import { expect, test, vi } from 'vite-plus/test' +import { logger } from './logger.js' +import { REDACTED, UNSUPPORTED } from './redact.js' + +vi.mock('console') + +// console の spy は同一ファイル内の test をまたいで呼び出し履歴が蓄積するため、 +// test ごとに履歴をクリアしてから使う +function spyOnConsole(method: 'log' | 'error') { + const spy = vi.spyOn(console, method).mockImplementation(() => { + return undefined + }) + spy.mockClear() + return spy +} + +test('logger.log outputs level / label / body as a structured log', () => { + const logMock = spyOnConsole('log') + + logger.log({ label: 'foo', body: 'bar' }) + + expect(logMock).toHaveBeenCalledTimes(1) + expect(logMock).toHaveBeenCalledWith( + JSON.stringify({ + level: 'INFO', + label: 'foo', + body: 'bar' + }) + ) +}) + +test('logger.log carries requestId as a top level field', () => { + const logMock = spyOnConsole('log') + + logger.log({ + label: 'access', + requestId: 'request-id-1', + body: 'GET /api/user', + meta: { status: 200 } + }) + + expect(logMock).toHaveBeenCalledTimes(1) + expect(logMock).toHaveBeenCalledWith( + JSON.stringify({ + level: 'INFO', + label: 'access', + requestId: 'request-id-1', + body: 'GET /api/user', + meta: { status: 200 } + }) + ) +}) + +test('logger.log redacts Authorization / Cookie / token in meta', () => { + const logMock = spyOnConsole('log') + + logger.log({ + label: 'auth', + body: 'verified', + meta: { + authorization: 'Bearer super-secret-token', + cookie: 'session=abcdef', + provider: { idToken: 'id-token-value', userId: 1 } + } + }) + + const [output] = logMock.mock.calls[0] + expect(output).not.toContain('super-secret-token') + expect(output).not.toContain('abcdef') + expect(output).not.toContain('id-token-value') + expect(JSON.parse(output)).toStrictEqual({ + level: 'INFO', + label: 'auth', + body: 'verified', + meta: { + authorization: REDACTED, + cookie: REDACTED, + provider: { idToken: REDACTED, userId: 1 } + } + }) +}) + +test('logger.log does not expand a Headers passed through meta', () => { + const logMock = spyOnConsole('log') + + logger.log({ + body: 'request', + meta: { + headers: new Headers({ authorization: 'Bearer super-secret-token' }) + } + }) + + const [output] = logMock.mock.calls[0] + expect(output).not.toContain('super-secret-token') + expect(JSON.parse(output)).toStrictEqual({ + level: 'INFO', + body: 'request', + meta: { headers: UNSUPPORTED } + }) +}) + +test('logger.error serializes an Error into a limited shape', () => { + const logMock = spyOnConsole('error') + + logger.error({ + label: 'handleError', + requestId: 'request-id-1', + body: 'error', + error: new Error('boom') + }) + + expect(logMock).toHaveBeenCalledTimes(1) + const [output] = logMock.mock.calls[0] + const parsed = JSON.parse(output) + expect(parsed.level).toBe('ERROR') + expect(parsed.label).toBe('handleError') + expect(parsed.requestId).toBe('request-id-1') + expect(parsed.error.name).toBe('Error') + expect(parsed.error.message).toBe('boom') + expect(typeof parsed.error.stack).toBe('string') +}) + +test('logger.error does not leak enumerable properties of an external error', () => { + const logMock = spyOnConsole('error') + + class ProviderError extends Error { + config = { headers: { authorization: 'Bearer super-secret-token' } } + refreshToken = 'refresh-token-value' + } + + logger.error({ + label: 'provider', + body: 'provider call failed', + error: new ProviderError('provider failed') + }) + + const [output] = logMock.mock.calls[0] + expect(output).not.toContain('super-secret-token') + expect(output).not.toContain('refresh-token-value') + expect(Object.keys(JSON.parse(output).error).sort()).toStrictEqual(['message', 'name', 'stack']) +}) + +test('logger.error serializes a non-Error value without spreading it', () => { + const logMock = spyOnConsole('error') + + logger.error({ + label: 'foo', + body: 'error', + error: { code: 'auth/invalid-id-token', message: 'invalid', idToken: 'id' } + }) + + expect(logMock).toHaveBeenCalledWith( + JSON.stringify({ + level: 'ERROR', + label: 'foo', + body: 'error', + error: { name: 'auth/invalid-id-token', message: 'invalid' } + }) + ) +}) diff --git a/packages/task/src/lib/logger.ts b/packages/task/src/lib/logger.ts index 591b237..8499635 100644 --- a/packages/task/src/lib/logger.ts +++ b/packages/task/src/lib/logger.ts @@ -1,44 +1,56 @@ import { ENV } from '../config.js' +import { redact, serializeError } from './redact.js' -type Options = { +/** + * structured log の単一入口。application code から console を直接呼ばず、 + * 必ずこの logger を経由する(`no-console` lint で強制している)。 + * + * - `meta` は `redact()` を通し、sensitive key と plain object 以外を落とす + * - `error` は `serializeError()` で name/message/stack/cause の限定 shape にする + * - `requestId` は 1 実行を横断して追跡するための共通 field(呼び出し側が明示的に渡す) + */ +type LogOptions = { label?: string + requestId?: string body: string - meta?: any + meta?: Record + error?: unknown } -type ErrorOptions = Options & { - error?: any +type Level = 'INFO' | 'ERROR' | 'DEBUG' + +function buildLog(level: Level, options: LogOptions) { + const { label, requestId, body, meta, error } = options + return { + level, + ...(label === undefined ? {} : { label }), + ...(requestId === undefined ? {} : { requestId }), + body, + ...(meta === undefined ? {} : { meta: redact(meta) }), + ...(error === undefined ? {} : { error: serializeError(error) }) + } } export const logger = { - log: (options: Options) => { - const json = { - ...options, - level: 'INFO' - } + log: (options: LogOptions) => { + const json = buildLog('INFO', options) if (ENV.local) { console.dir(json, { depth: null }) return } console.log(JSON.stringify(json)) }, - error: (options: ErrorOptions) => { - const json = { - ...options, - level: 'ERROR' - } + error: (options: LogOptions) => { + const json = buildLog('ERROR', options) if (ENV.local) { console.dir(json, { depth: null }) return } console.error(JSON.stringify(json)) }, - debug: (options: Options) => { + debug: (options: LogOptions) => { if (!ENV.production) { - const json = { - ...options, - level: 'DEBUG' - } + const json = buildLog('DEBUG', options) console.dir(json, { depth: null }) } } diff --git a/packages/task/src/lib/redact.test.ts b/packages/task/src/lib/redact.test.ts new file mode 100644 index 0000000..c4f9fe3 --- /dev/null +++ b/packages/task/src/lib/redact.test.ts @@ -0,0 +1,239 @@ +import { expect, test } from 'vite-plus/test' +import { + CIRCULAR, + DEPTH_LIMIT, + REDACTED, + UNSUPPORTED, + isSensitiveKey, + redact, + serializeError +} from './redact.js' + +test('isSensitiveKey matches credential-ish keys regardless of case and separators', () => { + expect(isSensitiveKey('Authorization')).toBe(true) + expect(isSensitiveKey('cookie')).toBe(true) + expect(isSensitiveKey('set-cookie')).toBe(true) + expect(isSensitiveKey('Set-Cookie')).toBe(true) + expect(isSensitiveKey('password')).toBe(true) + expect(isSensitiveKey('client_secret')).toBe(true) + expect(isSensitiveKey('idToken')).toBe(true) + expect(isSensitiveKey('accessToken')).toBe(true) + expect(isSensitiveKey('refresh_token')).toBe(true) + expect(isSensitiveKey('sessionToken')).toBe(true) + expect(isSensitiveKey('x-api-key')).toBe(true) + + expect(isSensitiveKey('status')).toBe(false) + expect(isSensitiveKey('requestId')).toBe(false) + expect(isSensitiveKey('userId')).toBe(false) +}) + +test('redact masks Authorization / Cookie / token values including nested ones', () => { + const actual = redact({ + method: 'GET', + Authorization: 'Bearer super-secret-token', + cookie: 'session=abcdef', + session: { + 'set-cookie': 'session=abcdef; HttpOnly', + idToken: 'id-token-value', + accessToken: 'access-token-value', + refreshToken: 'refresh-token-value', + userId: 1 + } + }) + + expect(actual).toStrictEqual({ + method: 'GET', + Authorization: REDACTED, + cookie: REDACTED, + session: { + 'set-cookie': REDACTED, + idToken: REDACTED, + accessToken: REDACTED, + refreshToken: REDACTED, + userId: 1 + } + }) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') + expect(JSON.stringify(actual)).not.toContain('abcdef') +}) + +test('redact does not expand Headers / Request / Response', () => { + const headers = new Headers({ + authorization: 'Bearer super-secret-token', + cookie: 'session=abcdef' + }) + const request = new Request('https://example.com/api/user', { + method: 'POST', + headers, + body: JSON.stringify({ password: 'p@ssw0rd' }) + }) + + const actual = redact({ headers, request, response: new Response('body') }) + + expect(actual).toStrictEqual({ + headers: UNSUPPORTED, + request: UNSUPPORTED, + response: UNSUPPORTED + }) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') + expect(JSON.stringify(actual)).not.toContain('p@ssw0rd') +}) + +test('redact does not expand class instances or functions', () => { + class Provider { + accessToken = 'access-token-value' + } + + const actual = redact({ + provider: new Provider(), + map: new Map([['token', 'token-value']]), + fn: () => { + return 'noop' + } + }) + + expect(actual).toStrictEqual({ + provider: UNSUPPORTED, + map: UNSUPPORTED, + fn: UNSUPPORTED + }) +}) + +test('redact keeps primitives and normalizes Date / BigInt', () => { + const date = new Date('2026-09-01T00:00:00.000Z') + + expect( + redact({ + count: 1, + enabled: true, + empty: null, + date, + big: 10n + }) + ).toStrictEqual({ + count: 1, + enabled: true, + empty: null, + date: '2026-09-01T00:00:00.000Z', + big: '10' + }) +}) + +test('redact walks arrays and truncates long ones', () => { + expect(redact({ users: [{ password: 'x', name: 'a' }] })).toStrictEqual({ + users: [{ password: REDACTED, name: 'a' }] + }) + + const long = Array.from({ length: 52 }).map((_, i) => { + return i + }) + const actual = redact(long) + + expect(Array.isArray(actual) && actual.length).toBe(51) + expect(Array.isArray(actual) && actual.at(-1)).toBe('[2 more items]') +}) + +test('redact stops at circular references and depth limit', () => { + const circular: Record = { name: 'root' } + circular.self = circular + + expect(redact(circular)).toStrictEqual({ name: 'root', self: CIRCULAR }) + + expect(redact({ a: { b: { c: { d: { e: { f: { g: 'deep' } } } } } } })).toStrictEqual({ + a: { b: { c: { d: { e: { f: DEPTH_LIMIT } } } } } + }) +}) + +test('serializeError keeps only name / message / stack', () => { + const error = new Error('boom') + const actual = serializeError(error) + + expect(actual.name).toBe('Error') + expect(actual.message).toBe('boom') + expect(typeof actual.stack).toBe('string') + expect(actual.cause).toBeUndefined() +}) + +test('serializeError does not leak arbitrary enumerable properties of an error', () => { + class ProviderError extends Error { + // external SDK が request 情報を error に詰めてくるケース + config = { + headers: { authorization: 'Bearer super-secret-token' }, + body: { password: 'p@ssw0rd' } + } + accessToken = 'access-token-value' + } + const error = new ProviderError('provider failed') + error.name = 'ProviderError' + + const actual = serializeError(error) + + expect(actual.name).toBe('ProviderError') + expect(actual.message).toBe('provider failed') + expect(Object.keys(actual).sort()).toStrictEqual(['message', 'name', 'stack']) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') + expect(JSON.stringify(actual)).not.toContain('access-token-value') + expect(JSON.stringify(actual)).not.toContain('p@ssw0rd') +}) + +test('serializeError follows the cause chain within a bounded depth', () => { + const error = new Error('level0', { + cause: new Error('level1', { + cause: new Error('level2', { cause: new Error('level3') }) + }) + }) + + const actual = serializeError(error) + + expect(actual.message).toBe('level0') + expect(actual.cause?.message).toBe('level1') + expect(actual.cause?.cause?.message).toBe('level2') + expect(actual.cause?.cause?.cause?.message).toBe('level3') + expect(actual.cause?.cause?.cause?.cause).toBeUndefined() +}) + +test('serializeError stops on a circular cause chain', () => { + const error = new Error('self referencing') + error.cause = error + + expect(serializeError(error).cause?.cause?.cause?.cause).toBeUndefined() +}) + +test('serializeError narrows non-Error values to name / message', () => { + expect(serializeError('boom')).toStrictEqual({ + name: 'NonError', + message: 'boom' + }) + expect(serializeError(500)).toStrictEqual({ + name: 'NonError', + message: '500' + }) + expect(serializeError(undefined)).toStrictEqual({ + name: 'NonError', + message: 'undefined' + }) + // external SDK の plain object error。message / name(なければ code)だけを残す + expect( + serializeError({ + code: 'auth/invalid-id-token', + message: 'invalid token', + idToken: 'id-token-value', + headers: { authorization: 'Bearer super-secret-token' } + }) + ).toStrictEqual({ + name: 'auth/invalid-id-token', + message: 'invalid token' + }) +}) + +test('redact converts nested errors through serializeError', () => { + const actual = redact({ failure: new Error('nested boom') }) + + expect(actual).toStrictEqual({ + failure: { + name: 'Error', + message: 'nested boom', + stack: expect.any(String) + } + }) +}) diff --git a/packages/task/src/lib/redact.ts b/packages/task/src/lib/redact.ts new file mode 100644 index 0000000..8265dc5 --- /dev/null +++ b/packages/task/src/lib/redact.ts @@ -0,0 +1,212 @@ +/** + * ログ出力する値から credential / PII を落とすための redaction ヘルパー。 + * logger の単一入口(`lib/logger.ts`)から呼ばれる想定で、直接 console へ + * 書き出す実装は持たない。 + * + * 設計方針(agents/logging.md 参照): + * - key 名による redaction と「plain object 以外は展開しない」ホワイトリスト方式を併用する。 + * key 名だけに依存すると `Headers` / `Request` / provider response のような + * 丸ごとのオブジェクトから secret が漏れるため + * - Error は enumerable property を spread せず、name/message/stack/cause の + * 限定 shape へ変換する + * + * 各パッケージは実行コンテキストが異なるため(AGENTS.md の Monorepo Guidelines)、 + * このファイルは shared へ切り出さず api/client/task がそれぞれ自己完結して持つ。 + */ + +/** 値が sensitive な key に紐づいていたことを示すプレースホルダ。 */ +export const REDACTED = '[REDACTED]' +/** plain object / 配列 / プリミティブ以外(Headers, Request, Map, 関数等)。 */ +export const UNSUPPORTED = '[UNSUPPORTED]' +/** 循環参照を検出した位置。 */ +export const CIRCULAR = '[CIRCULAR]' +/** ネストが深すぎて打ち切った位置。 */ +export const DEPTH_LIMIT = '[DEPTH_LIMIT]' + +/** ネストの最大深さ。これを超えた位置は DEPTH_LIMIT に置き換える。 */ +const MAX_DEPTH = 6 +/** 配列の最大要素数。超過分は件数だけを残す。 */ +const MAX_ARRAY_LENGTH = 50 +/** stack trace として残す最大行数(ログ量を有界にするため)。 */ +const MAX_STACK_LINES = 20 +/** cause チェーンを辿る最大段数(cause の循環でも停止させるため)。 */ +const MAX_CAUSE_DEPTH = 3 + +/** + * 正規化(小文字化 + 区切り文字除去)した key 名に対する部分一致で判定する。 + * `token` が idToken / accessToken / refreshToken / sessionToken を、 + * `cookie` が set-cookie を、それぞれまとめて拾う。 + * 判定回数を key ごとに 1 回へ抑えるため単一の正規表現にまとめている。 + */ +const SENSITIVE_KEY_PATTERN = + /authorization|cookie|password|passwd|secret|token|credential|apikey|privatekey|sessionid/ + +const KEY_SEPARATOR_PATTERN = /[-_\s.]/g + +/** ログの key 名が redaction 対象かどうかを返す。 */ +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERN.test(key.toLowerCase().replace(KEY_SEPARATOR_PATTERN, '')) +} + +/** + * `redact()` が出力しうる値。JSON へそのまま流せる形だけを許す。 + * `redactValue` が再帰しており戻り値型を推論できないため明示する。 + */ +export type RedactedValue = + | string + | number + | boolean + | null + | undefined + | SerializedError + | RedactedValue[] + | { [key: string]: RedactedValue } + +/** Error を JSON へ安全に落とし込むための限定 shape。 */ +export type SerializedError = { + name: string + message: string + stack?: string + cause?: SerializedError +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** + * plain object(リテラル/`Object.create(null)`/`structuredClone` 由来)だけを + * 展開対象にする。`Headers` / `Request` / `Response` / `Map` / class instance は + * ここで false になり、丸ごとログへ流れることを防ぐ。 + */ +function isPlainObject(value: unknown): value is Record { + if (!isRecord(value)) { + return false + } + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function trimStack(stack: string | undefined): string | undefined { + if (!stack) { + return undefined + } + const lines = stack.split('\n') + if (lines.length <= MAX_STACK_LINES) { + return stack + } + return [ + ...lines.slice(0, MAX_STACK_LINES), + ` ... ${lines.length - MAX_STACK_LINES} more lines` + ].join('\n') +} + +/** + * external SDK が投げる「Error ではないが name/message を持つ」オブジェクトから + * 文字列フィールドだけを取り出す。任意の enumerable property は読まない。 + */ +function serializeNonError(value: unknown): SerializedError { + if (typeof value === 'string') { + return { name: 'NonError', message: value } + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return { name: 'NonError', message: `${value}` } + } + if (!isRecord(value)) { + return { name: 'NonError', message: `${typeof value}` } + } + const name = + typeof value.name === 'string' + ? value.name + : typeof value.code === 'string' + ? value.code + : 'NonError' + const message = typeof value.message === 'string' ? value.message : '[unknown]' + return { name, message } +} + +function serializeErrorWithDepth(error: unknown, depth: number): SerializedError { + if (!(error instanceof Error)) { + return serializeNonError(error) + } + const cause = + error.cause !== undefined && depth < MAX_CAUSE_DEPTH + ? serializeErrorWithDepth(error.cause, depth + 1) + : undefined + const stack = trimStack(error.stack) + return { + name: error.name, + message: error.message, + ...(stack === undefined ? {} : { stack }), + ...(cause === undefined ? {} : { cause }) + } +} + +/** + * Error / external SDK error を name/message/stack/cause の限定 shape へ変換する。 + * `{ ...error }` のような spread と違い、任意の enumerable property + * (provider が詰めた credential や request payload)を持ち出さない。 + */ +export function serializeError(error: unknown): SerializedError { + return serializeErrorWithDepth(error, 0) +} + +function redactValue(value: unknown, depth: number, seen: WeakSet): RedactedValue { + if (value === null || value === undefined) { + return value + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value + } + if (typeof value === 'bigint') { + return `${value}` + } + if (typeof value !== 'object') { + // function / symbol は構造化ログに載せる意味が無く、toString で + // クロージャの中身が漏れる余地もあるため展開しない + return UNSUPPORTED + } + if (value instanceof Error) { + return serializeError(value) + } + if (value instanceof Date) { + return value.toISOString() + } + if (seen.has(value)) { + return CIRCULAR + } + if (depth >= MAX_DEPTH) { + return DEPTH_LIMIT + } + seen.add(value) + if (Array.isArray(value)) { + const items = value.slice(0, MAX_ARRAY_LENGTH).map((item) => { + return redactValue(item, depth + 1, seen) + }) + seen.delete(value) + return value.length > MAX_ARRAY_LENGTH + ? [...items, `[${value.length - MAX_ARRAY_LENGTH} more items]`] + : items + } + if (!isPlainObject(value)) { + // Headers / Request / Response / Map / class instance など。 + // 丸ごとログへ流すと credential が混入するため中身を見ない + seen.delete(value) + return UNSUPPORTED + } + const result: Record = {} + for (const [key, entry] of Object.entries(value)) { + result[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, depth + 1, seen) + } + seen.delete(value) + return result +} + +/** + * ログの meta に載せる値から sensitive field を落とす。 + * sensitive な key は `[REDACTED]` に、plain object 以外のオブジェクトは + * `[UNSUPPORTED]` に置き換える。 + */ +export function redact(value: unknown): RedactedValue { + return redactValue(value, 0, new WeakSet()) +} diff --git a/vite.config.ts b/vite.config.ts index 32d0e76..8110e16 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,12 +49,30 @@ export default defineConfig({ ], rules: { 'coding-style/no-process-env-outside-config': 'error', - 'coding-style/enforce-zod-entrypoint': 'error' + 'coding-style/enforce-zod-entrypoint': 'error', + // structured logging の規約(agents/logging.md)。console は logger + // 実装だけに閉じ込め、logger へ secret / request payload を渡させない。 + 'coding-style/no-sensitive-logging': 'error', + 'no-console': 'error' } }, { - files: ['**/*.test.ts'], + // logger 実装本体だけが console へ書き出す(structured log の単一入口)。 + files: [ + 'packages/api/src/lib/logger.ts', + 'packages/client/src/app/_lib/logger.ts', + 'packages/task/src/lib/logger.ts' + ], + rules: { + 'no-console': 'off' + } + }, + { + files: ['**/*.test.ts', '**/*.test.tsx'], rules: { + // テストは logger の出力先である console を spy/assert するため対象外にする + // (no-sensitive-logging 側の除外はルール実装がファイル名で判定する)。 + 'no-console': 'off', // 並列耐性テストの規約(AGENTS.md 参照)として describe() を禁止する。 // 一時的な reminder ではなく恒久的なコーディング規約のため error にする。 'no-restricted-imports': [ From 64b9c7ae07a903a5b3c3a5e2b82ff5762a2c8f37 Mon Sep 17 00:00:00 2001 From: kohta ito Date: Tue, 1 Sep 2026 20:12:56 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20logging=20payload=20=E3=82=92?= =?UTF-8?q?=E5=9E=8B=E3=81=A7=E5=88=B6=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents/logging.md | 4 +- packages/api/src/lib/logger.test.ts | 21 +---- packages/api/src/lib/logger.ts | 5 +- packages/api/src/lib/redact.test.ts | 84 ++----------------- packages/api/src/lib/redact.ts | 67 ++++----------- packages/client/src/app/_lib/logger.test.ts | 21 +---- packages/client/src/app/_lib/logger.ts | 5 +- packages/client/src/app/_lib/redact.test.ts | 93 ++------------------- packages/client/src/app/_lib/redact.ts | 67 ++++----------- packages/task/src/lib/logger.test.ts | 21 +---- packages/task/src/lib/logger.ts | 5 +- packages/task/src/lib/redact.test.ts | 84 ++----------------- packages/task/src/lib/redact.ts | 67 ++++----------- 13 files changed, 87 insertions(+), 457 deletions(-) diff --git a/agents/logging.md b/agents/logging.md index 1e793f5..8a224e4 100644 --- a/agents/logging.md +++ b/agents/logging.md @@ -37,11 +37,11 @@ `redact()`(`redact.ts`)は 2 つの防御を併用する。key 名だけに頼ると `Headers` や provider response のような「丸ごとのオブジェクト」から漏れるため。 1. **key 名による redaction** — 小文字化 + 区切り文字除去で正規化した key 名が `authorization` / `cookie`(`set-cookie` を含む) / `password` / `secret` / `token`(`idToken` / `accessToken` / `refreshToken` / `sessionToken` を含む) / `credential` / `apikey` / `privatekey` / `sessionid` に部分一致したら値を `[REDACTED]` にする -2. **plain object 以外は展開しない** — object literal / `Object.create(null)` / 配列 / プリミティブ以外(`Headers` / `Request` / `Response` / `Map` / class instance / 関数)は中身を見ずに `[UNSUPPORTED]` にする +2. **型による入力制限** — `LogValue` / `LogMeta` は JSON-safe なプリミティブ、配列、object のみを許可する。`Headers` / `Request` / `Response` / provider response などは呼び出し側で必要な field だけを明示的に抽出する 加えて、循環参照は `[CIRCULAR]`、深すぎるネストは `[DEPTH_LIMIT]`、長すぎる配列は末尾を件数へ畳み、ログ量を有界にする。 -redaction は最後の防波堤であって、設計上の免罪符ではない。**request / response / headers / body を丸ごと `meta` へ渡さず、必要な field だけを抜き出して渡す**。 +redaction は最後の防波堤であって、設計上の免罪符ではない。**request / response / headers / body を丸ごと `meta` へ渡さず、必要な field だけを抜き出して渡す**。型検査に失敗する値を assertion で無理に logger へ渡してはならない。 ```ts // NG: 何が載るか呼び出し側で分からない diff --git a/packages/api/src/lib/logger.test.ts b/packages/api/src/lib/logger.test.ts index 70ee944..50d8568 100644 --- a/packages/api/src/lib/logger.test.ts +++ b/packages/api/src/lib/logger.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vite-plus/test' import { logger } from './logger.js' -import { REDACTED, UNSUPPORTED } from './redact.js' +import { REDACTED } from './redact.js' vi.mock('console') @@ -80,25 +80,6 @@ test('logger.log redacts Authorization / Cookie / token in meta', () => { }) }) -test('logger.log does not expand a Headers passed through meta', () => { - const logMock = spyOnConsole('log') - - logger.log({ - body: 'request', - meta: { - headers: new Headers({ authorization: 'Bearer super-secret-token' }) - } - }) - - const [output] = logMock.mock.calls[0] - expect(output).not.toContain('super-secret-token') - expect(JSON.parse(output)).toStrictEqual({ - level: 'INFO', - body: 'request', - meta: { headers: UNSUPPORTED } - }) -}) - test('logger.error serializes an Error into a limited shape', () => { const logMock = spyOnConsole('error') diff --git a/packages/api/src/lib/logger.ts b/packages/api/src/lib/logger.ts index 0ed3962..3bf34c9 100644 --- a/packages/api/src/lib/logger.ts +++ b/packages/api/src/lib/logger.ts @@ -1,11 +1,12 @@ import { ENV } from '../config.js' import { redact, serializeError } from './redact.js' +import type { LogMeta } from './redact.js' /** * structured log の単一入口。application code から console を直接呼ばず、 * 必ずこの logger を経由する(`no-console` lint で強制している)。 * - * - `meta` は `redact()` を通し、sensitive key と plain object 以外を落とす + * - `meta` は JSON-safe な `LogMeta` 型で受け取り、sensitive key を redact する * - `error` は `serializeError()` で name/message/stack/cause の限定 shape にする * - `requestId` は access log / application log を横断して追跡するための共通 field。 * api では accessLogMiddleware が発行した値を `c.get('requestId')` から渡す @@ -14,7 +15,7 @@ type LogOptions = { label?: string requestId?: string body: string - meta?: Record + meta?: LogMeta error?: unknown } diff --git a/packages/api/src/lib/redact.test.ts b/packages/api/src/lib/redact.test.ts index c4f9fe3..789bbb5 100644 --- a/packages/api/src/lib/redact.test.ts +++ b/packages/api/src/lib/redact.test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vite-plus/test' +import type { LogMeta } from './redact.js' import { CIRCULAR, DEPTH_LIMIT, REDACTED, - UNSUPPORTED, isSensitiveKey, redact, serializeError @@ -27,6 +27,12 @@ test('isSensitiveKey matches credential-ish keys regardless of case and separato expect(isSensitiveKey('userId')).toBe(false) }) +test('LogValue rejects non-JSON runtime objects at compile time', () => { + const headers = new Headers() + // @ts-expect-error Headers are intentionally not valid LogValue values + redact(headers) +}) + test('redact masks Authorization / Cookie / token values including nested ones', () => { const actual = redact({ method: 'GET', @@ -57,68 +63,6 @@ test('redact masks Authorization / Cookie / token values including nested ones', expect(JSON.stringify(actual)).not.toContain('abcdef') }) -test('redact does not expand Headers / Request / Response', () => { - const headers = new Headers({ - authorization: 'Bearer super-secret-token', - cookie: 'session=abcdef' - }) - const request = new Request('https://example.com/api/user', { - method: 'POST', - headers, - body: JSON.stringify({ password: 'p@ssw0rd' }) - }) - - const actual = redact({ headers, request, response: new Response('body') }) - - expect(actual).toStrictEqual({ - headers: UNSUPPORTED, - request: UNSUPPORTED, - response: UNSUPPORTED - }) - expect(JSON.stringify(actual)).not.toContain('super-secret-token') - expect(JSON.stringify(actual)).not.toContain('p@ssw0rd') -}) - -test('redact does not expand class instances or functions', () => { - class Provider { - accessToken = 'access-token-value' - } - - const actual = redact({ - provider: new Provider(), - map: new Map([['token', 'token-value']]), - fn: () => { - return 'noop' - } - }) - - expect(actual).toStrictEqual({ - provider: UNSUPPORTED, - map: UNSUPPORTED, - fn: UNSUPPORTED - }) -}) - -test('redact keeps primitives and normalizes Date / BigInt', () => { - const date = new Date('2026-09-01T00:00:00.000Z') - - expect( - redact({ - count: 1, - enabled: true, - empty: null, - date, - big: 10n - }) - ).toStrictEqual({ - count: 1, - enabled: true, - empty: null, - date: '2026-09-01T00:00:00.000Z', - big: '10' - }) -}) - test('redact walks arrays and truncates long ones', () => { expect(redact({ users: [{ password: 'x', name: 'a' }] })).toStrictEqual({ users: [{ password: REDACTED, name: 'a' }] @@ -134,7 +78,7 @@ test('redact walks arrays and truncates long ones', () => { }) test('redact stops at circular references and depth limit', () => { - const circular: Record = { name: 'root' } + const circular: LogMeta = { name: 'root' } circular.self = circular expect(redact(circular)).toStrictEqual({ name: 'root', self: CIRCULAR }) @@ -225,15 +169,3 @@ test('serializeError narrows non-Error values to name / message', () => { message: 'invalid token' }) }) - -test('redact converts nested errors through serializeError', () => { - const actual = redact({ failure: new Error('nested boom') }) - - expect(actual).toStrictEqual({ - failure: { - name: 'Error', - message: 'nested boom', - stack: expect.any(String) - } - }) -}) diff --git a/packages/api/src/lib/redact.ts b/packages/api/src/lib/redact.ts index 8265dc5..836aeba 100644 --- a/packages/api/src/lib/redact.ts +++ b/packages/api/src/lib/redact.ts @@ -4,9 +4,8 @@ * 書き出す実装は持たない。 * * 設計方針(agents/logging.md 参照): - * - key 名による redaction と「plain object 以外は展開しない」ホワイトリスト方式を併用する。 - * key 名だけに依存すると `Headers` / `Request` / provider response のような - * 丸ごとのオブジェクトから secret が漏れるため + * - `LogValue` / `LogMeta` 型で JSON-safe な値だけを受け付ける。 + * Headers / Request / provider response などは呼び出し側で必要な field だけを抽出する * - Error は enumerable property を spread せず、name/message/stack/cause の * 限定 shape へ変換する * @@ -16,8 +15,6 @@ /** 値が sensitive な key に紐づいていたことを示すプレースホルダ。 */ export const REDACTED = '[REDACTED]' -/** plain object / 配列 / プリミティブ以外(Headers, Request, Map, 関数等)。 */ -export const UNSUPPORTED = '[UNSUPPORTED]' /** 循環参照を検出した位置。 */ export const CIRCULAR = '[CIRCULAR]' /** ネストが深すぎて打ち切った位置。 */ @@ -52,15 +49,16 @@ export function isSensitiveKey(key: string): boolean { * `redact()` が出力しうる値。JSON へそのまま流せる形だけを許す。 * `redactValue` が再帰しており戻り値型を推論できないため明示する。 */ -export type RedactedValue = +export type LogValue = | string | number | boolean | null | undefined - | SerializedError - | RedactedValue[] - | { [key: string]: RedactedValue } + | LogValue[] + | { [key: string]: LogValue } + +export type LogMeta = { [key: string]: LogValue } /** Error を JSON へ安全に落とし込むための限定 shape。 */ export type SerializedError = { @@ -70,23 +68,6 @@ export type SerializedError = { cause?: SerializedError } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -/** - * plain object(リテラル/`Object.create(null)`/`structuredClone` 由来)だけを - * 展開対象にする。`Headers` / `Request` / `Response` / `Map` / class instance は - * ここで false になり、丸ごとログへ流れることを防ぐ。 - */ -function isPlainObject(value: unknown): value is Record { - if (!isRecord(value)) { - return false - } - const prototype = Object.getPrototypeOf(value) - return prototype === Object.prototype || prototype === null -} - function trimStack(stack: string | undefined): string | undefined { if (!stack) { return undefined @@ -101,6 +82,10 @@ function trimStack(stack: string | undefined): string | undefined { ].join('\n') } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + /** * external SDK が投げる「Error ではないが name/message を持つ」オブジェクトから * 文字列フィールドだけを取り出す。任意の enumerable property は読まない。 @@ -151,27 +136,13 @@ export function serializeError(error: unknown): SerializedError { return serializeErrorWithDepth(error, 0) } -function redactValue(value: unknown, depth: number, seen: WeakSet): RedactedValue { +function redactValue(value: LogValue, depth: number, seen: WeakSet): LogValue { if (value === null || value === undefined) { return value } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return value } - if (typeof value === 'bigint') { - return `${value}` - } - if (typeof value !== 'object') { - // function / symbol は構造化ログに載せる意味が無く、toString で - // クロージャの中身が漏れる余地もあるため展開しない - return UNSUPPORTED - } - if (value instanceof Error) { - return serializeError(value) - } - if (value instanceof Date) { - return value.toISOString() - } if (seen.has(value)) { return CIRCULAR } @@ -188,13 +159,7 @@ function redactValue(value: unknown, depth: number, seen: WeakSet): Reda ? [...items, `[${value.length - MAX_ARRAY_LENGTH} more items]`] : items } - if (!isPlainObject(value)) { - // Headers / Request / Response / Map / class instance など。 - // 丸ごとログへ流すと credential が混入するため中身を見ない - seen.delete(value) - return UNSUPPORTED - } - const result: Record = {} + const result: Record = {} for (const [key, entry] of Object.entries(value)) { result[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, depth + 1, seen) } @@ -204,9 +169,9 @@ function redactValue(value: unknown, depth: number, seen: WeakSet): Reda /** * ログの meta に載せる値から sensitive field を落とす。 - * sensitive な key は `[REDACTED]` に、plain object 以外のオブジェクトは - * `[UNSUPPORTED]` に置き換える。 + * sensitive な key の値を `[REDACTED]` に置き換える。その他の値は + * `LogValue` 型で表現できる JSON-safe な値だけを受け付ける。 */ -export function redact(value: unknown): RedactedValue { +export function redact(value: LogValue): LogValue { return redactValue(value, 0, new WeakSet()) } diff --git a/packages/client/src/app/_lib/logger.test.ts b/packages/client/src/app/_lib/logger.test.ts index 75373dc..2dbf9d5 100644 --- a/packages/client/src/app/_lib/logger.test.ts +++ b/packages/client/src/app/_lib/logger.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vite-plus/test' import { logger } from './logger' -import { REDACTED, UNSUPPORTED } from './redact' +import { REDACTED } from './redact' vi.mock('console') @@ -80,25 +80,6 @@ test('logger.log redacts Authorization / Cookie / token in meta', () => { }) }) -test('logger.log does not expand a Headers passed through meta', () => { - const logMock = spyOnConsole('log') - - logger.log({ - body: 'request', - meta: { - headers: new Headers({ authorization: 'Bearer super-secret-token' }) - } - }) - - const [output] = logMock.mock.calls[0] - expect(output).not.toContain('super-secret-token') - expect(JSON.parse(output)).toStrictEqual({ - level: 'INFO', - body: 'request', - meta: { headers: UNSUPPORTED } - }) -}) - test('logger.error serializes an Error into a limited shape', () => { const logMock = spyOnConsole('error') diff --git a/packages/client/src/app/_lib/logger.ts b/packages/client/src/app/_lib/logger.ts index a3276cd..9468a9d 100644 --- a/packages/client/src/app/_lib/logger.ts +++ b/packages/client/src/app/_lib/logger.ts @@ -1,11 +1,12 @@ import { NEXT_PUBLIC_ENV } from '../../constants' import { redact, serializeError } from './redact' +import type { LogMeta } from './redact' /** * structured log の単一入口。application code から console を直接呼ばず、 * 必ずこの logger を経由する(`no-console` lint で強制している)。 * - * - `meta` は `redact()` を通し、sensitive key と plain object 以外を落とす + * - `meta` は JSON-safe な `LogMeta` 型で受け取り、sensitive key を redact する * - `error` は `serializeError()` で name/message/stack/cause の限定 shape にする * - `requestId` は api の access log と突き合わせるための共通 field(呼び出し側が明示的に渡す) */ @@ -13,7 +14,7 @@ type LogOptions = { label?: string requestId?: string body: string - meta?: Record + meta?: LogMeta error?: unknown } diff --git a/packages/client/src/app/_lib/redact.test.ts b/packages/client/src/app/_lib/redact.test.ts index e15c7ee..8e68ca5 100644 --- a/packages/client/src/app/_lib/redact.test.ts +++ b/packages/client/src/app/_lib/redact.test.ts @@ -1,13 +1,6 @@ import { expect, test } from 'vite-plus/test' -import { - CIRCULAR, - DEPTH_LIMIT, - REDACTED, - UNSUPPORTED, - isSensitiveKey, - redact, - serializeError -} from './redact' +import type { LogMeta } from './redact' +import { CIRCULAR, DEPTH_LIMIT, REDACTED, isSensitiveKey, redact, serializeError } from './redact' test('isSensitiveKey matches credential-ish keys regardless of case and separators', () => { expect(isSensitiveKey('Authorization')).toBe(true) @@ -27,6 +20,12 @@ test('isSensitiveKey matches credential-ish keys regardless of case and separato expect(isSensitiveKey('userId')).toBe(false) }) +test('LogValue rejects non-JSON runtime objects at compile time', () => { + const headers = new Headers() + // @ts-expect-error Headers are intentionally not valid LogValue values + redact(headers) +}) + test('redact masks Authorization / Cookie / token values including nested ones', () => { const actual = redact({ method: 'GET', @@ -57,68 +56,6 @@ test('redact masks Authorization / Cookie / token values including nested ones', expect(JSON.stringify(actual)).not.toContain('abcdef') }) -test('redact does not expand Headers / Request / Response', () => { - const headers = new Headers({ - authorization: 'Bearer super-secret-token', - cookie: 'session=abcdef' - }) - const request = new Request('https://example.com/api/user', { - method: 'POST', - headers, - body: JSON.stringify({ password: 'p@ssw0rd' }) - }) - - const actual = redact({ headers, request, response: new Response('body') }) - - expect(actual).toStrictEqual({ - headers: UNSUPPORTED, - request: UNSUPPORTED, - response: UNSUPPORTED - }) - expect(JSON.stringify(actual)).not.toContain('super-secret-token') - expect(JSON.stringify(actual)).not.toContain('p@ssw0rd') -}) - -test('redact does not expand class instances or functions', () => { - class Provider { - accessToken = 'access-token-value' - } - - const actual = redact({ - provider: new Provider(), - map: new Map([['token', 'token-value']]), - fn: () => { - return 'noop' - } - }) - - expect(actual).toStrictEqual({ - provider: UNSUPPORTED, - map: UNSUPPORTED, - fn: UNSUPPORTED - }) -}) - -test('redact keeps primitives and normalizes Date / BigInt', () => { - const date = new Date('2026-09-01T00:00:00.000Z') - - expect( - redact({ - count: 1, - enabled: true, - empty: null, - date, - big: BigInt(10) - }) - ).toStrictEqual({ - count: 1, - enabled: true, - empty: null, - date: '2026-09-01T00:00:00.000Z', - big: '10' - }) -}) - test('redact walks arrays and truncates long ones', () => { expect(redact({ users: [{ password: 'x', name: 'a' }] })).toStrictEqual({ users: [{ password: REDACTED, name: 'a' }] @@ -134,7 +71,7 @@ test('redact walks arrays and truncates long ones', () => { }) test('redact stops at circular references and depth limit', () => { - const circular: Record = { name: 'root' } + const circular: LogMeta = { name: 'root' } circular.self = circular expect(redact(circular)).toStrictEqual({ name: 'root', self: CIRCULAR }) @@ -225,15 +162,3 @@ test('serializeError narrows non-Error values to name / message', () => { message: 'invalid token' }) }) - -test('redact converts nested errors through serializeError', () => { - const actual = redact({ failure: new Error('nested boom') }) - - expect(actual).toStrictEqual({ - failure: { - name: 'Error', - message: 'nested boom', - stack: expect.any(String) - } - }) -}) diff --git a/packages/client/src/app/_lib/redact.ts b/packages/client/src/app/_lib/redact.ts index d383d70..5e601ab 100644 --- a/packages/client/src/app/_lib/redact.ts +++ b/packages/client/src/app/_lib/redact.ts @@ -4,9 +4,8 @@ * 書き出す実装は持たない。 * * 設計方針(agents/logging.md 参照): - * - key 名による redaction と「plain object 以外は展開しない」ホワイトリスト方式を併用する。 - * key 名だけに依存すると `Headers` / `Request` / provider response のような - * 丸ごとのオブジェクトから secret が漏れるため + * - `LogValue` / `LogMeta` 型で JSON-safe な値だけを受け付ける。 + * Headers / Request / provider response などは呼び出し側で必要な field だけを抽出する * - Error は enumerable property を spread せず、name/message/stack/cause の * 限定 shape へ変換する * @@ -16,8 +15,6 @@ /** 値が sensitive な key に紐づいていたことを示すプレースホルダ。 */ export const REDACTED = '[REDACTED]' -/** plain object / 配列 / プリミティブ以外(Headers, Request, Map, 関数等)。 */ -export const UNSUPPORTED = '[UNSUPPORTED]' /** 循環参照を検出した位置。 */ export const CIRCULAR = '[CIRCULAR]' /** ネストが深すぎて打ち切った位置。 */ @@ -52,15 +49,16 @@ export function isSensitiveKey(key: string): boolean { * `redact()` が出力しうる値。JSON へそのまま流せる形だけを許す。 * `redactValue` が再帰しており戻り値型を推論できないため明示する。 */ -export type RedactedValue = +export type LogValue = | string | number | boolean | null | undefined - | SerializedError - | RedactedValue[] - | { [key: string]: RedactedValue } + | LogValue[] + | { [key: string]: LogValue } + +export type LogMeta = { [key: string]: LogValue } /** Error を JSON へ安全に落とし込むための限定 shape。 */ export type SerializedError = { @@ -70,23 +68,6 @@ export type SerializedError = { cause?: SerializedError } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -/** - * plain object(リテラル/`Object.create(null)`/`structuredClone` 由来)だけを - * 展開対象にする。`Headers` / `Request` / `Response` / `Map` / class instance は - * ここで false になり、丸ごとログへ流れることを防ぐ。 - */ -function isPlainObject(value: unknown): value is Record { - if (!isRecord(value)) { - return false - } - const prototype = Object.getPrototypeOf(value) - return prototype === Object.prototype || prototype === null -} - function trimStack(stack: string | undefined): string | undefined { if (!stack) { return undefined @@ -101,6 +82,10 @@ function trimStack(stack: string | undefined): string | undefined { ].join('\n') } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + /** * external SDK が投げる「Error ではないが name/message を持つ」オブジェクトから * 文字列フィールドだけを取り出す。任意の enumerable property は読まない。 @@ -151,27 +136,13 @@ export function serializeError(error: unknown): SerializedError { return serializeErrorWithDepth(error, 0) } -function redactValue(value: unknown, depth: number, seen: WeakSet): RedactedValue { +function redactValue(value: LogValue, depth: number, seen: WeakSet): LogValue { if (value === null || value === undefined) { return value } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return value } - if (typeof value === 'bigint') { - return `${value}` - } - if (typeof value !== 'object') { - // function / symbol は構造化ログに載せる意味が無く、toString で - // クロージャの中身が漏れる余地もあるため展開しない - return UNSUPPORTED - } - if (value instanceof Error) { - return serializeError(value) - } - if (value instanceof Date) { - return value.toISOString() - } if (seen.has(value)) { return CIRCULAR } @@ -188,13 +159,7 @@ function redactValue(value: unknown, depth: number, seen: WeakSet): Reda ? [...items, `[${value.length - MAX_ARRAY_LENGTH} more items]`] : items } - if (!isPlainObject(value)) { - // Headers / Request / Response / Map / class instance など。 - // 丸ごとログへ流すと credential が混入するため中身を見ない - seen.delete(value) - return UNSUPPORTED - } - const result: Record = {} + const result: Record = {} for (const [key, entry] of Object.entries(value)) { result[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, depth + 1, seen) } @@ -204,9 +169,9 @@ function redactValue(value: unknown, depth: number, seen: WeakSet): Reda /** * ログの meta に載せる値から sensitive field を落とす。 - * sensitive な key は `[REDACTED]` に、plain object 以外のオブジェクトは - * `[UNSUPPORTED]` に置き換える。 + * sensitive な key の値を `[REDACTED]` に置き換える。その他の値は + * `LogValue` 型で表現できる JSON-safe な値だけを受け付ける。 */ -export function redact(value: unknown): RedactedValue { +export function redact(value: LogValue): LogValue { return redactValue(value, 0, new WeakSet()) } diff --git a/packages/task/src/lib/logger.test.ts b/packages/task/src/lib/logger.test.ts index 70ee944..50d8568 100644 --- a/packages/task/src/lib/logger.test.ts +++ b/packages/task/src/lib/logger.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vite-plus/test' import { logger } from './logger.js' -import { REDACTED, UNSUPPORTED } from './redact.js' +import { REDACTED } from './redact.js' vi.mock('console') @@ -80,25 +80,6 @@ test('logger.log redacts Authorization / Cookie / token in meta', () => { }) }) -test('logger.log does not expand a Headers passed through meta', () => { - const logMock = spyOnConsole('log') - - logger.log({ - body: 'request', - meta: { - headers: new Headers({ authorization: 'Bearer super-secret-token' }) - } - }) - - const [output] = logMock.mock.calls[0] - expect(output).not.toContain('super-secret-token') - expect(JSON.parse(output)).toStrictEqual({ - level: 'INFO', - body: 'request', - meta: { headers: UNSUPPORTED } - }) -}) - test('logger.error serializes an Error into a limited shape', () => { const logMock = spyOnConsole('error') diff --git a/packages/task/src/lib/logger.ts b/packages/task/src/lib/logger.ts index 8499635..b8c3609 100644 --- a/packages/task/src/lib/logger.ts +++ b/packages/task/src/lib/logger.ts @@ -1,11 +1,12 @@ import { ENV } from '../config.js' import { redact, serializeError } from './redact.js' +import type { LogMeta } from './redact.js' /** * structured log の単一入口。application code から console を直接呼ばず、 * 必ずこの logger を経由する(`no-console` lint で強制している)。 * - * - `meta` は `redact()` を通し、sensitive key と plain object 以外を落とす + * - `meta` は JSON-safe な `LogMeta` 型で受け取り、sensitive key を redact する * - `error` は `serializeError()` で name/message/stack/cause の限定 shape にする * - `requestId` は 1 実行を横断して追跡するための共通 field(呼び出し側が明示的に渡す) */ @@ -13,7 +14,7 @@ type LogOptions = { label?: string requestId?: string body: string - meta?: Record + meta?: LogMeta error?: unknown } diff --git a/packages/task/src/lib/redact.test.ts b/packages/task/src/lib/redact.test.ts index c4f9fe3..789bbb5 100644 --- a/packages/task/src/lib/redact.test.ts +++ b/packages/task/src/lib/redact.test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vite-plus/test' +import type { LogMeta } from './redact.js' import { CIRCULAR, DEPTH_LIMIT, REDACTED, - UNSUPPORTED, isSensitiveKey, redact, serializeError @@ -27,6 +27,12 @@ test('isSensitiveKey matches credential-ish keys regardless of case and separato expect(isSensitiveKey('userId')).toBe(false) }) +test('LogValue rejects non-JSON runtime objects at compile time', () => { + const headers = new Headers() + // @ts-expect-error Headers are intentionally not valid LogValue values + redact(headers) +}) + test('redact masks Authorization / Cookie / token values including nested ones', () => { const actual = redact({ method: 'GET', @@ -57,68 +63,6 @@ test('redact masks Authorization / Cookie / token values including nested ones', expect(JSON.stringify(actual)).not.toContain('abcdef') }) -test('redact does not expand Headers / Request / Response', () => { - const headers = new Headers({ - authorization: 'Bearer super-secret-token', - cookie: 'session=abcdef' - }) - const request = new Request('https://example.com/api/user', { - method: 'POST', - headers, - body: JSON.stringify({ password: 'p@ssw0rd' }) - }) - - const actual = redact({ headers, request, response: new Response('body') }) - - expect(actual).toStrictEqual({ - headers: UNSUPPORTED, - request: UNSUPPORTED, - response: UNSUPPORTED - }) - expect(JSON.stringify(actual)).not.toContain('super-secret-token') - expect(JSON.stringify(actual)).not.toContain('p@ssw0rd') -}) - -test('redact does not expand class instances or functions', () => { - class Provider { - accessToken = 'access-token-value' - } - - const actual = redact({ - provider: new Provider(), - map: new Map([['token', 'token-value']]), - fn: () => { - return 'noop' - } - }) - - expect(actual).toStrictEqual({ - provider: UNSUPPORTED, - map: UNSUPPORTED, - fn: UNSUPPORTED - }) -}) - -test('redact keeps primitives and normalizes Date / BigInt', () => { - const date = new Date('2026-09-01T00:00:00.000Z') - - expect( - redact({ - count: 1, - enabled: true, - empty: null, - date, - big: 10n - }) - ).toStrictEqual({ - count: 1, - enabled: true, - empty: null, - date: '2026-09-01T00:00:00.000Z', - big: '10' - }) -}) - test('redact walks arrays and truncates long ones', () => { expect(redact({ users: [{ password: 'x', name: 'a' }] })).toStrictEqual({ users: [{ password: REDACTED, name: 'a' }] @@ -134,7 +78,7 @@ test('redact walks arrays and truncates long ones', () => { }) test('redact stops at circular references and depth limit', () => { - const circular: Record = { name: 'root' } + const circular: LogMeta = { name: 'root' } circular.self = circular expect(redact(circular)).toStrictEqual({ name: 'root', self: CIRCULAR }) @@ -225,15 +169,3 @@ test('serializeError narrows non-Error values to name / message', () => { message: 'invalid token' }) }) - -test('redact converts nested errors through serializeError', () => { - const actual = redact({ failure: new Error('nested boom') }) - - expect(actual).toStrictEqual({ - failure: { - name: 'Error', - message: 'nested boom', - stack: expect.any(String) - } - }) -}) diff --git a/packages/task/src/lib/redact.ts b/packages/task/src/lib/redact.ts index 8265dc5..836aeba 100644 --- a/packages/task/src/lib/redact.ts +++ b/packages/task/src/lib/redact.ts @@ -4,9 +4,8 @@ * 書き出す実装は持たない。 * * 設計方針(agents/logging.md 参照): - * - key 名による redaction と「plain object 以外は展開しない」ホワイトリスト方式を併用する。 - * key 名だけに依存すると `Headers` / `Request` / provider response のような - * 丸ごとのオブジェクトから secret が漏れるため + * - `LogValue` / `LogMeta` 型で JSON-safe な値だけを受け付ける。 + * Headers / Request / provider response などは呼び出し側で必要な field だけを抽出する * - Error は enumerable property を spread せず、name/message/stack/cause の * 限定 shape へ変換する * @@ -16,8 +15,6 @@ /** 値が sensitive な key に紐づいていたことを示すプレースホルダ。 */ export const REDACTED = '[REDACTED]' -/** plain object / 配列 / プリミティブ以外(Headers, Request, Map, 関数等)。 */ -export const UNSUPPORTED = '[UNSUPPORTED]' /** 循環参照を検出した位置。 */ export const CIRCULAR = '[CIRCULAR]' /** ネストが深すぎて打ち切った位置。 */ @@ -52,15 +49,16 @@ export function isSensitiveKey(key: string): boolean { * `redact()` が出力しうる値。JSON へそのまま流せる形だけを許す。 * `redactValue` が再帰しており戻り値型を推論できないため明示する。 */ -export type RedactedValue = +export type LogValue = | string | number | boolean | null | undefined - | SerializedError - | RedactedValue[] - | { [key: string]: RedactedValue } + | LogValue[] + | { [key: string]: LogValue } + +export type LogMeta = { [key: string]: LogValue } /** Error を JSON へ安全に落とし込むための限定 shape。 */ export type SerializedError = { @@ -70,23 +68,6 @@ export type SerializedError = { cause?: SerializedError } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -/** - * plain object(リテラル/`Object.create(null)`/`structuredClone` 由来)だけを - * 展開対象にする。`Headers` / `Request` / `Response` / `Map` / class instance は - * ここで false になり、丸ごとログへ流れることを防ぐ。 - */ -function isPlainObject(value: unknown): value is Record { - if (!isRecord(value)) { - return false - } - const prototype = Object.getPrototypeOf(value) - return prototype === Object.prototype || prototype === null -} - function trimStack(stack: string | undefined): string | undefined { if (!stack) { return undefined @@ -101,6 +82,10 @@ function trimStack(stack: string | undefined): string | undefined { ].join('\n') } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + /** * external SDK が投げる「Error ではないが name/message を持つ」オブジェクトから * 文字列フィールドだけを取り出す。任意の enumerable property は読まない。 @@ -151,27 +136,13 @@ export function serializeError(error: unknown): SerializedError { return serializeErrorWithDepth(error, 0) } -function redactValue(value: unknown, depth: number, seen: WeakSet): RedactedValue { +function redactValue(value: LogValue, depth: number, seen: WeakSet): LogValue { if (value === null || value === undefined) { return value } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return value } - if (typeof value === 'bigint') { - return `${value}` - } - if (typeof value !== 'object') { - // function / symbol は構造化ログに載せる意味が無く、toString で - // クロージャの中身が漏れる余地もあるため展開しない - return UNSUPPORTED - } - if (value instanceof Error) { - return serializeError(value) - } - if (value instanceof Date) { - return value.toISOString() - } if (seen.has(value)) { return CIRCULAR } @@ -188,13 +159,7 @@ function redactValue(value: unknown, depth: number, seen: WeakSet): Reda ? [...items, `[${value.length - MAX_ARRAY_LENGTH} more items]`] : items } - if (!isPlainObject(value)) { - // Headers / Request / Response / Map / class instance など。 - // 丸ごとログへ流すと credential が混入するため中身を見ない - seen.delete(value) - return UNSUPPORTED - } - const result: Record = {} + const result: Record = {} for (const [key, entry] of Object.entries(value)) { result[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, depth + 1, seen) } @@ -204,9 +169,9 @@ function redactValue(value: unknown, depth: number, seen: WeakSet): Reda /** * ログの meta に載せる値から sensitive field を落とす。 - * sensitive な key は `[REDACTED]` に、plain object 以外のオブジェクトは - * `[UNSUPPORTED]` に置き換える。 + * sensitive な key の値を `[REDACTED]` に置き換える。その他の値は + * `LogValue` 型で表現できる JSON-safe な値だけを受け付ける。 */ -export function redact(value: unknown): RedactedValue { +export function redact(value: LogValue): LogValue { return redactValue(value, 0, new WeakSet()) } From bf16fa417c67b77e5599ded78f8c732691d775a7 Mon Sep 17 00:00:00 2001 From: kohta ito Date: Wed, 2 Sep 2026 09:14:33 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20logging=20metadata=E3=82=92clos?= =?UTF-8?q?ed=20schema=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents/lint-rules.md | 2 +- agents/logging.md | 82 +------- lint-rules/coding-style.ts | 4 +- lint-rules/rules/enforce-logger-literal.ts | 113 +++++++++++ lint-rules/rules/no-sensitive-logging.ts | 204 -------------------- lint-rules/run-tests.ts | 52 ++--- packages/api/src/lib/logger.test.ts | 53 +++-- packages/api/src/lib/logger.ts | 50 ++++- packages/api/src/lib/redact.test.ts | 86 +++------ packages/api/src/lib/redact.ts | 104 ++++------ packages/client/src/app/_lib/logger.test.ts | 65 +++++-- packages/client/src/app/_lib/logger.ts | 48 ++++- packages/client/src/app/_lib/redact.test.ts | 79 +++----- packages/client/src/app/_lib/redact.ts | 104 ++++------ packages/task/src/lib/logger.test.ts | 67 +++++-- packages/task/src/lib/logger.ts | 43 ++++- packages/task/src/lib/redact.test.ts | 86 +++------ packages/task/src/lib/redact.ts | 104 ++++------ vite.config.ts | 7 +- 19 files changed, 600 insertions(+), 753 deletions(-) create mode 100644 lint-rules/rules/enforce-logger-literal.ts delete mode 100644 lint-rules/rules/no-sensitive-logging.ts diff --git a/agents/lint-rules.md b/agents/lint-rules.md index ebb1ebe..0b2ee05 100644 --- a/agents/lint-rules.md +++ b/agents/lint-rules.md @@ -8,7 +8,7 @@ - `coding-style/no-process-env-outside-config` → `process.env` の参照を config ファイル(`config.ts` / `config.server.ts` 等)に集約する(テストファイルは対象外) - `coding-style/enforce-zod-entrypoint` → zod の entrypoint をパッケージごとに強制する(client は `zod/mini`、api は `zod`) -- `coding-style/no-sensitive-logging` → `logger.*` へ credential / request payload を渡すことを禁止する(テストファイルは意図的な leakage fixture を持つため対象外。詳細は `agents/logging.md`) +- `coding-style/enforce-logger-literal` → `logger.*` の第1引数と `meta` を object literal(spread / computed key なし)に限定する。何をログへ載せてよいかは logger の closed event schema(型)が決め、lint は excess property check が確実に効く形だけを保証する(テストファイルは runtime の最終防衛を検証するため対象外。詳細は `agents/logging.md`) builtin ルールでは `no-console` を `packages/{api,client,task}/src` に対して error にし、logger 実装本体とテストだけ `vite.config.ts` の override で除外している(structured log の単一入口を保つため)。 diff --git a/agents/logging.md b/agents/logging.md index 8a224e4..4c5c99f 100644 --- a/agents/logging.md +++ b/agents/logging.md @@ -1,75 +1,11 @@ # Logging Guidelines -ログ出力を伴うコード(`logger.*` の呼び出し、logger / redact 実装の変更、access log や error handler の変更)を書くときに参照する。 - -目的は、ログを observability の共通基盤にしつつ credential / PII / request payload が accidental に流出しないようにすること。 - -## 単一入口 - -- application code は `console.*` を直接呼ばず、パッケージごとの logger を経由する - - api: `packages/api/src/lib/logger.ts` - - client: `packages/client/src/app/_lib/logger.ts` - - task: `packages/task/src/lib/logger.ts` -- `console` を直接呼んでよいのは上記 logger 実装本体と、その出力を spy/assert するテストだけ。`vite.config.ts` の `no-console` override で機械的に強制している -- dev 専用 CLI(`packages/bin`)は structured log の対象外(人間が読むための出力なので `console` を使う) - -## 共通フィールド - -`logger.log / error / debug` はいずれも同じ shape を受け取る。 - -| field | 用途 | -| --- | --- | -| `level` | logger が付与する(`INFO` / `ERROR` / `DEBUG`) | -| `label` | ログの発生箇所を識別する短い名前(`access` / `handleError` 等) | -| `requestId` | access log と application log を突き合わせるための ID | -| `body` | 人間が読むメッセージ(文字列のみ) | -| `meta` | 構造化された付加情報。`redact()` を通る | -| `error` | 例外。`serializeError()` で限定 shape になる | - -### requestId - -- api では `accessLogMiddleware` が `randomUUID()` で発行し、hono の `c.set('requestId', ...)` に載せる -- application log 側は `c.get('requestId')` を `logger.*` の `requestId` に渡す。これで 1 リクエストの access log と application log が同じ ID で追える -- client / task は横断して追跡したい単位(api から受け取った ID、1 実行の ID 等)を呼び出し側が明示的に渡す - -## redaction - -`redact()`(`redact.ts`)は 2 つの防御を併用する。key 名だけに頼ると `Headers` や provider response のような「丸ごとのオブジェクト」から漏れるため。 - -1. **key 名による redaction** — 小文字化 + 区切り文字除去で正規化した key 名が `authorization` / `cookie`(`set-cookie` を含む) / `password` / `secret` / `token`(`idToken` / `accessToken` / `refreshToken` / `sessionToken` を含む) / `credential` / `apikey` / `privatekey` / `sessionid` に部分一致したら値を `[REDACTED]` にする -2. **型による入力制限** — `LogValue` / `LogMeta` は JSON-safe なプリミティブ、配列、object のみを許可する。`Headers` / `Request` / `Response` / provider response などは呼び出し側で必要な field だけを明示的に抽出する - -加えて、循環参照は `[CIRCULAR]`、深すぎるネストは `[DEPTH_LIMIT]`、長すぎる配列は末尾を件数へ畳み、ログ量を有界にする。 - -redaction は最後の防波堤であって、設計上の免罪符ではない。**request / response / headers / body を丸ごと `meta` へ渡さず、必要な field だけを抜き出して渡す**。型検査に失敗する値を assertion で無理に logger へ渡してはならない。 - -```ts -// NG: 何が載るか呼び出し側で分からない -logger.log({ body: 'req', meta: { headers: c.req.raw.headers, payload: await c.req.json() } }) - -// OK: 必要な field だけを明示する -logger.log({ label: 'access', requestId, body: `${c.req.method} ${c.req.path}`, meta: { status: c.res.status } }) -``` - -## error の serialize - -- `Error` を `{ ...error }` で spread しない。external SDK の error は request / credential を enumerable property に詰めてくることがある -- `logger.*` の `error` に渡された値は `serializeError()` が `name` / `message` / `stack` / `cause` だけの shape へ変換する。任意の property は読まない -- `Error` ではない値(文字列、`{ code, message }` 形式の SDK error 等)も `name` / `message` へ narrowing する -- `cause` チェーンは有界の深さまで辿る(循環していても停止する) -- stack trace は行数上限で切り詰める - -## 静的検査 - -`coding-style/no-sensitive-logging`(`lint-rules/rules/no-sensitive-logging.ts`)が `logger.log / error / debug` の第 1 引数を検査し、以下を error にする。 - -- sensitive な key 名を持つ property(`{ authorization: ... }` / `{ 'set-cookie': ... }` 等) -- sensitive な名前の変数・プロパティ参照(`{ sessionToken }` / `{ value: provider.accessToken }` 等) -- request / response を丸ごと渡す property(`c.req.raw.headers` / `req.body` / `await c.req.json()` / `headers()` / `cookies()` 等) -- 第 1 引数オブジェクト内での spread(何が載るか静的に追えないため) - -テストファイル(`*.test.ts` / `test/` 配下)は対象外。redaction が効いていることを検証するために、意図的な leakage fixture を logger へ渡す必要があるため。 - -## パッケージごとの重複について - -`logger.ts` / `redact.ts` は api / client / task がそれぞれ自己完結して持ち、`shared` へは切り出さない。実行コンテキスト(Node ESM バックエンド / Next.js フロントエンド / CLI)が異なり、今の実装が似ているのは偶然であるため(AGENTS.md の Monorepo Guidelines)。片方だけ出力先や level policy を変えたくなったときに、共通化が足枷になる。 +ログ出力を伴うコード(`logger.*` の呼び出し、logger / redact 実装、access log / error handler)を書くときの原則。機械検査の詳細は `agents/lint-rules.md` を、何を meta へ載せられるかは各 logger の `LogEvents` 型を参照する。 + +- application code は `console.*` を直接呼ばず、パッケージごとの logger(api: `packages/api/src/lib/logger.ts`、client: `packages/client/src/app/_lib/logger.ts`、task: `packages/task/src/lib/logger.ts`)を単一入口にする。dev 専用 CLI(`packages/bin`)は人間が読む出力なので対象外 +- logger の public API は closed event schema。meta を持てるのは各 logger の `LogEvents` に定義した label だけで、field は primitive のみ。新しい情報をログへ載せたいときは、値を渡す前に `LogEvents` へ label / field を追加する +- `LogEvents` に secret / credential(authorization / cookie / token 等)を表す field や、request / response / headers / body を丸ごと表す field を追加しない。必要な primitive field だけを schema に起こす +- 何を渡してよいかの判断は型に寄せる。型検査に失敗する値を assertion で無理に通さない。runtime の `redactMeta()`(sensitive key 名 → `[REDACTED]`、primitive 以外 → `[UNSUPPORTED]`)は型をすり抜けた値への最後の防波堤であって、設計上の免罪符ではない +- `error` は `serializeError()` が name / message / stack / cause の限定 shape へ変換する。`{ ...error }` のような spread は、external SDK が enumerable property に詰めた credential / request payload を持ち出すため行わない +- `requestId` で access log と application log を突き合わせる。api では `accessLogMiddleware` が発行した値を `c.get('requestId')` から渡す。client / task は追跡したい単位の ID を呼び出し側が明示的に渡す +- `logger.ts` / `redact.ts` は api / client / task がそれぞれ自己完結して持ち、shared へ切り出さない。実行コンテキストが異なるため(AGENTS.md の Monorepo Guidelines) diff --git a/lint-rules/coding-style.ts b/lint-rules/coding-style.ts index b10e7b0..30449fd 100644 --- a/lint-rules/coding-style.ts +++ b/lint-rules/coding-style.ts @@ -6,7 +6,7 @@ import type { Plugin } from './types.ts' import { enforceZodEntrypointRule } from './rules/enforce-zod-entrypoint.ts' import { noProcessEnvOutsideConfigRule } from './rules/no-process-env-outside-config.ts' -import { noSensitiveLoggingRule } from './rules/no-sensitive-logging.ts' +import { enforceLoggerLiteralRule } from './rules/enforce-logger-literal.ts' const plugin: Plugin = { meta: { @@ -15,7 +15,7 @@ const plugin: Plugin = { rules: { 'no-process-env-outside-config': noProcessEnvOutsideConfigRule, 'enforce-zod-entrypoint': enforceZodEntrypointRule, - 'no-sensitive-logging': noSensitiveLoggingRule + 'enforce-logger-literal': enforceLoggerLiteralRule } } diff --git a/lint-rules/rules/enforce-logger-literal.ts b/lint-rules/rules/enforce-logger-literal.ts new file mode 100644 index 0000000..1fee834 --- /dev/null +++ b/lint-rules/rules/enforce-logger-literal.ts @@ -0,0 +1,113 @@ +/** + * logger 呼び出しの引数を、型検査(closed event schema)が確実に効く形に + * 固定するルール。 + * + * 何をログへ載せてよいかは各パッケージ logger の `LogEvents` 型が決める。 + * sensitive な値そのものの検出は名前ベースの heuristic になるため lint では + * 行わず、この lint は AST で確実に判定できる形の制約だけを強制する。 + * + * - 第1引数は object literal に限定する(変数渡しでは TypeScript の + * excess property check が効かず、未知 field の混入を検出できない) + * - `meta` の値も object literal に限定する(同上) + * - 引数オブジェクト / meta 内での spread を禁止する(何が載るか静的に追えない) + * - computed key を禁止する(key 名を静的に追えない) + * + * テストファイルは対象外。runtime の最終防衛(`redactMeta()`)を検証するために、 + * 型検査をすり抜けた値を意図的に logger へ渡す必要があるため。 + */ +import type { CallExpressionNode, ObjectExpressionNode, Rule } from '../types.ts' + +const LOGGER_OBJECT = 'logger' +const LOGGER_METHODS = new Set(['log', 'error', 'debug']) + +/** 他のルールと同じ判定式。テストファイル・テスト補助ファイルを対象外にする。 */ +const TEST_FILE = /(\.test\.(ts|tsx)$|(^|\/)test\/)/ + +const ARGUMENT_LITERAL_MESSAGE = + 'logger の第1引数は object literal で渡してください。変数渡しでは closed event schema の excess property check が効きません(agents/logging.md 参照)' +const META_LITERAL_MESSAGE = + 'meta は object literal で渡してください。変数渡しでは closed event schema の excess property check が効きません(agents/logging.md 参照)' +const SPREAD_MESSAGE = + 'logger へ渡すオブジェクトを spread しないでください。何がログに載るか静的に追えなくなります(agents/logging.md 参照)' +const COMPUTED_KEY_MESSAGE = + 'logger へ渡すオブジェクトで computed key を使わないでください。key 名を静的に追えなくなります(agents/logging.md 参照)' + +function isLoggerCall(node: CallExpressionNode): boolean { + const callee = node.callee + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === LOGGER_OBJECT && + callee.property.type === 'Identifier' && + LOGGER_METHODS.has(callee.property.name) + ) +} + +type KeyNode = ObjectExpressionNode['properties'][number] + +function getStaticKeyName(property: KeyNode): string | null { + if (property.type !== 'Property' || property.computed) { + return null + } + if (property.key.type === 'Identifier') { + return property.key.name + } + if (property.key.type === 'Literal' && typeof property.key.value === 'string') { + return property.key.value + } + return null +} + +export const enforceLoggerLiteralRule: Rule = { + meta: { + docs: { + description: + 'logger の引数を、closed event schema の型検査が効く object literal に限定する' + } + }, + create(context) { + if (TEST_FILE.test(context.filename)) { + return {} + } + function checkStaticShape(node: ObjectExpressionNode): void { + for (const property of node.properties) { + if (property.type === 'SpreadElement') { + context.report({ node: property, message: SPREAD_MESSAGE }) + continue + } + if (property.type === 'Property' && property.computed) { + context.report({ node: property, message: COMPUTED_KEY_MESSAGE }) + } + } + } + + return { + CallExpression(node) { + if (!isLoggerCall(node)) { + return + } + const [argument] = node.arguments + if (!argument) { + return + } + if (argument.type !== 'ObjectExpression') { + context.report({ node: argument, message: ARGUMENT_LITERAL_MESSAGE }) + return + } + checkStaticShape(argument) + for (const property of argument.properties) { + if (property.type !== 'Property' || getStaticKeyName(property) !== 'meta') { + continue + } + const meta = property.value + if (meta.type !== 'ObjectExpression') { + context.report({ node: property, message: META_LITERAL_MESSAGE }) + continue + } + checkStaticShape(meta) + } + } + } + } +} diff --git a/lint-rules/rules/no-sensitive-logging.ts b/lint-rules/rules/no-sensitive-logging.ts deleted file mode 100644 index f74ad77..0000000 --- a/lint-rules/rules/no-sensitive-logging.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * logger 呼び出しへ credential / request payload を渡すことを禁止するルール。 - * - * runtime 側の `redact()` は key 名と「plain object 以外は展開しない」判定で - * 防御するが、静的にも同じ pattern を落として実装時に気付けるようにする。 - * 検査対象は `logger.log/error/debug({ ... })` の第1引数オブジェクトのみ。 - * - * - sensitive な key 名(`authorization` / `cookie` / `*token` 等)を持つ property - * - sensitive な名前の変数をそのまま渡す property(`{ sessionToken }` 等) - * - request / response を丸ごと渡す property(`c.req.raw`, `req.headers`, - * `res.body`, `await c.req.json()`, `headers()`, `cookies()` 等) - * - logger 引数内での spread(何が載るか静的に追えないため) - * - * テストファイルは対象外。redaction が効いていることを検証するために - * 意図的な leakage fixture(`{ authorization: 'Bearer ...' }` 等)を - * logger へ渡す必要があるため。 - */ -import type { CallExpressionNode, ObjectExpressionNode, Rule } from '../types.ts' - -const LOGGER_OBJECT = 'logger' -const LOGGER_METHODS = new Set(['log', 'error', 'debug']) - -/** 他のルールと同じ判定式。テストファイル・テスト補助ファイルを対象外にする。 */ -const TEST_FILE = /(\.test\.(ts|tsx)$|(^|\/)test\/)/ - -/** - * 正規化(小文字化 + 区切り文字除去)した名前への部分一致で判定する。 - * runtime の `redact()` が持つ SENSITIVE_KEY_PATTERN と同じ語彙を使う。 - */ -const SENSITIVE_NAME_PATTERN = - /authorization|cookie|password|passwd|secret|token|credential|apikey|privatekey|sessionid/ - -const NAME_SEPARATOR_PATTERN = /[-_\s.]/g - -/** 丸ごと渡すと request / response の中身が載るプロパティ名。 */ -const RAW_PAYLOAD_PROPERTIES = new Set(['headers', 'rawHeaders', 'cookies', 'body', 'raw']) - -/** `c.req.json()` のように request payload を取り出すメソッド名。 */ -const REQUEST_READER_METHODS = new Set([ - 'header', - 'json', - 'text', - 'parseBody', - 'formData', - 'arrayBuffer', - 'blob' -]) - -/** next/headers の `headers()` / `cookies()` のような引数なしの取得関数。 */ -const GLOBAL_PAYLOAD_READERS = new Set(['headers', 'cookies']) - -/** ネストしたオブジェクトを辿る最大深さ(壊れた AST でも停止させるため)。 */ -const MAX_DEPTH = 8 - -const SENSITIVE_MESSAGE = - 'secret / credential をログへ渡しています。ログに載せない、または redact 済みの値だけを渡してください(agents/logging.md 参照)' -const RAW_PAYLOAD_MESSAGE = - 'request / response の headers / body を丸ごとログへ渡しています。必要な field だけを抜き出してください(agents/logging.md 参照)' -const SPREAD_MESSAGE = - 'logger へ渡すオブジェクトを spread しないでください。何がログに載るか静的に追えなくなります(agents/logging.md 参照)' - -function isSensitiveName(name: string): boolean { - return SENSITIVE_NAME_PATTERN.test(name.toLowerCase().replace(NAME_SEPARATOR_PATTERN, '')) -} - -function isLoggerCall(node: CallExpressionNode): boolean { - const callee = node.callee - return ( - callee.type === 'MemberExpression' && - !callee.computed && - callee.object.type === 'Identifier' && - callee.object.name === LOGGER_OBJECT && - callee.property.type === 'Identifier' && - LOGGER_METHODS.has(callee.property.name) - ) -} - -type KeyNode = ObjectExpressionNode['properties'][number] - -function getStaticKeyName(property: KeyNode): string | null { - if (property.type !== 'Property' || property.computed) { - return null - } - if (property.key.type === 'Identifier') { - return property.key.name - } - if (property.key.type === 'Literal' && typeof property.key.value === 'string') { - return property.key.value - } - return null -} - -type ValueNode = Extract['value'] - -/** `await expr` / `expr as T` / `expr!` のようなラッパーを剥がす。 */ -function unwrap(node: ValueNode): ValueNode { - if (node.type === 'AwaitExpression') { - return unwrap(node.argument) - } - if (node.type === 'TSNonNullExpression' || node.type === 'TSAsExpression') { - return unwrap(node.expression) - } - return node -} - -function isRawPayloadExpression(node: ValueNode): boolean { - const target = unwrap(node) - if (target.type === 'MemberExpression' && !target.computed) { - return target.property.type === 'Identifier' && RAW_PAYLOAD_PROPERTIES.has(target.property.name) - } - if (target.type === 'CallExpression') { - const callee = target.callee - if (callee.type === 'Identifier' && GLOBAL_PAYLOAD_READERS.has(callee.name)) { - return true - } - return ( - callee.type === 'MemberExpression' && - !callee.computed && - callee.property.type === 'Identifier' && - REQUEST_READER_METHODS.has(callee.property.name) - ) - } - return false -} - -function isSensitiveExpression(node: ValueNode): boolean { - const target = unwrap(node) - if (target.type === 'Identifier') { - return isSensitiveName(target.name) - } - if ( - target.type === 'MemberExpression' && - !target.computed && - target.property.type === 'Identifier' - ) { - return isSensitiveName(target.property.name) - } - return false -} - -export const noSensitiveLoggingRule: Rule = { - meta: { - docs: { - description: 'logger へ secret / credential / request payload を渡すことを禁止する' - } - }, - create(context) { - if (TEST_FILE.test(context.filename)) { - return {} - } - function checkObject(node: ObjectExpressionNode, depth: number) { - if (depth > MAX_DEPTH) { - return - } - for (const property of node.properties) { - if (property.type === 'SpreadElement') { - context.report({ node: property, message: SPREAD_MESSAGE }) - continue - } - if (property.type !== 'Property') { - continue - } - const key = getStaticKeyName(property) - if (key !== null && isSensitiveName(key)) { - context.report({ node: property, message: SENSITIVE_MESSAGE }) - continue - } - const value = unwrap(property.value) - if (value.type === 'ObjectExpression') { - checkObject(value, depth + 1) - continue - } - if (value.type === 'ArrayExpression') { - for (const element of value.elements) { - if (element?.type === 'ObjectExpression') { - checkObject(element, depth + 1) - } - } - continue - } - if (isRawPayloadExpression(value)) { - context.report({ node: property, message: RAW_PAYLOAD_MESSAGE }) - continue - } - if (isSensitiveExpression(value)) { - context.report({ node: property, message: SENSITIVE_MESSAGE }) - } - } - } - - return { - CallExpression(node) { - if (!isLoggerCall(node)) { - return - } - const [argument] = node.arguments - if (!argument || argument.type !== 'ObjectExpression') { - return - } - checkObject(argument, 0) - } - } - } -} diff --git a/lint-rules/run-tests.ts b/lint-rules/run-tests.ts index 52b46e0..bb9427a 100644 --- a/lint-rules/run-tests.ts +++ b/lint-rules/run-tests.ts @@ -9,7 +9,7 @@ import { requireHttpExceptionResRule } from './rules/require-httpexception-res.t import { requireValidatorForParamQueryRule } from './rules/require-validator-for-param-query.ts' import { noProcessEnvOutsideConfigRule } from './rules/no-process-env-outside-config.ts' import { enforceZodEntrypointRule } from './rules/enforce-zod-entrypoint.ts' -import { noSensitiveLoggingRule } from './rules/no-sensitive-logging.ts' +import { enforceLoggerLiteralRule } from './rules/enforce-logger-literal.ts' const handlerFile = 'packages/api/src/handlers/user/index.ts' const tester = new RuleTester() @@ -130,11 +130,11 @@ tester.run('enforce-zod-entrypoint', enforceZodEntrypointRule, { const loggerFile = 'packages/api/src/lib/middleware.ts' -tester.run('no-sensitive-logging', noSensitiveLoggingRule, { +tester.run('enforce-logger-literal', enforceLoggerLiteralRule, { valid: [ - // 準拠: 必要な field だけを抜き出して渡す + // 準拠: 第1引数・meta とも object literal(値の型は closed event schema が検査する) { - code: "logger.log({ label: 'access', requestId, body: 'GET /api/user', meta: { status: c.res.status } })", + code: "logger.log({ label: 'access', requestId, body: 'GET /api/user', meta: { method: c.req.method, path: c.req.path, status: c.res.status, duration: 12 } })", filename: loggerFile }, // 準拠: error は logger 側の serializeError で安全な shape になる @@ -143,59 +143,47 @@ tester.run('no-sensitive-logging', noSensitiveLoggingRule, { filename: loggerFile }, // 対象外: logger 以外の呼び出し - { code: 'client(url, { headers: c.req.raw.headers })', filename: loggerFile }, - // 対象外: テストファイルは redaction を検証するため意図的な leakage fixture を渡す + { code: 'client(url, { ...options })', filename: loggerFile }, + // 対象外: テストファイルは runtime の最終防衛を検証するため変数渡しを許す { - code: "logger.log({ body: 'auth', meta: { authorization: 'Bearer secret' } })", + code: 'logger.log(leakageFixture)', filename: 'packages/api/src/lib/logger.test.ts' } ], invalid: [ - // key 名が sensitive + // 第1引数の変数渡しは excess property check が効かない { - code: "logger.log({ body: 'auth', meta: { authorization: value } })", + code: 'logger.log(options)', filename: loggerFile, errors: 1 }, - // shorthand の sensitive な変数 + // 第1引数オブジェクトでの spread { - code: "logger.debug({ body: 'auth', meta: { sessionToken } })", + code: "logger.log({ ...base, body: 'x' })", filename: loggerFile, errors: 1 }, - // set-cookie / idToken / refreshToken も同じ語彙で拾う + // meta の変数渡し { - code: "logger.log({ body: 'auth', meta: { headerValues: { 'set-cookie': raw, idToken: id, refreshToken: refresh } } })", - filename: loggerFile, - errors: 3 - }, - // sensitive な名前のプロパティ参照 - { - code: "logger.log({ body: 'auth', meta: { value: provider.accessToken } })", + code: "logger.debug({ body: 'x', meta: metaValues })", filename: loggerFile, errors: 1 }, - // request headers / body / raw を丸ごと渡す + // meta 内での spread { - code: "logger.log({ body: 'req', meta: { headers: c.req.raw.headers, payload: req.body } })", + code: "logger.log({ body: 'x', meta: { ...payload } })", filename: loggerFile, - errors: 2 - }, - // request payload を読み出す呼び出し - { - code: "logger.debug({ body: 'req', meta: { payload: await c.req.json(), cookie: cookies() } })", - filename: loggerFile, - errors: 2 + errors: 1 }, - // spread は何が載るか静的に追えない + // 第1引数オブジェクトでの computed key { - code: "logger.log({ body: 'req', meta: { ...req } })", + code: "logger.log({ body: 'x', [key]: value })", filename: loggerFile, errors: 1 }, - // 配列内の object も検査する + // meta 内での computed key { - code: "logger.log({ body: 'auth', meta: { values: [{ accessToken }] } })", + code: "logger.log({ body: 'x', meta: { [key]: value } })", filename: loggerFile, errors: 1 } diff --git a/packages/api/src/lib/logger.test.ts b/packages/api/src/lib/logger.test.ts index 50d8568..7832dcb 100644 --- a/packages/api/src/lib/logger.test.ts +++ b/packages/api/src/lib/logger.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vite-plus/test' import { logger } from './logger.js' -import { REDACTED } from './redact.js' +import { REDACTED, UNSUPPORTED } from './redact.js' vi.mock('console') @@ -36,7 +36,7 @@ test('logger.log carries requestId as a top level field', () => { label: 'access', requestId: 'request-id-1', body: 'GET /api/user', - meta: { status: 200 } + meta: { method: 'GET', path: '/api/user', status: 200, duration: 3 } }) expect(logMock).toHaveBeenCalledTimes(1) @@ -46,36 +46,61 @@ test('logger.log carries requestId as a top level field', () => { label: 'access', requestId: 'request-id-1', body: 'GET /api/user', - meta: { status: 200 } + meta: { method: 'GET', path: '/api/user', status: 200, duration: 3 } }) ) }) -test('logger.log redacts Authorization / Cookie / token in meta', () => { +test('logger meta is a closed event schema at compile time', () => { const logMock = spyOnConsole('log') + // @ts-expect-error LogEvents に無い label は meta を持てない + logger.log({ label: 'unknown-label', body: 'x', meta: { status: 200 } }) + + // @ts-expect-error 既知 label でも schema に無い field は渡せない + logger.log({ label: 'handleError', body: 'x', meta: { status: 500, authorization: 'Bearer x' } }) + + // @ts-expect-error meta の値は primitive のみで nested object は渡せない + logger.log({ label: 'handleError', body: 'x', meta: { status: { code: 500 } } }) + + const payload: Record = { status: 500 } + // @ts-expect-error 型の広い payload 変数を meta へ渡せない + logger.log({ label: 'handleError', body: 'x', meta: payload }) + + // @ts-expect-error payload 変数の spread も渡せない + logger.log({ label: 'handleError', body: 'x', meta: { ...payload } }) + + // 上記はいずれも型検査で弾かれることの検証であり、runtime では出力自体は行われる + expect(logMock).toHaveBeenCalledTimes(5) +}) + +test('redactMeta remains as a runtime last line of defense', () => { + const logMock = spyOnConsole('log') + + // wide な型の変数は closed event schema に代入できない(実際に型エラーになる) + const leaked: Record = { + status: 200, + authorization: 'Bearer super-secret-token', + provider: { idToken: 'id-token-value' } + } logger.log({ - label: 'auth', + label: 'handleError', body: 'verified', - meta: { - authorization: 'Bearer super-secret-token', - cookie: 'session=abcdef', - provider: { idToken: 'id-token-value', userId: 1 } - } + // @ts-expect-error 型検査をすり抜けた値が runtime で redact されることを検証する + meta: leaked }) const [output] = logMock.mock.calls[0] expect(output).not.toContain('super-secret-token') - expect(output).not.toContain('abcdef') expect(output).not.toContain('id-token-value') expect(JSON.parse(output)).toStrictEqual({ level: 'INFO', - label: 'auth', + label: 'handleError', body: 'verified', meta: { + status: 200, authorization: REDACTED, - cookie: REDACTED, - provider: { idToken: REDACTED, userId: 1 } + provider: UNSUPPORTED } }) }) diff --git a/packages/api/src/lib/logger.ts b/packages/api/src/lib/logger.ts index 3bf34c9..0fbbcf6 100644 --- a/packages/api/src/lib/logger.ts +++ b/packages/api/src/lib/logger.ts @@ -1,24 +1,60 @@ import { ENV } from '../config.js' -import { redact, serializeError } from './redact.js' -import type { LogMeta } from './redact.js' +import { redactMeta, serializeError } from './redact.js' /** * structured log の単一入口。application code から console を直接呼ばず、 * 必ずこの logger を経由する(`no-console` lint で強制している)。 * - * - `meta` は JSON-safe な `LogMeta` 型で受け取り、sensitive key を redact する + * public API は closed event schema。meta を持てるのは `LogEvents` に定義した + * label だけで、field は primitive に限る。新しい情報をログへ載せたいときは、 + * 値を渡す前に `LogEvents` へ label / field を追加する(agents/logging.md 参照)。 + * * - `error` は `serializeError()` で name/message/stack/cause の限定 shape にする * - `requestId` は access log / application log を横断して追跡するための共通 field。 * api では accessLogMiddleware が発行した値を `c.get('requestId')` から渡す */ -type LogOptions = { - label?: string + +/** + * label ごとに meta として許可する field の閉じた schema。 + * secret / credential(authorization / cookie / token 等)を表す field や、 + * request / response / headers / body を丸ごと表す field を追加してはならない。 + */ +type LogEvents = { + /** accessLogMiddleware が出力する access log */ + access: { + method: string + path: string + status: number + duration: number + } + /** handleError が HTTPException をレスポンスへ変換するときの詳細 */ + handleError: { + status: number + } +} + +type BaseOptions = { requestId?: string body: string - meta?: LogMeta error?: unknown } +/** `LogEvents` に定義した label は、その schema どおりの meta を渡せる。 */ +type EventLogOptions = { + [Label in keyof LogEvents]: BaseOptions & { + label: Label + meta: LogEvents[Label] + } +}[keyof LogEvents] + +/** それ以外の label は meta を持てない(`meta?: never`)。 */ +type PlainLogOptions = BaseOptions & { + label?: string + meta?: never +} + +type LogOptions = EventLogOptions | PlainLogOptions + type Level = 'INFO' | 'ERROR' | 'DEBUG' function buildLog(level: Level, options: LogOptions) { @@ -28,7 +64,7 @@ function buildLog(level: Level, options: LogOptions) { ...(label === undefined ? {} : { label }), ...(requestId === undefined ? {} : { requestId }), body, - ...(meta === undefined ? {} : { meta: redact(meta) }), + ...(meta === undefined ? {} : { meta: redactMeta(meta) }), ...(error === undefined ? {} : { error: serializeError(error) }) } } diff --git a/packages/api/src/lib/redact.test.ts b/packages/api/src/lib/redact.test.ts index 789bbb5..8428865 100644 --- a/packages/api/src/lib/redact.test.ts +++ b/packages/api/src/lib/redact.test.ts @@ -1,13 +1,5 @@ import { expect, test } from 'vite-plus/test' -import type { LogMeta } from './redact.js' -import { - CIRCULAR, - DEPTH_LIMIT, - REDACTED, - isSensitiveKey, - redact, - serializeError -} from './redact.js' +import { REDACTED, UNSUPPORTED, isSensitiveKey, redactMeta, serializeError } from './redact.js' test('isSensitiveKey matches credential-ish keys regardless of case and separators', () => { expect(isSensitiveKey('Authorization')).toBe(true) @@ -27,65 +19,49 @@ test('isSensitiveKey matches credential-ish keys regardless of case and separato expect(isSensitiveKey('userId')).toBe(false) }) -test('LogValue rejects non-JSON runtime objects at compile time', () => { - const headers = new Headers() - // @ts-expect-error Headers are intentionally not valid LogValue values - redact(headers) -}) - -test('redact masks Authorization / Cookie / token values including nested ones', () => { - const actual = redact({ - method: 'GET', +test('redactMeta masks values bound to sensitive keys', () => { + const actual = redactMeta({ + status: 200, Authorization: 'Bearer super-secret-token', - cookie: 'session=abcdef', - session: { - 'set-cookie': 'session=abcdef; HttpOnly', - idToken: 'id-token-value', - accessToken: 'access-token-value', - refreshToken: 'refresh-token-value', - userId: 1 - } + 'set-cookie': 'session=abcdef; HttpOnly', + idToken: 'id-token-value', + refreshToken: 'refresh-token-value' }) expect(actual).toStrictEqual({ - method: 'GET', + status: 200, Authorization: REDACTED, - cookie: REDACTED, - session: { - 'set-cookie': REDACTED, - idToken: REDACTED, - accessToken: REDACTED, - refreshToken: REDACTED, - userId: 1 - } + 'set-cookie': REDACTED, + idToken: REDACTED, + refreshToken: REDACTED }) expect(JSON.stringify(actual)).not.toContain('super-secret-token') expect(JSON.stringify(actual)).not.toContain('abcdef') }) -test('redact walks arrays and truncates long ones', () => { - expect(redact({ users: [{ password: 'x', name: 'a' }] })).toStrictEqual({ - users: [{ password: REDACTED, name: 'a' }] - }) - - const long = Array.from({ length: 52 }).map((_, i) => { - return i +test('redactMeta keeps primitives and replaces non-primitive values', () => { + const actual = redactMeta({ + name: 'task', + count: 3, + ok: true, + empty: null, + missing: undefined, + headers: new Headers({ authorization: 'Bearer super-secret-token' }), + nested: { userId: 1 }, + list: [1, 2, 3] }) - const actual = redact(long) - expect(Array.isArray(actual) && actual.length).toBe(51) - expect(Array.isArray(actual) && actual.at(-1)).toBe('[2 more items]') -}) - -test('redact stops at circular references and depth limit', () => { - const circular: LogMeta = { name: 'root' } - circular.self = circular - - expect(redact(circular)).toStrictEqual({ name: 'root', self: CIRCULAR }) - - expect(redact({ a: { b: { c: { d: { e: { f: { g: 'deep' } } } } } } })).toStrictEqual({ - a: { b: { c: { d: { e: { f: DEPTH_LIMIT } } } } } + expect(actual).toStrictEqual({ + name: 'task', + count: 3, + ok: true, + empty: null, + missing: undefined, + headers: UNSUPPORTED, + nested: UNSUPPORTED, + list: UNSUPPORTED }) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') }) test('serializeError keeps only name / message / stack', () => { diff --git a/packages/api/src/lib/redact.ts b/packages/api/src/lib/redact.ts index 836aeba..f4dcb5c 100644 --- a/packages/api/src/lib/redact.ts +++ b/packages/api/src/lib/redact.ts @@ -1,13 +1,12 @@ /** - * ログ出力する値から credential / PII を落とすための redaction ヘルパー。 - * logger の単一入口(`lib/logger.ts`)から呼ばれる想定で、直接 console へ - * 書き出す実装は持たない。 + * ログ出力を安全側に倒すための redaction ヘルパー。logger の単一入口 + * (`lib/logger.ts`)から呼ばれる想定で、直接 console へ書き出す実装は持たない。 * - * 設計方針(agents/logging.md 参照): - * - `LogValue` / `LogMeta` 型で JSON-safe な値だけを受け付ける。 - * Headers / Request / provider response などは呼び出し側で必要な field だけを抽出する - * - Error は enumerable property を spread せず、name/message/stack/cause の - * 限定 shape へ変換する + * 第一の防御は logger の closed event schema(`logger.ts` の `LogEvents` 型)。 + * ここは型検査をすり抜けた値(assertion 等)に対する最後の防波堤: + * - `redactMeta()` は flat な meta を走査し、sensitive な key 名の値を + * `[REDACTED]` へ、primitive でない値を `[UNSUPPORTED]` へ置き換える + * - `serializeError()` は Error を name/message/stack/cause の限定 shape へ変換する * * 各パッケージは実行コンテキストが異なるため(AGENTS.md の Monorepo Guidelines)、 * このファイルは shared へ切り出さず api/client/task がそれぞれ自己完結して持つ。 @@ -15,15 +14,9 @@ /** 値が sensitive な key に紐づいていたことを示すプレースホルダ。 */ export const REDACTED = '[REDACTED]' -/** 循環参照を検出した位置。 */ -export const CIRCULAR = '[CIRCULAR]' -/** ネストが深すぎて打ち切った位置。 */ -export const DEPTH_LIMIT = '[DEPTH_LIMIT]' +/** primitive でない値が meta へ渡っていたことを示すプレースホルダ。 */ +export const UNSUPPORTED = '[UNSUPPORTED]' -/** ネストの最大深さ。これを超えた位置は DEPTH_LIMIT に置き換える。 */ -const MAX_DEPTH = 6 -/** 配列の最大要素数。超過分は件数だけを残す。 */ -const MAX_ARRAY_LENGTH = 50 /** stack trace として残す最大行数(ログ量を有界にするため)。 */ const MAX_STACK_LINES = 20 /** cause チェーンを辿る最大段数(cause の循環でも停止させるため)。 */ @@ -45,20 +38,35 @@ export function isSensitiveKey(key: string): boolean { return SENSITIVE_KEY_PATTERN.test(key.toLowerCase().replace(KEY_SEPARATOR_PATTERN, '')) } +/** meta の field として logger が出力する値。primitive のみを許す。 */ +export type LogPrimitive = string | number | boolean | null | undefined + /** - * `redact()` が出力しうる値。JSON へそのまま流せる形だけを許す。 - * `redactValue` が再帰しており戻り値型を推論できないため明示する。 + * ログの meta を flat に走査し、型検査をすり抜けた値を安全な形へ落とす。 + * sensitive な key 名の値は `[REDACTED]`、primitive でない値(object / + * Headers / provider response 等)は `[UNSUPPORTED]` に置き換える。 */ -export type LogValue = - | string - | number - | boolean - | null - | undefined - | LogValue[] - | { [key: string]: LogValue } - -export type LogMeta = { [key: string]: LogValue } +export function redactMeta(meta: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(meta)) { + if (isSensitiveKey(key)) { + result[key] = REDACTED + continue + } + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + result[key] = value + continue + } + result[key] = UNSUPPORTED + } + return result +} /** Error を JSON へ安全に落とし込むための限定 shape。 */ export type SerializedError = { @@ -135,43 +143,3 @@ function serializeErrorWithDepth(error: unknown, depth: number): SerializedError export function serializeError(error: unknown): SerializedError { return serializeErrorWithDepth(error, 0) } - -function redactValue(value: LogValue, depth: number, seen: WeakSet): LogValue { - if (value === null || value === undefined) { - return value - } - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value - } - if (seen.has(value)) { - return CIRCULAR - } - if (depth >= MAX_DEPTH) { - return DEPTH_LIMIT - } - seen.add(value) - if (Array.isArray(value)) { - const items = value.slice(0, MAX_ARRAY_LENGTH).map((item) => { - return redactValue(item, depth + 1, seen) - }) - seen.delete(value) - return value.length > MAX_ARRAY_LENGTH - ? [...items, `[${value.length - MAX_ARRAY_LENGTH} more items]`] - : items - } - const result: Record = {} - for (const [key, entry] of Object.entries(value)) { - result[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, depth + 1, seen) - } - seen.delete(value) - return result -} - -/** - * ログの meta に載せる値から sensitive field を落とす。 - * sensitive な key の値を `[REDACTED]` に置き換える。その他の値は - * `LogValue` 型で表現できる JSON-safe な値だけを受け付ける。 - */ -export function redact(value: LogValue): LogValue { - return redactValue(value, 0, new WeakSet()) -} diff --git a/packages/client/src/app/_lib/logger.test.ts b/packages/client/src/app/_lib/logger.test.ts index 2dbf9d5..2f45f34 100644 --- a/packages/client/src/app/_lib/logger.test.ts +++ b/packages/client/src/app/_lib/logger.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vite-plus/test' import { logger } from './logger' -import { REDACTED } from './redact' +import { REDACTED, UNSUPPORTED } from './redact' vi.mock('console') @@ -33,49 +33,74 @@ test('logger.log carries requestId as a top level field', () => { const logMock = spyOnConsole('log') logger.log({ - label: 'access', + label: 'fetchUserList', requestId: 'request-id-1', - body: 'GET /api/user', - meta: { status: 200 } + body: 'unexpected status', + meta: { status: 500, url: 'http://localhost:8000/api/user' } }) expect(logMock).toHaveBeenCalledTimes(1) expect(logMock).toHaveBeenCalledWith( JSON.stringify({ level: 'INFO', - label: 'access', + label: 'fetchUserList', requestId: 'request-id-1', - body: 'GET /api/user', - meta: { status: 200 } + body: 'unexpected status', + meta: { status: 500, url: 'http://localhost:8000/api/user' } }) ) }) -test('logger.log redacts Authorization / Cookie / token in meta', () => { +test('logger meta is a closed event schema at compile time', () => { const logMock = spyOnConsole('log') + // @ts-expect-error LogEvents に無い label は meta を持てない + logger.log({ label: 'unknown-label', body: 'x', meta: { status: 200 } }) + + // @ts-expect-error 既知 label でも schema に無い field は渡せない + logger.log({ label: 'Top', body: 'x', meta: { ok: true, authorization: 'Bearer x' } }) + + // @ts-expect-error meta の値は primitive のみで nested object は渡せない + logger.log({ label: 'Top', body: 'x', meta: { ok: { rendered: true } } }) + + const payload: Record = { ok: true } + // @ts-expect-error 型の広い payload 変数を meta へ渡せない + logger.log({ label: 'Top', body: 'x', meta: payload }) + + // @ts-expect-error payload 変数の spread も渡せない + logger.log({ label: 'Top', body: 'x', meta: { ...payload } }) + + // 上記はいずれも型検査で弾かれることの検証であり、runtime では出力自体は行われる + expect(logMock).toHaveBeenCalledTimes(5) +}) + +test('redactMeta remains as a runtime last line of defense', () => { + const logMock = spyOnConsole('log') + + // wide な型の変数は closed event schema に代入できない(実際に型エラーになる) + const leaked: Record = { + ok: true, + authorization: 'Bearer super-secret-token', + provider: { idToken: 'id-token-value' } + } logger.log({ - label: 'auth', + label: 'Top', body: 'verified', - meta: { - authorization: 'Bearer super-secret-token', - cookie: 'session=abcdef', - provider: { idToken: 'id-token-value', userId: 1 } - } + // @ts-expect-error 型検査をすり抜けた値が runtime で redact されることを検証する + meta: leaked }) const [output] = logMock.mock.calls[0] expect(output).not.toContain('super-secret-token') - expect(output).not.toContain('abcdef') expect(output).not.toContain('id-token-value') expect(JSON.parse(output)).toStrictEqual({ level: 'INFO', - label: 'auth', + label: 'Top', body: 'verified', meta: { + ok: true, authorization: REDACTED, - cookie: REDACTED, - provider: { idToken: REDACTED, userId: 1 } + provider: UNSUPPORTED } }) }) @@ -84,7 +109,7 @@ test('logger.error serializes an Error into a limited shape', () => { const logMock = spyOnConsole('error') logger.error({ - label: 'handleError', + label: 'fetcher', requestId: 'request-id-1', body: 'error', error: new Error('boom') @@ -94,7 +119,7 @@ test('logger.error serializes an Error into a limited shape', () => { const [output] = logMock.mock.calls[0] const parsed = JSON.parse(output) expect(parsed.level).toBe('ERROR') - expect(parsed.label).toBe('handleError') + expect(parsed.label).toBe('fetcher') expect(parsed.requestId).toBe('request-id-1') expect(parsed.error.name).toBe('Error') expect(parsed.error.message).toBe('boom') diff --git a/packages/client/src/app/_lib/logger.ts b/packages/client/src/app/_lib/logger.ts index 9468a9d..dd92dbd 100644 --- a/packages/client/src/app/_lib/logger.ts +++ b/packages/client/src/app/_lib/logger.ts @@ -1,23 +1,57 @@ import { NEXT_PUBLIC_ENV } from '../../constants' -import { redact, serializeError } from './redact' -import type { LogMeta } from './redact' +import { redactMeta, serializeError } from './redact' /** * structured log の単一入口。application code から console を直接呼ばず、 * 必ずこの logger を経由する(`no-console` lint で強制している)。 * - * - `meta` は JSON-safe な `LogMeta` 型で受け取り、sensitive key を redact する + * public API は closed event schema。meta を持てるのは `LogEvents` に定義した + * label だけで、field は primitive に限る。新しい情報をログへ載せたいときは、 + * 値を渡す前に `LogEvents` へ label / field を追加する(agents/logging.md 参照)。 + * * - `error` は `serializeError()` で name/message/stack/cause の限定 shape にする * - `requestId` は api の access log と突き合わせるための共通 field(呼び出し側が明示的に渡す) */ -type LogOptions = { - label?: string + +/** + * label ごとに meta として許可する field の閉じた schema。 + * secret / credential(authorization / cookie / token 等)を表す field や、 + * request / response / headers / body を丸ごと表す field を追加してはならない。 + */ +type LogEvents = { + /** Server Actions の fetch 結果(fetchUserList) */ + fetchUserList: { + status: number + url: string + } + /** Top ページの render デバッグ */ + Top: { + ok: boolean + } +} + +type BaseOptions = { requestId?: string body: string - meta?: LogMeta error?: unknown } +/** `LogEvents` に定義した label は、その schema どおりの meta を渡せる。 */ +type EventLogOptions = { + [Label in keyof LogEvents]: BaseOptions & { + label: Label + meta: LogEvents[Label] + } +}[keyof LogEvents] + +/** それ以外の label は meta を持てない(`meta?: never`)。 */ +type PlainLogOptions = BaseOptions & { + label?: string + meta?: never +} + +type LogOptions = EventLogOptions | PlainLogOptions + type Level = 'INFO' | 'ERROR' | 'DEBUG' function buildLog(level: Level, options: LogOptions) { @@ -27,7 +61,7 @@ function buildLog(level: Level, options: LogOptions) { ...(label === undefined ? {} : { label }), ...(requestId === undefined ? {} : { requestId }), body, - ...(meta === undefined ? {} : { meta: redact(meta) }), + ...(meta === undefined ? {} : { meta: redactMeta(meta) }), ...(error === undefined ? {} : { error: serializeError(error) }) } } diff --git a/packages/client/src/app/_lib/redact.test.ts b/packages/client/src/app/_lib/redact.test.ts index 8e68ca5..9e11eab 100644 --- a/packages/client/src/app/_lib/redact.test.ts +++ b/packages/client/src/app/_lib/redact.test.ts @@ -1,6 +1,5 @@ import { expect, test } from 'vite-plus/test' -import type { LogMeta } from './redact' -import { CIRCULAR, DEPTH_LIMIT, REDACTED, isSensitiveKey, redact, serializeError } from './redact' +import { REDACTED, UNSUPPORTED, isSensitiveKey, redactMeta, serializeError } from './redact' test('isSensitiveKey matches credential-ish keys regardless of case and separators', () => { expect(isSensitiveKey('Authorization')).toBe(true) @@ -20,65 +19,49 @@ test('isSensitiveKey matches credential-ish keys regardless of case and separato expect(isSensitiveKey('userId')).toBe(false) }) -test('LogValue rejects non-JSON runtime objects at compile time', () => { - const headers = new Headers() - // @ts-expect-error Headers are intentionally not valid LogValue values - redact(headers) -}) - -test('redact masks Authorization / Cookie / token values including nested ones', () => { - const actual = redact({ - method: 'GET', +test('redactMeta masks values bound to sensitive keys', () => { + const actual = redactMeta({ + status: 200, Authorization: 'Bearer super-secret-token', - cookie: 'session=abcdef', - session: { - 'set-cookie': 'session=abcdef; HttpOnly', - idToken: 'id-token-value', - accessToken: 'access-token-value', - refreshToken: 'refresh-token-value', - userId: 1 - } + 'set-cookie': 'session=abcdef; HttpOnly', + idToken: 'id-token-value', + refreshToken: 'refresh-token-value' }) expect(actual).toStrictEqual({ - method: 'GET', + status: 200, Authorization: REDACTED, - cookie: REDACTED, - session: { - 'set-cookie': REDACTED, - idToken: REDACTED, - accessToken: REDACTED, - refreshToken: REDACTED, - userId: 1 - } + 'set-cookie': REDACTED, + idToken: REDACTED, + refreshToken: REDACTED }) expect(JSON.stringify(actual)).not.toContain('super-secret-token') expect(JSON.stringify(actual)).not.toContain('abcdef') }) -test('redact walks arrays and truncates long ones', () => { - expect(redact({ users: [{ password: 'x', name: 'a' }] })).toStrictEqual({ - users: [{ password: REDACTED, name: 'a' }] - }) - - const long = Array.from({ length: 52 }).map((_, i) => { - return i +test('redactMeta keeps primitives and replaces non-primitive values', () => { + const actual = redactMeta({ + name: 'task', + count: 3, + ok: true, + empty: null, + missing: undefined, + headers: new Headers({ authorization: 'Bearer super-secret-token' }), + nested: { userId: 1 }, + list: [1, 2, 3] }) - const actual = redact(long) - expect(Array.isArray(actual) && actual.length).toBe(51) - expect(Array.isArray(actual) && actual.at(-1)).toBe('[2 more items]') -}) - -test('redact stops at circular references and depth limit', () => { - const circular: LogMeta = { name: 'root' } - circular.self = circular - - expect(redact(circular)).toStrictEqual({ name: 'root', self: CIRCULAR }) - - expect(redact({ a: { b: { c: { d: { e: { f: { g: 'deep' } } } } } } })).toStrictEqual({ - a: { b: { c: { d: { e: { f: DEPTH_LIMIT } } } } } + expect(actual).toStrictEqual({ + name: 'task', + count: 3, + ok: true, + empty: null, + missing: undefined, + headers: UNSUPPORTED, + nested: UNSUPPORTED, + list: UNSUPPORTED }) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') }) test('serializeError keeps only name / message / stack', () => { diff --git a/packages/client/src/app/_lib/redact.ts b/packages/client/src/app/_lib/redact.ts index 5e601ab..e4a931b 100644 --- a/packages/client/src/app/_lib/redact.ts +++ b/packages/client/src/app/_lib/redact.ts @@ -1,13 +1,12 @@ /** - * ログ出力する値から credential / PII を落とすための redaction ヘルパー。 - * logger の単一入口(`_lib/logger.ts`)から呼ばれる想定で、直接 console へ - * 書き出す実装は持たない。 + * ログ出力を安全側に倒すための redaction ヘルパー。logger の単一入口 + * (`_lib/logger.ts`)から呼ばれる想定で、直接 console へ書き出す実装は持たない。 * - * 設計方針(agents/logging.md 参照): - * - `LogValue` / `LogMeta` 型で JSON-safe な値だけを受け付ける。 - * Headers / Request / provider response などは呼び出し側で必要な field だけを抽出する - * - Error は enumerable property を spread せず、name/message/stack/cause の - * 限定 shape へ変換する + * 第一の防御は logger の closed event schema(`logger.ts` の `LogEvents` 型)。 + * ここは型検査をすり抜けた値(assertion 等)に対する最後の防波堤: + * - `redactMeta()` は flat な meta を走査し、sensitive な key 名の値を + * `[REDACTED]` へ、primitive でない値を `[UNSUPPORTED]` へ置き換える + * - `serializeError()` は Error を name/message/stack/cause の限定 shape へ変換する * * 各パッケージは実行コンテキストが異なるため(AGENTS.md の Monorepo Guidelines)、 * このファイルは shared へ切り出さず api/client/task がそれぞれ自己完結して持つ。 @@ -15,15 +14,9 @@ /** 値が sensitive な key に紐づいていたことを示すプレースホルダ。 */ export const REDACTED = '[REDACTED]' -/** 循環参照を検出した位置。 */ -export const CIRCULAR = '[CIRCULAR]' -/** ネストが深すぎて打ち切った位置。 */ -export const DEPTH_LIMIT = '[DEPTH_LIMIT]' +/** primitive でない値が meta へ渡っていたことを示すプレースホルダ。 */ +export const UNSUPPORTED = '[UNSUPPORTED]' -/** ネストの最大深さ。これを超えた位置は DEPTH_LIMIT に置き換える。 */ -const MAX_DEPTH = 6 -/** 配列の最大要素数。超過分は件数だけを残す。 */ -const MAX_ARRAY_LENGTH = 50 /** stack trace として残す最大行数(ログ量を有界にするため)。 */ const MAX_STACK_LINES = 20 /** cause チェーンを辿る最大段数(cause の循環でも停止させるため)。 */ @@ -45,20 +38,35 @@ export function isSensitiveKey(key: string): boolean { return SENSITIVE_KEY_PATTERN.test(key.toLowerCase().replace(KEY_SEPARATOR_PATTERN, '')) } +/** meta の field として logger が出力する値。primitive のみを許す。 */ +export type LogPrimitive = string | number | boolean | null | undefined + /** - * `redact()` が出力しうる値。JSON へそのまま流せる形だけを許す。 - * `redactValue` が再帰しており戻り値型を推論できないため明示する。 + * ログの meta を flat に走査し、型検査をすり抜けた値を安全な形へ落とす。 + * sensitive な key 名の値は `[REDACTED]`、primitive でない値(object / + * Headers / provider response 等)は `[UNSUPPORTED]` に置き換える。 */ -export type LogValue = - | string - | number - | boolean - | null - | undefined - | LogValue[] - | { [key: string]: LogValue } - -export type LogMeta = { [key: string]: LogValue } +export function redactMeta(meta: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(meta)) { + if (isSensitiveKey(key)) { + result[key] = REDACTED + continue + } + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + result[key] = value + continue + } + result[key] = UNSUPPORTED + } + return result +} /** Error を JSON へ安全に落とし込むための限定 shape。 */ export type SerializedError = { @@ -135,43 +143,3 @@ function serializeErrorWithDepth(error: unknown, depth: number): SerializedError export function serializeError(error: unknown): SerializedError { return serializeErrorWithDepth(error, 0) } - -function redactValue(value: LogValue, depth: number, seen: WeakSet): LogValue { - if (value === null || value === undefined) { - return value - } - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value - } - if (seen.has(value)) { - return CIRCULAR - } - if (depth >= MAX_DEPTH) { - return DEPTH_LIMIT - } - seen.add(value) - if (Array.isArray(value)) { - const items = value.slice(0, MAX_ARRAY_LENGTH).map((item) => { - return redactValue(item, depth + 1, seen) - }) - seen.delete(value) - return value.length > MAX_ARRAY_LENGTH - ? [...items, `[${value.length - MAX_ARRAY_LENGTH} more items]`] - : items - } - const result: Record = {} - for (const [key, entry] of Object.entries(value)) { - result[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, depth + 1, seen) - } - seen.delete(value) - return result -} - -/** - * ログの meta に載せる値から sensitive field を落とす。 - * sensitive な key の値を `[REDACTED]` に置き換える。その他の値は - * `LogValue` 型で表現できる JSON-safe な値だけを受け付ける。 - */ -export function redact(value: LogValue): LogValue { - return redactValue(value, 0, new WeakSet()) -} diff --git a/packages/task/src/lib/logger.test.ts b/packages/task/src/lib/logger.test.ts index 50d8568..efda425 100644 --- a/packages/task/src/lib/logger.test.ts +++ b/packages/task/src/lib/logger.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vite-plus/test' import { logger } from './logger.js' -import { REDACTED } from './redact.js' +import { REDACTED, UNSUPPORTED } from './redact.js' vi.mock('console') @@ -33,49 +33,74 @@ test('logger.log carries requestId as a top level field', () => { const logMock = spyOnConsole('log') logger.log({ - label: 'access', + label: 'task', requestId: 'request-id-1', - body: 'GET /api/user', - meta: { status: 200 } + body: 'start', + meta: { command: 'migrate' } }) expect(logMock).toHaveBeenCalledTimes(1) expect(logMock).toHaveBeenCalledWith( JSON.stringify({ level: 'INFO', - label: 'access', + label: 'task', requestId: 'request-id-1', - body: 'GET /api/user', - meta: { status: 200 } + body: 'start', + meta: { command: 'migrate' } }) ) }) -test('logger.log redacts Authorization / Cookie / token in meta', () => { +test('logger meta is a closed event schema at compile time', () => { const logMock = spyOnConsole('log') + // @ts-expect-error LogEvents に無い label は meta を持てない + logger.log({ label: 'unknown-label', body: 'x', meta: { command: 'migrate' } }) + + // @ts-expect-error 既知 label でも schema に無い field は渡せない + logger.log({ label: 'task', body: 'x', meta: { command: 'migrate', secret: 'x' } }) + + // @ts-expect-error meta の値は primitive のみで nested object は渡せない + logger.log({ label: 'task', body: 'x', meta: { command: { name: 'migrate' } } }) + + const payload: Record = { command: 'migrate' } + // @ts-expect-error 型の広い payload 変数を meta へ渡せない + logger.log({ label: 'task', body: 'x', meta: payload }) + + // @ts-expect-error payload 変数の spread も渡せない + logger.log({ label: 'task', body: 'x', meta: { ...payload } }) + + // 上記はいずれも型検査で弾かれることの検証であり、runtime では出力自体は行われる + expect(logMock).toHaveBeenCalledTimes(5) +}) + +test('redactMeta remains as a runtime last line of defense', () => { + const logMock = spyOnConsole('log') + + // wide な型の変数は closed event schema に代入できない(実際に型エラーになる) + const leaked: Record = { + command: 'migrate', + authorization: 'Bearer super-secret-token', + provider: { idToken: 'id-token-value' } + } logger.log({ - label: 'auth', + label: 'task', body: 'verified', - meta: { - authorization: 'Bearer super-secret-token', - cookie: 'session=abcdef', - provider: { idToken: 'id-token-value', userId: 1 } - } + // @ts-expect-error 型検査をすり抜けた値が runtime で redact されることを検証する + meta: leaked }) const [output] = logMock.mock.calls[0] expect(output).not.toContain('super-secret-token') - expect(output).not.toContain('abcdef') expect(output).not.toContain('id-token-value') expect(JSON.parse(output)).toStrictEqual({ level: 'INFO', - label: 'auth', + label: 'task', body: 'verified', meta: { + command: 'migrate', authorization: REDACTED, - cookie: REDACTED, - provider: { idToken: REDACTED, userId: 1 } + provider: UNSUPPORTED } }) }) @@ -84,9 +109,9 @@ test('logger.error serializes an Error into a limited shape', () => { const logMock = spyOnConsole('error') logger.error({ - label: 'handleError', + label: 'task', requestId: 'request-id-1', - body: 'error', + body: 'failed', error: new Error('boom') }) @@ -94,7 +119,7 @@ test('logger.error serializes an Error into a limited shape', () => { const [output] = logMock.mock.calls[0] const parsed = JSON.parse(output) expect(parsed.level).toBe('ERROR') - expect(parsed.label).toBe('handleError') + expect(parsed.label).toBe('task') expect(parsed.requestId).toBe('request-id-1') expect(parsed.error.name).toBe('Error') expect(parsed.error.message).toBe('boom') diff --git a/packages/task/src/lib/logger.ts b/packages/task/src/lib/logger.ts index b8c3609..b06925f 100644 --- a/packages/task/src/lib/logger.ts +++ b/packages/task/src/lib/logger.ts @@ -1,23 +1,52 @@ import { ENV } from '../config.js' -import { redact, serializeError } from './redact.js' -import type { LogMeta } from './redact.js' +import { redactMeta, serializeError } from './redact.js' /** * structured log の単一入口。application code から console を直接呼ばず、 * 必ずこの logger を経由する(`no-console` lint で強制している)。 * - * - `meta` は JSON-safe な `LogMeta` 型で受け取り、sensitive key を redact する + * public API は closed event schema。meta を持てるのは `LogEvents` に定義した + * label だけで、field は primitive に限る。新しい情報をログへ載せたいときは、 + * 値を渡す前に `LogEvents` へ label / field を追加する(agents/logging.md 参照)。 + * * - `error` は `serializeError()` で name/message/stack/cause の限定 shape にする * - `requestId` は 1 実行を横断して追跡するための共通 field(呼び出し側が明示的に渡す) */ -type LogOptions = { - label?: string + +/** + * label ごとに meta として許可する field の閉じた schema。 + * secret / credential(authorization / cookie / token 等)を表す field や、 + * request / response / headers / body を丸ごと表す field を追加してはならない。 + */ +type LogEvents = { + /** task runner の開始 / 終了 */ + task: { + command: string + } +} + +type BaseOptions = { requestId?: string body: string - meta?: LogMeta error?: unknown } +/** `LogEvents` に定義した label は、その schema どおりの meta を渡せる。 */ +type EventLogOptions = { + [Label in keyof LogEvents]: BaseOptions & { + label: Label + meta: LogEvents[Label] + } +}[keyof LogEvents] + +/** それ以外の label は meta を持てない(`meta?: never`)。 */ +type PlainLogOptions = BaseOptions & { + label?: string + meta?: never +} + +type LogOptions = EventLogOptions | PlainLogOptions + type Level = 'INFO' | 'ERROR' | 'DEBUG' function buildLog(level: Level, options: LogOptions) { @@ -27,7 +56,7 @@ function buildLog(level: Level, options: LogOptions) { ...(label === undefined ? {} : { label }), ...(requestId === undefined ? {} : { requestId }), body, - ...(meta === undefined ? {} : { meta: redact(meta) }), + ...(meta === undefined ? {} : { meta: redactMeta(meta) }), ...(error === undefined ? {} : { error: serializeError(error) }) } } diff --git a/packages/task/src/lib/redact.test.ts b/packages/task/src/lib/redact.test.ts index 789bbb5..8428865 100644 --- a/packages/task/src/lib/redact.test.ts +++ b/packages/task/src/lib/redact.test.ts @@ -1,13 +1,5 @@ import { expect, test } from 'vite-plus/test' -import type { LogMeta } from './redact.js' -import { - CIRCULAR, - DEPTH_LIMIT, - REDACTED, - isSensitiveKey, - redact, - serializeError -} from './redact.js' +import { REDACTED, UNSUPPORTED, isSensitiveKey, redactMeta, serializeError } from './redact.js' test('isSensitiveKey matches credential-ish keys regardless of case and separators', () => { expect(isSensitiveKey('Authorization')).toBe(true) @@ -27,65 +19,49 @@ test('isSensitiveKey matches credential-ish keys regardless of case and separato expect(isSensitiveKey('userId')).toBe(false) }) -test('LogValue rejects non-JSON runtime objects at compile time', () => { - const headers = new Headers() - // @ts-expect-error Headers are intentionally not valid LogValue values - redact(headers) -}) - -test('redact masks Authorization / Cookie / token values including nested ones', () => { - const actual = redact({ - method: 'GET', +test('redactMeta masks values bound to sensitive keys', () => { + const actual = redactMeta({ + status: 200, Authorization: 'Bearer super-secret-token', - cookie: 'session=abcdef', - session: { - 'set-cookie': 'session=abcdef; HttpOnly', - idToken: 'id-token-value', - accessToken: 'access-token-value', - refreshToken: 'refresh-token-value', - userId: 1 - } + 'set-cookie': 'session=abcdef; HttpOnly', + idToken: 'id-token-value', + refreshToken: 'refresh-token-value' }) expect(actual).toStrictEqual({ - method: 'GET', + status: 200, Authorization: REDACTED, - cookie: REDACTED, - session: { - 'set-cookie': REDACTED, - idToken: REDACTED, - accessToken: REDACTED, - refreshToken: REDACTED, - userId: 1 - } + 'set-cookie': REDACTED, + idToken: REDACTED, + refreshToken: REDACTED }) expect(JSON.stringify(actual)).not.toContain('super-secret-token') expect(JSON.stringify(actual)).not.toContain('abcdef') }) -test('redact walks arrays and truncates long ones', () => { - expect(redact({ users: [{ password: 'x', name: 'a' }] })).toStrictEqual({ - users: [{ password: REDACTED, name: 'a' }] - }) - - const long = Array.from({ length: 52 }).map((_, i) => { - return i +test('redactMeta keeps primitives and replaces non-primitive values', () => { + const actual = redactMeta({ + name: 'task', + count: 3, + ok: true, + empty: null, + missing: undefined, + headers: new Headers({ authorization: 'Bearer super-secret-token' }), + nested: { userId: 1 }, + list: [1, 2, 3] }) - const actual = redact(long) - expect(Array.isArray(actual) && actual.length).toBe(51) - expect(Array.isArray(actual) && actual.at(-1)).toBe('[2 more items]') -}) - -test('redact stops at circular references and depth limit', () => { - const circular: LogMeta = { name: 'root' } - circular.self = circular - - expect(redact(circular)).toStrictEqual({ name: 'root', self: CIRCULAR }) - - expect(redact({ a: { b: { c: { d: { e: { f: { g: 'deep' } } } } } } })).toStrictEqual({ - a: { b: { c: { d: { e: { f: DEPTH_LIMIT } } } } } + expect(actual).toStrictEqual({ + name: 'task', + count: 3, + ok: true, + empty: null, + missing: undefined, + headers: UNSUPPORTED, + nested: UNSUPPORTED, + list: UNSUPPORTED }) + expect(JSON.stringify(actual)).not.toContain('super-secret-token') }) test('serializeError keeps only name / message / stack', () => { diff --git a/packages/task/src/lib/redact.ts b/packages/task/src/lib/redact.ts index 836aeba..f4dcb5c 100644 --- a/packages/task/src/lib/redact.ts +++ b/packages/task/src/lib/redact.ts @@ -1,13 +1,12 @@ /** - * ログ出力する値から credential / PII を落とすための redaction ヘルパー。 - * logger の単一入口(`lib/logger.ts`)から呼ばれる想定で、直接 console へ - * 書き出す実装は持たない。 + * ログ出力を安全側に倒すための redaction ヘルパー。logger の単一入口 + * (`lib/logger.ts`)から呼ばれる想定で、直接 console へ書き出す実装は持たない。 * - * 設計方針(agents/logging.md 参照): - * - `LogValue` / `LogMeta` 型で JSON-safe な値だけを受け付ける。 - * Headers / Request / provider response などは呼び出し側で必要な field だけを抽出する - * - Error は enumerable property を spread せず、name/message/stack/cause の - * 限定 shape へ変換する + * 第一の防御は logger の closed event schema(`logger.ts` の `LogEvents` 型)。 + * ここは型検査をすり抜けた値(assertion 等)に対する最後の防波堤: + * - `redactMeta()` は flat な meta を走査し、sensitive な key 名の値を + * `[REDACTED]` へ、primitive でない値を `[UNSUPPORTED]` へ置き換える + * - `serializeError()` は Error を name/message/stack/cause の限定 shape へ変換する * * 各パッケージは実行コンテキストが異なるため(AGENTS.md の Monorepo Guidelines)、 * このファイルは shared へ切り出さず api/client/task がそれぞれ自己完結して持つ。 @@ -15,15 +14,9 @@ /** 値が sensitive な key に紐づいていたことを示すプレースホルダ。 */ export const REDACTED = '[REDACTED]' -/** 循環参照を検出した位置。 */ -export const CIRCULAR = '[CIRCULAR]' -/** ネストが深すぎて打ち切った位置。 */ -export const DEPTH_LIMIT = '[DEPTH_LIMIT]' +/** primitive でない値が meta へ渡っていたことを示すプレースホルダ。 */ +export const UNSUPPORTED = '[UNSUPPORTED]' -/** ネストの最大深さ。これを超えた位置は DEPTH_LIMIT に置き換える。 */ -const MAX_DEPTH = 6 -/** 配列の最大要素数。超過分は件数だけを残す。 */ -const MAX_ARRAY_LENGTH = 50 /** stack trace として残す最大行数(ログ量を有界にするため)。 */ const MAX_STACK_LINES = 20 /** cause チェーンを辿る最大段数(cause の循環でも停止させるため)。 */ @@ -45,20 +38,35 @@ export function isSensitiveKey(key: string): boolean { return SENSITIVE_KEY_PATTERN.test(key.toLowerCase().replace(KEY_SEPARATOR_PATTERN, '')) } +/** meta の field として logger が出力する値。primitive のみを許す。 */ +export type LogPrimitive = string | number | boolean | null | undefined + /** - * `redact()` が出力しうる値。JSON へそのまま流せる形だけを許す。 - * `redactValue` が再帰しており戻り値型を推論できないため明示する。 + * ログの meta を flat に走査し、型検査をすり抜けた値を安全な形へ落とす。 + * sensitive な key 名の値は `[REDACTED]`、primitive でない値(object / + * Headers / provider response 等)は `[UNSUPPORTED]` に置き換える。 */ -export type LogValue = - | string - | number - | boolean - | null - | undefined - | LogValue[] - | { [key: string]: LogValue } - -export type LogMeta = { [key: string]: LogValue } +export function redactMeta(meta: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(meta)) { + if (isSensitiveKey(key)) { + result[key] = REDACTED + continue + } + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + result[key] = value + continue + } + result[key] = UNSUPPORTED + } + return result +} /** Error を JSON へ安全に落とし込むための限定 shape。 */ export type SerializedError = { @@ -135,43 +143,3 @@ function serializeErrorWithDepth(error: unknown, depth: number): SerializedError export function serializeError(error: unknown): SerializedError { return serializeErrorWithDepth(error, 0) } - -function redactValue(value: LogValue, depth: number, seen: WeakSet): LogValue { - if (value === null || value === undefined) { - return value - } - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value - } - if (seen.has(value)) { - return CIRCULAR - } - if (depth >= MAX_DEPTH) { - return DEPTH_LIMIT - } - seen.add(value) - if (Array.isArray(value)) { - const items = value.slice(0, MAX_ARRAY_LENGTH).map((item) => { - return redactValue(item, depth + 1, seen) - }) - seen.delete(value) - return value.length > MAX_ARRAY_LENGTH - ? [...items, `[${value.length - MAX_ARRAY_LENGTH} more items]`] - : items - } - const result: Record = {} - for (const [key, entry] of Object.entries(value)) { - result[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, depth + 1, seen) - } - seen.delete(value) - return result -} - -/** - * ログの meta に載せる値から sensitive field を落とす。 - * sensitive な key の値を `[REDACTED]` に置き換える。その他の値は - * `LogValue` 型で表現できる JSON-safe な値だけを受け付ける。 - */ -export function redact(value: LogValue): LogValue { - return redactValue(value, 0, new WeakSet()) -} diff --git a/vite.config.ts b/vite.config.ts index 8110e16..3e00119 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -51,8 +51,9 @@ export default defineConfig({ 'coding-style/no-process-env-outside-config': 'error', 'coding-style/enforce-zod-entrypoint': 'error', // structured logging の規約(agents/logging.md)。console は logger - // 実装だけに閉じ込め、logger へ secret / request payload を渡させない。 - 'coding-style/no-sensitive-logging': 'error', + // 実装だけに閉じ込め、logger の引数は closed event schema の型検査 + // (excess property check)が確実に効く object literal に限定する。 + 'coding-style/enforce-logger-literal': 'error', 'no-console': 'error' } }, @@ -71,7 +72,7 @@ export default defineConfig({ files: ['**/*.test.ts', '**/*.test.tsx'], rules: { // テストは logger の出力先である console を spy/assert するため対象外にする - // (no-sensitive-logging 側の除外はルール実装がファイル名で判定する)。 + // (enforce-logger-literal 側の除外はルール実装がファイル名で判定する)。 'no-console': 'off', // 並列耐性テストの規約(AGENTS.md 参照)として describe() を禁止する。 // 一時的な reminder ではなく恒久的なコーディング規約のため error にする。 From a971f6ff3b0207a0054816c01ade6b820e4fc7c4 Mon Sep 17 00:00:00 2001 From: kohta ito Date: Sun, 6 Sep 2026 13:39:10 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20logger=E9=85=8D=E4=B8=8B?= =?UTF-8?q?=E3=81=B8redact=E3=82=92=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents/logging.md | 4 +- lint-rules/run-tests.ts | 2 +- packages/api/src/index.ts | 2 +- .../{logger.test.ts => logger/index.test.ts} | 2 +- .../src/lib/{logger.ts => logger/index.ts} | 2 +- .../api/src/lib/{ => logger}/redact.test.ts | 0 .../app/_lib => api/src/lib/logger}/redact.ts | 4 +- packages/api/src/lib/middleware.ts | 2 +- packages/api/src/lib/wrap.ts | 2 +- packages/client/src/app/_lib/api.ts | 214 +++++++++++++++++- .../{logger.test.ts => logger/index.test.ts} | 2 +- .../app/_lib/{logger.ts => logger/index.ts} | 2 +- .../src/app/_lib/{ => logger}/redact.test.ts | 0 .../src/app/_lib/logger}/redact.ts | 4 +- packages/task/src/index.ts | 2 +- .../{logger.test.ts => logger/index.test.ts} | 2 +- .../src/lib/{logger.ts => logger/index.ts} | 2 +- .../task/src/lib/{ => logger}/redact.test.ts | 0 packages/task/src/lib/{ => logger}/redact.ts | 4 +- packages/task/src/tasks/migrate.ts | 2 +- vite.config.ts | 6 +- 21 files changed, 225 insertions(+), 35 deletions(-) rename packages/api/src/lib/{logger.test.ts => logger/index.test.ts} (99%) rename packages/api/src/lib/{logger.ts => logger/index.ts} (98%) rename packages/api/src/lib/{ => logger}/redact.test.ts (100%) rename packages/{client/src/app/_lib => api/src/lib/logger}/redact.ts (96%) rename packages/client/src/app/_lib/{logger.test.ts => logger/index.test.ts} (99%) rename packages/client/src/app/_lib/{logger.ts => logger/index.ts} (98%) rename packages/client/src/app/_lib/{ => logger}/redact.test.ts (100%) rename packages/{api/src/lib => client/src/app/_lib/logger}/redact.ts (96%) rename packages/task/src/lib/{logger.test.ts => logger/index.test.ts} (99%) rename packages/task/src/lib/{logger.ts => logger/index.ts} (98%) rename packages/task/src/lib/{ => logger}/redact.test.ts (100%) rename packages/task/src/lib/{ => logger}/redact.ts (96%) diff --git a/agents/logging.md b/agents/logging.md index 4c5c99f..a8a3beb 100644 --- a/agents/logging.md +++ b/agents/logging.md @@ -2,10 +2,10 @@ ログ出力を伴うコード(`logger.*` の呼び出し、logger / redact 実装、access log / error handler)を書くときの原則。機械検査の詳細は `agents/lint-rules.md` を、何を meta へ載せられるかは各 logger の `LogEvents` 型を参照する。 -- application code は `console.*` を直接呼ばず、パッケージごとの logger(api: `packages/api/src/lib/logger.ts`、client: `packages/client/src/app/_lib/logger.ts`、task: `packages/task/src/lib/logger.ts`)を単一入口にする。dev 専用 CLI(`packages/bin`)は人間が読む出力なので対象外 +- application code は `console.*` を直接呼ばず、パッケージごとの logger(api: `packages/api/src/lib/logger/index.ts`、client: `packages/client/src/app/_lib/logger/index.ts`、task: `packages/task/src/lib/logger/index.ts`)を単一入口にする。dev 専用 CLI(`packages/bin`)は人間が読む出力なので対象外 - logger の public API は closed event schema。meta を持てるのは各 logger の `LogEvents` に定義した label だけで、field は primitive のみ。新しい情報をログへ載せたいときは、値を渡す前に `LogEvents` へ label / field を追加する - `LogEvents` に secret / credential(authorization / cookie / token 等)を表す field や、request / response / headers / body を丸ごと表す field を追加しない。必要な primitive field だけを schema に起こす - 何を渡してよいかの判断は型に寄せる。型検査に失敗する値を assertion で無理に通さない。runtime の `redactMeta()`(sensitive key 名 → `[REDACTED]`、primitive 以外 → `[UNSUPPORTED]`)は型をすり抜けた値への最後の防波堤であって、設計上の免罪符ではない - `error` は `serializeError()` が name / message / stack / cause の限定 shape へ変換する。`{ ...error }` のような spread は、external SDK が enumerable property に詰めた credential / request payload を持ち出すため行わない - `requestId` で access log と application log を突き合わせる。api では `accessLogMiddleware` が発行した値を `c.get('requestId')` から渡す。client / task は追跡したい単位の ID を呼び出し側が明示的に渡す -- `logger.ts` / `redact.ts` は api / client / task がそれぞれ自己完結して持ち、shared へ切り出さない。実行コンテキストが異なるため(AGENTS.md の Monorepo Guidelines) +- `logger/` は api / client / task がそれぞれ自己完結して持ち、shared へ切り出さない。実行コンテキストが異なるため(AGENTS.md の Monorepo Guidelines) diff --git a/lint-rules/run-tests.ts b/lint-rules/run-tests.ts index bb9427a..f92464c 100644 --- a/lint-rules/run-tests.ts +++ b/lint-rules/run-tests.ts @@ -147,7 +147,7 @@ tester.run('enforce-logger-literal', enforceLoggerLiteralRule, { // 対象外: テストファイルは runtime の最終防衛を検証するため変数渡しを許す { code: 'logger.log(leakageFixture)', - filename: 'packages/api/src/lib/logger.test.ts' + filename: 'packages/api/src/lib/logger/index.test.ts' } ], invalid: [ diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a8b92a8..5aa25df 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,7 +1,7 @@ import { serve } from '@hono/node-server' import { createApp } from './app.js' import { ENV, LOCAL_PROXY_CONFIG_DIR, PORT } from './config.js' -import { logger } from './lib/logger.js' +import { logger } from './lib/logger/index.js' let server: ReturnType | null = null diff --git a/packages/api/src/lib/logger.test.ts b/packages/api/src/lib/logger/index.test.ts similarity index 99% rename from packages/api/src/lib/logger.test.ts rename to packages/api/src/lib/logger/index.test.ts index 7832dcb..ce91869 100644 --- a/packages/api/src/lib/logger.test.ts +++ b/packages/api/src/lib/logger/index.test.ts @@ -1,5 +1,5 @@ import { expect, test, vi } from 'vite-plus/test' -import { logger } from './logger.js' +import { logger } from './index.js' import { REDACTED, UNSUPPORTED } from './redact.js' vi.mock('console') diff --git a/packages/api/src/lib/logger.ts b/packages/api/src/lib/logger/index.ts similarity index 98% rename from packages/api/src/lib/logger.ts rename to packages/api/src/lib/logger/index.ts index 0fbbcf6..1c55449 100644 --- a/packages/api/src/lib/logger.ts +++ b/packages/api/src/lib/logger/index.ts @@ -1,4 +1,4 @@ -import { ENV } from '../config.js' +import { ENV } from '../../config.js' import { redactMeta, serializeError } from './redact.js' /** diff --git a/packages/api/src/lib/redact.test.ts b/packages/api/src/lib/logger/redact.test.ts similarity index 100% rename from packages/api/src/lib/redact.test.ts rename to packages/api/src/lib/logger/redact.test.ts diff --git a/packages/client/src/app/_lib/redact.ts b/packages/api/src/lib/logger/redact.ts similarity index 96% rename from packages/client/src/app/_lib/redact.ts rename to packages/api/src/lib/logger/redact.ts index e4a931b..6daea31 100644 --- a/packages/client/src/app/_lib/redact.ts +++ b/packages/api/src/lib/logger/redact.ts @@ -1,8 +1,8 @@ /** * ログ出力を安全側に倒すための redaction ヘルパー。logger の単一入口 - * (`_lib/logger.ts`)から呼ばれる想定で、直接 console へ書き出す実装は持たない。 + * (`lib/logger/index.ts`)から呼ばれる想定で、直接 console へ書き出す実装は持たない。 * - * 第一の防御は logger の closed event schema(`logger.ts` の `LogEvents` 型)。 + * 第一の防御は logger の closed event schema(`logger/index.ts` の `LogEvents` 型)。 * ここは型検査をすり抜けた値(assertion 等)に対する最後の防波堤: * - `redactMeta()` は flat な meta を走査し、sensitive な key 名の値を * `[REDACTED]` へ、primitive でない値を `[UNSUPPORTED]` へ置き換える diff --git a/packages/api/src/lib/middleware.ts b/packages/api/src/lib/middleware.ts index c01048b..368111c 100644 --- a/packages/api/src/lib/middleware.ts +++ b/packages/api/src/lib/middleware.ts @@ -5,7 +5,7 @@ import type * as schema from 'shared/src/schema' import { CORS_OPTIONS } from '../config.js' import { localTokenVerifier } from '../lib/auth/local-verifier.js' import type { TokenVerifier } from '../lib/auth/token-verifier.js' -import { logger } from '../lib/logger.js' +import { logger } from './logger/index.js' import { createHttpException } from './wrap.js' // 実プロバイダ(Firebase Admin 等)に差し替える場合はここを変更する diff --git a/packages/api/src/lib/wrap.ts b/packages/api/src/lib/wrap.ts index 593ff51..1acdbde 100644 --- a/packages/api/src/lib/wrap.ts +++ b/packages/api/src/lib/wrap.ts @@ -1,6 +1,6 @@ import type { Context } from 'hono' import { HTTPException } from 'hono/http-exception' -import { logger } from './logger.js' +import { logger } from './logger/index.js' export type ProblemDetails = { type: string diff --git a/packages/client/src/app/_lib/api.ts b/packages/client/src/app/_lib/api.ts index e7fa3a5..167a14e 100644 --- a/packages/client/src/app/_lib/api.ts +++ b/packages/client/src/app/_lib/api.ts @@ -1,16 +1,197 @@ import type { Result } from 'shared/src/index' -import { logger } from './logger' - -export { - client, - createFetchOptions, - extractErrorMessage -} from 'shared/src/api-client' -export type { - APIResult, - ClientResponse, - HttpMethod -} from 'shared/src/api-client' +import type * as schema from 'shared/src/schema' +import { logger } from './logger/index' + +type Prettify = { + [K in keyof T]: T[K] +} & {} + +type StatusNumKeys = Extract +type TypedResponse = { + [S in StatusNumKeys]: { + status: S + body: R[S] extends { content: { 'application/json': infer U } } + ? U + : R[S] extends { content: { 'text/plain': infer TP } } + ? TP extends string + ? TP + : string + : unknown + } +}[StatusNumKeys] + +type HttpStatus = 200 | 201 | 400 | 401 | 403 | 500 + +export type ClientResponse = + | TypedResponse + | { status: Exclude>; body: string } + +export type HttpMethod = + | 'get' + | 'post' + | 'put' + | 'delete' + | 'options' + | 'head' + | 'patch' + | 'trace' + +type OptionsFor< + T extends keyof schema.paths, + K extends HttpMethod +> = Parameters>[0] + +type ExtractQuery = Op extends { + parameters: { query?: infer Q } +} + ? NonNullable + : never + +type QueryType< + T extends keyof schema.paths, + K extends HttpMethod +> = K extends keyof schema.paths[T] ? ExtractQuery : never + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export async function client< + T extends keyof schema.paths, + K extends HttpMethod +>( + url: string, + options: OptionsFor & { + appendQuery?: ( + urlSearchParams: URLSearchParams, + query: QueryType + ) => URLSearchParams + }, + _init?: { next?: RequestInit['next'] } & RequestInit +): Promise< + Prettify< + ClientResponse< + schema.paths[T][K] extends { responses: infer R } ? R : never + > + > +> { + const _options = options + const init: Parameters[1] = { + ..._init, + method: _options.method, + headers: { + 'Content-Type': 'application/json', + ..._init?.headers + } + } + + function hasHeader(o: typeof _options): o is typeof _options & { + parameters: { header: Record } + } { + if (!isRecord(o) || !('parameters' in o)) return false + const { parameters } = o + return isRecord(parameters) && 'header' in parameters + } + + if (hasHeader(_options)) { + init.headers = { + ...init.headers, + ..._options.parameters.header + } + } + + function hasRequestBody(o: typeof _options) { + return isRecord(o) && 'requestBody' in o + } + + if (hasRequestBody(_options)) { + init.body = JSON.stringify(_options.requestBody) + } + + function hasQuery(o: typeof _options): o is typeof _options & { + parameters: { query: QueryType } + } { + if (!isRecord(o) || !('parameters' in o)) return false + const { parameters } = o + return isRecord(parameters) && 'query' in parameters + } + + let _url = url + if (_options.appendQuery && hasQuery(_options)) { + const urlSearchParams = new URLSearchParams() + const sp = _options.appendQuery( + urlSearchParams, + _options.parameters.query + ) + const qs = sp.toString() + if (qs) { + _url = `${url}${url.includes('?') ? '&' : '?'}${qs}` + } + } + + const res = await fetch(_url, init) + let body = await res.text() + const contentType = res.headers.get('Content-Type') + if ( + body.length > 0 && + init.headers && + typeof contentType === 'string' && + contentType.includes('application/json') + ) { + body = JSON.parse(body) + } + + type Res = schema.paths[T][K] extends { responses: infer R } ? R : never + + return { + status: res.status, + body + } as Prettify> +} + +export function createFetchOptions< + T extends keyof schema.paths, + K extends HttpMethod +>( + options: { + path: T + method: K + } & (schema.paths[T][K] extends { parameters: infer P } + ? { parameters: P } + : Record) & + (schema.paths[T][K] extends { + requestBody: { content: { 'application/json': infer Q } } + } + ? { requestBody: Q } + : Record) +) { + return options +} + +type APIResultBase< + ClientFn extends (...args: never[]) => unknown, + SuccessStatus extends number, + APIResponse extends { status: number; body: unknown } = + Awaited> extends { status: number; body: unknown } + ? Awaited> + : never +> = Result< + Extract['body'], + | Extract< + APIResponse, + { status: Exclude; body: unknown } + >['body'] + | string +> + +// ClientFn には `typeof client<'/api/user', 'get'>` のように、path/method で +// 具体化した client() の型をそのまま渡す。path/method を APIResult 側で +// 再指定しない(単一の情報源から導出する)ことで、client() の型と APIResult の +// 型が食い違う余地を無くす +export type APIResult< + ClientFn extends (...args: never[]) => unknown, + SuccessStatus extends number +> = APIResultBase // 例外を握って `{ ok: false, status: 500, body: <メッセージ> }` に落とす高階関数。 // Server Action / クライアント関数の実装から try/catch による 500 フォールバックの @@ -31,3 +212,12 @@ export function fetcher( } } } + +// RFC9457 Problem Details (`detail`) からのエラーメッセージ抽出の単一入口。 +// エラー表示は必ずこのヘルパー経由に統一し、`body.detail` 等を直接参照しない +export function extractErrorMessage(body: unknown): string { + if (isRecord(body) && typeof body.detail === 'string') { + return body.detail + } + return 'エラーが発生しました' +} diff --git a/packages/client/src/app/_lib/logger.test.ts b/packages/client/src/app/_lib/logger/index.test.ts similarity index 99% rename from packages/client/src/app/_lib/logger.test.ts rename to packages/client/src/app/_lib/logger/index.test.ts index 2f45f34..643f89e 100644 --- a/packages/client/src/app/_lib/logger.test.ts +++ b/packages/client/src/app/_lib/logger/index.test.ts @@ -1,5 +1,5 @@ import { expect, test, vi } from 'vite-plus/test' -import { logger } from './logger' +import { logger } from './index' import { REDACTED, UNSUPPORTED } from './redact' vi.mock('console') diff --git a/packages/client/src/app/_lib/logger.ts b/packages/client/src/app/_lib/logger/index.ts similarity index 98% rename from packages/client/src/app/_lib/logger.ts rename to packages/client/src/app/_lib/logger/index.ts index dd92dbd..67bf074 100644 --- a/packages/client/src/app/_lib/logger.ts +++ b/packages/client/src/app/_lib/logger/index.ts @@ -1,4 +1,4 @@ -import { NEXT_PUBLIC_ENV } from '../../constants' +import { NEXT_PUBLIC_ENV } from '../../../constants' import { redactMeta, serializeError } from './redact' /** diff --git a/packages/client/src/app/_lib/redact.test.ts b/packages/client/src/app/_lib/logger/redact.test.ts similarity index 100% rename from packages/client/src/app/_lib/redact.test.ts rename to packages/client/src/app/_lib/logger/redact.test.ts diff --git a/packages/api/src/lib/redact.ts b/packages/client/src/app/_lib/logger/redact.ts similarity index 96% rename from packages/api/src/lib/redact.ts rename to packages/client/src/app/_lib/logger/redact.ts index f4dcb5c..f80e584 100644 --- a/packages/api/src/lib/redact.ts +++ b/packages/client/src/app/_lib/logger/redact.ts @@ -1,8 +1,8 @@ /** * ログ出力を安全側に倒すための redaction ヘルパー。logger の単一入口 - * (`lib/logger.ts`)から呼ばれる想定で、直接 console へ書き出す実装は持たない。 + * (`_lib/logger/index.ts`)から呼ばれる想定で、直接 console へ書き出す実装は持たない。 * - * 第一の防御は logger の closed event schema(`logger.ts` の `LogEvents` 型)。 + * 第一の防御は logger の closed event schema(`logger/index.ts` の `LogEvents` 型)。 * ここは型検査をすり抜けた値(assertion 等)に対する最後の防波堤: * - `redactMeta()` は flat な meta を走査し、sensitive な key 名の値を * `[REDACTED]` へ、primitive でない値を `[UNSUPPORTED]` へ置き換える diff --git a/packages/task/src/index.ts b/packages/task/src/index.ts index 0b31c65..cc3759b 100644 --- a/packages/task/src/index.ts +++ b/packages/task/src/index.ts @@ -1,5 +1,5 @@ import { parseArgs } from 'node:util' -import { logger } from './lib/logger.js' +import { logger } from './lib/logger/index.js' const { positionals } = parseArgs({ allowPositionals: true, diff --git a/packages/task/src/lib/logger.test.ts b/packages/task/src/lib/logger/index.test.ts similarity index 99% rename from packages/task/src/lib/logger.test.ts rename to packages/task/src/lib/logger/index.test.ts index efda425..ffee6bb 100644 --- a/packages/task/src/lib/logger.test.ts +++ b/packages/task/src/lib/logger/index.test.ts @@ -1,5 +1,5 @@ import { expect, test, vi } from 'vite-plus/test' -import { logger } from './logger.js' +import { logger } from './index.js' import { REDACTED, UNSUPPORTED } from './redact.js' vi.mock('console') diff --git a/packages/task/src/lib/logger.ts b/packages/task/src/lib/logger/index.ts similarity index 98% rename from packages/task/src/lib/logger.ts rename to packages/task/src/lib/logger/index.ts index b06925f..625e07b 100644 --- a/packages/task/src/lib/logger.ts +++ b/packages/task/src/lib/logger/index.ts @@ -1,4 +1,4 @@ -import { ENV } from '../config.js' +import { ENV } from '../../config.js' import { redactMeta, serializeError } from './redact.js' /** diff --git a/packages/task/src/lib/redact.test.ts b/packages/task/src/lib/logger/redact.test.ts similarity index 100% rename from packages/task/src/lib/redact.test.ts rename to packages/task/src/lib/logger/redact.test.ts diff --git a/packages/task/src/lib/redact.ts b/packages/task/src/lib/logger/redact.ts similarity index 96% rename from packages/task/src/lib/redact.ts rename to packages/task/src/lib/logger/redact.ts index f4dcb5c..6daea31 100644 --- a/packages/task/src/lib/redact.ts +++ b/packages/task/src/lib/logger/redact.ts @@ -1,8 +1,8 @@ /** * ログ出力を安全側に倒すための redaction ヘルパー。logger の単一入口 - * (`lib/logger.ts`)から呼ばれる想定で、直接 console へ書き出す実装は持たない。 + * (`lib/logger/index.ts`)から呼ばれる想定で、直接 console へ書き出す実装は持たない。 * - * 第一の防御は logger の closed event schema(`logger.ts` の `LogEvents` 型)。 + * 第一の防御は logger の closed event schema(`logger/index.ts` の `LogEvents` 型)。 * ここは型検査をすり抜けた値(assertion 等)に対する最後の防波堤: * - `redactMeta()` は flat な meta を走査し、sensitive な key 名の値を * `[REDACTED]` へ、primitive でない値を `[UNSUPPORTED]` へ置き換える diff --git a/packages/task/src/tasks/migrate.ts b/packages/task/src/tasks/migrate.ts index 167ca19..9718fdd 100644 --- a/packages/task/src/tasks/migrate.ts +++ b/packages/task/src/tasks/migrate.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' -import { logger } from '../lib/logger.js' +import { logger } from '../lib/logger/index.js' const execFileAsync = promisify(execFile) diff --git a/vite.config.ts b/vite.config.ts index 3e00119..671daee 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -60,9 +60,9 @@ export default defineConfig({ { // logger 実装本体だけが console へ書き出す(structured log の単一入口)。 files: [ - 'packages/api/src/lib/logger.ts', - 'packages/client/src/app/_lib/logger.ts', - 'packages/task/src/lib/logger.ts' + 'packages/api/src/lib/logger/index.ts', + 'packages/client/src/app/_lib/logger/index.ts', + 'packages/task/src/lib/logger/index.ts' ], rules: { 'no-console': 'off' From 565afd7cd856e0bd2d201422c9ca7dc1dcf602cb Mon Sep 17 00:00:00 2001 From: kohta ito Date: Thu, 24 Sep 2026 16:36:06 +0900 Subject: [PATCH 5/5] fix: scope structured logging lint to logger packages --- vite.config.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 671daee..e282d32 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,10 +49,20 @@ export default defineConfig({ ], rules: { 'coding-style/no-process-env-outside-config': 'error', - 'coding-style/enforce-zod-entrypoint': 'error', - // structured logging の規約(agents/logging.md)。console は logger - // 実装だけに閉じ込め、logger の引数は closed event schema の型検査 - // (excess property check)が確実に効く object literal に限定する。 + 'coding-style/enforce-zod-entrypoint': 'error' + } + }, + { + // structured logging の規約(agents/logging.md)。logger を持つ + // api/client/task では console を logger 実装だけに閉じ込め、logger の + // 引数は closed event schema の型検査(excess property check)が確実に + // 効く object literal に限定する。 + files: [ + 'packages/api/src/**', + 'packages/client/src/**', + 'packages/task/src/**' + ], + rules: { 'coding-style/enforce-logger-literal': 'error', 'no-console': 'error' }