From 6ec5aeb31f7f4b220cfd47e41dd314378248e134 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 12:08:20 +0200 Subject: [PATCH] feat(core-shared): PII scrub processors for spans + log records --- .../{sentry => otel}/pii-fields.ts | 2 +- .../otel/pii-scrub-processor.test.ts | 121 ++++++++++++++++++ .../otel/pii-scrub-processor.ts | 80 ++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) rename packages/core-shared/src/instrumentation/{sentry => otel}/pii-fields.ts (94%) create mode 100644 packages/core-shared/src/instrumentation/otel/pii-scrub-processor.test.ts create mode 100644 packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts diff --git a/packages/core-shared/src/instrumentation/sentry/pii-fields.ts b/packages/core-shared/src/instrumentation/otel/pii-fields.ts similarity index 94% rename from packages/core-shared/src/instrumentation/sentry/pii-fields.ts rename to packages/core-shared/src/instrumentation/otel/pii-fields.ts index 3a1d28b..7117801 100644 --- a/packages/core-shared/src/instrumentation/sentry/pii-fields.ts +++ b/packages/core-shared/src/instrumentation/otel/pii-fields.ts @@ -1,4 +1,4 @@ -// packages/core-shared/src/instrumentation/sentry/pii-fields.ts +// packages/core-shared/src/instrumentation/otel/pii-fields.ts // R32 — substring match on event keys (case-insensitive) export const PII_KEY_SUBSTRINGS = [ diff --git a/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.test.ts b/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.test.ts new file mode 100644 index 0000000..20d423f --- /dev/null +++ b/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { + LoggerProvider, + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, +} from "@opentelemetry/sdk-logs"; +import { SeverityNumber } from "@opentelemetry/api-logs"; +import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor"; + +const spanExporter = new InMemorySpanExporter(); +const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new PiiScrubSpanProcessor(), new SimpleSpanProcessor(spanExporter)], +}); + +// Use addLogRecordProcessor to chain processors in the right order. +const logExporter = new InMemoryLogRecordExporter(); +const logProvider = new LoggerProvider(); +logProvider.addLogRecordProcessor(new PiiScrubLogRecordProcessor()); +logProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(logExporter)); + +beforeEach(() => { + spanExporter.reset(); + logExporter.reset(); +}); + +describe("PiiScrubSpanProcessor", () => { + it("redacts attributes whose names contain PII substrings", () => { + const tracer = tracerProvider.getTracer("test"); + const span = tracer.startSpan("test-span", { + attributes: { + "user.email": "alice@example.com", + "user.id": "u_123", + "auth.token": "secret-token", + "request.path": "/api/users", + }, + }); + span.end(); + const exported = spanExporter.getFinishedSpans(); + expect(exported[0]!.attributes["user.email"]).toBe("[redacted]"); + expect(exported[0]!.attributes["auth.token"]).toBe("[redacted]"); + expect(exported[0]!.attributes["user.id"]).toBe("u_123"); // id is fine per R36 + expect(exported[0]!.attributes["request.path"]).toBe("/api/users"); + }); + + it("preserves non-PII attributes unchanged", () => { + const tracer = tracerProvider.getTracer("test"); + const span = tracer.startSpan("test-span", { + attributes: { + "http.method": "GET", + "span.op": "use-case", + feature: "blog", + }, + }); + span.end(); + const exported = spanExporter.getFinishedSpans(); + expect(exported[0]!.attributes["http.method"]).toBe("GET"); + expect(exported[0]!.attributes["span.op"]).toBe("use-case"); + expect(exported[0]!.attributes["feature"]).toBe("blog"); + }); + + it("redacts attributes with cookie and apikey substrings", () => { + const tracer = tracerProvider.getTracer("test"); + const span = tracer.startSpan("test-span", { + attributes: { + "request.cookie": "session=xyz", + "x-api-key": "key123", + "secret.value": "mysecret", + }, + }); + span.end(); + const exported = spanExporter.getFinishedSpans(); + expect(exported[0]!.attributes["request.cookie"]).toBe("[redacted]"); + expect(exported[0]!.attributes["x-api-key"]).toBe("[redacted]"); + expect(exported[0]!.attributes["secret.value"]).toBe("[redacted]"); + }); +}); + +describe("PiiScrubLogRecordProcessor", () => { + it("redacts log record attributes whose names contain PII substrings", () => { + const logger = logProvider.getLogger("test"); + logger.emit({ + severityNumber: SeverityNumber.ERROR, + severityText: "ERROR", + body: "test", + attributes: { + "user.email": "alice@example.com", + "exception.message": "boom", + }, + }); + const records = logExporter.getFinishedLogRecords(); + expect(records[0]!.attributes!["user.email"]).toBe("[redacted]"); + expect(records[0]!.attributes!["exception.message"]).toBe("boom"); + }); + + it("redacts log body when it contains PII substrings", () => { + const logger = logProvider.getLogger("test"); + logger.emit({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + body: "user signed in with email alice@example.com", + }); + const records = logExporter.getFinishedLogRecords(); + expect(records[0]!.body).toBe("[redacted]"); + }); + + it("preserves log body when it contains no PII substrings", () => { + const logger = logProvider.getLogger("test"); + logger.emit({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + body: "user signed in successfully", + }); + const records = logExporter.getFinishedLogRecords(); + expect(records[0]!.body).toBe("user signed in successfully"); + }); +}); diff --git a/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts b/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts new file mode 100644 index 0000000..81fbc7e --- /dev/null +++ b/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts @@ -0,0 +1,80 @@ +// packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts +// +// PII scrub processors for OTel spans and log records. +// These run FIRST in their respective processor chains so downstream exporters +// (including the Sentry exporter) see scrubbed data. This replaces the old +// Sentry beforeSend / beforeSendTransaction hooks (R32, R33) — scrubbing now +// happens at the OTel layer, vendor-agnostic. + +import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base"; +import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs"; +import { PII_KEY_SUBSTRINGS, REDACTED_VALUE } from "./pii-fields"; + +function isPiiKey(key: string): boolean { + const lower = key.toLowerCase(); + return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s)); +} + +function containsPiiSubstring(s: string): boolean { + const lower = s.toLowerCase(); + return PII_KEY_SUBSTRINGS.some((sub) => lower.includes(sub)); +} + +function scrubAttributes(attrs: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(attrs)) { + out[key] = isPiiKey(key) ? REDACTED_VALUE : value; + } + return out; +} + +/** + * Runs FIRST in the span processor chain so downstream exporters see scrubbed attributes. + * Redacts any span attribute whose key contains a PII substring (case-insensitive). + * R32 — attribute-key-based PII redaction. + */ +export class PiiScrubSpanProcessor implements SpanProcessor { + forceFlush(): Promise { + return Promise.resolve(); + } + + shutdown(): Promise { + return Promise.resolve(); + } + + onStart(_span: Span): void { + // no-op — scrub on completion when all attributes are set + } + + onEnd(span: ReadableSpan): void { + const scrubbed = scrubAttributes(span.attributes as Record); + Object.assign(span.attributes, scrubbed); + } +} + +/** + * Runs FIRST in the log processor chain. + * - Strips PII from attributes (key-based substring match, case-insensitive). + * - Strips PII from the log body string (substring match — if any PII substring + * appears in the body, the entire body is redacted to avoid partial leakage). + * R32 — attribute-key-based PII redaction; R33 — body-level redaction. + */ +export class PiiScrubLogRecordProcessor implements LogRecordProcessor { + forceFlush(): Promise { + return Promise.resolve(); + } + + shutdown(): Promise { + return Promise.resolve(); + } + + onEmit(record: LogRecord): void { + if (record.attributes) { + const scrubbed = scrubAttributes(record.attributes as Record); + Object.assign(record.attributes, scrubbed); + } + if (typeof record.body === "string" && containsPiiSubstring(record.body)) { + record.body = REDACTED_VALUE; + } + } +}