/** * @module @dotdo/postgres-shared/sanitize * * Connection string and credential sanitization utilities. * Ensures sensitive information like passwords and API keys * never appear in logs, error messages, or debug output. */ // ============================================================================= // Redaction Constants // ============================================================================= /** * The string used to replace redacted values */ export const REDACTED = '***' /** * The string used to replace redacted values in URLs */ export const REDACTED_PASSWORD = ':***@' // ============================================================================= // Connection String Sanitization // ============================================================================= /** * Sanitize a PostgreSQL connection string by removing sensitive credentials. * * Handles various connection string formats: * - Standard PostgreSQL: postgres://user:password@host:port/database * - With special characters in password: postgres://user:p%40ss@host/db * - Multiple @ symbols: postgres://user:pass@word@host/db * - Query string parameters: postgres://host/db?password=secret * - Alternative auth parameters: sslpassword, apikey, api_key, secret, token * * @param connectionString - The connection string to sanitize * @returns The sanitized connection string with credentials removed * * @example * ```typescript * sanitizeConnectionString('postgres://user:secret123@localhost/db') * // => 'postgres://user:***@localhost/db' * * sanitizeConnectionString('postgres://user:p%40ss@host/db?sslpassword=secret') * // => 'postgres://user:***@host/db?sslpassword=***' * ``` */ export function sanitizeConnectionString(connectionString: string | undefined | null): string { if (!connectionString) { return '' } // Handle non-string inputs gracefully if (typeof connectionString !== 'string') { return '' } let sanitized = connectionString // Pattern 1: Handle password in URL format (user:password@host) // This regex handles: // - Simple passwords: user:password@host // - URL-encoded passwords: user:p%40ssword@host // - Passwords with special chars (up to the last @ before host): user:pass@word@host // Match from the first colon after ://, up to the last @ before the host sanitized = sanitized.replace( /^((?:postgres(?:ql)?|postgresql|pg):\/\/[^:@]*):([^@]*)@/i, `$1${REDACTED_PASSWORD}` ) // Pattern 2: Handle password in query string parameters // Common sensitive query parameters const sensitiveParams = [ 'password', 'pass', 'pwd', 'sslpassword', 'ssl_password', 'apikey', 'api_key', 'apiKey', 'secret', 'token', 'auth_token', 'authToken', 'access_token', 'accessToken', 'credential', 'credentials', ] for (const param of sensitiveParams) { // Match param=value in query string (case-insensitive param name) const paramRegex = new RegExp(`([?&])${param}=([^&]*)`, 'gi') sanitized = sanitized.replace(paramRegex, `$1${param}=${REDACTED}`) } return sanitized } /** * Sanitize a URL by removing credentials from the authority section. * This is a more general function that works with any URL scheme. * * @param url - The URL to sanitize * @returns The sanitized URL with credentials removed * * @example * ```typescript * sanitizeUrl('https://user:secret@api.example.com/path') * // => 'https://user:***@api.example.com/path' * ``` */ export function sanitizeUrl(url: string | undefined | null): string { if (!url) { return '' } if (typeof url !== 'string') { return '' } try { const parsed = new URL(url) if (parsed.password) { parsed.password = REDACTED } // Also sanitize sensitive query parameters const sensitiveParams = ['password', 'apikey', 'api_key', 'secret', 'token', 'auth_token', 'access_token'] for (const param of sensitiveParams) { if (parsed.searchParams.has(param)) { parsed.searchParams.set(param, REDACTED) } } return parsed.toString() } catch { // If URL parsing fails, fall back to regex-based sanitization return sanitizeConnectionString(url) } } // ============================================================================= // Error Message Sanitization // ============================================================================= /** * Sanitize an error message by removing potential credentials. * Scans for common patterns that might contain sensitive data. * * @param message - The error message to sanitize * @returns The sanitized error message * * @example * ```typescript * sanitizeErrorMessage('Failed to connect to postgres://user:secret@host') * // => 'Failed to connect to postgres://user:***@host' * ``` */ export function sanitizeErrorMessage(message: string | undefined | null): string { if (!message) { return '' } if (typeof message !== 'string') { return '' } let sanitized = message // Look for postgres:// URLs in the message and sanitize them const postgresUrlRegex = /postgres(?:ql)?:\/\/[^\s'"]+/gi sanitized = sanitized.replace(postgresUrlRegex, (match) => sanitizeConnectionString(match)) // Look for other URLs that might contain credentials const genericUrlRegex = /https?:\/\/[^\s'"]+/gi sanitized = sanitized.replace(genericUrlRegex, (match) => { // Only sanitize if it looks like it contains credentials if (match.includes('@') || match.includes('password=') || match.includes('apikey=')) { return sanitizeUrl(match) } return match }) // Sanitize standalone password assignments // Pattern: password = "value" or password: "value" sanitized = sanitized.replace( /\b(password|secret|apikey|api_key|token|credential)\s*[:=]\s*["']?([^"'\s,}]+)["']?/gi, `$1: ${REDACTED}` ) return sanitized } // ============================================================================= // Object Sanitization // ============================================================================= /** * Keys that should have their values redacted */ const SENSITIVE_KEYS = new Set([ 'password', 'pass', 'pwd', 'secret', 'apikey', 'apiKey', 'api_key', 'token', 'auth_token', 'authToken', 'access_token', 'accessToken', 'refresh_token', 'refreshToken', 'credential', 'credentials', 'connectionString', 'connection_string', 'sslpassword', 'ssl_password', 'privateKey', 'private_key', ]) /** * Check if a key name suggests it contains sensitive data */ function isSensitiveKey(key: string): boolean { const lowerKey = key.toLowerCase() return SENSITIVE_KEYS.has(key) || SENSITIVE_KEYS.has(lowerKey) || lowerKey.includes('password') || lowerKey.includes('secret') || lowerKey.includes('apikey') || lowerKey.includes('token') || lowerKey.includes('credential') } /** * Recursively sanitize an object by redacting sensitive values. * Useful for logging configuration objects safely. * * @param obj - The object to sanitize * @param maxDepth - Maximum recursion depth (default: 10) * @returns A new object with sensitive values redacted * * @example * ```typescript * sanitizeObject({ * host: 'localhost', * user: 'admin', * password: 'secret123', * connectionString: 'postgres://user:pass@host/db' * }) * // => { host: 'localhost', user: 'admin', password: '***', connectionString: '***' } * ``` */ export function sanitizeObject>( obj: T | undefined | null, maxDepth: number = 10 ): T | null { if (obj === null || obj === undefined) { return null } if (typeof obj !== 'object') { return obj } if (Array.isArray(obj)) { return obj.map(item => { if (typeof item === 'object' && item !== null) { return sanitizeObject(item as Record, maxDepth - 1) } return item }) as unknown as T } if (maxDepth <= 0) { return { _truncated: true } as unknown as T } const sanitized: Record = {} for (const [key, value] of Object.entries(obj)) { if (isSensitiveKey(key)) { // For connection strings, partially sanitize to show host/database if (key.toLowerCase().includes('connection') && typeof value === 'string') { sanitized[key] = sanitizeConnectionString(value) } else { sanitized[key] = REDACTED } } else if (typeof value === 'string') { // Check if the string value looks like a connection string if (value.startsWith('postgres://') || value.startsWith('postgresql://')) { sanitized[key] = sanitizeConnectionString(value) } else if (value.startsWith('http://') || value.startsWith('https://')) { // Only sanitize URLs that contain credentials if (value.includes('@') || value.includes('password=')) { sanitized[key] = sanitizeUrl(value) } else { sanitized[key] = value } } else { sanitized[key] = value } } else if (typeof value === 'object' && value !== null) { sanitized[key] = sanitizeObject(value as Record, maxDepth - 1) } else { sanitized[key] = value } } return sanitized as T } // ============================================================================= // Debug String Sanitization // ============================================================================= /** * Create a safe debug string from a connection configuration. * Shows useful information without exposing credentials. * * @param config - The connection configuration * @returns A safe debug string * * @example * ```typescript * createSafeDebugString({ * host: 'db.example.com', * port: 5432, * database: 'mydb', * user: 'admin', * password: 'secret' * }) * // => 'host=db.example.com port=5432 database=mydb user=admin' * ``` */ export function createSafeDebugString(config: { host?: string port?: number | string database?: string user?: string ssl?: boolean | string | object [key: string]: unknown }): string { const parts: string[] = [] if (config.host) parts.push(`host=${config.host}`) if (config.port) parts.push(`port=${config.port}`) if (config.database) parts.push(`database=${config.database}`) if (config.user) parts.push(`user=${config.user}`) if (config.ssl !== undefined) { if (typeof config.ssl === 'boolean') { parts.push(`ssl=${config.ssl}`) } else if (typeof config.ssl === 'string') { parts.push(`ssl=${config.ssl}`) } else { parts.push('ssl=configured') } } return parts.join(' ') } // ============================================================================= // Logging Utilities // ============================================================================= /** * Wrap a value for safe logging. * Returns a string representation that can be safely logged. * * @param value - The value to wrap * @returns A safe string representation */ export function safeLog(value: unknown): string { if (value === null || value === undefined) { return String(value) } if (typeof value === 'string') { return sanitizeErrorMessage(value) } if (typeof value === 'object') { try { const sanitized = sanitizeObject(value as Record) return JSON.stringify(sanitized, null, 2) } catch { return '[Object - failed to serialize]' } } return String(value) }