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.

542 lines 21.3 kB
/** * Advanced output handlers for log-engine * Provides file, HTTP, and other production-ready output handlers */ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; /** * Secure filesystem operations for logging operations * * SECURITY NOTE: These functions implement comprehensive path validation and access controls * to prevent path traversal attacks, directory injection, and unauthorized file access. * ESLint security rules are disabled for specific fs operations because: * * 1. All paths are validated through validatePath() which: * - Prevents directory traversal (../) * - Restricts access to predefined safe directories * - Blocks access to system directories * - Normalizes and resolves paths securely * * 2. The logging library requires dynamic file paths by design (user-configurable log files) * 3. All operations are wrapped in try-catch with comprehensive error handling * 4. File operations are restricted to log and temp directories only */ /** * Predefined safe base directories for different operation types * Restricted to specific subdirectories to prevent unauthorized access */ const SAFE_BASE_DIRS = { LOG_FILES: [path.resolve('./logs'), path.resolve('./var/log'), path.resolve('./data/logs')], TEMP_FILES: [path.resolve('./temp'), path.resolve('./logs'), path.resolve('./tmp'), os.tmpdir()], CONFIG_FILES: [path.resolve('./config'), path.resolve('./etc'), path.resolve('./logs')] }; /** * Validates file path with comprehensive security checks * Prevents path traversal, restricts to safe directories, blocks system paths */ function validatePath(filePath) { if (!filePath || typeof filePath !== 'string') { throw new Error('File path must be a non-empty string'); } // Resolve and normalize the path to handle relative paths and traversal attempts const resolvedPath = path.resolve(filePath); const normalizedPath = path.normalize(resolvedPath); // Check for path traversal attempts in original path - reject ANY use of '..' if (filePath.includes('..')) { throw new Error(`Path traversal detected: ${filePath}`); } // Ensure path is within safe directories (logs, current directory, or temp) const safeBaseDirs = [ ...SAFE_BASE_DIRS.LOG_FILES, ...SAFE_BASE_DIRS.TEMP_FILES, ...SAFE_BASE_DIRS.CONFIG_FILES ]; const isInSafeDir = safeBaseDirs.some(safeDir => normalizedPath.startsWith(safeDir)); if (!isInSafeDir) { throw new Error(`File path outside allowed directories: ${filePath}`); } // Block access to dangerous system directories const dangerousPaths = [ '/etc', '/sys', '/proc', '/dev', '/root', '/bin', '/sbin', 'C:\\Windows', 'C:\\System32', 'C:\\Program Files', 'C:\\Users\\All Users' ]; if (dangerousPaths.some(dangerous => normalizedPath.startsWith(dangerous))) { throw new Error(`Access denied to system directory: ${filePath}`); } return normalizedPath; } /** * Secure file existence check * Uses fs.accessSync instead of fs.existsSync for better security practices */ function secureExistsSync(filePath) { try { const safePath = validatePath(filePath); // SECURITY: Path has been validated and restricted to safe directories fs.accessSync(safePath, fs.constants.F_OK); return true; } catch { return false; } } /** * Secure directory creation with recursive option support * Restricted to log and temp directories only */ function secureMkdirSync(dirPath, options) { const safePath = validatePath(dirPath); try { const mkdirOptions = { recursive: Boolean(options?.recursive) }; // SECURITY: Path has been validated and restricted to safe directories fs.mkdirSync(safePath, mkdirOptions); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to create directory ${dirPath}: ${errorMessage}`); } } /** * Secure file stat operation * Returns file system statistics for validated paths only */ function secureStatSync(filePath) { const safePath = validatePath(filePath); try { // SECURITY: Path has been validated and restricted to safe directories return fs.statSync(safePath); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to stat file ${filePath}: ${errorMessage}`); } } /** * Secure file write operation * Validates path and data before writing to prevent injection attacks */ function secureWriteFileSync(filePath, data, options) { const safePath = validatePath(filePath); // Validate data parameter if (typeof data !== 'string') { throw new Error('Data must be a string for security'); } try { const writeOptions = options || {}; // SECURITY: Path has been validated and restricted to safe directories fs.writeFileSync(safePath, data, writeOptions); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to write file ${filePath}: ${errorMessage}`); } } /** * Secure file deletion * Restricted to log and temp files only for safety */ function secureUnlinkSync(filePath) { const safePath = validatePath(filePath); // Additional safety check: only allow deletion of log and temp files const logDirs = [...SAFE_BASE_DIRS.LOG_FILES, ...SAFE_BASE_DIRS.TEMP_FILES]; const isInLogDir = logDirs.some(logDir => safePath.startsWith(logDir)); if (!isInLogDir) { throw new Error(`File deletion not allowed outside log/temp directories: ${filePath}`); } try { // SECURITY: Path has been validated and restricted to log/temp directories fs.unlinkSync(safePath); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to delete file ${filePath}: ${errorMessage}`); } } /** * Secure file rename/move operation * Both source and destination must be in safe directories */ function secureRenameSync(oldPath, newPath) { const safeOldPath = validatePath(oldPath); const safeNewPath = validatePath(newPath); // Ensure both paths are in allowed directories (log/temp only for safety) const allowedDirs = [...SAFE_BASE_DIRS.LOG_FILES, ...SAFE_BASE_DIRS.TEMP_FILES]; const oldInAllowed = allowedDirs.some(dir => safeOldPath.startsWith(dir)); const newInAllowed = allowedDirs.some(dir => safeNewPath.startsWith(dir)); if (!oldInAllowed || !newInAllowed) { throw new Error(`File rename not allowed outside safe directories: ${oldPath} -> ${newPath}`); } try { // SECURITY: Both paths have been validated and restricted to safe directories fs.renameSync(safeOldPath, safeNewPath); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to rename file ${oldPath} to ${newPath}: ${errorMessage}`); } } /** * File output handler with rotation support and concurrency protection * Implements atomic file operations and write queuing to prevent corruption */ export class FileOutputHandler { constructor(config) { this.currentFileSize = 0; this.rotationInProgress = false; this.writeQueue = []; /** * Default formatter for file output */ this.defaultFormatter = (level, message, data) => { const timestamp = new Date().toISOString(); const dataStr = data ? ` ${JSON.stringify(data)}` : ''; return `${timestamp} [${level.toUpperCase()}] ${message}${dataStr}\n`; }; /** * Write log to file with rotation support and concurrency protection * Queues writes during rotation to prevent file corruption */ this.write = (level, message, data) => { // If rotation is in progress, queue the write if (this.rotationInProgress) { this.writeQueue.push({ level, message, data }); return; } try { this.writeToFile(level, message, data); } catch (error) { // Fallback to console if file writing fails console.error('File output handler failed:', error); console.log(`[${level.toUpperCase()}] ${message}`, data); } }; // Set defaults this.config = { filePath: config.filePath, append: config.append ?? true, maxFileSize: config.maxFileSize ?? 0, // 0 means no rotation maxBackupFiles: config.maxBackupFiles ?? 3, formatter: config.formatter ?? this.defaultFormatter }; // Ensure directory exists and validate paths try { const dir = path.dirname(this.config.filePath); if (!secureExistsSync(dir)) { secureMkdirSync(dir, { recursive: true }); } // Get current file size if it exists if (secureExistsSync(this.config.filePath)) { this.currentFileSize = secureStatSync(this.config.filePath).size; } } catch (error) { // Re-throw with context for better error handling const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to initialize file output handler: ${errorMessage}`); } } /** * Write to file with concurrency protection and rotation check * If rotation is in progress, messages are queued to prevent corruption */ writeToFile(level, message, data) { const formattedMessage = this.config.formatter(level, message, data); // Check if rotation is needed if (this.config.maxFileSize > 0 && this.currentFileSize + Buffer.byteLength(formattedMessage) > this.config.maxFileSize) { this.rotateFile(); } // Write to file using secure filesystem wrapper const writeOptions = this.config.append ? { flag: 'a' } : { flag: 'w' }; secureWriteFileSync(this.config.filePath, formattedMessage, writeOptions); this.currentFileSize += Buffer.byteLength(formattedMessage); } /** * Process queued writes after rotation completes */ processWriteQueue() { while (this.writeQueue.length > 0) { const queuedWrite = this.writeQueue.shift(); if (queuedWrite) { try { this.writeToFile(queuedWrite.level, queuedWrite.message, queuedWrite.data); } catch (error) { console.error('Failed to process queued write:', error); console.log(`[${queuedWrite.level.toUpperCase()}] ${queuedWrite.message}`, queuedWrite.data); } } } } /** * Rotate log files when size limit is reached * Implements concurrency protection to prevent corruption during rotation */ rotateFile() { // Prevent concurrent rotations if (this.rotationInProgress) { return; } this.rotationInProgress = true; try { // Move backup files for (let i = this.config.maxBackupFiles - 1; i >= 1; i--) { const oldFile = `${this.config.filePath}.${i}`; const newFile = `${this.config.filePath}.${i + 1}`; if (secureExistsSync(oldFile)) { if (i === this.config.maxBackupFiles - 1) { // Delete the oldest file secureUnlinkSync(oldFile); } else { secureRenameSync(oldFile, newFile); } } } // Move current file to .1 if (secureExistsSync(this.config.filePath)) { const backupFile = `${this.config.filePath}.1`; secureRenameSync(this.config.filePath, backupFile); } this.currentFileSize = 0; } catch (error) { console.error('File rotation failed:', error); } finally { // Always reset rotation flag and process queued writes this.rotationInProgress = false; this.processWriteQueue(); } } /** * Clean up resources and process any remaining queued writes */ destroy() { // Process any remaining queued writes if (this.writeQueue.length > 0) { this.processWriteQueue(); } // Clear the write queue this.writeQueue = []; this.rotationInProgress = false; } } /** * HTTP output handler for sending logs to remote endpoints */ export class HttpOutputHandler { constructor(config) { this.logBuffer = []; this.flushTimeout = null; /** * Default formatter for HTTP output */ this.defaultFormatter = (logs) => { return { logs: logs.map(log => ({ timestamp: log.timestamp, level: log.level, message: log.message, data: log.data })) }; }; /** * Write log to HTTP endpoint with batching support */ this.write = (level, message, data) => { try { // Add to buffer this.logBuffer.push({ level, message, data, timestamp: new Date().toISOString() }); // Flush if batch size reached if (this.logBuffer.length >= this.config.batchSize) { this.flush(); } else { // Schedule a flush if not already scheduled if (!this.flushTimeout) { this.flushTimeout = setTimeout(() => { this.flush(); }, 1000); // Flush after 1 second if batch isn't full } } } catch (error) { // Fallback to console if HTTP fails console.error('HTTP output handler failed:', error); console.log(`[${level.toUpperCase()}] ${message}`, data); } }; // Set defaults this.config = { url: config.url, method: config.method ?? 'POST', headers: config.headers ?? { 'Content-Type': 'application/json' }, batchSize: config.batchSize ?? 1, timeout: config.timeout ?? 5000, formatter: config.formatter ?? this.defaultFormatter }; } /** * Flush buffered logs to HTTP endpoint */ flush() { if (this.logBuffer.length === 0) { return; } try { const payload = this.config.formatter([...this.logBuffer]); this.logBuffer = []; // Clear buffer if (this.flushTimeout) { clearTimeout(this.flushTimeout); this.flushTimeout = null; } // Send HTTP request (using fetch if available, otherwise fall back) this.sendHttpRequest(payload); } catch (error) { console.error('HTTP flush failed:', error); } } /** * Send HTTP request with appropriate method based on environment */ sendHttpRequest(payload) { // Try to use fetch (Node.js 18+ or browser) if (typeof fetch !== 'undefined') { fetch(this.config.url, { method: this.config.method, headers: this.config.headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(this.config.timeout) }).catch(error => { console.error('HTTP request failed:', error); }); } else { // Fallback for older Node.js versions this.sendHttpRequestNodeJS(payload); } } /** * Fallback HTTP implementation for Node.js environments without fetch */ sendHttpRequestNodeJS(payload) { try { const https = require('https'); const parsedUrl = new URL(this.config.url); const isHttps = parsedUrl.protocol === 'https:'; // Security: Block HTTP (cleartext) connections by default if (!isHttps) { throw new Error('SECURITY ERROR: HTTP (cleartext) connections are not allowed for log transmission. Use HTTPS URLs only.'); } const postData = JSON.stringify(payload); const options = { hostname: parsedUrl.hostname, port: parsedUrl.port ? parseInt(parsedUrl.port, 10) : 443, path: parsedUrl.pathname + parsedUrl.search, method: this.config.method, headers: { ...this.config.headers, 'Content-Length': Buffer.byteLength(postData) }, timeout: this.config.timeout }; const req = https.request(options, (res) => { // Handle response (optional: log success/failure) res.on('data', () => { }); // Consume response res.on('end', () => { }); }); req.on('error', (error) => { console.error('HTTP request failed:', error); }); req.on('timeout', () => { req.destroy(); console.error('HTTP request timed out'); }); req.write(postData); req.end(); } catch (error) { console.error('HTTP request setup failed:', error); } } /** * Cleanup method to prevent memory leaks */ destroy() { if (this.flushTimeout) { clearTimeout(this.flushTimeout); this.flushTimeout = null; } // Flush any remaining logs this.flush(); } } /** * Returns a logging handler function based on the specified type and configuration. * * Supported types are: * - `'console'`: Logs to the console using the appropriate method for the log level. * - `'silent'`: Returns a no-op handler that discards all logs. * - `'file'`: Writes logs to a file with optional rotation; requires `filePath` in config. * - `'http'`: Sends logs to a remote HTTP endpoint; requires `url` in config. * * If required configuration is missing or initialization fails, logs an error and returns either a fallback handler or `null`. * * @param type - The type of output handler to create (`'console'`, `'silent'`, `'file'`, or `'http'`) * @returns A log handler function or `null` if the handler cannot be created */ export function createBuiltInHandler(type, config) { switch (type) { case 'console': return (level, message, data) => { const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log'; // Use safe method call to prevent object injection if (Object.prototype.hasOwnProperty.call(console, method) && typeof console[method] === 'function') { console[method](message, data); } else { console.log(message, data); } }; case 'silent': return () => { }; // No-op handler case 'file': if (config && typeof config.filePath === 'string') { try { const handler = new FileOutputHandler(config); return handler.write; } catch (error) { // Return a handler that logs the expected error message and falls back to console return (level, message, data) => { console.error('File output handler failed:', error); console.log(`[${level.toUpperCase()}] ${message}`, data); }; } } console.error('File output handler requires filePath in config'); return null; case 'http': if (config && typeof config.url === 'string') { const handler = new HttpOutputHandler(config); return handler.write; } console.error('HTTP output handler requires url in config'); return null; default: return null; } } // Export secure filesystem functions for testing export { secureExistsSync, secureMkdirSync, secureStatSync, secureWriteFileSync, secureUnlinkSync, secureRenameSync, validatePath, SAFE_BASE_DIRS }; //# sourceMappingURL=advanced-outputs.js.map