From fb12f098e6254430596c3f9a29fe1926e488fbc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 03:03:54 +0200 Subject: [PATCH] fix(security): redact secrets in admin app-logs meta + audit-log details (H1/H2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `redactEventForEgress()` only redacted `error.message`, `error.stack`, `warnings`, `http.user_agent` and `external_calls[*].error`. The free-shape `meta` field and `action.details` blob carry annotated key=value pairs from `annotate()` calls — including the provider-runner's `ai_chain_hop__reason` which captures the upstream error body capped at 240 chars. If an upstream provider ever echoes a Bearer token in its error body, the raw token would land in `meta` without any scrub. Added a recursive `redactDeep()` walker that runs `redactSecrets()` over every string-shaped value inside both blobs. Same gap on `/api/admin/audit-log`: the `details` JSON column was returned verbatim. A user-supplied identifier in a failed-login row (e.g. a typo'd "Bearer hlk_..." in a username field) flowed through the deepened B4 admin viewer + CSV export. Run `redactSecrets()` over each row's `details` at egress so already-persisted rows are scrubbed on read without a backfill migration. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/admin/app-logs/route.ts | 31 ++++++++++++++++++++++++++++ src/app/api/admin/audit-log/route.ts | 14 ++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/app/api/admin/app-logs/route.ts b/src/app/api/admin/app-logs/route.ts index 82bcb783b..eee82faf4 100644 --- a/src/app/api/admin/app-logs/route.ts +++ b/src/app/api/admin/app-logs/route.ts @@ -64,6 +64,28 @@ export const GET = apiHandler(async (request: NextRequest) => { }); }); +/** + * Recursively walk a JSON-shaped value and run `redactSecrets()` on every + * string. Used by `redactEventForEgress` to scrub the open-shape `meta` + * and `action.details` blobs — those carry annotated key=value pairs from + * `annotate()` calls (e.g. provider-runner's `ai_chain_hop__reason` + * which captures the upstream error body capped at 240 chars). If an + * upstream provider ever echoes a Bearer token in its error body, the + * raw token would land in `meta` without this scrub. + */ +function redactDeep(value: unknown): unknown { + if (typeof value === "string") return redactSecrets(value); + if (Array.isArray(value)) return value.map((v) => redactDeep(v)); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = redactDeep(v); + } + return out; + } + return value; +} + /** * Apply `redactSecrets()` to every string-shaped field that could carry a * leaked credential. Keeps the structure intact so the UI can still render @@ -92,5 +114,14 @@ function redactEventForEgress(event: WideEvent): WideEvent { error: c.error ? redactSecrets(c.error) : c.error, })); } + if (out.meta) { + out.meta = redactDeep(out.meta) as Record; + } + if (out.action?.details) { + out.action.details = redactDeep(out.action.details) as Record< + string, + unknown + >; + } return out; } diff --git a/src/app/api/admin/audit-log/route.ts b/src/app/api/admin/audit-log/route.ts index 9a096b820..a05b78543 100644 --- a/src/app/api/admin/audit-log/route.ts +++ b/src/app/api/admin/audit-log/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { redactSecrets } from "@/lib/logging/redact"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -145,8 +146,19 @@ export const GET = apiHandler(async (request: NextRequest) => { prisma.auditLog.count({ where }), ]); + // v1.4.16 phase D reconcile (security H2) — `details` is a JSON string + // column written by `auditLog()` calls across the app. A user-controlled + // value can land in here (e.g. `auth.login.failed → details.identifier`) + // and a typo'd "Bearer hlk_..." in a username field would otherwise be + // shown verbatim in the admin viewer + CSV export. Run the redaction + // pass at egress so already-persisted rows are scrubbed too. + const redactedEntries = entries.map((entry) => ({ + ...entry, + details: entry.details ? redactSecrets(entry.details) : entry.details, + })); + return apiSuccess({ - entries, + entries: redactedEntries, meta: { total, // Both shapes returned so old callers keep working.