feat(core-shared): PII scrub processors for spans + log records
This commit is contained in:
@@ -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 = [
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user