feat(core-shared): PII scrub processors for spans + log records

This commit is contained in:
2026-05-11 12:08:20 +02:00
parent cdfca850ac
commit 6ec5aeb31f
3 changed files with 202 additions and 1 deletions

View File

@@ -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 = [

View File

@@ -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");
});
});

View File

@@ -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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
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<string, unknown>);
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<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
return Promise.resolve();
}
onEmit(record: LogRecord): void {
if (record.attributes) {
const scrubbed = scrubAttributes(record.attributes as Record<string, unknown>);
Object.assign(record.attributes, scrubbed);
}
if (typeof record.body === "string" && containsPiiSubstring(record.body)) {
record.body = REDACTED_VALUE;
}
}
}