diff --git a/packages/core-shared/src/instrumentation/sentry/pii-fields.ts b/packages/core-shared/src/instrumentation/sentry/pii-fields.ts new file mode 100644 index 0000000..897b830 --- /dev/null +++ b/packages/core-shared/src/instrumentation/sentry/pii-fields.ts @@ -0,0 +1,46 @@ +// packages/core-shared/src/instrumentation/sentry/pii-fields.ts + +// R32 — substring match on event keys (case-insensitive) +export const PII_KEY_SUBSTRINGS = [ + "email", + "password", + "token", + "cookie", + "authorization", + "set-cookie", + "x-api-key", + "apikey", + "api_key", + "secret", +] as const; + +// R33 — substring match on URL query-param keys (case-insensitive) +export const PII_QUERY_PARAM_SUBSTRINGS = [ + "token", + "email", + "password", + "key", + "sig", + "signature", + "access_token", + "accesstoken", + "secret", +] as const; + +export const REDACTED_VALUE = "[redacted]" as const; +export const REDACTED_IP = "[redacted-ip]" as const; + +// IPv4: simple dotted-quad; IPv6: any colon-separated hex with at least one :: +export const IPV4_REGEX = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g; +export const IPV6_REGEX = + /\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}/g; + +export function keyContainsPii(key: string): boolean { + const lower = key.toLowerCase(); + return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s)); +} + +export function queryParamContainsPii(key: string): boolean { + const lower = key.toLowerCase(); + return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s)); +} diff --git a/packages/core-shared/src/instrumentation/sentry/scrub.test.ts b/packages/core-shared/src/instrumentation/sentry/scrub.test.ts new file mode 100644 index 0000000..5b5ff07 --- /dev/null +++ b/packages/core-shared/src/instrumentation/sentry/scrub.test.ts @@ -0,0 +1,139 @@ +// packages/core-shared/src/instrumentation/sentry/scrub.test.ts +import { describe, it, expect } from "vitest"; +import { beforeSend, beforeSendTransaction } from "@/instrumentation/sentry/scrub"; + +describe("beforeSend", () => { + it("redacts top-level keys whose names contain PII substrings", () => { + const event = { + extra: { email: "a@b.c", username: "alice" }, + contexts: { custom: { password: "p", note: "ok" } }, + } as Parameters[0]; + const result = beforeSend(event, {} as Parameters[1]) as Record< + string, + unknown + >; + const extra = result["extra"] as Record; + const contexts = result["contexts"] as { custom: Record }; + expect(extra["email"]).toBe("[redacted]"); + expect(extra["username"]).toBe("alice"); + expect(contexts.custom["password"]).toBe("[redacted]"); + expect(contexts.custom["note"]).toBe("ok"); + }); + + it("redacts derived key names (substring match): userEmail, accessToken, apiKey", () => { + const event = { + extra: { userEmail: "a@b.c", accessToken: "t", apiKey: "k", id: "u1" }, + } as Parameters[0]; + const result = beforeSend(event, {} as Parameters[1]) as Record< + string, + unknown + >; + const extra = result["extra"] as Record; + expect(extra["userEmail"]).toBe("[redacted]"); + expect(extra["accessToken"]).toBe("[redacted]"); + expect(extra["apiKey"]).toBe("[redacted]"); + expect(extra["id"]).toBe("u1"); + }); + + it("redacts headers map keys case-insensitively", () => { + const event = { + request: { + headers: { Authorization: "Bearer x", "Set-Cookie": "session=abc", "User-Agent": "ua" }, + }, + } as Parameters[0]; + const result = beforeSend(event, {} as Parameters[1]) as Record< + string, + unknown + >; + const request = result["request"] as { headers: Record }; + expect(request.headers["Authorization"]).toBe("[redacted]"); + expect(request.headers["Set-Cookie"]).toBe("[redacted]"); + expect(request.headers["User-Agent"]).toBe("ua"); + }); + + it("redacts IPv4 addresses found in string values", () => { + const event = { extra: { note: "Connection from 192.168.1.10 failed" } } as Parameters< + typeof beforeSend + >[0]; + const result = beforeSend(event, {} as Parameters[1]) as Record< + string, + unknown + >; + const extra = result["extra"] as Record; + expect(extra["note"]).toBe("Connection from [redacted-ip] failed"); + }); + + it("redacts IPv6 addresses found in string values", () => { + const event = { extra: { note: "Tunnel to fe80::1ff:fe23:4567:890a established" } } as Parameters< + typeof beforeSend + >[0]; + const result = beforeSend(event, {} as Parameters[1]) as Record< + string, + unknown + >; + const extra = result["extra"] as Record; + expect(extra["note"] as string).toContain("[redacted-ip]"); + }); + + it("does not crash on null/undefined branches", () => { + expect(beforeSend({ extra: null } as Parameters[0], {} as Parameters[1])).toBeTruthy(); + expect(beforeSend({} as Parameters[0], {} as Parameters[1])).toBeTruthy(); + }); + + it("returns the event (not null) — keeps Sentry transport flowing", () => { + expect( + beforeSend({ extra: { ok: true } } as Parameters[0], {} as Parameters[1]), + ).toBeTruthy(); + }); +}); + +describe("beforeSendTransaction", () => { + it("strips PII query params from request.url", () => { + const event = { + request: { url: "https://app/api/foo?token=secret&user=alice&email=a@b.c" }, + } as Parameters[0]; + const result = beforeSendTransaction( + event, + {} as Parameters[1], + ) as Record; + const request = result["request"] as { url: string }; + expect(request.url).toContain("token=%5Bredacted%5D"); + expect(request.url).toContain("email=%5Bredacted%5D"); + expect(request.url).toContain("user=alice"); + }); + + it("strips PII query params from event.transaction", () => { + const event = { transaction: "/foo?token=x&id=y" } as Parameters< + typeof beforeSendTransaction + >[0]; + const result = beforeSendTransaction( + event, + {} as Parameters[1], + ) as Record; + expect(result["transaction"] as string).toContain("token=%5Bredacted%5D"); + expect(result["transaction"] as string).toContain("id=y"); + }); + + it("matches derived param names (accessToken, ApiSecret)", () => { + const event = { request: { url: "https://x/y?accessToken=t&ApiSecret=z&safe=1" } } as Parameters< + typeof beforeSendTransaction + >[0]; + const result = beforeSendTransaction( + event, + {} as Parameters[1], + ) as Record; + const request = result["request"] as { url: string }; + expect(request.url).toContain("accessToken=%5Bredacted%5D"); + expect(request.url).toContain("ApiSecret=%5Bredacted%5D"); + expect(request.url).toContain("safe=1"); + }); + + it("returns the event when no URL present", () => { + expect( + beforeSendTransaction( + {} as Parameters[0], + {} as Parameters[1], + ), + ).toBeTruthy(); + }); +}); diff --git a/packages/core-shared/src/instrumentation/sentry/scrub.ts b/packages/core-shared/src/instrumentation/sentry/scrub.ts new file mode 100644 index 0000000..8c8ba14 --- /dev/null +++ b/packages/core-shared/src/instrumentation/sentry/scrub.ts @@ -0,0 +1,70 @@ +// packages/core-shared/src/instrumentation/sentry/scrub.ts +import type { ErrorEvent, EventHint, TransactionEvent } from "@sentry/nextjs"; +import { + IPV4_REGEX, + IPV6_REGEX, + REDACTED_IP, + REDACTED_VALUE, + keyContainsPii, + queryParamContainsPii, +} from "./pii-fields"; + +function redactString(s: string): string { + // Create new regexes each call since regexes with /g are stateful + const ipv4 = new RegExp(IPV4_REGEX.source, "g"); + const ipv6 = new RegExp(IPV6_REGEX.source, "g"); + return s.replace(ipv4, REDACTED_IP).replace(ipv6, REDACTED_IP); +} + +function deepScrub(value: unknown, parentKey = ""): unknown { + if (value === null || value === undefined) return value; + if (typeof value === "string") { + return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value); + } + if (typeof value === "number" || typeof value === "boolean") { + return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value; + } + if (Array.isArray(value)) { + return value.map((v) => deepScrub(v, parentKey)); + } + if (typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = keyContainsPii(k) ? REDACTED_VALUE : deepScrub(v, k); + } + return out; + } + return value; +} + +export function beforeSend(event: ErrorEvent, _hint: EventHint): ErrorEvent | null { + return deepScrub(event) as ErrorEvent; +} + +function scrubUrl(url: string): string { + try { + const u = new URL(url, "http://placeholder.local"); + for (const [k] of Array.from(u.searchParams.entries())) { + if (queryParamContainsPii(k)) { + u.searchParams.set(k, REDACTED_VALUE); + } + } + return url.startsWith("/") ? `${u.pathname}${u.search}` : u.toString(); + } catch { + return url; + } +} + +export function beforeSendTransaction( + event: TransactionEvent, + _hint: EventHint, +): TransactionEvent | null { + const out: TransactionEvent = { ...event }; + if (out.request?.url) { + out.request = { ...out.request, url: scrubUrl(out.request.url) }; + } + if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) { + out.transaction = scrubUrl(out.transaction); + } + return out; +}