diff --git a/client/src/Utils/logger.ts b/client/src/Utils/logger.ts index 74be87918..1b7d7a7a5 100644 --- a/client/src/Utils/logger.ts +++ b/client/src/Utils/logger.ts @@ -16,11 +16,7 @@ interface ErrorLogData { timestamp: string; level: LogLevel; message: string; - error?: { - message: string; - name: string; - stack?: string; - }; + error?: Record; context?: LogContext; url: string; userAgent: string; @@ -34,14 +30,127 @@ const shouldLog = (level: LogLevel): boolean => { return LOG_LEVEL_PRIORITY[level] >= configuredPriority; }; -const formatError = (error?: Error) => { - if (!error) return undefined; +/** + * Keys whose values must never reach the console. Matched case-insensitively + * against a substring of the key, so `newPassword` and `X-Api-Key` are covered + * by `password` and `apikey` respectively. + */ +const SENSITIVE_KEY_PATTERNS = [ + "password", + "passwd", + "pwd", + "secret", + "token", + "authorization", + "apikey", + "credential", + "passphrase", + "cookie", + "jwt", + "privatekey", + "sessionid", + "csrf", +] as const; - return { - message: error.message, +const REDACTED = "[REDACTED]"; + +const isSensitiveKey = (key: string): boolean => { + const normalized = key.toLowerCase().replace(/[-_]/g, ""); + return SENSITIVE_KEY_PATTERNS.some((pattern) => normalized.includes(pattern)); +}; + +/** + * Opaque browser types that must not be walked: they carry no useful log data + * and may hold large buffers. Resolved defensively because these globals are + * absent in SSR and some test environments. + */ +const isOpaqueObject = (value: object): boolean => { + const opaqueTypes = ["FormData", "Blob", "File", "ArrayBuffer"] as const; + return opaqueTypes.some((name) => { + const ctor = (globalThis as Record)[name]; + return typeof ctor === "function" && value instanceof (ctor as new () => object); + }); +}; + +/** + * Deep-copies a value with sensitive fields replaced by a placeholder. Walks + * class instances as well as object literals, so a credential on a DTO is + * redacted too. Opaque types (FormData, Blob) render as a type marker. + * + * `ancestors` tracks only the current path, so the same object appearing twice + * as a sibling is still rendered in full — only a genuine cycle is cut. + */ +const redactSensitive = (value: unknown, ancestors: readonly object[] = []): unknown => { + if (value === null || typeof value !== "object") return value; + + if (ancestors.includes(value)) return "[CIRCULAR]"; + + if (isOpaqueObject(value)) return `[${value.constructor?.name ?? "Object"}]`; + + // Dates and RegExps stringify usefully on their own; walking them yields nothing. + if (value instanceof Date || value instanceof RegExp) return value; + + const path = [...ancestors, value]; + + if (Array.isArray(value)) { + return value.map((item) => redactSensitive(item, path)); + } + + // Walk own enumerable properties of ANY object, including class instances — + // a credential on a DTO must be redacted just as it is on an object literal. + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + result[key] = isSensitiveKey(key) ? REDACTED : redactSensitive(entry, path); + } + return result; +}; + +/** + * Parses a serialized request body so its fields can be redacted by key. A body + * that is not JSON is replaced wholesale rather than logged raw, since an + * unparseable payload (e.g. form-encoded) may still carry credentials. + */ +const safeJsonParse = (raw: string): unknown => { + try { + const parsed = JSON.parse(raw); + return typeof parsed === "object" && parsed !== null ? parsed : "[UNPARSED BODY]"; + } catch { + return "[UNPARSED BODY]"; + } +}; + +/** + * Errors are logged as-is by the debug branches, but an AxiosError carries the + * outgoing request on `config` — including the serialized body and the + * Authorization header — so the raw object cannot be handed to the console. + * Returns a redacted plain object safe to print. + */ +const toSafeError = (error: Error): Record => { + const candidate = error as unknown as { + config?: { url?: string; method?: string; data?: unknown; headers?: unknown }; + response?: { status?: number }; + }; + + const safe: Record = { name: error.name, + message: error.message, stack: error.stack, }; + + if (candidate.config) { + const { url, method, data, headers } = candidate.config; + safe.config = { + url, + method, + // Axios serializes the request body to a string, so key-based + // redaction cannot reach into it — parse it back first. + data: redactSensitive(typeof data === "string" ? safeJsonParse(data) : data), + headers: redactSensitive(headers), + }; + safe.status = candidate.response?.status; + } + + return redactSensitive(safe) as Record; }; const createLogData = ( @@ -54,8 +163,8 @@ const createLogData = ( timestamp: new Date().toISOString(), level, message, - error: formatError(error), - context, + error: error === undefined ? undefined : toSafeError(error), + context: context === undefined ? undefined : (redactSensitive(context) as LogContext), url: window.location.href, userAgent: navigator.userAgent, }; @@ -69,10 +178,10 @@ const error = (message: string, error?: Error, context?: LogContext): void => { if (configuredLevel === "debug") { console.group(`ERROR: ${message}`); if (error) { - console.error("Error:", error); + console.error("Error:", toSafeError(error)); } if (context && Object.keys(context).length > 0) { - console.log("Context:", context); + console.log("Context:", logData.context); } console.log("URL:", logData.url); console.log("Timestamp:", logData.timestamp); @@ -90,7 +199,7 @@ const warn = (message: string, context?: LogContext): void => { if (configuredLevel === "debug") { console.group(`WARN: ${message}`); if (context && Object.keys(context).length > 0) { - console.log("Context:", context); + console.log("Context:", logData.context); } console.groupEnd(); } else { @@ -103,7 +212,7 @@ const debug = (message: string, data?: any): void => { console.group(`DEBUG: ${message}`); if (data !== undefined) { - console.log(data); + console.log(redactSensitive(data)); } console.groupEnd(); }; @@ -111,14 +220,16 @@ const debug = (message: string, data?: any): void => { const info = (message: string, data?: any): void => { if (!shouldLog("info")) return; + const safeData = data === undefined ? undefined : redactSensitive(data); + if (configuredLevel === "debug") { - console.log(`INFO: ${message}`, data); + console.log(`INFO: ${message}`, safeData); } else { const logData = { timestamp: new Date().toISOString(), level: "info" as LogLevel, message, - data, + data: safeData, }; console.log(JSON.stringify(logData)); }