From 5a4755d1cf7ea2fd09763897514c06495a4150cb Mon Sep 17 00:00:00 2001 From: Dadam Rishikesh Reddy Date: Tue, 27 Jan 2026 18:11:44 +0530 Subject: [PATCH] fix: sensitive data exposure in console logs Ticket:WP-7503 Prevent sensitive data exposure in console logs Implements automatic sanitization of console output to prevent sensitive data from being logged in plain text. Intercepts all console methods (log, error, warn, etc.) and removes fields containing sensitive keywords before logging. Resolves issue where sensitive fields like tokens, passwords, private keys, and client credentials were exposed in plain text. Changes: - Added recursive sanitization with 18 sensitive keywords (token, bearer, privatekey, password, secret, client, oauth, mnemonic, seed, signature, otp, apikey, etc.) - Intercepts 9 console methods (log, error, warn, info, debug, dir, table, trace, assert) - Initialized console override in both entry points (bin/bitgo-express, src/expressApp.ts) --- modules/express/bin/bitgo-express | 4 + modules/express/src/expressApp.ts | 3 +- modules/express/src/utils/consoleOverride.ts | 108 +++++ modules/express/src/utils/sanitizeLog.ts | 99 +++++ .../test/unit/utils/sanitizeLog.test.ts | 408 ++++++++++++++++++ 5 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 modules/express/src/utils/consoleOverride.ts create mode 100644 modules/express/src/utils/sanitizeLog.ts create mode 100644 modules/express/test/unit/utils/sanitizeLog.test.ts diff --git a/modules/express/bin/bitgo-express b/modules/express/bin/bitgo-express index 1598a57a4c..dfa338e693 100755 --- a/modules/express/bin/bitgo-express +++ b/modules/express/bin/bitgo-express @@ -10,6 +10,10 @@ process.on('unhandledRejection', (reason, promise) => { const { init } = require('../dist/src/expressApp'); +// Initialize console sanitization +const { overrideConsole } = require('../dist/src/utils/consoleOverride'); +overrideConsole(); + if (require.main === module) { init().catch((err) => { console.log(`Fatal error: ${err.message}`); diff --git a/modules/express/src/expressApp.ts b/modules/express/src/expressApp.ts index 1dbc97f30b..b4aad739bb 100644 --- a/modules/express/src/expressApp.ts +++ b/modules/express/src/expressApp.ts @@ -14,7 +14,8 @@ import timeout from 'connect-timeout'; import * as bodyParser from 'body-parser'; import { Config, config } from './config'; - +// Initialize console sanitization (ensures override for TypeScript entry point) +import './utils/consoleOverride'; const debug = debugLib('bitgo:express'); import { SSL_OP_NO_TLSv1 } from 'constants'; diff --git a/modules/express/src/utils/consoleOverride.ts b/modules/express/src/utils/consoleOverride.ts new file mode 100644 index 0000000000..7af6bbd9d5 --- /dev/null +++ b/modules/express/src/utils/consoleOverride.ts @@ -0,0 +1,108 @@ +/** + * @prettier + */ + +import { sanitize } from './sanitizeLog'; + +// Store original console methods for restoration if needed +const originalConsole = { + log: console.log, + error: console.error, + warn: console.warn, + debug: console.debug, + info: console.info, + dir: console.dir, + table: console.table, + trace: console.trace, + assert: console.assert, +}; + +/** + * Override global console methods to sanitize sensitive data + * This intercepts all console calls and removes sensitive fields before logging + */ +export function overrideConsole(): void { + // Standard logging methods + /* eslint-disable no-console, @typescript-eslint/no-explicit-any */ + console.log = function (...args: any[]) { + const sanitizedArgs = args.map((arg) => sanitize(arg)); + originalConsole.log.apply(console, sanitizedArgs); + }; + + console.error = function (...args: any[]) { + const sanitizedArgs = args.map((arg) => sanitize(arg)); + originalConsole.error.apply(console, sanitizedArgs); + }; + + console.warn = function (...args: any[]) { + const sanitizedArgs = args.map((arg) => sanitize(arg)); + originalConsole.warn.apply(console, sanitizedArgs); + }; + + console.info = function (...args: any[]) { + const sanitizedArgs = args.map((arg) => sanitize(arg)); + originalConsole.info.apply(console, sanitizedArgs); + }; + + console.debug = function (...args: any[]) { + const sanitizedArgs = args.map((arg) => sanitize(arg)); + originalConsole.debug.apply(console, sanitizedArgs); + }; + + // Special methods with specific handling + + /** + * console.dir(obj, options) + * Second argument is options, not data - don't sanitize it + */ + console.dir = function (obj: any, options?: any) { + const sanitizedObj = sanitize(obj); + originalConsole.dir.call(console, sanitizedObj, options); + }; + + /** + * console.table(data, properties) + * Second argument is column selection - don't sanitize it + */ + console.table = function (data: any, properties?: string[]) { + const sanitizedData = sanitize(data); + originalConsole.table.call(console, sanitizedData, properties); + }; + + /** + * console.trace(...args) + * Prints stack trace with optional message/data + */ + console.trace = function (...args: any[]) { + const sanitizedArgs = args.map((arg) => sanitize(arg)); + originalConsole.trace.apply(console, sanitizedArgs); + }; + + /** + * console.assert(condition, ...args) + * First argument is boolean condition - don't sanitize it + */ + console.assert = function (condition?: boolean, ...args: any[]) { + const sanitizedArgs = args.map((arg) => sanitize(arg)); + originalConsole.assert.call(console, condition, ...sanitizedArgs); + }; + /* eslint-enable no-console, @typescript-eslint/no-explicit-any */ +} + +/** + * Restore original console methods + * Useful for testing or if sanitization needs to be disabled + */ +export function restoreConsole(): void { + /* eslint-disable no-console */ + console.log = originalConsole.log; + console.error = originalConsole.error; + console.warn = originalConsole.warn; + console.debug = originalConsole.debug; + console.info = originalConsole.info; + console.dir = originalConsole.dir; + console.table = originalConsole.table; + console.trace = originalConsole.trace; + console.assert = originalConsole.assert; + /* eslint-enable no-console */ +} diff --git a/modules/express/src/utils/sanitizeLog.ts b/modules/express/src/utils/sanitizeLog.ts new file mode 100644 index 0000000000..6b564bcb4b --- /dev/null +++ b/modules/express/src/utils/sanitizeLog.ts @@ -0,0 +1,99 @@ +/** + * @prettier + */ + +/** + * List of sensitive keywords to detect and remove from logs + * Uses case-insensitive substring matching + */ +const SENSITIVE_KEYWORDS = [ + // Authentication & Authorization Tokens + 'token', // Catches: token, _token, accessToken, refreshToken, authToken, bearer_token, authtoken, etc. + 'bearer', // Catches: bearer, Bearer, bearer_token + 'authorization', // Catches: authorization, Authorization + 'authkey', // Catches: authKey, _authKey, myAuthKey + 'oauth', // Catches: oauth, oAuth, oauth_token, oauth_client + + // Client Credentials + 'client', // Catches: client, client_id, client_secret, clientSecret, clientId, oauth_client + + // Private Keys & Cryptographic Material + 'prv', // Catches: prv, xprv, encryptedPrv + 'privatekey', // Catches: privateKey, userPrivateKey, backupPrivateKey, rootPrivateKey, encryptedPrivateKey + 'secret', // Catches: secret, _secret, clientSecret, secretKey, apiSecretKey + + // Keychains + 'keychain', // Catches: keychain, keychains, userKeychain + + // Passwords & Passphrases + 'password', // Catches: password, _password, userPassword, walletPassword + 'passwd', // Catches: passwd, _passwd + 'passphrase', // Catches: passphrase, walletPassphrase + + // Recovery & Seeds + 'mnemonic', // Catches: mnemonic, mnemonicPhrase + 'seed', // Catches: seed, seedPhrase, userSeed + + // Signatures & OTP + 'signature', // Catches: signature, txSignature, walletSignature + 'otp', // Catches: otp, otpCode, totpSecret + + // API Keys + 'apikey', // Catches: apiKey, apiKeyValue, myApiKey +]; + +/** + * Recursively sanitize data by removing sensitive fields + * @param data - The data to sanitize + * @param seen - WeakSet to track circular references + * @param depth - Current recursion depth + * @returns Sanitized data with sensitive fields removed + */ +export function sanitize(data: any, seen: WeakSet> = new WeakSet(), depth = 0): any { + const MAX_DEPTH = 50; + + // Handle null/undefined + if (data === null || data === undefined) { + return data; + } + + // Max depth protection to prevent stack overflow + if (depth > MAX_DEPTH) { + return '[Max Depth]'; + } + + // Handle primitives (string, number, boolean) + if (typeof data !== 'object') { + return data; + } + + // Circular reference detection + if (seen.has(data)) { + return '[Circular]'; + } + seen.add(data); + + // Handle arrays + if (Array.isArray(data)) { + return data.map((item) => sanitize(item, seen, depth + 1)); + } + + // Handle objects + const sanitized: any = {}; + for (const key in data) { + if (data.hasOwnProperty(key)) { + const lowerKey = key.toLowerCase(); + + // Check if key contains any sensitive keyword (substring matching) + const isSensitive = SENSITIVE_KEYWORDS.some((keyword) => lowerKey.includes(keyword)); + + if (!isSensitive) { + // Safe field - recursively sanitize value + sanitized[key] = sanitize(data[key], seen, depth + 1); + } + // Sensitive fields are skipped (removed from output) + } + } + + return sanitized; +} diff --git a/modules/express/test/unit/utils/sanitizeLog.test.ts b/modules/express/test/unit/utils/sanitizeLog.test.ts new file mode 100644 index 0000000000..80f1a8af91 --- /dev/null +++ b/modules/express/test/unit/utils/sanitizeLog.test.ts @@ -0,0 +1,408 @@ +/** + * @prettier + */ + +import { sanitize } from '../../../src/utils/sanitizeLog'; +import 'should'; + +describe('sanitizeLog', () => { + describe('sensitive field removal', () => { + it('should remove token field', () => { + const input = { token: 'secret123', name: 'John' }; + const result = sanitize(input); + result.should.eql({ name: 'John' }); + (result as any).should.not.have.property('token'); + }); + + it('should remove _token field (underscore prefix)', () => { + const input = { _token: 'v2x54ef119d44dbdc76069d19d41f70c48ef5ea344215b5edc36552e1ba648d9d88', name: 'John' }; + const result = sanitize(input); + result.should.eql({ name: 'John' }); + (result as any).should.not.have.property('_token'); + }); + + it('should remove all token variations', () => { + const input = { + accessToken: 'abc', + refreshToken: 'def', + bearerToken: 'ghi', + authToken: 'jkl', + normalField: 'safe', + }; + const result = sanitize(input); + result.should.eql({ normalField: 'safe' }); + (result as any).should.not.have.property('accessToken'); + (result as any).should.not.have.property('refreshToken'); + (result as any).should.not.have.property('bearerToken'); + (result as any).should.not.have.property('authToken'); + }); + + it('should remove password fields', () => { + const input = { + password: 'mypass', + userPassword: 'p2', + passphrase: 'phrase', + passwd: 'p3', + name: 'safe', + }; + const result = sanitize(input); + result.should.eql({ name: 'safe' }); + (result as any).should.not.have.property('password'); + (result as any).should.not.have.property('userPassword'); + (result as any).should.not.have.property('passphrase'); + (result as any).should.not.have.property('passwd'); + }); + + it('should remove key fields', () => { + const input = { + privateKey: 'k1', + secretKey: 'k2', + apiKey: 'k3', + prv: 'k4', + keychain: 'k5', + name: 'safe', + }; + const result = sanitize(input); + result.should.eql({ name: 'safe' }); + (result as any).should.not.have.property('privateKey'); + (result as any).should.not.have.property('secretKey'); + (result as any).should.not.have.property('apiKey'); + (result as any).should.not.have.property('prv'); + (result as any).should.not.have.property('keychain'); + }); + + it('should be case-insensitive', () => { + const input = { TOKEN: 'abc', Token: 'def', ToKeN: 'ghi', name: 'safe' }; + const result = sanitize(input); + result.should.eql({ name: 'safe' }); + (result as any).should.not.have.property('TOKEN'); + (result as any).should.not.have.property('Token'); + (result as any).should.not.have.property('ToKeN'); + }); + + it('should remove clientSecret and clientId', () => { + const input = { + clientSecret: 'secret', + clientId: 'id123', + client_secret: 'secret2', + client_id: 'id456', + name: 'safe', + }; + const result = sanitize(input); + result.should.eql({ name: 'safe' }); + (result as any).should.not.have.property('clientSecret'); + (result as any).should.not.have.property('clientId'); + (result as any).should.not.have.property('client_secret'); + (result as any).should.not.have.property('client_id'); + }); + + it('should remove mnemonic and seed fields', () => { + const input = { + mnemonic: 'word1 word2 word3', + seed: 'seedphrase', + seedPhrase: 'phrase', + name: 'safe', + }; + const result = sanitize(input); + result.should.eql({ name: 'safe' }); + (result as any).should.not.have.property('mnemonic'); + (result as any).should.not.have.property('seed'); + (result as any).should.not.have.property('seedPhrase'); + }); + + it('should remove signature and otp fields', () => { + const input = { + signature: 'sig123', + otp: '123456', + otpCode: '654321', + name: 'safe', + }; + const result = sanitize(input); + result.should.eql({ name: 'safe' }); + (result as any).should.not.have.property('signature'); + (result as any).should.not.have.property('otp'); + (result as any).should.not.have.property('otpCode'); + }); + }); + + describe('nested objects', () => { + it('should sanitize deeply nested objects', () => { + const input = { + user: { + name: 'John', + credentials: { + token: 'secret', + apiKey: 'key123', + }, + }, + safe: 'data', + }; + const result = sanitize(input); + result.should.eql({ + user: { + name: 'John', + credentials: {}, + }, + safe: 'data', + }); + }); + + it('should handle the BitGo wallet case from real logs', () => { + const input = { + wallet: { + id: 'wallet123', + bitgo: { + _token: 'v2x-secret-token', + _baseUrl: 'https://app.bitgo-test.com', + }, + }, + amount: 100, + }; + const result = sanitize(input); + result.should.eql({ + wallet: { + id: 'wallet123', + bitgo: { + _baseUrl: 'https://app.bitgo-test.com', + }, + }, + amount: 100, + }); + (result.wallet.bitgo as any).should.not.have.property('_token'); + }); + + it('should handle very deep nesting', () => { + const input = { + level1: { + level2: { + level3: { + level4: { + level5: { + token: 'deep-secret', + safe: 'visible', + }, + }, + }, + }, + }, + }; + const result = sanitize(input); + result.should.eql({ + level1: { + level2: { + level3: { + level4: { + level5: { + safe: 'visible', + }, + }, + }, + }, + }, + }); + }); + }); + + describe('circular references', () => { + it('should handle direct circular references', () => { + const input: any = { name: 'Test' }; + input.self = input; + const result = sanitize(input); + result.should.have.property('name', 'Test'); + result.should.have.property('self', '[Circular]'); + }); + + it('should handle nested circular references', () => { + const parent: any = { name: 'Parent' }; + const child: any = { name: 'Child', parent }; + parent.child = child; + const result = sanitize(parent); + result.should.have.property('name', 'Parent'); + result.child.should.have.property('name', 'Child'); + result.child.parent.should.equal('[Circular]'); + }); + + it('should handle circular references with sensitive fields', () => { + const obj: any = { token: 'secret', name: 'Test' }; + obj.self = obj; + const result = sanitize(obj); + result.should.eql({ name: 'Test', self: '[Circular]' }); + (result as any).should.not.have.property('token'); + }); + }); + + describe('arrays', () => { + it('should sanitize arrays of objects', () => { + const input = { + users: [ + { name: 'Alice', token: 's1' }, + { name: 'Bob', apiKey: 's2' }, + ], + }; + const result = sanitize(input); + result.should.eql({ + users: [{ name: 'Alice' }, { name: 'Bob' }], + }); + }); + + it('should handle nested arrays', () => { + const input = { + data: [[{ token: 'a', value: 1 }], [{ password: 'b', value: 2 }]], + }; + const result = sanitize(input); + result.should.eql({ + data: [[{ value: 1 }], [{ value: 2 }]], + }); + }); + + it('should handle arrays with mixed types', () => { + const input = { + mixed: ['string', 123, { token: 'secret', safe: 'data' }, [1, 2, 3]], + }; + const result = sanitize(input); + result.should.eql({ + mixed: ['string', 123, { safe: 'data' }, [1, 2, 3]], + }); + }); + }); + + describe('primitives', () => { + it('should return primitives unchanged', () => { + sanitize('string').should.equal('string'); + sanitize(123).should.equal(123); + sanitize(true).should.equal(true); + sanitize(false).should.equal(false); + (sanitize(null) === null).should.be.true(); + (sanitize(undefined) === undefined).should.be.true(); + }); + + it('should handle empty strings and numbers', () => { + sanitize('').should.equal(''); + sanitize(0).should.equal(0); + sanitize(-1).should.equal(-1); + }); + }); + + describe('edge cases', () => { + it('should handle empty objects', () => { + const result = sanitize({}); + result.should.eql({}); + }); + + it('should handle empty arrays', () => { + const result = sanitize([]); + result.should.eql([]); + }); + + it('should handle objects with only sensitive fields', () => { + const input = { + token: 'a', + password: 'b', + apiKey: 'c', + }; + const result = sanitize(input); + result.should.eql({}); + (result as any).should.not.have.property('token'); + (result as any).should.not.have.property('password'); + (result as any).should.not.have.property('apiKey'); + }); + + it('should handle max depth protection', () => { + // Create a very deep object (200 levels) + let deep: any = { value: 'bottom' }; + for (let i = 0; i < 200; i++) { + deep = { nested: deep }; + } + + // Should not throw and should handle gracefully + const result = sanitize(deep); + result.should.be.an.Object(); + }); + + it('should preserve safe fields that contain sensitive keywords in values', () => { + const input = { + description: 'This is a token description', + note: 'Remember your password', + value: 'key-value-pair', + }; + const result = sanitize(input); + result.should.eql({ + description: 'This is a token description', + note: 'Remember your password', + value: 'key-value-pair', + }); + }); + }); + + describe('real-world scenarios', () => { + it('should sanitize Express request-like objects', () => { + const input = { + method: 'POST', + url: '/api/wallet', + headers: { + authorization: 'Bearer secret-token', + 'content-type': 'application/json', + 'x-custom': 'value', + }, + body: { + walletId: 'w123', + passphrase: 'secret-pass', + }, + }; + const result = sanitize(input); + result.should.eql({ + method: 'POST', + url: '/api/wallet', + headers: { + 'content-type': 'application/json', + 'x-custom': 'value', + }, + body: { + walletId: 'w123', + }, + }); + (result.headers as any).should.not.have.property('authorization'); + (result.body as any).should.not.have.property('passphrase'); + }); + + it('should sanitize BitGo transaction params', () => { + const input = { + coin: 'btc', + txPrebuild: { + txHex: '01000000...', + tx: { + inputs: [{ address: 'addr1' }], + outputs: [{ address: 'addr2' }], + }, + }, + wallet: { + _id: 'w123', + bitgo: { + _token: 'v2x123...', // This should be removed + _baseUrl: 'https://bitgo.com', + }, + }, + prv: 'xprv123...', // This should be removed + }; + const result = sanitize(input); + result.should.eql({ + coin: 'btc', + txPrebuild: { + txHex: '01000000...', + tx: { + inputs: [{ address: 'addr1' }], + outputs: [{ address: 'addr2' }], + }, + }, + wallet: { + _id: 'w123', + bitgo: { + _baseUrl: 'https://bitgo.com', + }, + }, + }); + (result as any).should.not.have.property('prv'); + (result.wallet.bitgo as any).should.not.have.property('_token'); + }); + }); +});