logging.js
1 import winston from "winston"; 2 import DailyRotateFile from "winston-daily-rotate-file"; 3 import path from "path"; 4 import { fileURLToPath } from "url"; 5 import fs from "fs"; 6 import { safeStringify } from "../utils/sanitizer.js"; 7 8 const __filename = fileURLToPath(import.meta.url); 9 const __dirname = path.dirname(__filename); 10 11 const logDir = path.join(__dirname, "../../logs"); 12 const checkLogDirPermissions = () => { 13 try { 14 if (!fs.existsSync(logDir)) { 15 fs.mkdirSync(logDir, { recursive: true, mode: 0o750 }); 16 } 17 18 const stats = fs.statSync(logDir); 19 if (!stats.isDirectory()) { 20 throw new Error(`Log path exists but is not a directory: ${logDir}`); 21 } 22 23 fs.accessSync(logDir, fs.constants.R_OK | fs.constants.W_OK); 24 25 const testFile = path.join(logDir, ".permission_test"); 26 fs.writeFileSync(testFile, "test", { mode: 0o640 }); 27 fs.unlinkSync(testFile); 28 } catch (error) { 29 logger.error(`Log directory permission check failed: ${error.message}`); 30 throw new Error(`Cannot access log directory: ${error.message}`); 31 } 32 }; 33 34 const levels = { 35 important: 0, 36 error: 1, 37 warn: 2, 38 info: 3, 39 http: 4, 40 debug: 5, 41 }; 42 43 const sanitizeLogData = (data) => { 44 if (typeof data !== "object" || data === null) { 45 return data; 46 } 47 const sensitiveKeys = [ 48 "password", "token", "secret", "key", "privateKey", "mnemonic", "seed", "signature", 49 "accessToken", "refreshToken", "apiKey", "apiSecret", "authToken", "sessionToken", 50 "private_key", "api_key", "api_secret", "auth_token", "session_token", "refresh_token", 51 "access_token", "mnemonic_phrase", "seed_phrase", "wallet_signature", "encryption_key", 52 ]; 53 const sanitized = { ...data }; 54 55 for (const key of Object.keys(sanitized)) { 56 const lowerKey = key.toLowerCase(); 57 const camelCaseKey = key.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase()); 58 const snakeCaseKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); 59 60 if (sensitiveKeys.some(sensitive => 61 lowerKey.includes(sensitive.toLowerCase()) || 62 camelCaseKey.includes(sensitive) || 63 snakeCaseKey.includes(sensitive.toLowerCase()), 64 )) { 65 if (Object.prototype.hasOwnProperty.call(sanitized, key)) { 66 sanitized[key] = "[REDACTED]"; 67 } 68 } else if (typeof sanitized[key] === "object" && sanitized[key] !== null) { 69 if (Object.prototype.hasOwnProperty.call(sanitized, key)) { 70 sanitized[key] = sanitizeLogData(sanitized[key]); 71 } 72 } 73 } 74 return sanitized; 75 }; 76 77 const consoleFormat = winston.format.combine( 78 winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), 79 winston.format.colorize({ 80 all: true, 81 colors: { 82 important: "magenta", 83 error: "red", 84 warn: "yellow", 85 info: "green", 86 http: "blue", 87 debug: "gray", 88 }, 89 }), 90 winston.format.printf((info) => { 91 const sanitizedMetadata = info.metadata && Object.keys(info.metadata).length > 0 92 ? " " + safeStringify(sanitizeLogData(info.metadata)) 93 : ""; 94 return `${info.timestamp} [${info.level}]: ${info.message}${sanitizedMetadata}`; 95 }), 96 ); 97 98 const fileFormat = winston.format.combine( 99 winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), 100 winston.format.errors({ stack: true }), 101 winston.format.metadata({ fillExcept: ["message", "level", "timestamp", "label"] }), 102 winston.format.json(), 103 ); 104 105 const errorStackFormat = winston.format((info) => { 106 if (info.error instanceof Error) { 107 return { 108 ...info, 109 stack: "[Error stack hidden]", 110 message: info.error.message, 111 }; 112 } 113 return info; 114 }); 115 116 const createFileTransport = (filename, level, maxSize = "20m", maxFiles = "14d") => { 117 return new DailyRotateFile({ 118 filename: path.join(logDir, filename), 119 datePattern: "YYYY-MM-DD", 120 format: fileFormat, 121 maxSize, 122 maxFiles, 123 level, 124 permissions: 0o640, 125 handleExceptions: level === "error", 126 handleRejections: level === "error", 127 auditFile: path.join(logDir, `.audit-${filename.replace(".log", "")}.json`), 128 createSymlink: false, 129 symlinkName: null, 130 watchLog: true, 131 watchOptions: { 132 persistent: false, 133 interval: 1000, 134 }, 135 /* eslint-disable security/detect-non-literal-fs-filename */ 136 onRotate: (oldFilename, _newFilename) => { 137 try { 138 const validatedOldPath = path.isAbsolute(oldFilename) ? oldFilename : path.join(logDir, oldFilename); 139 const normalizedPath = path.normalize(validatedOldPath); 140 if (!normalizedPath.startsWith(logDir) || normalizedPath.includes("..")) { 141 throw new Error("Invalid path in log rotation"); 142 } 143 if (fs.existsSync(normalizedPath)) { 144 fs.chmodSync(normalizedPath, 0o640); 145 } 146 } catch (error) { 147 console.error("Failed to set file permissions during rotation:", error); 148 } 149 }, 150 /* eslint-enable security/detect-non-literal-fs-filename */ 151 onArchive: (zipFilename) => { 152 logger.info(`Log file archived: ${zipFilename}`); 153 }, 154 }); 155 }; 156 157 const loggerTransports = [ 158 new winston.transports.Console({ 159 format: consoleFormat, 160 level: "info", 161 }), 162 createFileTransport("info-%DATE%.log", "info"), 163 createFileTransport("error-%DATE%.log", "error"), 164 ]; 165 166 export const logger = winston.createLogger({ 167 level: "info", 168 levels, 169 format: winston.format.combine( 170 errorStackFormat(), 171 winston.format.metadata({ fillExcept: ["message", "level", "timestamp"] }), 172 ), 173 transports: loggerTransports, 174 exitOnError: false, 175 }); 176 177 export const configureLogger = (config) => { 178 const newLevel = config.logging?.level || "info"; 179 const maxSize = config.logging?.maxSize || "20m"; 180 const maxFiles = config.logging?.maxFiles || "14d"; 181 const newTransports = [ 182 new winston.transports.Console({ 183 format: consoleFormat, 184 level: newLevel, 185 }), 186 createFileTransport("info-%DATE%.log", newLevel, maxSize, maxFiles), 187 createFileTransport("error-%DATE%.log", "error", maxSize, maxFiles), 188 ]; 189 190 logger.level = newLevel; 191 logger.clear(); 192 newTransports.forEach(transport => logger.add(transport)); 193 }; 194 195 checkLogDirPermissions(); 196 197 let isShuttingDown = false; 198 export const setLoggerShutdown = () => { 199 isShuttingDown = true; 200 }; 201 202 const originalError = logger.error.bind(logger); 203 logger.error = (...args) => { 204 if (!isShuttingDown) { 205 return originalError(...args); 206 } 207 }; 208 209 const originalWarn = logger.warn.bind(logger); 210 logger.warn = (...args) => { 211 if (!isShuttingDown) { 212 return originalWarn(...args); 213 } 214 }; 215 216 const originalInfo = logger.info.bind(logger); 217 logger.info = (...args) => { 218 if (!isShuttingDown) { 219 return originalInfo(...args); 220 } 221 }; 222 223 const originalDebug = logger.debug.bind(logger); 224 logger.debug = (...args) => { 225 if (!isShuttingDown) { 226 return originalDebug(...args); 227 } 228 }; 229 230 const originalImportant = logger.important.bind(logger); 231 logger.important = (...args) => { 232 if (isShuttingDown) { 233 return; 234 } 235 236 const savedLevel = logger.level; 237 logger.level = "important"; 238 239 try { 240 originalImportant(...args); 241 } finally { 242 logger.level = savedLevel; 243 } 244 }; 245 246 function sanitizeObject(obj) { 247 if (!obj || typeof obj !== "object") { 248 return obj; 249 } 250 251 const result = { ...obj }; 252 const sensitiveKeys = [ 253 "password", "token", "refreshToken", "accessToken", "secret", "privateKey", "signature", 254 "private_key", "access_token", "refresh_token", "api_key", "api_secret", "auth_token", 255 "session_token", "mnemonic_phrase", "seed_phrase", "wallet_signature", "encryption_key", 256 ]; 257 258 for (const field of Object.keys(result)) { 259 const lowerField = field.toLowerCase(); 260 const camelCaseField = field.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase()); 261 const snakeCaseField = field.replace(/([A-Z])/g, "_$1").toLowerCase(); 262 263 if (sensitiveKeys.some(sensitive => 264 lowerField.includes(sensitive.toLowerCase()) || 265 camelCaseField.includes(sensitive) || 266 snakeCaseField.includes(sensitive.toLowerCase()), 267 )) { 268 if (Object.prototype.hasOwnProperty.call(result, field)) { 269 result[field] = "[REDACTED]"; 270 } 271 } 272 } 273 return result; 274 } 275 276 function formatBytes(bytes) { 277 if (bytes === 0) { 278 return "0 B"; 279 } 280 const k = 1024; 281 const sizes = ["B", "KB", "MB", "GB"]; 282 const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k)); 283 return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; 284 } 285 286 logger.logError = (error, context = null, additionalInfo = {}) => { 287 const logData = { 288 errorName: error.name, 289 errorMessage: error.message, 290 stack: error.stack, 291 ...additionalInfo, 292 }; 293 if (context) { 294 if (context.id) { 295 logData.requestId = context.id; 296 } 297 if (context.originalUrl) { 298 logData.url = context.originalUrl; 299 } 300 if (context.method) { 301 logData.method = context.method; 302 } 303 if (context.ip) { 304 logData.ip = context.ip; 305 } 306 if (context.user) { 307 logData.userId = context.user.id || context.user._id; 308 } 309 } 310 logger.error(error.message || "An error occurred", logData); 311 }; 312 313 logger.logAuth = (action, context, result, details = {}) => { 314 const logData = { 315 action, 316 result, 317 timestamp: new Date().toISOString(), 318 ...details, 319 }; 320 if (context) { 321 if (context.ip) { 322 logData.ip = context.ip; 323 } 324 if (context.headers && context.headers["user-agent"]) { 325 logData.userAgent = context.headers["user-agent"]; 326 } 327 if (context.id) { 328 logData.requestId = context.id; 329 } 330 if (context.user) { 331 logData.userId = context.user.id || context.user._id; 332 if (context.user.walletAddress) { 333 logData.walletAddress = context.user.walletAddress; 334 } 335 } 336 } 337 const severity = result === "success" || result === "succeeded" ? "info" : result === "warning" ? "warn" : "error"; 338 const logMethod = logger[severity]; 339 if (typeof logMethod === "function") { 340 logMethod(`${String(action).charAt(0).toUpperCase() + String(action).slice(1)} ${result}`, logData); 341 } 342 }; 343 344 logger.logSecurity = (event, context = null, details = {}) => { 345 const logData = { 346 event, 347 timestamp: new Date().toISOString(), 348 ...details, 349 }; 350 351 if (context) { 352 if (context.id) { 353 logData.requestId = context.id; 354 } 355 if (context.originalUrl) { 356 logData.url = context.originalUrl; 357 } 358 if (context.method) { 359 logData.method = context.method; 360 } 361 if (context.ip) { 362 logData.ip = context.ip; 363 } 364 if (context.headers && context.headers["user-agent"]) { 365 logData.userAgent = context.headers["user-agent"]; 366 } 367 if (context.user) { 368 logData.userId = context.user.id || context.user._id; 369 } 370 } 371 372 const severity = details.severity || "warn"; 373 const logMethod = logger[severity]; 374 if (typeof logMethod === "function") { 375 logMethod(`Security: ${event}`, logData); 376 } 377 }; 378 379 logger.logDbOperation = (operation, collection, query, duration, result = null) => { 380 const logData = { 381 operation, 382 collection, 383 query: typeof query === "object" ? JSON.stringify(query) : query, 384 duration: `${duration.toFixed(2)}ms`, 385 timestamp: new Date().toISOString(), 386 resultCount: result ? (Array.isArray(result) ? result.length : 1) : null, 387 }; 388 logger.debug(`DB Operation: ${operation} on ${collection}`, logData); 389 }; 390 391 logger.critical = (message, meta = {}) => { 392 logger.error(message, { ...meta, severity: "critical" }); 393 }; 394 395 logger.sanitizeObject = sanitizeObject; 396 logger.formatBytes = formatBytes; 397 398 logger.startTimer = () => { 399 return { 400 start: process.hrtime(), 401 lap: function () { 402 const lap = process.hrtime(this.start); 403 return lap[0] * 1000 + lap[1] / 1000000; 404 }, 405 }; 406 }; 407 408 export { sanitizeObject, formatBytes };