mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
2f057f4a07
commit
fb12f098e6
2 files changed
+44
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user