fix(security): redact secrets in admin app-logs meta + audit-log details (H1/H2)

`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_<n>_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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 03:03:54 +02:00
1 parent 2f057f4a07
commit fb12f098e6
2 files changed
+44 -1

No files matched your search

+31
View File
@@ -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_<n>_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<string, unknown> = {};
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<string, unknown>;
}
if (out.action?.details) {
out.action.details = redactDeep(out.action.details) as Record<
string,
unknown
>;
}
return out;
}
+13 -1
View File
@@ -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.