@logtape/redaction
Version:
Redact sensitive data from log messages
429 lines (403 loc) • 13.2 kB
text/typescript
import type { LogRecord, Sink } from "@logtape/logtape";
/**
* The type for a field pattern used in redaction. A string or a regular
* expression that matches field names.
* @since 0.10.0
*/
export type FieldPattern = string | RegExp;
/**
* An array of field patterns used for redaction. Each pattern can be
* a string or a regular expression that matches field names.
* @since 0.10.0
*/
export type FieldPatterns = FieldPattern[];
/**
* Default field patterns for redaction. These patterns will match
* common sensitive fields such as passwords, tokens, and personal
* information.
* @since 0.10.0
*/
export const DEFAULT_REDACT_FIELDS: FieldPatterns = [
/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,
];
/**
* Options for redacting fields in a {@link LogRecord}. Used by
* the {@link redactByField} function.
* @since 0.10.0
*/
export interface FieldRedactionOptions {
/**
* The field patterns to match against. This can be an array of
* strings or regular expressions. If a field matches any of the
* patterns, it will be redacted.
* @defaultValue {@link DEFAULT_REDACT_FIELDS}
*/
readonly fieldPatterns: FieldPatterns;
/**
* The action to perform on the matched fields. If not provided,
* the default action is to delete the field from the properties.
* If a function is provided, it will be called with the
* value of the field, and the return value will be used to replace
* the field in the properties.
* If the action is `"delete"`, the field will be removed from the
* properties.
* @default `"delete"`
*/
readonly action?: "delete" | ((value: unknown) => unknown);
}
/**
* 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
*/
export function redactByField(
sink: Sink | Sink & Disposable | Sink & AsyncDisposable,
options: FieldRedactionOptions | FieldPatterns = DEFAULT_REDACT_FIELDS,
): Sink | Sink & Disposable | Sink & AsyncDisposable {
const opts = Array.isArray(options) ? { fieldPatterns: options } : options;
const wrapped = (record: LogRecord) => {
const redactedProperties = redactProperties(record.properties, opts);
let redactedMessage = record.message;
if (typeof record.rawMessage === "string") {
// String template: redact by placeholder names
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 {
// Tagged template: redact by comparing values
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
*/
export function redactProperties(
properties: Record<string, unknown>,
options: FieldRedactionOptions,
): Record<string, unknown> {
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];
// Check if value is an array:
if (Array.isArray(value)) {
copy[field] = value.map((item) => {
if (
typeof item === "object" && item !== null &&
(Object.getPrototypeOf(item) === Object.prototype ||
Object.getPrototypeOf(item) === null)
) {
// @ts-ignore: item is always Record<string, unknown>
return redactProperties(item, options);
}
return item;
});
// Check if value is a vanilla object:
} else if (
typeof value === "object" && value !== null &&
(Object.getPrototypeOf(value) === Object.prototype ||
Object.getPrototypeOf(value) === null)
) {
// @ts-ignore: value is always Record<string, unknown>
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
*/
export function shouldFieldRedacted(
field: string,
fieldPatterns: FieldPatterns,
): boolean {
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: string): string[] {
const placeholders: string[] = [];
for (let i = 0; i < template.length; i++) {
if (template[i] === "{") {
// Check for escaped brace
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: string): string[] {
const segments: string[] = [];
let current = "";
for (const char of path) {
if (char === "." || char === "[") {
if (current) segments.push(current);
current = "";
} else if (char === "]" || char === "?") {
// Skip these characters
} 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: string[],
fieldPatterns: FieldPatterns,
): { redactedIndices: Set<number>; wildcardIndices: Set<number> } {
const redactedIndices = new Set<number>();
const wildcardIndices = new Set<number>();
for (let i = 0; i < placeholders.length; i++) {
const placeholder = placeholders[i];
// Track wildcard {*} separately
if (placeholder === "*") {
wildcardIndices.add(i);
continue;
}
// Check the full placeholder name
if (shouldFieldRedacted(placeholder, fieldPatterns)) {
redactedIndices.add(i);
continue;
}
// For nested paths, check each segment
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: readonly unknown[],
redactedIndices: Set<number>,
wildcardIndices: Set<number>,
redactedProperties: Record<string, unknown>,
action: "delete" | ((value: unknown) => unknown) | undefined,
): readonly unknown[] {
if (redactedIndices.size === 0 && wildcardIndices.size === 0) return message;
const result: unknown[] = [];
let placeholderIndex = 0;
for (let i = 0; i < message.length; i++) {
if (i % 2 === 0) {
// Even index: text segment
result.push(message[i]);
} else {
// Odd index: value/placeholder
if (wildcardIndices.has(placeholderIndex)) {
// Wildcard {*}: replace with redacted properties
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: Record<string, unknown>,
redacted: Record<string, unknown>,
map: Map<unknown, unknown>,
): void {
for (const key in original) {
const origVal = original[key];
const redVal = redacted[key];
if (origVal !== redVal) {
map.set(origVal, redVal);
}
// Recurse into nested objects
if (
typeof origVal === "object" && origVal !== null &&
typeof redVal === "object" && redVal !== null &&
!Array.isArray(origVal)
) {
collectRedactedValues(
origVal as Record<string, unknown>,
redVal as Record<string, unknown>,
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: Record<string, unknown>,
redacted: Record<string, unknown>,
): Map<unknown, unknown> {
const map = new Map<unknown, unknown>();
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: readonly unknown[],
redactedValues: Map<unknown, unknown>,
): readonly unknown[] {
if (redactedValues.size === 0) return message;
const result: unknown[] = [];
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;
}