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
|
// 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 = [
|
export const PII_KEY_SUBSTRINGS = [
|
||||||
"email",
|
"email",
|
||||||
"password",
|
"password",
|
||||||
@@ -13,6 +16,16 @@ export const PII_KEY_SUBSTRINGS = [
|
|||||||
"api_key",
|
"api_key",
|
||||||
"secret",
|
"secret",
|
||||||
"ipaddress",
|
"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;
|
] as const;
|
||||||
|
|
||||||
// R33 — substring match on URL query-param keys (case-insensitive)
|
// R33 — substring match on URL query-param keys (case-insensitive)
|
||||||
|
|||||||
@@ -118,4 +118,59 @@ describe("PiiScrubLogRecordProcessor", () => {
|
|||||||
const records = logExporter.getFinishedLogRecords();
|
const records = logExporter.getFinishedLogRecords();
|
||||||
expect(records[0]!.body).toBe("user signed in successfully");
|
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 { Span } from "@opentelemetry/api";
|
||||||
import type { Context } from "@opentelemetry/api";
|
import type { Context } from "@opentelemetry/api";
|
||||||
import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs";
|
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 {
|
function isPiiKey(key: string): boolean {
|
||||||
const lower = key.toLowerCase();
|
const lower = key.toLowerCase();
|
||||||
@@ -22,10 +22,31 @@ function containsPiiSubstring(s: string): boolean {
|
|||||||
return PII_KEY_SUBSTRINGS.some((sub) => lower.includes(sub));
|
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> {
|
function scrubAttributes(attrs: Record<string, unknown>): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const [key, value] of Object.entries(attrs)) {
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -75,8 +96,16 @@ export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
|
|||||||
const scrubbed = scrubAttributes(record.attributes as Record<string, unknown>);
|
const scrubbed = scrubAttributes(record.attributes as Record<string, unknown>);
|
||||||
Object.assign(record.attributes, scrubbed);
|
Object.assign(record.attributes, scrubbed);
|
||||||
}
|
}
|
||||||
if (typeof record.body === "string" && containsPiiSubstring(record.body)) {
|
if (typeof record.body === "string") {
|
||||||
record.body = REDACTED_VALUE;
|
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