feat(core-shared): PII scrubbers — beforeSend (R32) + beforeSendTransaction (R33)
This commit is contained in:
@@ -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));
|
||||||
|
}
|
||||||
139
packages/core-shared/src/instrumentation/sentry/scrub.test.ts
Normal file
139
packages/core-shared/src/instrumentation/sentry/scrub.test.ts
Normal file
@@ -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<typeof beforeSend>[0];
|
||||||
|
const result = beforeSend(event, {} as Parameters<typeof beforeSend>[1]) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const extra = result["extra"] as Record<string, unknown>;
|
||||||
|
const contexts = result["contexts"] as { custom: Record<string, unknown> };
|
||||||
|
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<typeof beforeSend>[0];
|
||||||
|
const result = beforeSend(event, {} as Parameters<typeof beforeSend>[1]) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const extra = result["extra"] as Record<string, unknown>;
|
||||||
|
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<typeof beforeSend>[0];
|
||||||
|
const result = beforeSend(event, {} as Parameters<typeof beforeSend>[1]) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const request = result["request"] as { headers: Record<string, unknown> };
|
||||||
|
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<typeof beforeSend>[1]) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const extra = result["extra"] as Record<string, unknown>;
|
||||||
|
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<typeof beforeSend>[1]) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const extra = result["extra"] as Record<string, unknown>;
|
||||||
|
expect(extra["note"] as string).toContain("[redacted-ip]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not crash on null/undefined branches", () => {
|
||||||
|
expect(beforeSend({ extra: null } as Parameters<typeof beforeSend>[0], {} as Parameters<typeof beforeSend>[1])).toBeTruthy();
|
||||||
|
expect(beforeSend({} as Parameters<typeof beforeSend>[0], {} as Parameters<typeof beforeSend>[1])).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the event (not null) — keeps Sentry transport flowing", () => {
|
||||||
|
expect(
|
||||||
|
beforeSend({ extra: { ok: true } } as Parameters<typeof beforeSend>[0], {} as Parameters<typeof beforeSend>[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<typeof beforeSendTransaction>[0];
|
||||||
|
const result = beforeSendTransaction(
|
||||||
|
event,
|
||||||
|
{} as Parameters<typeof beforeSendTransaction>[1],
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
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<typeof beforeSendTransaction>[1],
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
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<typeof beforeSendTransaction>[1],
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
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<typeof beforeSendTransaction>[0],
|
||||||
|
{} as Parameters<typeof beforeSendTransaction>[1],
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
70
packages/core-shared/src/instrumentation/sentry/scrub.ts
Normal file
70
packages/core-shared/src/instrumentation/sentry/scrub.ts
Normal file
@@ -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<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user