From 7c74a1c9e10e2ed86ce0afcdddf66347ce546660 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 12:39:22 +0200 Subject: [PATCH] fix(otel): scrub IP addresses in attribute values + log bodies (R32 compliance) PII_KEY_SUBSTRINGS extended with all OTel HTTP semconv IP attribute keys (http.client_ip, client.address, net.peer.ip, etc.) so they are key-redacted. scrubValue() added to pii-scrub-processor.ts applies IPV4_REGEX / IPV6_REGEX replacement for attribute values whose keys are not PII-keyed, and for log record bodies that pass the substring check. Closes the gap left by deletion of the old beforeSend value-level IP scrubbers. 5 new tests added. Co-Authored-By: Claude Sonnet 4.6 --- .../src/instrumentation/otel/pii-fields.ts | 15 ++++- .../otel/pii-scrub-processor.test.ts | 55 +++++++++++++++++++ .../otel/pii-scrub-processor.ts | 37 +++++++++++-- 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/core-shared/src/instrumentation/otel/pii-fields.ts b/packages/core-shared/src/instrumentation/otel/pii-fields.ts index 7117801..8e5a703 100644 --- a/packages/core-shared/src/instrumentation/otel/pii-fields.ts +++ b/packages/core-shared/src/instrumentation/otel/pii-fields.ts @@ -1,6 +1,9 @@ // packages/core-shared/src/instrumentation/otel/pii-fields.ts -// R32 — substring match on event keys (case-insensitive) +// R32 — substring match on event keys (case-insensitive). +// IP address attribute KEYS from OTel HttpInstrumentation (semconv 1.20 and 1.27+) +// are listed here so they are key-redacted in addition to the value-level regex +// scrubbing in pii-scrub-processor.ts. export const PII_KEY_SUBSTRINGS = [ "email", "password", @@ -13,6 +16,16 @@ export const PII_KEY_SUBSTRINGS = [ "api_key", "secret", "ipaddress", + // OTel HTTP semantic conventions — IP / client address attributes + "client.address", + "client_ip", + "client.ip", + "net.peer.ip", + "net.sock.peer.addr", + "net.peer.addr", + "http.client_ip", + "server.address", + "host.ip", ] as const; // R33 — substring match on URL query-param keys (case-insensitive) 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 index 20d423f..a4593c2 100644 --- a/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.test.ts +++ b/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.test.ts @@ -118,4 +118,59 @@ describe("PiiScrubLogRecordProcessor", () => { const records = logExporter.getFinishedLogRecords(); expect(records[0]!.body).toBe("user signed in successfully"); }); + + it("scrubs IPv4 in log record body (C2 / R32)", () => { + const logger = logProvider.getLogger("test"); + logger.emit({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + body: "request from 10.0.0.1 finished", + }); + const records = logExporter.getFinishedLogRecords(); + expect(records[0]!.body).toContain("[redacted-ip]"); + expect(records[0]!.body).not.toContain("10.0.0.1"); + }); +}); + +describe("PiiScrubSpanProcessor — IP address scrubbing (C2 / R32)", () => { + it("scrubs IPv4 addresses in attribute values", () => { + const tracer = tracerProvider.getTracer("test"); + const span = tracer.startSpan("test-span", { + attributes: { "request.note": "request from 10.0.0.1" }, + }); + span.end(); + const exported = spanExporter.getFinishedSpans(); + expect(exported[0]!.attributes["request.note"]).toBe("request from [redacted-ip]"); + }); + + it("scrubs IPv6 addresses in attribute values", () => { + const tracer = tracerProvider.getTracer("test"); + const span = tracer.startSpan("test-span", { + attributes: { "request.note": "request from 2001:0db8::1" }, + }); + span.end(); + const exported = spanExporter.getFinishedSpans(); + expect(exported[0]!.attributes["request.note"]).toContain("[redacted-ip]"); + expect(exported[0]!.attributes["request.note"]).not.toContain("2001:0db8"); + }); + + it("redacts http.client_ip via key match (semconv 1.20)", () => { + const tracer = tracerProvider.getTracer("test"); + const span = tracer.startSpan("test-span", { + attributes: { "http.client_ip": "10.0.0.1" }, + }); + span.end(); + const exported = spanExporter.getFinishedSpans(); + expect(exported[0]!.attributes["http.client_ip"]).toBe("[redacted]"); + }); + + it("redacts client.address via key match (semconv 1.27+)", () => { + const tracer = tracerProvider.getTracer("test"); + const span = tracer.startSpan("test-span", { + attributes: { "client.address": "10.0.0.1" }, + }); + span.end(); + const exported = spanExporter.getFinishedSpans(); + expect(exported[0]!.attributes["client.address"]).toBe("[redacted]"); + }); }); diff --git a/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts b/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts index baf0a50..639bc93 100644 --- a/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts +++ b/packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts @@ -10,7 +10,7 @@ import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base" import type { Span } from "@opentelemetry/api"; import type { Context } from "@opentelemetry/api"; import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs"; -import { PII_KEY_SUBSTRINGS, REDACTED_VALUE } from "./pii-fields"; +import { PII_KEY_SUBSTRINGS, REDACTED_VALUE, IPV4_REGEX, IPV6_REGEX, REDACTED_IP } from "./pii-fields"; function isPiiKey(key: string): boolean { const lower = key.toLowerCase(); @@ -22,10 +22,31 @@ function containsPiiSubstring(s: string): boolean { return PII_KEY_SUBSTRINGS.some((sub) => lower.includes(sub)); } +/** + * Scrubs IP addresses from a string value using regex replacement. + * Called for attribute values whose KEYS did not match a PII substring — the + * old Sentry beforeSend hook performed this kind of value-level scrubbing; we + * replicate it here so IP addresses embedded in non-IP-keyed attributes + * (e.g. "request.note": "from 10.0.0.1") are still redacted (C2 fix / R32). + */ +function scrubValue(value: unknown): unknown { + if (typeof value !== "string") return value; + // The regexes are global (`/g`) so they must be reset between calls via new RegExp + // or by relying on the fact that each string replace runs against a fresh lastIndex. + // String.prototype.replace with a regex literal (global) resets lastIndex automatically. + let scrubbed = value.replace(IPV4_REGEX, REDACTED_IP); + scrubbed = scrubbed.replace(IPV6_REGEX, REDACTED_IP); + return scrubbed; +} + function scrubAttributes(attrs: Record): Record { const out: Record = {}; for (const [key, value] of Object.entries(attrs)) { - out[key] = isPiiKey(key) ? REDACTED_VALUE : value; + if (isPiiKey(key)) { + out[key] = REDACTED_VALUE; + } else { + out[key] = scrubValue(value); + } } return out; } @@ -75,8 +96,16 @@ export class PiiScrubLogRecordProcessor implements LogRecordProcessor { const scrubbed = scrubAttributes(record.attributes as Record); Object.assign(record.attributes, scrubbed); } - if (typeof record.body === "string" && containsPiiSubstring(record.body)) { - record.body = REDACTED_VALUE; + if (typeof record.body === "string") { + if (containsPiiSubstring(record.body)) { + // Body contains a PII keyword (email, password, etc.) — redact entirely + // to avoid partial leakage. + record.body = REDACTED_VALUE; + } else { + // No PII keyword, but may still contain IP addresses embedded in text. + // Apply value-level regex scrubbing (C2 fix / R32). + record.body = scrubValue(record.body) as string; + } } } }