/ src / middleware / logging.js
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 };