diff --git a/modules/logger/.mocharc.yml b/modules/logger/.mocharc.yml new file mode 100644 index 0000000000..b18501b155 --- /dev/null +++ b/modules/logger/.mocharc.yml @@ -0,0 +1,8 @@ +require: 'tsx' +timeout: '20000' +reporter: 'min' +reporter-option: + - 'cdn=true' + - 'json=false' +exit: true +spec: ['test/unit/**/*.ts'] diff --git a/modules/logger/package.json b/modules/logger/package.json index 7b4dd642b4..762a5ea4cc 100644 --- a/modules/logger/package.json +++ b/modules/logger/package.json @@ -13,7 +13,9 @@ "check-fmt": "prettier --check '**/*.{ts,js,json}'", "clean": "rm -r ./dist", "lint": "eslint --quiet .", - "prepare": "npm run build" + "prepare": "npm run build", + "test": "npm run unit-test", + "unit-test": "mocha 'test/unit/**/*.ts'" }, "author": "BitGo SDK Team ", "license": "MIT", diff --git a/modules/logger/src/logger.ts b/modules/logger/src/logger.ts index 46ad167d82..85d2f2c1a9 100644 --- a/modules/logger/src/logger.ts +++ b/modules/logger/src/logger.ts @@ -1,4 +1,4 @@ -import { sanitize } from './sanitizeLog'; +import { sanitize, getErrorData } from './sanitizeLog'; /** * BitGo Logger with automatic sanitization for all environments @@ -9,10 +9,10 @@ class BitGoLogger { */ private sanitizeArgs(args: unknown[]): unknown[] { return args.map((arg) => { - if (typeof arg === 'object' && arg !== null) { - return sanitize(arg); + if (arg instanceof Error) { + return sanitize(getErrorData(arg)); } - return arg; + return sanitize(arg); }); } diff --git a/modules/logger/src/sanitizeLog.ts b/modules/logger/src/sanitizeLog.ts index 12b49e81d9..25da5306ab 100644 --- a/modules/logger/src/sanitizeLog.ts +++ b/modules/logger/src/sanitizeLog.ts @@ -15,7 +15,7 @@ const SENSITIVE_KEYS = new Set([ '_token', ]); -const BEARER_V2_PATTERN = /^v2x[a-f0-9]{32,}$/i; +const SENSITIVE_PREFIXES = ['v2x', 'xprv']; /** * Checks if a key is sensitive (case-insensitive) @@ -25,10 +25,30 @@ function isSensitiveKey(key: string): boolean { } /** - * Checks if a value matches the bearer v2 token pattern + * Checks if a string value is sensitive based on known prefixes. + * Unlike isSensitiveKey (which checks property names), this identifies + * sensitive data by recognizable content patterns — useful when there + * is no key context (e.g. top-level strings, array elements). */ -function isBearerV2Token(value: unknown): boolean { - return typeof value === 'string' && BEARER_V2_PATTERN.test(value); +function isSensitiveStringValue(s: string): boolean { + return SENSITIVE_PREFIXES.some((prefix) => s.startsWith(prefix)); +} + +export function getErrorData(error: unknown): unknown { + if (!(error && error instanceof Error)) { + return error; + } + + const errorData: Record = { + name: error.name, + }; + + for (const key of Object.getOwnPropertyNames(error)) { + const value = (error as unknown as Record)[key]; + errorData[key] = value instanceof Error ? getErrorData(value) : value; + } + + return errorData; } /** @@ -36,16 +56,23 @@ function isBearerV2Token(value: unknown): boolean { * Handles circular references and nested structures */ export function sanitize(obj: unknown, seen = new WeakSet>(), depth = 0): unknown { - // Prevent infinite recursion - if (depth > 50) { + if (depth > 25) { return '[Max Depth Exceeded]'; } - // Handle primitives if (obj === null || obj === undefined) { return obj; } + // Handle BigInt (JSON.stringify(1n) throws TypeError) + if (typeof obj === 'bigint') { + return obj.toString(); + } + + if (typeof obj === 'string') { + return isSensitiveStringValue(obj) ? '' : obj; + } + if (typeof obj !== 'object') { return obj; } @@ -62,16 +89,21 @@ export function sanitize(obj: unknown, seen = new WeakSet sanitize(item, seen, depth + 1)); } + // Handle Date objects + if (obj instanceof Date) { + return isNaN(obj.getTime()) ? '[Invalid Date]' : obj.toISOString(); + } + // Handle objects const sanitized: Record = {}; for (const [key, value] of Object.entries(obj)) { - if (isSensitiveKey(key) || isBearerV2Token(value)) { + if (isSensitiveKey(key) || (typeof value === 'string' && isSensitiveStringValue(value))) { sanitized[key] = ''; - } else if (typeof value === 'object' && value !== null) { - sanitized[key] = sanitize(value, seen, depth + 1); + } else if (value instanceof Error) { + sanitized[key] = sanitize(getErrorData(value), seen, depth + 1); } else { - sanitized[key] = value; + sanitized[key] = sanitize(value, seen, depth + 1); } } diff --git a/modules/logger/test/unit/sanitizeLog.ts b/modules/logger/test/unit/sanitizeLog.ts new file mode 100644 index 0000000000..97eeb31569 --- /dev/null +++ b/modules/logger/test/unit/sanitizeLog.ts @@ -0,0 +1,380 @@ +import assert from 'assert'; +import { sanitize, getErrorData } from '../../src/sanitizeLog'; + +const V2_TOKEN = 'v2xaabbccdd112233445566778899aabbccddeeff'; +const XPRV_KEY = + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi'; + +describe('sanitize', function () { + describe('primitives', function () { + it('should pass through null', function () { + assert.strictEqual(sanitize(null), null); + }); + + it('should pass through undefined', function () { + assert.strictEqual(sanitize(undefined), undefined); + }); + + it('should pass through numbers', function () { + assert.strictEqual(sanitize(42), 42); + }); + + it('should pass through booleans', function () { + assert.strictEqual(sanitize(true), true); + }); + + it('should pass through plain strings', function () { + assert.strictEqual(sanitize('hello world'), 'hello world'); + }); + }); + + describe('sensitive string value redaction', function () { + it('should redact a string starting with v2x', function () { + assert.strictEqual(sanitize(V2_TOKEN), ''); + }); + + it('should redact a short string starting with v2x', function () { + assert.strictEqual(sanitize('v2xaabb'), ''); + }); + + it('should redact a string starting with xprv', function () { + assert.strictEqual(sanitize(XPRV_KEY), ''); + }); + + it('should not redact when sensitive prefix is not at the start', function () { + assert.strictEqual(sanitize(`Bearer ${V2_TOKEN}`), `Bearer ${V2_TOKEN}`); + assert.strictEqual(sanitize('not an xprv key'), 'not an xprv key'); + }); + + it('should not redact a plain string without sensitive prefix', function () { + assert.strictEqual(sanitize('normal message'), 'normal message'); + }); + }); + + describe('sensitive key redaction', function () { + it('should redact token key', function () { + assert.deepStrictEqual(sanitize({ token: 'abc' }), { token: '' }); + }); + + it('should redact password key', function () { + assert.deepStrictEqual(sanitize({ password: 'secret' }), { password: '' }); + }); + + it('should redact prv key', function () { + assert.deepStrictEqual(sanitize({ prv: 'xprv123' }), { prv: '' }); + }); + + it('should redact xprv key', function () { + assert.deepStrictEqual(sanitize({ xprv: 'xprvkey' }), { xprv: '' }); + }); + + it('should redact privateKey key (case-insensitive)', function () { + assert.deepStrictEqual(sanitize({ privateKey: 'key' }), { privateKey: '' }); + }); + + it('should redact otp key', function () { + assert.deepStrictEqual(sanitize({ otp: '123456' }), { otp: '' }); + }); + + it('should redact passphrase key', function () { + assert.deepStrictEqual(sanitize({ passphrase: 'secret' }), { passphrase: '' }); + }); + + it('should redact walletPassphrase key', function () { + assert.deepStrictEqual(sanitize({ walletPassphrase: 'pass' }), { walletPassphrase: '' }); + }); + + it('should redact bearer key', function () { + assert.deepStrictEqual(sanitize({ bearer: 'token123' }), { bearer: '' }); + }); + + it('should redact _token key', function () { + assert.deepStrictEqual(sanitize({ _token: 'abc' }), { _token: '' }); + }); + + it('should keep non-sensitive keys', function () { + assert.deepStrictEqual(sanitize({ user: 'alice' }), { user: 'alice' }); + }); + }); + + describe('sensitive string value redaction in object values', function () { + it('should redact an object value starting with v2x', function () { + assert.deepStrictEqual(sanitize({ auth: V2_TOKEN }), { auth: '' }); + }); + + it('should redact an object value starting with xprv', function () { + assert.deepStrictEqual(sanitize({ key: XPRV_KEY }), { key: '' }); + }); + + it('should redact a short v2x object value', function () { + assert.deepStrictEqual(sanitize({ key: 'v2xaabb' }), { key: '' }); + }); + + it('should not redact when sensitive prefix is not at the start of value', function () { + assert.deepStrictEqual(sanitize({ msg: `token is ${V2_TOKEN}` }), { msg: `token is ${V2_TOKEN}` }); + assert.deepStrictEqual(sanitize({ header: `Bearer ${V2_TOKEN}` }), { header: `Bearer ${V2_TOKEN}` }); + }); + + it('should keep an object value without sensitive prefix', function () { + assert.deepStrictEqual(sanitize({ msg: 'hello' }), { msg: 'hello' }); + }); + }); + + describe('sensitive string values in arrays', function () { + it('should redact a v2x string in an array', function () { + assert.deepStrictEqual(sanitize([V2_TOKEN, 'hello']), ['', 'hello']); + }); + + it('should redact an xprv string in an array', function () { + assert.deepStrictEqual(sanitize([XPRV_KEY, 'hello']), ['', 'hello']); + }); + }); + + describe('nested objects with sensitive keys', function () { + it('should redact sensitive keys at any depth', function () { + const obj = { + user: 'alice', + credentials: { + password: 'secret', + otp: '123456', + config: { + prv: 'private-key', + endpoint: 'https://api.bitgo.com', + }, + }, + }; + const result = sanitize(obj) as any; + assert.strictEqual(result.user, 'alice'); + assert.strictEqual(result.credentials.password, ''); + assert.strictEqual(result.credentials.otp, ''); + assert.strictEqual(result.credentials.config.prv, ''); + assert.strictEqual(result.credentials.config.endpoint, 'https://api.bitgo.com'); + }); + }); + + describe('nested Error inside objects', function () { + it('should preserve Error properties nested in an object', function () { + const obj = { action: 'transfer', error: new Error('nested failure') }; + const result = sanitize(obj) as any; + assert.strictEqual(result.error.name, 'Error'); + assert.strictEqual(result.error.message, 'nested failure'); + assert.strictEqual(typeof result.error.stack, 'string'); + }); + + it('should preserve Error properties at any depth', function () { + const obj = { data: { inner: { error: new Error('deep') } } }; + const result = sanitize(obj) as any; + assert.strictEqual(result.data.inner.error.message, 'deep'); + }); + + it('should redact sensitive custom properties on nested Errors', function () { + const err: any = new Error('fail'); + err.token = 'secret'; + err.statusCode = 500; + const obj = { error: err }; + const result = sanitize(obj) as any; + assert.strictEqual(result.error.token, ''); + assert.strictEqual(result.error.statusCode, 500); + assert.strictEqual(result.error.message, 'fail'); + }); + }); + + describe('Date handling', function () { + it('should convert a top-level Date to ISO string', function () { + assert.strictEqual(sanitize(new Date('2026-03-02T10:30:00.000Z')), '2026-03-02T10:30:00.000Z'); + }); + + it('should convert a Date inside an object to ISO string', function () { + const obj = { createdAt: new Date('2026-01-15T00:00:00.000Z'), user: 'bob' }; + const result = sanitize(obj) as any; + assert.strictEqual(result.createdAt, '2026-01-15T00:00:00.000Z'); + assert.strictEqual(result.user, 'bob'); + }); + + it('should handle an invalid Date without throwing', function () { + assert.strictEqual(sanitize(new Date('not-a-date')), '[Invalid Date]'); + }); + }); + + describe('BigInt handling', function () { + it('should convert a top-level BigInt to string', function () { + assert.strictEqual(sanitize(100n), '100'); + }); + + it('should convert BigInts inside an object to strings', function () { + assert.deepStrictEqual(sanitize({ amount: 100n, fee: 50n }), { amount: '100', fee: '50' }); + }); + + it('should convert BigInts inside an array to strings', function () { + assert.deepStrictEqual(sanitize([1n, 2n, 3n]), ['1', '2', '3']); + }); + }); + + describe('circular reference handling', function () { + it('should replace circular references with [Circular]', function () { + const circular: any = { name: 'test' }; + circular.self = circular; + const result = sanitize(circular) as any; + assert.strictEqual(result.name, 'test'); + assert.strictEqual(result.self, '[Circular]'); + }); + }); + + describe('array handling', function () { + it('should pass through a simple array', function () { + assert.deepStrictEqual(sanitize([1, 'hello', true]), [1, 'hello', true]); + }); + + it('should sanitize objects inside arrays', function () { + assert.deepStrictEqual(sanitize([{ password: 'secret' }, { user: 'alice' }]), [ + { password: '' }, + { user: 'alice' }, + ]); + }); + + it('should handle nested arrays', function () { + assert.deepStrictEqual( + sanitize([ + [1, 2], + [3, 4], + ]), + [ + [1, 2], + [3, 4], + ] + ); + }); + }); + + describe('max depth handling', function () { + it('should return [Max Depth Exceeded] beyond depth limit', function () { + let deepObj: any = { value: 'bottom' }; + for (let i = 0; i < 30; i++) { + deepObj = { nested: deepObj }; + } + const result = sanitize(deepObj) as any; + let current = result; + let reachedLimit = false; + for (let i = 0; i < 30; i++) { + if (current.nested === '[Max Depth Exceeded]') { + reachedLimit = true; + break; + } + current = current.nested; + } + assert.strictEqual(reachedLimit, true); + }); + }); + + describe('mixed complex object', function () { + it('should handle all data types together', function () { + const complexObj = { + user: 'alice', + password: 'secret', + transaction: { + amount: 100n, + createdAt: new Date('2026-06-15T12:00:00.000Z'), + error: new Error('validation failed'), + token: 'my-token', + }, + authToken: V2_TOKEN, + privateKey: XPRV_KEY, + tags: ['transfer', 'urgent'], + }; + const result = sanitize(complexObj) as any; + assert.strictEqual(result.password, ''); + assert.strictEqual(result.transaction.amount, '100'); + assert.strictEqual(result.transaction.createdAt, '2026-06-15T12:00:00.000Z'); + assert.strictEqual(result.transaction.error.message, 'validation failed'); + assert.strictEqual(result.transaction.token, ''); + assert.strictEqual(result.authToken, ''); + assert.strictEqual(result.privateKey, ''); + assert.strictEqual(result.tags[0], 'transfer'); + assert.strictEqual(result.tags[1], 'urgent'); + }); + }); +}); + +describe('getErrorData', function () { + it('should extract name, message, and stack from an Error', function () { + const err = new Error('something broke'); + const result = getErrorData(err) as Record; + assert.strictEqual(result.name, 'Error'); + assert.strictEqual(result.message, 'something broke'); + assert.strictEqual(typeof result.stack, 'string'); + }); + + it('should preserve custom enumerable properties', function () { + const err: any = new Error('auth failed'); + err.statusCode = 401; + err.url = 'https://api.bitgo.com'; + const result = getErrorData(err) as Record; + assert.strictEqual(result.statusCode, 401); + assert.strictEqual(result.url, 'https://api.bitgo.com'); + assert.strictEqual(result.name, 'Error'); + assert.strictEqual(result.message, 'auth failed'); + }); + + it('should return non-Error values as-is', function () { + assert.strictEqual(getErrorData('hello'), 'hello'); + assert.strictEqual(getErrorData(null), null); + assert.strictEqual(getErrorData(42), 42); + assert.strictEqual(getErrorData(undefined), undefined); + }); + + it('should extract Error.cause when present', function () { + const inner = new Error('db connection failed'); + const outer = new (Error as any)('transaction failed', { cause: inner }); + const result = getErrorData(outer) as Record; + assert.strictEqual(result.message, 'transaction failed'); + const causeData = result.cause as Record; + assert.strictEqual(causeData.name, 'Error'); + assert.strictEqual(causeData.message, 'db connection failed'); + assert.strictEqual(typeof causeData.stack, 'string'); + }); + + it('should extract non-enumerable custom properties', function () { + const err = new Error('fail'); + Object.defineProperty(err, 'code', { value: 'ECONNREFUSED', enumerable: false }); + const result = getErrorData(err) as Record; + assert.strictEqual(result.code, 'ECONNREFUSED'); + assert.strictEqual(result.message, 'fail'); + }); + + it('should handle nested Error causes', function () { + const root = new Error('root cause'); + const middle = new (Error as any)('middle', { cause: root }); + const outer = new (Error as any)('outer', { cause: middle }); + const result = getErrorData(outer) as Record; + const middleData = result.cause as Record; + assert.strictEqual(middleData.message, 'middle'); + const rootData = middleData.cause as Record; + assert.strictEqual(rootData.message, 'root cause'); + }); + + describe('full Error sanitization flow (getErrorData + sanitize)', function () { + it('should preserve Error info and redact sensitive custom properties', function () { + const err: any = new Error('failed'); + err.token = 'secret-token'; + err.password = 'hunter2'; + err.statusCode = 500; + const result = sanitize(getErrorData(err)) as Record; + assert.strictEqual(result.name, 'Error'); + assert.strictEqual(result.message, 'failed'); + assert.strictEqual(typeof result.stack, 'string'); + assert.strictEqual(result.token, ''); + assert.strictEqual(result.password, ''); + assert.strictEqual(result.statusCode, 500); + }); + + it('should redact sensitive string values in custom Error properties', function () { + const err: any = new Error('bad request'); + err.authHeader = V2_TOKEN; + err.key = XPRV_KEY; + const result = sanitize(getErrorData(err)) as Record; + assert.strictEqual(result.authHeader, ''); + assert.strictEqual(result.key, ''); + assert.strictEqual(result.message, 'bad request'); + }); + }); +}); diff --git a/modules/sdk-coin-sol/src/lib/transaction.ts b/modules/sdk-coin-sol/src/lib/transaction.ts index 6c8f219b88..e91bfcb1d3 100644 --- a/modules/sdk-coin-sol/src/lib/transaction.ts +++ b/modules/sdk-coin-sol/src/lib/transaction.ts @@ -507,8 +507,15 @@ export class Transaction extends BaseTransaction { // This validates the WASM path against production traffic before // replacing the legacy implementation for all networks. if (this._coinConfig.name === 'tsol') { + // explainTransaction should work on unsigned/partially-signed transactions + // (e.g., during parseTransaction round-trips where null signatures become + // zero-filled buffers). Use serialize without signature validation since + // we only need to read the transaction, not broadcast it. + const txBase64 = Buffer.from( + this._solTransaction.serialize({ verifySignatures: false, requireAllSignatures: false }) + ).toString('base64'); return explainSolTransaction({ - txBase64: this.toBroadcastFormat(), + txBase64, feeInfo: this._lamportsPerSignature ? { fee: this._lamportsPerSignature.toString() } : undefined, tokenAccountRentExemptAmount: this._tokenAccountRentExemptAmount, coinName: this._coinConfig.name, diff --git a/modules/statics/src/coins/generateERC20.ts b/modules/statics/src/coins/generateERC20.ts index 911db54d94..63fe31f117 100644 --- a/modules/statics/src/coins/generateERC20.ts +++ b/modules/statics/src/coins/generateERC20.ts @@ -1,6 +1,6 @@ import { erc20, erc20Token, terc20 } from '../account'; import { BaseCoin, CoinFeature, UnderlyingAsset } from '../base'; -import { AccountNetwork, EthereumNetwork } from '../networks'; +import { AccountNetwork, EthereumNetwork, Networks } from '../networks'; import { ofcerc20, tofcerc20 } from '../ofc'; // --- Shared config interfaces --- @@ -89,7 +89,7 @@ export function generateTestErc20Coin(config: Erc20CoinConfig): Readonly