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.