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 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 12:39:22 +02:00
parent 4ea9a5c38e
commit 7c74a1c9e1
3 changed files with 102 additions and 5 deletions

View File

@@ -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)

View File

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

View File

@@ -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<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;
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<string, unknown>);
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;
}
}
}
}