diff --git a/modules/sdk-core/src/index.ts b/modules/sdk-core/src/index.ts index 1b07844be5..f028482d36 100644 --- a/modules/sdk-core/src/index.ts +++ b/modules/sdk-core/src/index.ts @@ -17,3 +17,4 @@ export { SShare } from './bitgo/tss/ecdsa/types'; import * as common from './common'; export * from './units'; export { common }; +import './utils/consoleOverride'; diff --git a/modules/sdk-core/src/utils/consoleOverride.ts b/modules/sdk-core/src/utils/consoleOverride.ts new file mode 100644 index 0000000000..f7b20e98ea --- /dev/null +++ b/modules/sdk-core/src/utils/consoleOverride.ts @@ -0,0 +1,57 @@ +/** + * @prettier + */ + +import { sanitize } from './sanitizeLog'; + +// Store original console methods (only the ones we override) +/* eslint-disable no-console */ +const originalConsole = { + error: console.error, + log: console.log, + warn: console.warn, + info: console.info, +}; +/* eslint-enable no-console */ + +export function overrideConsole(): void { + /* eslint-disable no-console, @typescript-eslint/no-explicit-any */ + + console.error = function (...args: any[]) { + const sanitizedArgs = args.map((arg) => sanitize(arg)); + originalConsole.error.apply(console, sanitizedArgs); + }; + + console.log = function (...args: any[]) { + const sanitizedArgs = args.map((arg) => sanitize(arg)); + originalConsole.log.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); + }; + + /* eslint-enable no-console, @typescript-eslint/no-explicit-any */ +} +/** + * Restore original console methods (only the ones we overrode) + */ +export function restoreConsole(): void { + /* eslint-disable no-console */ + console.error = originalConsole.error; + console.log = originalConsole.log; + console.warn = originalConsole.warn; + console.info = originalConsole.info; + /* eslint-enable no-console */ +} + +// Auto-activate console sanitization in testing and staging +if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'staging') { + overrideConsole(); +} diff --git a/modules/sdk-core/src/utils/sanitizeLog.ts b/modules/sdk-core/src/utils/sanitizeLog.ts new file mode 100644 index 0000000000..4e1908d777 --- /dev/null +++ b/modules/sdk-core/src/utils/sanitizeLog.ts @@ -0,0 +1,71 @@ +/** + * @prettier + */ +/** + * Set of sensitive keywords for exact key matching (case-insensitive). + * Matches key names like 'token', 'bearer', 'prv', 'privatekey', 'password', 'otp'. + * Using Set for O(1) lookup performance. + */ +const SENSITIVE_KEYS = new Set(['token', 'bearer', 'prv', 'privatekey', 'password', 'otp']); + +/** + * Pattern to detect bearer v2 token values (e.g., v2xea99e123bba182f1360ad35529a7a6ae77cfc0bc4e5dcb4f88a6dd4e4bf6a8db) + * Matches strings starting with v2x followed by at least 32 hexadecimal characters + */ +const BEARER_V2_PATTERN = /^v2x[a-f0-9]{32,}$/i; + +/** + * 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 + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +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; + } + // Prevent stack overflow + if (depth > MAX_DEPTH) { + return '[Max Depth]'; + } + // Handle primitives + if (typeof data !== 'object') { + // Check if string value is a bearer v2 token + if (typeof data === 'string' && BEARER_V2_PATTERN.test(data)) { + return ''; + } + return data; + } + // Handle circular references + 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 - replace sensitive field values with + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sanitized: any = {}; + for (const key in data) { + if (data.hasOwnProperty(key)) { + // Check if key exactly matches any sensitive keyword (case-insensitive) + if (SENSITIVE_KEYS.has(key.toLowerCase())) { + // Keep the field but replace value with + sanitized[key] = ''; + } else { + // Recursively sanitize non-sensitive fields + sanitized[key] = sanitize(data[key], seen, depth + 1); + } + } + } + return sanitized; +} diff --git a/modules/sdk-core/test/unit/utils/sanitizeLog.ts b/modules/sdk-core/test/unit/utils/sanitizeLog.ts new file mode 100644 index 0000000000..b54cd719e0 --- /dev/null +++ b/modules/sdk-core/test/unit/utils/sanitizeLog.ts @@ -0,0 +1,313 @@ +/** + * @prettier + */ +import 'should'; +import { sanitize } from '../../../src/utils/sanitizeLog'; + +describe('Sanitize Log', () => { + describe('Sensitive Keys', () => { + it('should redact exact key matches (case-insensitive)', () => { + const data = { + token: 'secret-token', + TOKEN: 'another-token', + bearer: 'Bearer xyz123', + password: 'mypassword', + prv: 'xprv123456', + privatekey: 'private-key-value', + otp: '123456', + }; + + const result = sanitize(data); + + result.should.have.property('token', ''); + result.should.have.property('TOKEN', ''); + result.should.have.property('bearer', ''); + result.should.have.property('password', ''); + result.should.have.property('prv', ''); + result.should.have.property('privatekey', ''); + result.should.have.property('otp', ''); + }); + + it('should NOT redact keys that contain sensitive words but are not exact matches', () => { + const data = { + userToken: 'should-be-visible', + _token: 'should-be-visible', + tokenId: 'should-be-visible', + myPassword: 'should-be-visible', + privateKeyEncrypted: 'should-be-visible', + }; + + const result = sanitize(data); + + result.should.have.property('userToken', 'should-be-visible'); + result.should.have.property('_token', 'should-be-visible'); + result.should.have.property('tokenId', 'should-be-visible'); + result.should.have.property('myPassword', 'should-be-visible'); + result.should.have.property('privateKeyEncrypted', 'should-be-visible'); + }); + + it('should preserve non-sensitive keys', () => { + const data = { + user: 'alice', + publicKey: 'pub123', + apiKey: 'api-key-123', + metadata: 'some-data', + }; + + const result = sanitize(data); + + result.should.have.property('user', 'alice'); + result.should.have.property('publicKey', 'pub123'); + result.should.have.property('apiKey', 'api-key-123'); + result.should.have.property('metadata', 'some-data'); + }); + }); + + describe('V2x Token Pattern', () => { + it('should redact v2x tokens (32+ hex chars)', () => { + const validV2xToken = 'v2xea99e123bba182f1360ad35529a7a6ae77cfc0bc4e5dcb4f88a6dd4e4bf6a8db'; + + const result = sanitize(validV2xToken); + result.should.equal(''); + }); + + it('should redact v2x tokens in object values', () => { + const data = { + accessToken: 'v2xea99e123bba182f1360ad35529a7a6ae77cfc0bc4e5dcb4f88a6dd4e4bf6a8db', + user: 'alice', + }; + + const result = sanitize(data); + + result.should.have.property('accessToken', ''); + result.should.have.property('user', 'alice'); + }); + + it('should NOT redact short v2x-like strings', () => { + const shortV2x = 'v2x123'; // Too short (< 32 hex chars) + + const result = sanitize(shortV2x); + result.should.equal('v2x123'); + }); + + it('should NOT redact v2x with non-hex characters', () => { + const invalidV2x = 'v2xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'; + + const result = sanitize(invalidV2x); + result.should.equal('v2xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'); + }); + + it('should redact v2x tokens case-insensitively', () => { + const upperV2x = 'V2XEA99E123BBA182F1360AD35529A7A6AE77CFC0BC4E5DCB4F88A6DD4E4BF6A8DB'; + + const result = sanitize(upperV2x); + result.should.equal(''); + }); + }); + + describe('Nested Objects', () => { + it('should sanitize deeply nested objects', () => { + const data = { + user: { + name: 'bob', + credentials: { + token: 'secret-token', + otp: '123456', + metadata: { + password: 'deep-password', + publicKey: 'pub123', + }, + }, + }, + }; + + const result = sanitize(data); + + result.user.name.should.equal('bob'); + result.user.credentials.token.should.equal(''); + result.user.credentials.otp.should.equal(''); + result.user.credentials.metadata.password.should.equal(''); + result.user.credentials.metadata.publicKey.should.equal('pub123'); + }); + + it('should handle mixed nested structures', () => { + const data = { + level1: { + level2: { + level3: { + token: 'secret', + safeData: 'visible', + }, + }, + }, + }; + + const result = sanitize(data); + + result.level1.level2.level3.token.should.equal(''); + result.level1.level2.level3.safeData.should.equal('visible'); + }); + }); + + describe('Arrays', () => { + it('should sanitize objects in arrays', () => { + const data = [ + { name: 'user1', password: 'pass1' }, + { name: 'user2', token: 'token2' }, + { name: 'user3', publicKey: 'pub3' }, + ]; + + const result = sanitize(data); + + result[0].name.should.equal('user1'); + result[0].password.should.equal(''); + result[1].name.should.equal('user2'); + result[1].token.should.equal(''); + result[2].name.should.equal('user3'); + result[2].publicKey.should.equal('pub3'); + }); + + it('should sanitize v2x tokens in arrays', () => { + const data = ['v2xea99e123bba182f1360ad35529a7a6ae77cfc0bc4e5dcb4f88a6dd4e4bf6a8db', 'safe-string', 'v2x123']; + + const result = sanitize(data); + + result[0].should.equal(''); + result[1].should.equal('safe-string'); + result[2].should.equal('v2x123'); + }); + + it('should handle nested arrays', () => { + const data = [[{ token: 'secret1' }], [{ token: 'secret2' }, { password: 'secret3' }]]; + + const result = sanitize(data); + + result[0][0].token.should.equal(''); + result[1][0].token.should.equal(''); + result[1][1].password.should.equal(''); + }); + }); + + describe('Circular References', () => { + it('should handle circular references without infinite loops', () => { + const data: any = { + user: 'alice', + token: 'secret', + }; + data.self = data; // Circular reference + + const result = sanitize(data); + + result.user.should.equal('alice'); + result.token.should.equal(''); + result.self.should.equal('[Circular]'); + }); + + it('should handle deeply nested circular references', () => { + const data: any = { + level1: { + level2: { + token: 'secret', + }, + }, + }; + data.level1.level2.circular = data.level1; + + const result = sanitize(data); + + result.level1.level2.token.should.equal(''); + result.level1.level2.circular.should.equal('[Circular]'); + }); + }); + + describe('Max Depth Protection', () => { + it('should stop recursion at max depth', () => { + // Create a deeply nested object (51 levels) + let data: any = { value: 'deepest' }; + for (let i = 0; i < 51; i++) { + data = { nested: data }; + } + + const result = sanitize(data); + + // Should have stopped at depth 50 + let current = result; + let depth = 0; + while (current.nested && depth < 60) { + current = current.nested; + depth++; + } + + // Should hit '[Max Depth]' at level 50 + (typeof current === 'string' && current === '[Max Depth]').should.be.true(); + }); + }); + + describe('Primitives', () => { + it('should handle null and undefined', () => { + const nullResult = sanitize(null); + const undefinedResult = sanitize(undefined); + + (nullResult === null).should.be.true(); + (undefinedResult === undefined).should.be.true(); + }); + + it('should handle numbers, booleans, and safe strings', () => { + sanitize(123).should.equal(123); + sanitize(true).should.equal(true); + sanitize(false).should.equal(false); + sanitize('safe-string').should.equal('safe-string'); + }); + + it('should redact v2x token strings', () => { + const token = 'v2xea99e123bba182f1360ad35529a7a6ae77cfc0bc4e5dcb4f88a6dd4e4bf6a8db'; + sanitize(token).should.equal(''); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty objects', () => { + const result = sanitize({}); + Object.keys(result).length.should.equal(0); + }); + + it('should handle empty arrays', () => { + const result = sanitize([]); + result.length.should.equal(0); + }); + + it('should handle objects with null values', () => { + const data = { + token: null, + user: 'alice', + }; + + const result = sanitize(data); + + result.token.should.equal(''); // Key is sensitive, even if value is null + result.user.should.equal('alice'); + }); + + it('should handle mixed data types', () => { + const data = { + string: 'text', + number: 42, + boolean: true, + null: null, + array: [1, 2, 3], + object: { nested: 'value' }, + token: 'secret', + }; + + const result = sanitize(data); + + result.string.should.equal('text'); + result.number.should.equal(42); + result.boolean.should.equal(true); + (result.null === null).should.be.true(); + result.array.should.deepEqual([1, 2, 3]); + result.object.should.deepEqual({ nested: 'value' }); + result.token.should.equal(''); + }); + }); +});