diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 0d134dbfc..79ffcac32 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -12,7 +12,7 @@ var Query = require('./query') var defaults = require('./defaults') var Connection = require('./connection') const dns = require('dns') -const { logger } = require('./logger') +const { logger, safeStringify, collectionToJSON, logLazy, redactConnectionString } = require('./logger') const YB_SERVERS_QUERY = 'SELECT * FROM yb_servers()' const DEFAULT_FAILED_HOST_TTL_SECONDS = 5 @@ -58,7 +58,8 @@ class Lock { const lock = new Lock() class Client extends EventEmitter { constructor(config) { - logger.silly("Received connection string " + config) + logLazy('silly', () => "Received connection string: " + + (typeof config === 'string' ? redactConnectionString(config) : safeStringify(config, true))) super() this.connectionParameters = new ConnectionParameters(config) this.user = this.connectionParameters.user @@ -159,7 +160,7 @@ class Client extends EventEmitter { } getLeastLoadedServer(hostsList) { - logger.silly("getLeastLoadedServer(): hostsList" + [...hostsList]) + logLazy('silly', () => "getLeastLoadedServer(): hostsList" + collectionToJSON(hostsList)) if (hostsList.size === 0) { return this.host } @@ -173,7 +174,7 @@ class Client extends EventEmitter { } else { hostServerInfo = new Map(Client.hostServerInfoRR); } - logger.silly("Potential hosts: " + [...hostServerInfo]) + logLazy('silly', () => "Potential hosts: " + collectionToJSON(hostServerInfo)) let minConnectionCount = Number.MAX_VALUE let leastLoadedHosts = [] for (var i = 1; i <= Client.topologyKeyMap.size; i++) { @@ -445,9 +446,9 @@ class Client extends EventEmitter { } async iterateHostList(client) { - logger.silly("hostServerInfoPrimary: " + [...Client.hostServerInfoPrimary]) - logger.silly("hostServerInfoRR: " + [...Client.hostServerInfoRR]) - logger.silly("failedHosts: " + [...Client.failedHosts]) + logLazy('silly', () => "hostServerInfoPrimary: " + collectionToJSON(Client.hostServerInfoPrimary)) + logLazy('silly', () => "hostServerInfoRR: " + collectionToJSON(Client.hostServerInfoRR)) + logLazy('silly', () => "failedHosts: " + collectionToJSON(Client.failedHosts)) let upHostsList = [...Client.hostServerInfoPrimary.keys(), ...Client.hostServerInfoRR.keys()][Symbol.iterator]() let upHost = upHostsList.next() let hostIsUp = false @@ -596,8 +597,8 @@ class Client extends EventEmitter { } } }) - logger.debug("Updated hostServerInfoPrimary to " + [...Client.hostServerInfoPrimary] + " and usePublic to " + Client.usePublic) - logger.debug("Updated hostServerInfoRR to " + [...Client.hostServerInfoRR] + " and usePublic to " + Client.usePublic) + logLazy('debug', () => "Updated hostServerInfoPrimary to " + collectionToJSON(Client.hostServerInfoPrimary) + " and usePublic to " + Client.usePublic) + logLazy('debug', () => "Updated hostServerInfoRR to " + collectionToJSON(Client.hostServerInfoRR) + " and usePublic to " + Client.usePublic) } createConnectionMap(data) { @@ -621,7 +622,7 @@ class Client extends EventEmitter { } } }) - logger.debug("Updated connection map " + [...Client.connectionMap]) + logLazy('debug', () => "Updated connection map " + collectionToJSON(Client.connectionMap)) } createTopologyKeyMap() { @@ -646,7 +647,7 @@ class Client extends EventEmitter { throw new Error('Bad Topology Key found - ' + key) } } - logger.debug("Updated topologyKey Map " + [...Client.topologyKeyMap]) + logLazy('debug', () => "Updated topologyKey Map " + collectionToJSON(Client.topologyKeyMap)) } createMetaData(data) { @@ -772,8 +773,8 @@ class Client extends EventEmitter { Client.connectionMap.delete(eachHost) } } - logger.debug("Updated connection Map after refresh " + [...Client.connectionMap]); - logger.debug("Updated failed host list after refresh " + [...Client.failedHosts]); + logLazy('debug', () => "Updated connection Map after refresh " + collectionToJSON(Client.connectionMap)); + logLazy('debug', () => "Updated failed host list after refresh " + collectionToJSON(Client.failedHosts)); } updateMetaData(data) { diff --git a/packages/pg/lib/logger.js b/packages/pg/lib/logger.js index 9dcaca0b3..7b4c88517 100644 --- a/packages/pg/lib/logger.js +++ b/packages/pg/lib/logger.js @@ -49,7 +49,117 @@ var setLogLevel = (level) => { logger.info('Log level set to ' + level); }; +const REDACTED_PLACEHOLDER = '****'; + +/* +Key names whose values are secrets and must never be written to logs/disk. +Matching is done on a normalized key (lowercased, non-alphanumerics removed) +against these substrings, so casing/separators don't matter: "Password", +"PASSWORD", "db_password", "user-password" and "userPassword" all match. + +The list is a superset of the node-postgres config secrets (`password`, +`connectionString` which is handled separately, and the `ssl` fields +`passphrase`/`pfx`/`key`) plus the common credential names used elsewhere +(mirroring what logging libraries like pino/fast-redact redact by default). +Password spellings/typos are covered explicitly rather than with a fuzzy +regex, to avoid redacting unrelated keys. +*/ +const REDACTED_KEY_SUBSTRINGS = [ + // password family (incl. common misspellings) + 'password', 'passwrd', 'passwd', 'psswrd', 'passphrase', 'passcode', 'passkey', 'pwd', + // generic credentials / secrets + 'secret', 'token', 'credential', 'apikey', 'accesskey', 'privatekey', 'authorization', + 'sessionid', 'cookie' +]; + +// TLS/SSL config objects (node-postgres passes `ssl` straight to tls.connect) +// carry private-key material under bare keys like `key`/`pfx`/`passphrase`. +// Those bare names are too generic to redact everywhere, so we only redact +// them when the surrounding object looks like a TLS config. +const TLS_SECRET_KEYS = new Set(['key', 'pfx', 'passphrase']); +const looksLikeTlsConfig = (holder) => + holder !== null && typeof holder === 'object' && + ('cert' in holder || 'ca' in holder || 'pfx' in holder || + 'rejectUnauthorized' in holder || 'passphrase' in holder); + +const isSecretKey = (key, holder) => { + if (typeof key !== 'string' || key.length === 0) { + return false; + } + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (REDACTED_KEY_SUBSTRINGS.some((s) => normalized.includes(s))) { + return true; + } + return TLS_SECRET_KEYS.has(normalized) && looksLikeTlsConfig(holder); +}; + +// Redact the password embedded in a connection string, e.g. +// postgresql://user:secret@host/db -> postgresql://user:****@host/db +const redactConnectionString = (str) => + str.replace(/(:\/\/[^:/?#@\s]+:)[^@/\s]+@/, '$1' + REDACTED_PLACEHOLDER + '@'); + +/* +Throw-safe JSON serializer for log messages. Unlike a bare JSON.stringify it: + - never throws (circular refs and BigInt are handled, other errors are caught), + so a log line can never break the code path it is instrumenting; and + - redacts secret fields so they are not persisted to disk via the winston + File transport. A `connectionString` value has only its embedded password + stripped (the rest stays useful for debugging); every other secret field + is fully masked. +*/ +const safeStringify = (value, pretty = false) => { + const seen = new WeakSet(); + try { + // Uses a non-arrow function so `this` is the object holding `key`, + // which lets us apply context-sensitive rules (e.g. TLS `key`). + return JSON.stringify(value, function (key, val) { + if (key === 'connectionString' && typeof val === 'string') { + return redactConnectionString(val); + } + if (isSecretKey(key, this)) { + return val == null ? val : REDACTED_PLACEHOLDER; + } + if (typeof val === 'bigint') { + return val.toString(); + } + if (val !== null && typeof val === 'object') { + if (seen.has(val)) { + return '[Circular]'; + } + seen.add(val); + } + return val; + }, pretty ? 2 : undefined); + } catch (err) { + return '[unserializable: ' + err.message + ']'; + } +}; + +// Serialize a Map/Set (or any iterable) consistently and throw-safely. +// Maps are rendered as their [key, value] entries; Sets/arrays as their values. +const collectionToJSON = (collection) => { + const entries = collection instanceof Map + ? Array.from(collection.entries()) + : Array.from(collection); + return safeStringify(entries); +}; + +/* +Log a message only when the given level is active, building it lazily via the +supplied callback. This avoids eagerly running (potentially expensive) +serialization on hot paths when the message would be discarded anyway. +*/ +const logLazy = (level, buildMessage) => { + if (logger.isLevelEnabled(level)) { + logger.log(level, buildMessage()); + } +}; + module.exports = { logger, - setLogLevel + setLogLevel, + safeStringify, + collectionToJSON, + logLazy, + redactConnectionString }; \ No newline at end of file