Merge pull request #3844 from bluewave-labs/fix/redact-sensitive-fields-client-logs
fix(security): redact sensitive fields from client-side logs
This commit is contained in:
1 file changed
+128
-17
+128
-17
@@ -16,11 +16,7 @@ interface ErrorLogData {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
error?: {
|
||||
message: string;
|
||||
name: string;
|
||||
stack?: string;
|
||||
};
|
||||
error?: Record<string, unknown>;
|
||||
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<string, unknown>)[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<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
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<string, unknown> => {
|
||||
const candidate = error as unknown as {
|
||||
config?: { url?: string; method?: string; data?: unknown; headers?: unknown };
|
||||
response?: { status?: number };
|
||||
};
|
||||
|
||||
const safe: Record<string, unknown> = {
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user