UNPKG

@wgtechlabs/log-engine

Version:

A lightweight, security-first logging utility with automatic data redaction for Node.js applications - the first logging library with built-in PII protection.

259 lines 11.4 kB
/** * Core data redaction engine * Handles automatic detection and redaction of sensitive information in log data */ import { defaultRedactionConfig, RedactionController } from './config.js'; /** * DataRedactor class - Core redaction logic for processing log data * Automatically detects and redacts sensitive information while preserving structure */ export class DataRedactor { /** * Update the redaction configuration with new settings * Merges provided config with existing settings and reloads environment variables * @param newConfig - Partial configuration to merge with current settings */ static updateConfig(newConfig) { // Reload environment configuration to pick up any changes const envConfig = RedactionController.getEnvironmentConfig(); DataRedactor.config = { ...defaultRedactionConfig, ...envConfig, ...newConfig }; } /** * Get the current redaction configuration * @returns Deep copy of current redaction configuration */ static getConfig() { return { ...DataRedactor.config, sensitiveFields: [...DataRedactor.config.sensitiveFields], contentFields: [...DataRedactor.config.contentFields], customPatterns: DataRedactor.config.customPatterns ? [...DataRedactor.config.customPatterns] : undefined }; } /** * Refresh configuration from environment variables * Useful for picking up runtime environment changes */ static refreshConfig() { const envConfig = RedactionController.getEnvironmentConfig(); DataRedactor.config = { ...defaultRedactionConfig, ...envConfig }; } /** * Add custom regex patterns for advanced field detection * @param patterns - Array of regex patterns to add */ static addCustomPatterns(patterns) { const currentPatterns = DataRedactor.config.customPatterns || []; DataRedactor.config = { ...DataRedactor.config, customPatterns: [...currentPatterns, ...patterns] }; } /** * Clear all custom regex patterns */ static clearCustomPatterns() { DataRedactor.config = { ...DataRedactor.config, customPatterns: [] }; } /** * Add custom sensitive field names to the existing list * @param fields - Array of field names to add */ static addSensitiveFields(fields) { DataRedactor.config = { ...DataRedactor.config, sensitiveFields: [...DataRedactor.config.sensitiveFields, ...fields] }; } /** * Test if a field name would be redacted with current configuration * @param fieldName - Field name to test * @returns true if field would be redacted, false otherwise */ static testFieldRedaction(fieldName) { const testObj = { [fieldName]: 'test-value' }; const result = DataRedactor.redactData(testObj); // Use safe property access to prevent object injection if (Object.prototype.hasOwnProperty.call(result, fieldName)) { // Safe access to avoid object injection const value = result[fieldName]; return value !== 'test-value'; } return false; } /** * Main entry point for data redaction * Processes any type of data and returns a redacted version * @param data - Data to be processed for redaction * @returns Redacted version of the data */ static redactData(data) { // Skip processing if redaction is disabled or data is null/undefined if (!DataRedactor.config.enabled || data === null || data === undefined) { return data; } return DataRedactor.processValue(data, new WeakSet(), 0); } /** * Process a value of any type (primitive, object, array) * Recursively handles nested structures when deepRedaction is enabled * Includes circular reference protection and recursion depth limiting * @param value - Value to process * @param visited - Set to track visited objects (prevents circular references) * @param depth - Current recursion depth (prevents stack overflow) * @returns Processed value with redaction applied */ static processValue(value, visited = new WeakSet(), depth = 0) { // Check recursion depth limit to prevent stack overflow if (depth >= DataRedactor.MAX_RECURSION_DEPTH) { return '[Max Depth Exceeded]'; } // Handle null and undefined if (value === null || value === undefined) { return value; } // Handle arrays - process each element if (Array.isArray(value)) { // Check for circular reference if (visited.has(value)) { return '[Circular Array]'; } visited.add(value); const result = value.map(item => DataRedactor.processValue(item, visited, depth + 1)); // Keep value in visited set to detect circular references across branches return result; } // Handle objects - process each property if (typeof value === 'object') { // Check for circular reference if (visited.has(value)) { return '[Circular Object]'; } visited.add(value); const result = DataRedactor.redactObject(value, visited, depth + 1); // Keep value in visited set to detect circular references across branches return result; } // Handle primitives (string, number, boolean) - return as-is return value; } /** * Process an object and redact sensitive fields * Handles field-level redaction and content truncation * @param obj - Object to process * @param visited - Set to track visited objects (prevents circular references) * @param depth - Current recursion depth (prevents stack overflow) * @returns Object with sensitive fields redacted */ static redactObject(obj, visited = new WeakSet(), depth = 0) { // Check recursion depth limit to prevent stack overflow if (depth >= DataRedactor.MAX_REDACT_OBJECT_DEPTH) { return { '[Max Depth Exceeded]': '[Max Depth Exceeded]' }; } const redacted = {}; for (const [key, value] of Object.entries(obj)) { // Check if this field should be completely redacted if (DataRedactor.isSensitiveField(key)) { Object.defineProperty(redacted, key, { value: DataRedactor.config.redactionText, enumerable: true, writable: true, configurable: true }); } else if (DataRedactor.isContentField(key) && typeof value === 'string') { // Check if this field should be truncated (for large content) Object.defineProperty(redacted, key, { value: DataRedactor.truncateContent(value), enumerable: true, writable: true, configurable: true }); } else if (DataRedactor.config.deepRedaction && (typeof value === 'object' && value !== null)) { // Recursively process nested objects/arrays if deep redaction is enabled Object.defineProperty(redacted, key, { value: DataRedactor.processValue(value, visited, depth + 1), enumerable: true, writable: true, configurable: true }); } else { // Keep the value unchanged Object.defineProperty(redacted, key, { value: value, enumerable: true, writable: true, configurable: true }); } } return redacted; } /** * Check if a field name indicates sensitive information * Uses case-insensitive matching with exact and partial matches * Includes smart filtering to avoid false positives and custom patterns * @param fieldName - Field name to check * @returns true if field should be redacted, false otherwise */ static isSensitiveField(fieldName) { const lowerField = fieldName.toLowerCase(); // Check custom regex patterns first (highest priority) if (DataRedactor.config.customPatterns && DataRedactor.config.customPatterns.length > 0) { for (const pattern of DataRedactor.config.customPatterns) { if (pattern.test(fieldName)) { return true; } } } return DataRedactor.config.sensitiveFields.some(sensitive => { const lowerSensitive = sensitive.toLowerCase(); // Exact match (highest confidence) if (lowerField === lowerSensitive) { return true; } // Field ends with sensitive term (e.g., "userPassword" ends with "password") if (lowerField.endsWith(lowerSensitive)) { return true; } // Field starts with sensitive term (e.g., "passwordHash" starts with "password") if (lowerField.startsWith(lowerSensitive)) { return true; } // Whitelist of short sensitive terms that should always trigger substring matching const shortSensitiveWhitelist = ['pin', 'cvv', 'cvc', 'ssn', 'pwd', 'key', 'jwt', 'dob', 'pii', 'auth', 'csrf']; // Field contains sensitive term - either from whitelist or length >= 5 to avoid false positives if ((shortSensitiveWhitelist.includes(lowerSensitive) || lowerSensitive.length >= 5) && lowerField.includes(lowerSensitive)) { return true; } // Handle compound words with underscores or camelCase if (lowerField.includes('_' + lowerSensitive) || lowerField.includes(lowerSensitive + '_')) { return true; } return false; }); } /** * Check if a field name indicates content that should be truncated * Uses exact case-insensitive matching for content fields * @param fieldName - Field name to check * @returns true if field is a content field, false otherwise */ static isContentField(fieldName) { const lowerField = fieldName.toLowerCase(); return DataRedactor.config.contentFields.some(content => content.toLowerCase() === lowerField); } /** * Truncate content that exceeds the maximum length * Preserves readability while preventing log bloat * @param content - Content string to potentially truncate * @returns Original content or truncated version with indicator */ static truncateContent(content) { if (content.length <= DataRedactor.config.maxContentLength) { return content; } return content.substring(0, DataRedactor.config.maxContentLength) + DataRedactor.config.truncationText; } } DataRedactor.config = { ...defaultRedactionConfig, ...RedactionController.getEnvironmentConfig() }; // Maximum recursion depth to prevent stack overflow attacks DataRedactor.MAX_RECURSION_DEPTH = 100; // Slightly lower limit for redactObject to ensure it can be reached DataRedactor.MAX_REDACT_OBJECT_DEPTH = 99; //# sourceMappingURL=redactor.js.map