@logtape/redaction
Version:
Redact sensitive data from log messages
255 lines (253 loc) • 9.35 kB
JavaScript
//#region src/field.ts
/**
* Default field patterns for redaction. These patterns will match
* common sensitive fields such as passwords, tokens, and personal
* information.
* @since 0.10.0
*/
const DEFAULT_REDACT_FIELDS = [
/pass(?:code|phrase|word)/i,
/secret/i,
/token/i,
/key/i,
/credential/i,
/auth/i,
/signature/i,
/sensitive/i,
/private/i,
/ssn/i,
/email/i,
/phone/i,
/address/i
];
/**
* Redacts properties and message values in a {@link LogRecord} based on the
* provided field patterns and action.
*
* Note that it is a decorator which wraps the sink and redacts properties
* and message values before passing them to the sink.
*
* For string templates (e.g., `"Hello, {name}!"`), placeholder names are
* matched against the field patterns to determine which values to redact.
*
* For tagged template literals (e.g., `` `Hello, ${name}!` ``), redaction
* is performed by comparing message values with redacted property values.
*
* @example
* ```ts
* import { getConsoleSink } from "@logtape/logtape";
* import { redactByField } from "@logtape/redaction";
*
* const sink = redactByField(getConsoleSink());
* ```
*
* @param sink The sink to wrap.
* @param options The redaction options.
* @returns The wrapped sink.
* @since 0.10.0
*/
function redactByField(sink, options = DEFAULT_REDACT_FIELDS) {
const opts = Array.isArray(options) ? { fieldPatterns: options } : options;
const wrapped = (record) => {
const redactedProperties = redactProperties(record.properties, opts);
let redactedMessage = record.message;
if (typeof record.rawMessage === "string") {
const placeholders = extractPlaceholderNames(record.rawMessage);
const { redactedIndices, wildcardIndices } = getRedactedPlaceholderIndices(placeholders, opts.fieldPatterns);
if (redactedIndices.size > 0 || wildcardIndices.size > 0) redactedMessage = redactMessageArray(record.message, redactedIndices, wildcardIndices, redactedProperties, opts.action);
} else {
const redactedValues = getRedactedValues(record.properties, redactedProperties);
if (redactedValues.size > 0) redactedMessage = redactMessageByValues(record.message, redactedValues);
}
sink({
...record,
message: redactedMessage,
properties: redactedProperties
});
};
if (Symbol.dispose in sink) wrapped[Symbol.dispose] = sink[Symbol.dispose];
if (Symbol.asyncDispose in sink) wrapped[Symbol.asyncDispose] = sink[Symbol.asyncDispose];
return wrapped;
}
/**
* Redacts properties from an object based on specified field patterns.
*
* This function creates a shallow copy of the input object and applies
* redaction rules to its properties. For properties that match the redaction
* patterns, the function either removes them or transforms their values based
* on the provided action.
*
* The redaction process is recursive and will be applied to nested objects
* as well, allowing for deep redaction of sensitive data in complex object
* structures.
* @param properties The properties to redact.
* @param options The redaction options.
* @returns The redacted properties.
* @since 0.10.0
*/
function redactProperties(properties, options) {
const copy = { ...properties };
for (const field in copy) {
if (shouldFieldRedacted(field, options.fieldPatterns)) {
if (options.action == null || options.action === "delete") delete copy[field];
else copy[field] = options.action(copy[field]);
continue;
}
const value = copy[field];
if (Array.isArray(value)) copy[field] = value.map((item) => {
if (typeof item === "object" && item !== null && (Object.getPrototypeOf(item) === Object.prototype || Object.getPrototypeOf(item) === null)) return redactProperties(item, options);
return item;
});
else if (typeof value === "object" && value !== null && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) copy[field] = redactProperties(value, options);
}
return copy;
}
/**
* Checks if a field should be redacted based on the provided field patterns.
* @param field The field name to check.
* @param fieldPatterns The field patterns to match against.
* @returns `true` if the field should be redacted, `false` otherwise.
* @since 0.10.0
*/
function shouldFieldRedacted(field, fieldPatterns) {
for (const fieldPattern of fieldPatterns) if (typeof fieldPattern === "string") {
if (fieldPattern === field) return true;
} else if (fieldPattern.test(field)) return true;
return false;
}
/**
* Extracts placeholder names from a message template string in order.
* @param template The message template string.
* @returns An array of placeholder names in the order they appear.
*/
function extractPlaceholderNames(template) {
const placeholders = [];
for (let i = 0; i < template.length; i++) if (template[i] === "{") {
if (i + 1 < template.length && template[i + 1] === "{") {
i++;
continue;
}
const closeIndex = template.indexOf("}", i + 1);
if (closeIndex === -1) continue;
const key = template.slice(i + 1, closeIndex).trim();
placeholders.push(key);
i = closeIndex;
}
return placeholders;
}
/**
* Parses a property path into its segments.
* @param path The property path (e.g., "user.password" or "users[0].email").
* @returns An array of path segments.
*/
function parsePathSegments(path) {
const segments = [];
let current = "";
for (const char of path) if (char === "." || char === "[") {
if (current) segments.push(current);
current = "";
} else if (char === "]" || char === "?") {} else current += char;
if (current) segments.push(current);
return segments;
}
/**
* Determines which placeholder indices should be redacted based on field
* patterns, and which are wildcard placeholders.
* @param placeholders Array of placeholder names from the template.
* @param fieldPatterns Field patterns to match against.
* @returns Object with redactedIndices and wildcardIndices.
*/
function getRedactedPlaceholderIndices(placeholders, fieldPatterns) {
const redactedIndices = /* @__PURE__ */ new Set();
const wildcardIndices = /* @__PURE__ */ new Set();
for (let i = 0; i < placeholders.length; i++) {
const placeholder = placeholders[i];
if (placeholder === "*") {
wildcardIndices.add(i);
continue;
}
if (shouldFieldRedacted(placeholder, fieldPatterns)) {
redactedIndices.add(i);
continue;
}
const segments = parsePathSegments(placeholder);
for (const segment of segments) if (shouldFieldRedacted(segment, fieldPatterns)) {
redactedIndices.add(i);
break;
}
}
return {
redactedIndices,
wildcardIndices
};
}
/**
* Redacts values in the message array based on the redacted placeholder
* indices and wildcard indices.
* @param message The original message array.
* @param redactedIndices Set of placeholder indices to redact.
* @param wildcardIndices Set of wildcard placeholder indices.
* @param redactedProperties The redacted properties object.
* @param action The redaction action.
* @returns New message array with redacted values.
*/
function redactMessageArray(message, redactedIndices, wildcardIndices, redactedProperties, action) {
if (redactedIndices.size === 0 && wildcardIndices.size === 0) return message;
const result = [];
let placeholderIndex = 0;
for (let i = 0; i < message.length; i++) if (i % 2 === 0) result.push(message[i]);
else {
if (wildcardIndices.has(placeholderIndex)) result.push(redactedProperties);
else if (redactedIndices.has(placeholderIndex)) if (action == null || action === "delete") result.push("");
else result.push(action(message[i]));
else result.push(message[i]);
placeholderIndex++;
}
return result;
}
/**
* Collects redacted value mappings from original to redacted properties.
* @param original The original properties.
* @param redacted The redacted properties.
* @param map The map to populate with original -> redacted value pairs.
*/
function collectRedactedValues(original, redacted, map) {
for (const key in original) {
const origVal = original[key];
const redVal = redacted[key];
if (origVal !== redVal) map.set(origVal, redVal);
if (typeof origVal === "object" && origVal !== null && typeof redVal === "object" && redVal !== null && !Array.isArray(origVal)) collectRedactedValues(origVal, redVal, map);
}
}
/**
* Gets a map of original values to their redacted replacements.
* @param original The original properties.
* @param redacted The redacted properties.
* @returns A map of original -> redacted values.
*/
function getRedactedValues(original, redacted) {
const map = /* @__PURE__ */ new Map();
collectRedactedValues(original, redacted, map);
return map;
}
/**
* Redacts message array values by comparing with redacted property values.
* Used for tagged template literals where placeholder names are not available.
* @param message The original message array.
* @param redactedValues Map of original -> redacted values.
* @returns New message array with redacted values.
*/
function redactMessageByValues(message, redactedValues) {
if (redactedValues.size === 0) return message;
const result = [];
for (let i = 0; i < message.length; i++) if (i % 2 === 0) result.push(message[i]);
else {
const val = message[i];
if (redactedValues.has(val)) result.push(redactedValues.get(val));
else result.push(val);
}
return result;
}
//#endregion
exports.DEFAULT_REDACT_FIELDS = DEFAULT_REDACT_FIELDS;
exports.redactByField = redactByField;