chore(observability): redact sensitive keys before wide-event excerpt + tighten Zod-issue audit

The v1.4.48 received_shape_excerpt + zod_issues surface is safe
for the current widget + series payloads (enum / boolean / int only)
but the same pattern is heading for routes that accept free-text.
Pre-empt the leak: introduce a shared redactSensitiveFields helper
with a SENSITIVE_KEY_PATTERNS denylist (password / token / secret /
apiKey / authorization / csrfState / nonce). The widget + series
routes now route their body through it before the 256-char truncate.

Audit-log sites that echoed Zod issues[].message into details now
strip the message string to {path, code} pair — message could carry
the offending user input verbatim for some Zod error codes.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 11:09:09 +02:00
1 parent dca9af9c71
commit 8eb74e6c76
21 files changed
+559 -29

No files matched your search

@@ -145,13 +145,14 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("dashboard.widgets.validation-failed");
const details = JSON.parse(call.data.details) as {
issues: Array<{ path: string; code: string; message: string }>;
issues: Array<{ path: string; code: string }>;
};
expect(details.issues.length).toBe(2);
for (const issue of details.issues) {
// Audit row carries the same sanitised shape — issue.params never
// hits the ledger either.
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
// v1.4.49 — audit row is the persisted surface. `message`
// strips here so a future Zod code that embeds the offending
// value cannot leak into the ledger.
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
@@ -240,4 +241,65 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
};
expect(body.details.issues.length).toBe(2);
});
it("redacts sensitive keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
// Caller adds a credential-shaped field — must not land in the
// wide-event excerpt verbatim. The denylist also covers nested
// members so `payload.apiKey` is redacted, while `version` /
// `widgets` stay readable for operator debug.
const payload = {
version: 2,
widgets: [],
apnsToken: "ff".repeat(32),
authorization: "Bearer leaked",
payload: { apiKey: "sk_live_xxx" },
};
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
// Sensitive values are never written into the excerpt.
expect(excerpt).not.toContain("ff".repeat(32));
expect(excerpt).not.toContain("Bearer leaked");
expect(excerpt).not.toContain("sk_live_xxx");
// The redactor leaves the literal sentinel behind so an operator
// can still see the shape of the rejected payload.
expect(excerpt).toContain("[redacted]");
// Non-sensitive keys still surface for debugging.
expect(meta.received_keys).toEqual(
expect.arrayContaining([
"version",
"widgets",
"apnsToken",
"authorization",
"payload",
]),
);
});
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
// The wide-event meta keeps full message for operator debugging,
// but the persisted auditLog row must only carry `path` + `code`
// so a future Zod code that embeds the offending value cannot
// leak into the ledger.
await callPut(makeReq({ version: 2, widgets: [] }));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { details: string };
};
const parsed = JSON.parse(call.data.details) as {
issues: Array<Record<string, unknown>>;
};
for (const issue of parsed.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
});
+17 -3
View File
@@ -29,6 +29,7 @@ import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id rendered by the Settings →
@@ -150,10 +151,15 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// the Zod rejection so a single wide-event line carries enough
// detail to diagnose serialiser drift in `HealthLog-iOS`. We log
// ONLY the top-level keys plus a hard 256-char JSON excerpt — never
<<<<<<< HEAD
// the full body — so PII / token-like fields cannot leak. The
// diagnostic shape is built by the shared `buildPayloadDiagnostic`
// helper (v1.4.49) so the widget + series routes can't drift.
const payloadDiagnostic = buildPayloadDiagnostic(body);
// helper (v1.4.49) so the widget + series routes can't drift; the
// body is routed through `redactSensitiveFields` first so any
// future field matching the denylist (password / token / secret /
// apiKey / authorization / csrfState / nonce) lands as the literal
// `"[redacted]"` instead of its raw value.
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
annotate({
action: { name: "dashboard.widgets.validation-failed" },
meta: {
@@ -173,12 +179,20 @@ export const PUT = apiHandler(async (request: NextRequest) => {
)
) {
// Best-effort breadcrumb — never block the 422 on a write miss.
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value in their default message
// (`invalid_enum_value` and similar) cannot leak user content
// through the audit surface. The wide-event excerpt above
// already carries the shape signal for operator debugging.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.widgets.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -85,12 +85,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "devices.register.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row. Device
// registration carries `token` + `apnsToken`; the default Zod
// message for `invalid_format` echoes the offending value, which
// would persist an APNs token (or a forged near-miss) into the
// audit ledger.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "devices.register.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+7 -1
View File
@@ -103,12 +103,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "ingest.medication.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; external
// ingest carries free-text `medicationName` + opaque
// `idempotencyKey`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: apiToken.userId,
action: "ingest.medication.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+7 -1
View File
@@ -152,12 +152,18 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
action: { name: "integrations.healthkit.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// HealthKit patch payload carries caller-provided `id` + `kind`
// strings that Zod can echo.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "integrations.healthkit.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -70,12 +70,20 @@ export const PUT = apiHandler(
action: { name: "measurements.update.validation-failed" },
meta: { issue_count: issues.length, measurement_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// update schema carries free-text `notes`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.update.validation-failed",
details: JSON.stringify({ issues, measurementId: id }),
details: JSON.stringify({
issues: auditIssues,
measurementId: id,
}),
},
})
.catch(() => {
+19 -3
View File
@@ -56,12 +56,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "measurements.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row to keep
// any free-text query slice (custom `dayKey`, attacker-shaped
// `type=`) out of the persisted breadcrumb.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -583,12 +589,17 @@ async function postMeasurement(request: NextRequest) {
action: { name: "measurements.create.batch.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; batch
// rows carry free-text `notes` per measurement.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.batch.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -671,12 +682,17 @@ async function postMeasurement(request: NextRequest) {
action: { name: "measurements.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; single
// measurement carries free-text `notes`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -219,4 +219,47 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
});
it("redacts sensitive query keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// An attacker-shaped query that mixes a valid `kind` with a
// credential-shaped `token` param. The excerpt must keep `kind`
// readable for operator debug but redact the token verbatim.
const res = await GET(
req("kind=garbage&days=0&token=hlk_secret_value&apiKey=sk_live_xxx"),
);
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
expect(excerpt).not.toContain("hlk_secret_value");
expect(excerpt).not.toContain("sk_live_xxx");
expect(excerpt).toContain("[redacted]");
// The key inventory still surfaces so operators see the shape.
expect(meta.received_keys).toEqual(
expect.arrayContaining(["kind", "days", "token", "apiKey"]),
);
});
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await GET(req("kind=garbage&days=0"));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { details: string };
};
const parsed = JSON.parse(call.data.details) as {
issues: Array<Record<string, unknown>>;
};
for (const issue of parsed.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
});
+14 -3
View File
@@ -20,6 +20,7 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { MeasurementType } from "@/generated/prisma/client";
const kindEnum = z.enum([
@@ -81,9 +82,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
// the Zod rejection. Top-level keys + a hard 256-char JSON excerpt
// only; never the full payload, so token-shaped query params (we
// do not currently accept any but the truncation keeps that
<<<<<<< HEAD
// invariant cheap) cannot leak. Shared helper via v1.4.49 so the
// widget + series routes can't drift on the diagnostic shape.
const payloadDiagnostic = buildPayloadDiagnostic(rawQuery);
// widget + series routes can't drift on the diagnostic shape; the
// query map is routed through `redactSensitiveFields` first so any
// future credential-shaped query string (`token`, `apiKey`,
// `csrfState`) redacts to `"[redacted]"` before the truncate.
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(rawQuery));
annotate({
action: { name: "measurements.series.validation-failed" },
meta: {
@@ -92,12 +97,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
zod_issues: issues,
},
});
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value (`invalid_enum_value` etc.)
// cannot leak user content through the audit surface.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.series.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -62,12 +62,20 @@ export const POST = apiHandler(
action: { name: "medications.intake.import.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; CSV
// imports carry caller-provided strings that Zod may echo.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.import.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
+17 -2
View File
@@ -49,12 +49,20 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
action: { name: "medications.intake.create.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// intake payload carries `idempotencyKey` (opaque caller string).
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.create.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
@@ -211,12 +219,19 @@ export const GET = apiHandler(
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
+5 -1
View File
@@ -120,12 +120,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
action: { name: "medications.intake.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.bulk.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+11 -2
View File
@@ -82,12 +82,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; iOS-sent
// query strings can flow into Zod issue messages.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -288,12 +293,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "medications.intake.update.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.update.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+6 -1
View File
@@ -75,12 +75,17 @@ export const PUT = apiHandler(
action: { name: "mood-entries.update.validation-failed" },
meta: { issue_count: issues.length, moodEntryId: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; mood
// update carries free-text `note` + `tags`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.update.validation-failed",
details: JSON.stringify({ issues, moodEntryId: id }),
details: JSON.stringify({ issues: auditIssues, moodEntryId: id }),
},
})
.catch(() => {
+6 -1
View File
@@ -122,12 +122,17 @@ async function postBulk(request: NextRequest): Promise<Response> {
action: { name: "mood.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; bulk mood
// entries carry free-text `note` + `tags` per row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood.bulk.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+16 -2
View File
@@ -44,12 +44,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "mood-entries.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value (`invalid_enum_value` etc.)
// cannot leak user-typed content. Mood-entries carry free-text
// `note` + `tags`; defensive strip everywhere mood content can
// reach a Zod issue.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -116,12 +124,18 @@ async function postMoodEntry(request: NextRequest) {
action: { name: "mood-entries.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mood-create schema's free-text `note` + `tags` could land in a
// Zod issue message verbatim.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+6 -1
View File
@@ -65,12 +65,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "tokens.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mint payload carries a free-text `name`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "tokens.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -123,4 +123,45 @@ describe("returnAllZodIssues", () => {
// from the JSON envelope, matching apiError's contract.
expect(body.meta?.headers).toBeUndefined();
});
// v1.4.49 — opt-in `stripValuesFromMessage` mode for audit-ledger
// writes. The default Zod message for `invalid_enum_value` and a
// handful of other codes embeds the offending value verbatim; an
// audit-row write on a free-text route would persist user content.
// The opt-in keeps the additive multi-issue envelope shape but
// drops `message` entirely so only `path` + `code` survive.
describe("sanitiseZodIssues with stripValuesFromMessage", () => {
it("returns { path, code } only when the option is set", () => {
const enumSchema = z.object({
kind: z.enum(["weight", "pulse"]),
});
const parsed = enumSchema.safeParse({ kind: "garbage-value-xxx" });
if (parsed.success) throw new Error("expected failure");
const stripped = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
expect(stripped.length).toBe(1);
for (const issue of stripped) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
// The offending value must not survive on any returned key.
expect(JSON.stringify(issue)).not.toContain("garbage-value-xxx");
}
});
it("default behaviour (no option) keeps message intact", () => {
const enumSchema = z.object({
kind: z.enum(["weight", "pulse"]),
});
const parsed = enumSchema.safeParse({ kind: "garbage" });
if (parsed.success) throw new Error("expected failure");
const kept = sanitiseZodIssues(parsed.error.issues);
expect(kept[0]).toMatchObject({
path: "kind",
code: expect.any(String),
message: expect.any(String),
});
});
});
});
+42 -1
View File
@@ -18,6 +18,25 @@ export interface SanitisedZodIssue {
message: string;
}
/**
* Variant used when the issues land in an audit-ledger row whose
* source field is free-text. `invalid_enum_value` and a handful of
* other Zod codes embed the offending value verbatim in
* `issue.message`, so a route that JSON-stringifies the sanitised
* array into `details` can leak user content. Callers opt in via
* `sanitiseZodIssues(error.issues, { stripValuesFromMessage: true })`.
*/
export interface SanitiseZodIssueOptions {
/**
* When true, drop `issue.message` from the returned shape entirely.
* Only `path` + `code` survive — enough for an operator to triage
* the rejection without persisting user-typed content. Default is
* `false` so the additive multi-issue envelope keeps its existing
* client contract.
*/
stripValuesFromMessage?: boolean;
}
/**
* v1.4.42 W2 — multi-issue Zod error envelope.
*
@@ -33,10 +52,32 @@ export interface SanitisedZodIssue {
* Privacy: only `path`, `code` and `message` are echoed. `issue.params`
* (which can carry the raw rejected value for some Zod issue codes)
* stays server-side.
*
* v1.4.49 — `stripValuesFromMessage` removes `message` for the
* auditLog-emission sites whose input fields are free-text. Some Zod
* codes (e.g. `invalid_enum_value`) embed the offending value in the
* default message string; a free-text route writing the message into
* the audit ledger would leak user content. The opt-in is additive —
* the default behaviour is unchanged so existing clients reading
* `details.issues[*].message` over the wire keep working.
*/
export function sanitiseZodIssues(
issues: readonly ZodIssue[],
): SanitisedZodIssue[] {
): SanitisedZodIssue[];
export function sanitiseZodIssues(
issues: readonly ZodIssue[],
options: { stripValuesFromMessage: true },
): Array<Omit<SanitisedZodIssue, "message">>;
export function sanitiseZodIssues(
issues: readonly ZodIssue[],
options?: SanitiseZodIssueOptions,
): SanitisedZodIssue[] | Array<Omit<SanitisedZodIssue, "message">> {
if (options?.stripValuesFromMessage) {
return issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
}));
}
return issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import {
redactSensitiveFields,
SENSITIVE_KEY_PATTERNS,
} from "../redact-payload";
// v1.4.49 — pinned redactor for the wide-event `received_shape_excerpt`
// surface introduced in v1.4.48 H-iOS-1 / H-iOS-2. The helper must
// strip values for any key matching the SENSITIVE_KEY_PATTERNS denylist
// before the caller's `JSON.stringify` lands in the meta excerpt.
describe("redactSensitiveFields", () => {
it("passes a body with no sensitive keys through unchanged", () => {
const body = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
comparisonBaseline: "lastMonth",
};
const result = redactSensitiveFields(body);
expect(result).toEqual(body);
// Returns a clone, not the original reference.
expect(result).not.toBe(body);
});
it("redacts a top-level `password` key", () => {
const body = { username: "alice", password: "hunter2" };
expect(redactSensitiveFields(body)).toEqual({
username: "alice",
password: "[redacted]",
});
});
it("redacts `Authorization` regardless of case (Bearer prefix preserved as redacted)", () => {
const body = { Authorization: "Bearer eyJhbGciOiJIUzI1NiJ9.xxx" };
expect(redactSensitiveFields(body)).toEqual({
Authorization: "[redacted]",
});
});
it("redacts a nested object's sensitive fields recursively", () => {
const body = {
kind: "device.register",
payload: {
deviceId: "abc-123",
apnsToken: "ff".repeat(32),
meta: { secret: "shh", model: "iPhone15" },
},
};
expect(redactSensitiveFields(body)).toEqual({
kind: "device.register",
payload: {
deviceId: "abc-123",
apnsToken: "[redacted]",
meta: { secret: "[redacted]", model: "iPhone15" },
},
});
});
it("redacts inside array members", () => {
const body = {
entries: [
{ id: "1", apiKey: "sk_live_xxx" },
{ id: "2", token: "bearer-xyz" },
{ id: "3", note: "free text" },
],
};
expect(redactSensitiveFields(body)).toEqual({
entries: [
{ id: "1", apiKey: "[redacted]" },
{ id: "2", token: "[redacted]" },
{ id: "3", note: "free text" },
],
});
});
it("matches the token denylist patterns case-insensitively (apnsToken, bearerToken, csrf_state)", () => {
const body = {
apnsToken: "ff".repeat(32),
bearerToken: "abc",
csrf_state: "xyz",
csrfState: "xyz2",
api_key: "sk_xxx",
Nonce: "01234",
randomField: "kept",
};
expect(redactSensitiveFields(body)).toEqual({
apnsToken: "[redacted]",
bearerToken: "[redacted]",
csrf_state: "[redacted]",
csrfState: "[redacted]",
api_key: "[redacted]",
Nonce: "[redacted]",
randomField: "kept",
});
});
it("passes scalars + null + undefined through unchanged", () => {
expect(redactSensitiveFields(null)).toBeNull();
expect(redactSensitiveFields(undefined)).toBeUndefined();
expect(redactSensitiveFields("string")).toBe("string");
expect(redactSensitiveFields(42)).toBe(42);
expect(redactSensitiveFields(true)).toBe(true);
});
it("does not recurse into non-plain objects (Date stays intact)", () => {
const date = new Date("2026-05-22T10:00:00Z");
const body = { measuredAt: date, password: "x" };
const result = redactSensitiveFields(body) as Record<string, unknown>;
expect(result.measuredAt).toBe(date);
expect(result.password).toBe("[redacted]");
});
it("exposes the SENSITIVE_KEY_PATTERNS denylist as a readonly tuple", () => {
// Pin the public surface so a future commit can't silently drop
// one of the documented patterns.
const required = [
"password",
"token",
"secret",
"apikey",
"authorization",
"csrfstate",
"nonce",
];
for (const key of required) {
const matched = SENSITIVE_KEY_PATTERNS.some((re) => re.test(key));
expect(matched, `pattern set should match ${key}`).toBe(true);
}
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* v1.4.49 — sensitive-field redactor for observability payloads.
*
* The v1.4.48 H-iOS-1 / H-iOS-2 work surfaces a 256-char truncated
* `JSON.stringify` of the request body to the wide-event
* `meta.received_shape_excerpt`. Today's iOS surface (widget layout +
* series query) only carries enum / boolean / int fields so a verbatim
* excerpt is safe, but the pattern is heading for routes that accept
* tokens (APNs registration, Withings re-auth, API tokens) and bearer
* credentials. Pre-empt the leak: every body that flows into a
* wide-event excerpt is first walked through `redactSensitiveFields`,
* which replaces any key matching one of the `SENSITIVE_KEY_PATTERNS`
* regexes with the literal string `"[redacted]"`.
*
* Adding a new sensitive token is a one-line append to
* `SENSITIVE_KEY_PATTERNS`. The match is case-insensitive and runs
* against the literal key name, so `apnsToken`, `bearerToken`,
* `APIKey`, `Authorization` and `csrf_state` all redact without
* extra spellings.
*
* The redactor recurses into nested objects + arrays so deeply-nested
* credentials (e.g. `{ creds: { password: "…" } }`) are caught the
* same way. Non-object scalar inputs pass through unchanged — the
* caller's `JSON.stringify` then truncates as before.
*/
export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
/password/i,
/token/i,
/secret/i,
/apikey/i,
/api[_-]key/i,
/authorization/i,
/csrfstate/i,
/csrf[_-]state/i,
/nonce/i,
];
const REDACTED = "[redacted]";
function keyIsSensitive(key: string): boolean {
for (const re of SENSITIVE_KEY_PATTERNS) {
if (re.test(key)) return true;
}
return false;
}
/**
* Returns a deep clone of `body` with any key matching the sensitive
* denylist replaced by the literal `"[redacted]"`. Arrays + plain
* objects are recursed into; scalars, `null`, `undefined`, `Date` and
* other non-plain objects pass through unchanged.
*
* The clone is intentionally shallow on non-plain-object boundaries so
* the helper stays cheap on the hot 422 path; the only goal is to keep
* the credentialed key's value out of the eventual JSON excerpt.
*/
export function redactSensitiveFields(body: unknown): unknown {
if (body === null || body === undefined) return body;
if (Array.isArray(body)) {
return body.map((entry) => redactSensitiveFields(entry));
}
if (typeof body !== "object") return body;
// Skip non-plain objects (Date, Map, Set, Buffer, ...) — they'll
// serialise to a benign form via JSON.stringify and we don't want to
// walk into framework internals.
const proto = Object.getPrototypeOf(body);
if (proto !== Object.prototype && proto !== null) return body;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
if (keyIsSensitive(key)) {
out[key] = REDACTED;
continue;
}
out[key] = redactSensitiveFields(value);
}
return out;
}