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:
@@ -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)
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user