autotel
Version:
Write Once, Observe Anywhere
369 lines (368 loc) • 12.4 kB
JavaScript
//#region src/attribute-redacting-processor.ts
/**
* Built-in patterns for detecting sensitive data
*/
const REDACTOR_PATTERNS = {
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi,
phone: /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g,
ssn: /\b\d{3}[-]?\d{2}[-]?\d{4}\b/g,
creditCard: /\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/g,
bearerToken: /Bearer\s+[A-Za-z0-9._~+/=-]+/gi,
apiKeyInValue: /(?:api[_-]?key|apikey|api_secret)[=:][\s"']*[A-Za-z0-9_-]+/gi,
jwt: /eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*/g,
sensitiveKey: /^(password|passwd|pwd|secret|token|api[_-]?key|auth|credential|private[_-]?key|authorization)$/i
};
/**
* Built-in PII detection patterns with smart masking.
* Each builtin preserves just enough signal for debugging while scrubbing PII.
*/
const builtinPatterns = {
/** Credit card numbers → ****1111 (PCI DSS: last 4 allowed) */
creditCard: {
pattern: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g,
mask: (m) => `****${m.replace(/[\s-]/g, "").slice(-4)}`
},
/** Email addresses → a***@***.com */
email: {
pattern: /[\w.+-]+@[\w-]+\.[\w.]+/g,
mask: (m) => {
if (m.indexOf("@") < 1) return "***@***";
const tld = m.slice(m.lastIndexOf("."));
return `${m[0]}***@***${tld}`;
}
},
/** IPv4 addresses → ***.***.***.100 (last octet only) */
ipv4: {
pattern: /\b(?!0\.0\.0\.0\b)(?!127\.0\.0\.1\b)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
mask: (m) => `***.***.***.${m.split(".").pop()}`
},
/**
* International / formatted phone numbers.
*
* Matches:
* - `+33 1 23 45 67 89` -> `+33******89`
* - `(415) 555-1234` -> `********34`
* - `555-123-4567` / `555.123.4567` / `5551234567` -> `********67`
*
* Bare short digit runs like `12345678` are intentionally not matched.
*/
phone: {
pattern: /(?:\+\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}|\(\d{1,4}\)(?:[\s.-]?\d{2,4}){2,4}|\b\d{3}[-.]?\d{3}[-.]?\d{4}\b)/g,
mask: (m) => {
const digits = m.replace(/[^\d]/g, "");
if (m.startsWith("+") && digits.length > 4) {
const ccMatch = m.match(/^\+\d{1,3}/);
return `${ccMatch ? ccMatch[0] : "+"}******${digits.slice(-2)}`;
}
if (digits.length > 2) return `${"*".repeat(digits.length - 2)}${digits.slice(-2)}`;
return "***";
}
},
/** JWT tokens → eyJ***.*** */
jwt: {
pattern: /\beyJ[\w-]*\.[\w-]*\.[\w-]*\b/g,
mask: () => "eyJ***.***"
},
/** Bearer tokens → Bearer *** */
bearer: {
pattern: /\bBearer\s+[\w\-.~+/]{8,}=*/gi,
mask: () => "Bearer ***"
},
/** IBAN → FR76****189 (country + check digits + last 3) */
iban: {
pattern: /\b[A-Z]{2}\d{2}[\s-]?[\dA-Z]{4}[\s-]?[\dA-Z]{4}[\s-]?[\dA-Z]{4}[\s-]?[\dA-Z]{0,4}[\s-]?[\dA-Z]{0,4}[\s-]?[\dA-Z]{0,4}\b/g,
mask: (m) => {
const clean = m.replace(/[\s-]/g, "");
return `${clean.slice(0, 4)}****${clean.slice(-3)}`;
}
}
};
function cloneRegex(re) {
return new RegExp(re.source, re.flags);
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toRegExp(value) {
if (value instanceof RegExp) return value;
if (typeof value === "string") return new RegExp(value, "g");
if (isPlainObject(value) && typeof value.source === "string") {
const flags = typeof value.flags === "string" ? value.flags : "g";
return new RegExp(value.source, flags);
}
}
function toRegExpArray(value) {
if (!Array.isArray(value)) return void 0;
const out = [];
for (const item of value) {
const re = toRegExp(item);
if (re) out.push(re);
}
return out.length > 0 ? out : [];
}
function builtinToValuePattern(name) {
const b = builtinPatterns[name];
return {
name,
pattern: cloneRegex(b.pattern),
mask: b.mask
};
}
/**
* Default value patterns for the 'default' preset
*/
const DEFAULT_VALUE_PATTERNS = [
builtinToValuePattern("email"),
builtinToValuePattern("phone"),
{
name: "ssn",
pattern: REDACTOR_PATTERNS.ssn
},
builtinToValuePattern("creditCard")
];
/**
* Built-in redactor presets
*/
const REDACTOR_PRESETS = {
/**
* Default preset - covers common PII patterns with smart masking
* Detects: emails (a***@***.com), phone numbers, SSNs, credit cards (****1111)
* Redacts keys: password, secret, token, apiKey, auth, credential
*/
default: {
keyPatterns: [REDACTOR_PATTERNS.sensitiveKey],
valuePatterns: DEFAULT_VALUE_PATTERNS,
builtins: true,
replacement: "[REDACTED]"
},
/**
* Strict preset - more aggressive redaction for high-security environments
* Includes everything in default plus: Bearer tokens, JWTs, IBAN, API keys in values
*/
strict: {
keyPatterns: [
REDACTOR_PATTERNS.sensitiveKey,
/bearer/i,
/jwt/i
],
valuePatterns: [
...DEFAULT_VALUE_PATTERNS,
builtinToValuePattern("jwt"),
builtinToValuePattern("bearer"),
builtinToValuePattern("iban"),
{
name: "apiKeyInValue",
pattern: REDACTOR_PATTERNS.apiKeyInValue
}
],
builtins: true,
replacement: "[REDACTED]"
},
/**
* PCI-DSS preset - focused on payment card industry compliance
* Redacts: credit card numbers (****1111), CVV-like patterns, card-related keys
*/
"pci-dss": {
keyPatterns: [
/card/i,
/cvv/i,
/cvc/i,
/pan/i,
/expir/i,
/ccn/i
],
valuePatterns: [builtinToValuePattern("creditCard")],
builtins: ["creditCard"],
replacement: "[REDACTED]"
}
};
/**
* Normalize redactor config that may have been deserialized from JSON/YAML.
* Converts regex-like values back to RegExp instances.
*/
function normalizeAttributeRedactorConfig(raw) {
if (raw === void 0 || raw === null) return void 0;
if (typeof raw === "string") return raw;
if (!isPlainObject(raw)) return void 0;
const config = {};
if (Array.isArray(raw.paths)) config.paths = raw.paths.filter((value) => typeof value === "string");
if (typeof raw.replacement === "string") config.replacement = raw.replacement;
if (typeof raw.builtins === "boolean") config.builtins = raw.builtins;
else if (Array.isArray(raw.builtins)) config.builtins = raw.builtins.filter((name) => typeof name === "string");
if (typeof raw.redactor === "function") config.redactor = raw.redactor;
const keyPatterns = toRegExpArray(raw.keyPatterns);
if (keyPatterns) config.keyPatterns = keyPatterns;
const patterns = toRegExpArray(raw.patterns);
if (patterns) config.patterns = patterns;
if (Array.isArray(raw.valuePatterns)) {
const valuePatterns = [];
for (const item of raw.valuePatterns) {
if (!isPlainObject(item) || typeof item.name !== "string") continue;
const pattern = toRegExp(item.pattern);
if (!pattern) continue;
valuePatterns.push({
name: item.name,
pattern,
replacement: typeof item.replacement === "string" ? item.replacement : void 0,
mask: typeof item.mask === "function" ? item.mask : void 0
});
}
config.valuePatterns = valuePatterns;
}
return config;
}
/**
* Resolve config to a normalized form
*/
function resolveConfig(config) {
const normalized = normalizeAttributeRedactorConfig(config);
if (!normalized) throw new Error("Invalid attribute redactor config");
if (typeof normalized === "string") {
const preset = REDACTOR_PRESETS[normalized];
if (!preset) throw new Error(`Unknown attribute redactor preset: "${normalized}". Available presets: ${Object.keys(REDACTOR_PRESETS).join(", ")}`);
return preset;
}
const resolvedConfig = {
...normalized,
keyPatterns: normalized.keyPatterns ? [...normalized.keyPatterns] : void 0,
valuePatterns: normalized.valuePatterns ? [...normalized.valuePatterns] : void 0,
paths: normalized.paths ? [...normalized.paths] : void 0,
patterns: normalized.patterns ? [...normalized.patterns] : void 0
};
if (resolvedConfig.builtins !== false) {
const builtinValuePatterns = (Array.isArray(resolvedConfig.builtins) ? resolvedConfig.builtins : Object.keys(builtinPatterns)).filter((name) => name in builtinPatterns).map(builtinToValuePattern);
resolvedConfig.valuePatterns = [...resolvedConfig.valuePatterns ?? [], ...builtinValuePatterns];
}
return resolvedConfig;
}
/**
* Create a redactor function from config
*/
function createRedactorFromConfig(config) {
if (config.redactor) return config.redactor;
const keyPatterns = config.keyPatterns ?? [];
const valuePatterns = config.valuePatterns ?? [];
const paths = config.paths ?? [];
const pathSet = new Set(paths);
const customPatterns = config.patterns ?? [];
const defaultReplacement = config.replacement ?? "[REDACTED]";
const maskers = valuePatterns.filter((vp) => vp.mask).map((vp) => [cloneRegex(vp.pattern), vp.mask]);
return (key, value) => {
if (typeof value === "string") {
for (const pattern of keyPatterns) {
pattern.lastIndex = 0;
if (pattern.test(key)) return defaultReplacement;
}
if (pathSet.has(key)) return defaultReplacement;
}
if (typeof value !== "string") {
if (Array.isArray(value)) return value.map((item) => {
if (typeof item === "string") return redactStringValue(item, valuePatterns, maskers, customPatterns, defaultReplacement);
return item;
});
return value;
}
return redactStringValue(value, valuePatterns, maskers, customPatterns, defaultReplacement);
};
}
/**
* Apply three-tier redaction strategy to a string
* 1. Masker-based: built-in patterns with smart partial masking
* 2. Pattern-based: custom RegExp patterns replaced with replacement
*/
function redactStringValue(value, patterns, maskers, customPatterns, defaultReplacement) {
let result = value;
for (const [pattern, mask] of maskers) {
pattern.lastIndex = 0;
result = result.replace(pattern, mask);
}
for (const { pattern, replacement, mask } of patterns) {
if (mask) continue;
pattern.lastIndex = 0;
result = result.replaceAll(pattern, replacement ?? defaultReplacement);
}
for (const pattern of customPatterns) {
pattern.lastIndex = 0;
result = result.replaceAll(pattern, defaultReplacement);
}
return result;
}
/**
* Create a proxy wrapper around ReadableSpan with redacted attributes
*
* Since ReadableSpan.attributes is readonly, we use a Proxy to intercept
* attribute access and return the redacted version.
*/
function createRedactedSpan(span, redactor) {
const redactedAttributes = {};
for (const [key, value] of Object.entries(span.attributes)) if (value !== void 0) redactedAttributes[key] = redactor(key, value);
return new Proxy(span, { get(target, prop) {
if (prop === "attributes") return redactedAttributes;
const value = Reflect.get(target, prop);
if (typeof value === "function") return value.bind(target);
return value;
} });
}
/**
* Create an attribute redactor function from a config or preset.
*
* This is useful when you need to apply the same redaction logic
* outside of the span processor pipeline (e.g., for canonical log lines).
*
* @example
* ```typescript
* const redactor = createAttributeRedactor('default');
* const redactedValue = redactor('user.password', 'secret123');
* // redactedValue === '[REDACTED]'
* ```
*/
function createAttributeRedactor(config) {
return createRedactorFromConfig(resolveConfig(config));
}
/**
* Span processor that redacts sensitive data from span attributes.
*
* Redaction happens in onEnd() when all attributes are finalized.
* Uses a Proxy wrapper to intercept attribute access since ReadableSpan
* attributes are readonly.
*
* Common use cases:
* - PII compliance (GDPR, CCPA)
* - PCI-DSS compliance for payment data
* - Preventing secrets from leaking to observability backends
*/
var AttributeRedactingProcessor = class {
wrappedProcessor;
redactor;
constructor(wrappedProcessor, options) {
this.wrappedProcessor = wrappedProcessor;
const config = resolveConfig(options.redactor);
this.redactor = createRedactorFromConfig(config);
}
/**
* Pass through onStart unchanged - attributes aren't finalized yet
*/
onStart(span, parentContext) {
this.wrappedProcessor.onStart(span, parentContext);
}
/**
* Redact attributes and forward to wrapped processor
*/
onEnd(span) {
try {
const redactedSpan = createRedactedSpan(span, this.redactor);
this.wrappedProcessor.onEnd(redactedSpan);
} catch {
this.wrappedProcessor.onEnd(span);
}
}
forceFlush() {
return this.wrappedProcessor.forceFlush();
}
shutdown() {
return this.wrappedProcessor.shutdown();
}
};
//#endregion
export { AttributeRedactingProcessor, REDACTOR_PATTERNS, REDACTOR_PRESETS, builtinPatterns, createAttributeRedactor, createRedactedSpan, normalizeAttributeRedactorConfig };
//# sourceMappingURL=attribute-redacting-processor.js.map