From 72b462ea50e22300a16158623ab93f122b7cb358 Mon Sep 17 00:00:00 2001 From: leojay Date: Sun, 30 Aug 2026 18:34:21 +0100 Subject: [PATCH] feat(ops): error-payload scrubbing wired into both error boundaries (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/observability/scrub.ts: scrubErrorPayload() masks Stellar addresses (G.../C... 56-char base32) in messages, stack traces and nested context, and drops request bodies, headers, auth and other sensitive keys. Recursion is cycle-safe and depth-bounded. - lib/observability/report.ts: captureError() — the single reporting entry point; all output goes through the scrubber first. Sentry transport (source maps, release = commit SHA, rate alerting) layers on here. - app/error.tsx + app/global-error.tsx now call captureError(). - tests/unit/scrub.test.ts asserts a known address string never survives scrubbing — the invariant that stays true as the codebase changes. Refs #76 --- app/error.tsx | 4 +- app/global-error.tsx | 4 +- lib/observability/report.ts | 31 +++++++++++ lib/observability/scrub.ts | 108 ++++++++++++++++++++++++++++++++++++ tests/unit/scrub.test.ts | 73 ++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 lib/observability/report.ts create mode 100644 lib/observability/scrub.ts create mode 100644 tests/unit/scrub.test.ts diff --git a/app/error.tsx b/app/error.tsx index 8c21f5c..a11d95e 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect } from "react"; +import { captureError } from "@/lib/observability/report"; export default function ErrorBoundary({ error, @@ -10,8 +11,7 @@ export default function ErrorBoundary({ reset: () => void; }) { useEffect(() => { - // Log the error to an error reporting service - console.error("ErrorBoundary caught:", error); + captureError(error, { boundary: "app/error", digest: error.digest }); }, [error]); return ( diff --git a/app/global-error.tsx b/app/global-error.tsx index c1acccc..2b16979 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import "./globals.css"; +import { captureError } from "@/lib/observability/report"; export default function GlobalError({ error, @@ -11,8 +12,7 @@ export default function GlobalError({ reset: () => void; }) { useEffect(() => { - // Log the error to an error reporting service - console.error("GlobalError caught:", error); + captureError(error, { boundary: "app/global-error", digest: error.digest }); }, [error]); return ( diff --git a/lib/observability/report.ts b/lib/observability/report.ts new file mode 100644 index 0000000..85576c0 --- /dev/null +++ b/lib/observability/report.ts @@ -0,0 +1,31 @@ +import { scrubErrorPayload, type ScrubbedError } from "./scrub"; + +/** + * Single entry point for reporting a client/server error (issue #76). + * + * Everything routes through {@link scrubErrorPayload} first, so wallet + * addresses and request data can never reach the tracker. The actual + * transport (Sentry with build-time source maps, release = commit SHA, + * rate-based alerting) is layered on here without any call site changing. + */ + +export interface ReportContext { + /** Which error boundary or subsystem caught this. */ + boundary?: string; + /** Next.js error digest, safe to keep — it is an opaque hash. */ + digest?: string; + [key: string]: unknown; +} + +export function captureError( + error: unknown, + context?: ReportContext, +): ScrubbedError { + const safe = scrubErrorPayload(error, context); + + // Until the tracker DSN is wired, surface the *scrubbed* payload only. + // eslint-disable-next-line no-console + console.error("[captureError]", safe); + + return safe; +} diff --git a/lib/observability/scrub.ts b/lib/observability/scrub.ts new file mode 100644 index 0000000..c08d9c2 --- /dev/null +++ b/lib/observability/scrub.ts @@ -0,0 +1,108 @@ +/** + * Payload scrubbing for the error tracker (issue #76). + * + * ModelTrace is privacy-forward: the error tracker must never become the leak. + * Before any error payload is sent it passes through {@link scrubErrorPayload}, + * which strips wallet addresses and request data wherever they appear — + * message strings, stack frames, and nested context objects. + * + * The guarantee that matters is behavioural, and is asserted by + * `tests/unit/scrub.test.ts`: a known address string never survives scrubbing. + */ + +export const REDACTED = "[redacted]"; + +/** + * Stellar public keys / contract ids: base32 (Crockford, RFC-4648 alphabet + * without 0/1/8/9) starting `G` (accounts) or `C` (contracts), 56 chars total. + */ +const STELLAR_ADDRESS = /\b[GC][A-Z2-7]{55}\b/g; + +/** Keys whose values are dropped entirely regardless of content. */ +const SENSITIVE_KEYS = new Set([ + "address", + "publickey", + "public_key", + "secret", + "secretkey", + "seed", + "mnemonic", + "privatekey", + "private_key", + "authorization", + "cookie", + "body", + "requestbody", + "request_body", + "payload", + "headers", + "params", + "searchparams", + "email", +]); + +function scrubString(value: string): string { + return value.replace(STELLAR_ADDRESS, REDACTED); +} + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.has(key.toLowerCase().replace(/[-\s]/g, "_")) || + SENSITIVE_KEYS.has(key.toLowerCase().replace(/[-\s_]/g, "")); +} + +/** + * Recursively scrub an arbitrary value. Strings have addresses masked; + * objects have sensitive keys removed and every other value scrubbed in + * turn. Cyclic references are handled. Depth is bounded so a pathological + * payload can't hang the reporter. + */ +export function scrubValue(value: unknown, _seen?: WeakSet, _depth = 0): unknown { + const seen = _seen ?? new WeakSet(); + + if (typeof value === "string") return scrubString(value); + if (typeof value !== "object" || value === null) return value; + if (_depth >= 8) return REDACTED; + if (seen.has(value)) return REDACTED; + seen.add(value); + + if (Array.isArray(value)) { + return value.map((item) => scrubValue(item, seen, _depth + 1)); + } + + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + if (isSensitiveKey(key)) { + out[key] = REDACTED; + continue; + } + out[key] = scrubValue(val, seen, _depth + 1); + } + return out; +} + +export interface ScrubbedError { + name: string; + message: string; + stack?: string; + context?: Record; +} + +/** Scrub an Error (plus optional structured context) into a safe payload. */ +export function scrubErrorPayload( + error: unknown, + context?: Record, +): ScrubbedError { + const err = + error instanceof Error + ? error + : new Error(typeof error === "string" ? error : "Unknown error"); + + return { + name: err.name, + message: scrubString(err.message), + ...(err.stack ? { stack: scrubString(err.stack) } : {}), + ...(context + ? { context: scrubValue(context) as Record } + : {}), + }; +} diff --git a/tests/unit/scrub.test.ts b/tests/unit/scrub.test.ts new file mode 100644 index 0000000..4f383fb --- /dev/null +++ b/tests/unit/scrub.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + scrubErrorPayload, + scrubValue, + REDACTED, +} from "../../lib/observability/scrub"; + +// A real-shaped Stellar public key (G + 55 base32 chars). +const ADDRESS = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; + +describe("scrubErrorPayload — the address never survives", () => { + it("removes a wallet address from the message", () => { + const out = scrubErrorPayload( + new Error(`wallet ${ADDRESS} failed to connect`), + ); + expect(JSON.stringify(out)).not.toContain(ADDRESS); + expect(out.message).toContain(REDACTED); + }); + + it("removes addresses from the stack trace", () => { + const err = new Error("boom"); + err.stack = `Error: boom\n at sign (${ADDRESS})\n at main`; + const out = scrubErrorPayload(err); + expect(JSON.stringify(out)).not.toContain(ADDRESS); + }); + + it("removes addresses and sensitive keys from nested context", () => { + const out = scrubErrorPayload(new Error("submit failed"), { + route: "/disputes/new", + address: ADDRESS, + request: { body: { reason: "long text", from: ADDRESS } }, + nested: [{ note: `paid ${ADDRESS}` }], + }); + + const serialised = JSON.stringify(out); + expect(serialised).not.toContain(ADDRESS); + expect(out.context?.address).toBe(REDACTED); + expect(out.context?.route).toBe("/disputes/new"); + }); + + it("coerces non-Error throwables", () => { + expect(scrubErrorPayload("string failure").message).toBe("string failure"); + expect(scrubErrorPayload(42).name).toBe("Error"); + }); +}); + +describe("scrubValue", () => { + it("drops request bodies, headers and auth wherever they appear", () => { + const out = scrubValue({ + ok: 1, + headers: { authorization: "Bearer x" }, + requestBody: { secret: "s" }, + params: { id: "1" }, + }) as Record; + expect(out.ok).toBe(1); + expect(out.headers).toBe(REDACTED); + expect(out.requestBody).toBe(REDACTED); + expect(out.params).toBe(REDACTED); + }); + + it("handles cyclic references without throwing", () => { + const cyclic: Record = { a: 1 }; + cyclic.self = cyclic; + expect(() => scrubValue(cyclic)).not.toThrow(); + }); + + it("redacts contract ids but leaves ordinary text alone", () => { + const cId = "C" + "A".repeat(55); // C-prefixed 56-char id + expect(scrubValue(`deployed ${cId}`)).toBe(`deployed ${REDACTED}`); + // A short string that merely starts with C is untouched. + expect(scrubValue("Contract deployed")).toBe("Contract deployed"); + }); +});