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'); + }); + }); +});