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:
Alexander Holliday authored and GitHub committed 2026-08-18 10:49:47 -07:00
commit bc25c15c34
1 file changed
+128 -17
+128 -17
View File
@@ -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));
}