From 7aa5489bcbb29c8ec1440171c124a757b8250776 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Tue, 10 Feb 2026 14:28:27 -0500 Subject: [PATCH 1/3] feat: Obfuscate potential PII in logs --- src/batchUploader.ts | 16 +-- src/identityApiClient.ts | 14 +- src/mp-instance.ts | 6 +- src/roktManager.ts | 8 +- src/utils.ts | 46 ++++++- src/vault.ts | 20 +-- test/jest/roktManager.spec.ts | 2 +- test/jest/utils.spec.ts | 246 ++++++++++++++++++++++++++++++++++ 8 files changed, 315 insertions(+), 43 deletions(-) diff --git a/src/batchUploader.ts b/src/batchUploader.ts index b4a5233b7..da1c75d67 100644 --- a/src/batchUploader.ts +++ b/src/batchUploader.ts @@ -3,7 +3,7 @@ import Constants from './constants'; import { SDKEvent, SDKEventCustomFlags, SDKLoggerApi } from './sdkRuntimeModels'; import { convertEvents } from './sdkToEventsApiConverter'; import { MessageType, EventType } from './types'; -import { getRampNumber, isEmpty } from './utils'; +import { getRampNumber, isEmpty, obfuscateData } from './utils'; import { SessionStorageVault, LocalStorageVault } from './vault'; import { AsyncUploader, @@ -76,17 +76,11 @@ export class BatchUploader { if (this.offlineStorageEnabled) { this.eventVault = new SessionStorageVault( - `${mpInstance._Store.storageName}-events`, - { - logger: mpInstance.Logger, - } + `${mpInstance._Store.storageName}-events` ); this.batchVault = new LocalStorageVault( - `${mpInstance._Store.storageName}-batches`, - { - logger: mpInstance.Logger, - } + `${mpInstance._Store.storageName}-batches` ); // Load Events from Session Storage in case we have any in storage @@ -264,7 +258,7 @@ export class BatchUploader { this.eventVault.store(this.eventsQueuedForProcessing); } - Logger.verbose(`Queuing event: ${JSON.stringify(event)}`); + Logger.verbose(`Queuing event: ${JSON.stringify(obfuscateData(event))}`); Logger.verbose(`Queued event count: ${this.eventsQueuedForProcessing.length}`); if (this.shouldTriggerImmediateUpload(event.EventDataType)) { @@ -454,7 +448,7 @@ export class BatchUploader { return null; } - logger.verbose(`Uploading batches: ${JSON.stringify(uploads)}`); + logger.verbose(`Uploading batches: ${JSON.stringify(obfuscateData(uploads))}`); logger.verbose(`Batch count: ${uploads.length}`); for (let i = 0; i < uploads.length; i++) { diff --git a/src/identityApiClient.ts b/src/identityApiClient.ts index 0c686f075..77fe46162 100644 --- a/src/identityApiClient.ts +++ b/src/identityApiClient.ts @@ -6,7 +6,7 @@ import { IFetchPayload, } from './uploaders'; import { CACHE_HEADER } from './identity-utils'; -import { parseNumber, valueof } from './utils'; +import { obfuscateData, parseNumber, valueof } from './utils'; import { IAliasCallback, IAliasRequest, @@ -277,8 +277,16 @@ export default function IdentityAPIClient( } } else { - message = 'Received Identity Response from server: '; - message += JSON.stringify(identityResponse.responseText); + const responseText = identityResponse.responseText; + const { matched_identities, ...rest } = responseText || {}; + const obfuscatedMatchedIdentities = obfuscateData(matched_identities); + + // Obfuscate matched_identities to prevent PII exposure + const responseToLog = matched_identities + ? { ...rest, matched_identities: obfuscatedMatchedIdentities } + : responseText; + + message = 'Received Identity Response from server: ' + JSON.stringify(responseToLog); } break; diff --git a/src/mp-instance.ts b/src/mp-instance.ts index 618feac05..f9c840fcd 100644 --- a/src/mp-instance.ts +++ b/src/mp-instance.ts @@ -1180,7 +1180,7 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan * @method setOptOut * @param {Boolean} isOptingOut boolean to opt out or not. When set to true, opt out of logging. */ - this.setOptOut = function(isOptingOut) { + this.setOptOut = function(isOptingOut: boolean) { const queued = queueIfNotInitialized(function() { self.setOptOut(isOptingOut); }, self); @@ -1573,9 +1573,7 @@ function createKitBlocker(config, mpInstance) { } function createIdentityCache(mpInstance) { - return new LocalStorageVault(`${mpInstance._Store.storageName}-id-cache`, { - logger: mpInstance.Logger, - }); + return new LocalStorageVault(`${mpInstance._Store.storageName}-id-cache`); } function runPreConfigFetchInitialization(mpInstance, apiKey, config) { diff --git a/src/roktManager.ts b/src/roktManager.ts index 2ee27c412..44be40904 100644 --- a/src/roktManager.ts +++ b/src/roktManager.ts @@ -9,6 +9,7 @@ import { isFunction, AttributeValue, isEmpty, + obfuscateData, } from "./utils"; import { SDKIdentityApi } from "./identity.interfaces"; import { SDKLoggerApi } from "./sdkRuntimeModels"; @@ -217,7 +218,7 @@ export default class RoktManager { const { attributes } = options; const sandboxValue = attributes?.sandbox || null; const mappedAttributes = this.mapPlacementAttributes(attributes, this.placementAttributesMapping); - this.logger?.verbose(`mParticle.Rokt selectPlacements called with attributes:\n${JSON.stringify(attributes, null, 2)}`); + this.logger?.verbose(`mParticle.Rokt selectPlacements called with attributes:\n${JSON.stringify(obfuscateData(attributes), null, 2)}`); this.currentUser = this.identityService.getCurrentUser(); const currentUserIdentities = this.currentUser?.getUserIdentities()?.userIdentities || {}; @@ -256,7 +257,7 @@ export default class RoktManager { newIdentities[this.mappedEmailShaIdentityType] = newHashedEmail; this.logger.warning(`emailsha256 mismatch detected. Current mParticle hashedEmail differs from hashedEmail passed to selectPlacements call. Proceeding to call identify with hashedEmail from selectPlacements call. Please verify your implementation.`); } - + if (!isEmpty(newIdentities)) { // Call identify with the new user identities try { @@ -271,7 +272,8 @@ export default class RoktManager { }); }); } catch (error) { - this.logger.error('Failed to identify user with new email: ' + JSON.stringify(error)); + const errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + this.logger.error('Failed to identify user with new email: ' + errorMessage); } } diff --git a/src/utils.ts b/src/utils.ts index 4e44ea914..b1ba5f7fd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -426,12 +426,53 @@ const filterDictionaryWithHash = ( } const parseConfig = (config: SDKInitConfig, moduleName: string, moduleId: number): IKitConfigs | null => { - return config.kitConfigs?.find((kitConfig: IKitConfigs) => - kitConfig.name === moduleName && + return config.kitConfigs?.find((kitConfig: IKitConfigs) => + kitConfig.name === moduleName && kitConfig.moduleId === moduleId ) || null; } +/** + * Obfuscates an object by replacing all primitive values with their type names, + * while preserving the structure of nested objects and arrays. + * This is useful for logging data structures without exposing PII. + * + * @param value - The value to obfuscate + * @returns The obfuscated value with types instead of actual values + * + * @example + * obfuscateData({ email: 'user@email.com', age: 30 }) + * // Returns: { email: 'string', age: 'number' } + * + * obfuscateData({ tags: ['premium', 'verified'] }) + * // Returns: { tags: ['string', 'string'] } + */ +const obfuscateData = (value: any): any => { + // Preserve null and undefined + if (value === null || value === undefined) { + return value; + } + + // Handle arrays - recursively obfuscate each element + if (Array.isArray(value)) { + return value.map(item => obfuscateData(item)); + } + + // Handle objects - recursively obfuscate each property + if (isObject(value)) { + const obfuscated: Dictionary = {}; + for (const key in value) { + if (value.hasOwnProperty(key)) { + obfuscated[key] = obfuscateData(value[key]); + } + } + return obfuscated; + } + + // For primitives and other types, return the type as a string + return typeof value; +}; + export { createCookieString, revertCookieString, @@ -449,6 +490,7 @@ export { inArray, isObject, isStringOrNumber, + obfuscateData, parseConfig, parseNumber, parseSettingsString, diff --git a/src/vault.ts b/src/vault.ts index 57d2a6dc1..ab40207f7 100644 --- a/src/vault.ts +++ b/src/vault.ts @@ -1,15 +1,12 @@ -import { Logger } from '@mparticle/web-sdk'; import { isEmpty, isNumber } from './utils'; export interface IVaultOptions { - logger?: Logger; offlineStorageEnabled?: boolean; } export abstract class BaseVault { public contents: StorableItem; protected readonly _storageKey: string; - protected logger?: Logger; protected storageObject: Storage; /** @@ -26,13 +23,6 @@ export abstract class BaseVault { this._storageKey = storageKey; this.storageObject = storageObject; - // Add a fake logger in case one is not provided or needed - this.logger = options?.logger || { - verbose: () => {}, - warning: () => {}, - error: () => {}, - }; - this.contents = this.retrieve(); } @@ -53,13 +43,8 @@ export abstract class BaseVault { try { this.storageObject.setItem(this._storageKey, stringifiedItem); - - this.logger.verbose(`Saving item to Storage: ${stringifiedItem}`); } catch (error) { - this.logger.error( - `Cannot Save items to Storage: ${stringifiedItem}` - ); - this.logger.error(error as string); + throw new Error('Cannot Save items to Storage'); } } @@ -75,8 +60,6 @@ export abstract class BaseVault { this.contents = item ? JSON.parse(item) : null; - this.logger.verbose(`Retrieving item from Storage: ${item}`); - return this.contents; } @@ -86,7 +69,6 @@ export abstract class BaseVault { * @method purge */ public purge(): void { - this.logger.verbose('Purging Storage'); this.contents = null; this.storageObject.removeItem(this._storageKey); } diff --git a/test/jest/roktManager.spec.ts b/test/jest/roktManager.spec.ts index 8da2be297..cd8fecab9 100644 --- a/test/jest/roktManager.spec.ts +++ b/test/jest/roktManager.spec.ts @@ -2468,7 +2468,7 @@ describe('RoktManager', () => { await roktManager.selectPlacements(options); expect(mockMPInstance.Logger.verbose).toHaveBeenCalledWith( - `mParticle.Rokt selectPlacements called with attributes:\n${JSON.stringify({ email: 'test@example.com', customAttr: 'value' }, null, 2)}` + `mParticle.Rokt selectPlacements called with attributes:\n${JSON.stringify({ email: 'string', customAttr: 'string' }, null, 2)}` ); }); }); diff --git a/test/jest/utils.spec.ts b/test/jest/utils.spec.ts index 4545e691e..961684b63 100644 --- a/test/jest/utils.spec.ts +++ b/test/jest/utils.spec.ts @@ -11,6 +11,7 @@ import { filterDictionaryWithHash, parseConfig, parseSettingsString, + obfuscateData, } from '../../src/utils'; import { deleteAllCookies } from './utils'; @@ -387,4 +388,249 @@ describe('Utils', () => { expect(() => parseSettingsString(settingsString)).toThrow('Settings string contains invalid JSON'); }); }); + + describe('#obfuscateData', () => { + describe('primitives', () => { + it('should replace string values with "string"', () => { + expect(obfuscateData('user@email.com')).toBe('string'); + expect(obfuscateData('John Doe')).toBe('string'); + expect(obfuscateData('')).toBe('string'); + }); + + it('should replace number values with "number"', () => { + expect(obfuscateData(42)).toBe('number'); + expect(obfuscateData(0)).toBe('number'); + expect(obfuscateData(-123.456)).toBe('number'); + expect(obfuscateData(NaN)).toBe('number'); + expect(obfuscateData(Infinity)).toBe('number'); + }); + + it('should replace boolean values with "boolean"', () => { + expect(obfuscateData(true)).toBe('boolean'); + expect(obfuscateData(false)).toBe('boolean'); + }); + + it('should preserve null', () => { + expect(obfuscateData(null)).toBe(null); + }); + + it('should preserve undefined', () => { + expect(obfuscateData(undefined)).toBe(undefined); + }); + + it('should replace function values with "function"', () => { + const fn = () => {}; + expect(obfuscateData(fn)).toBe('function'); + }); + }); + + describe('objects', () => { + it('should obfuscate flat objects', () => { + const input = { + email: 'user@email.com', + age: 30, + verified: true, + }; + + expect(obfuscateData(input)).toEqual({ + email: 'string', + age: 'number', + verified: 'boolean', + }); + }); + + it('should recursively obfuscate nested objects', () => { + const input = { + user: { + name: 'John Doe', + age: 30, + address: { + street: '123 Main St', + city: 'Springfield', + }, + }, + }; + + expect(obfuscateData(input)).toEqual({ + user: { + name: 'string', + age: 'number', + address: { + street: 'string', + city: 'string', + }, + }, + }); + }); + + it('should preserve null values in objects', () => { + const input = { + name: 'John', + metadata: null, + age: 30, + }; + + expect(obfuscateData(input)).toEqual({ + name: 'string', + metadata: null, + age: 'number', + }); + }); + + it('should handle empty objects', () => { + expect(obfuscateData({})).toEqual({}); + }); + }); + + describe('arrays', () => { + it('should obfuscate arrays of primitives', () => { + expect(obfuscateData(['premium', 'verified'])).toEqual(['string', 'string']); + expect(obfuscateData([1, 2, 3])).toEqual(['number', 'number', 'number']); + expect(obfuscateData([true, false])).toEqual(['boolean', 'boolean']); + }); + + it('should obfuscate arrays of objects', () => { + const input = [ + { id: 123, name: 'Product A' }, + { id: 456, name: 'Product B' }, + ]; + + expect(obfuscateData(input)).toEqual([ + { id: 'number', name: 'string' }, + { id: 'number', name: 'string' }, + ]); + }); + + it('should handle arrays with mixed types', () => { + const input = ['text', 42, true, null, undefined]; + + expect(obfuscateData(input)).toEqual(['string', 'number', 'boolean', null, undefined]); + }); + + it('should recursively obfuscate nested arrays', () => { + const input = [ + ['a', 'b'], + ['c', 'd'], + ]; + + expect(obfuscateData(input)).toEqual([ + ['string', 'string'], + ['string', 'string'], + ]); + }); + + it('should handle empty arrays', () => { + expect(obfuscateData([])).toEqual([]); + }); + }); + + describe('complex nested structures', () => { + it('should obfuscate complex event-like structures', () => { + const input = { + eventName: 'purchase', + user: { + email: 'user@email.com', + age: 30, + verified: true, + metadata: null, + }, + tags: ['premium', 'verified'], + items: [ + { id: 123, name: 'Product A', price: 29.99 }, + { id: 456, name: 'Product B', price: 49.99 }, + ], + }; + + expect(obfuscateData(input)).toEqual({ + eventName: 'string', + user: { + email: 'string', + age: 'number', + verified: 'boolean', + metadata: null, + }, + tags: ['string', 'string'], + items: [ + { id: 'number', name: 'string', price: 'number' }, + { id: 'number', name: 'string', price: 'number' }, + ], + }); + }); + + it('should handle objects with arrays of nested objects', () => { + const input = { + users: [ + { + name: 'John', + contacts: [ + { type: 'email', value: 'john@email.com' }, + { type: 'phone', value: '555-1234' }, + ], + }, + { + name: 'Jane', + contacts: [ + { type: 'email', value: 'jane@email.com' }, + ], + }, + ], + }; + + expect(obfuscateData(input)).toEqual({ + users: [ + { + name: 'string', + contacts: [ + { type: 'string', value: 'string' }, + { type: 'string', value: 'string' }, + ], + }, + { + name: 'string', + contacts: [ + { type: 'string', value: 'string' }, + ], + }, + ], + }); + }); + }); + + describe('edge cases', () => { + it('should handle objects with special values', () => { + const input = { + nan: NaN, + infinity: Infinity, + negativeInfinity: -Infinity, + }; + + expect(obfuscateData(input)).toEqual({ + nan: 'number', + infinity: 'number', + negativeInfinity: 'number', + }); + }); + + it('should not mutate the original object', () => { + const input = { + email: 'user@email.com', + age: 30, + }; + + const original = { ...input }; + obfuscateData(input); + + expect(input).toEqual(original); + }); + + it('should not mutate the original array', () => { + const input = ['test', 123, true]; + const original = [...input]; + + obfuscateData(input); + + expect(input).toEqual(original); + }); + }); + }); }); \ No newline at end of file From 4073113e75db923608bf05ff2b3620cbc27f5d3c Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Tue, 17 Feb 2026 14:24:29 -0500 Subject: [PATCH 2/3] fix: Address PR Comments --- src/batchUploader.ts | 23 +++++++++++++++++------ src/foregroundTimeTracker.ts | 6 +++++- src/identity-utils.ts | 12 ++++++++++-- src/roktManager.ts | 4 ++-- src/vault.ts | 18 ++++++------------ 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/batchUploader.ts b/src/batchUploader.ts index da1c75d67..3bc09517a 100644 --- a/src/batchUploader.ts +++ b/src/batchUploader.ts @@ -255,7 +255,11 @@ export class BatchUploader { this.eventsQueuedForProcessing.push(event); if (this.offlineStorageEnabled && this.eventVault) { - this.eventVault.store(this.eventsQueuedForProcessing); + try { + this.eventVault.store(this.eventsQueuedForProcessing); + } catch (error) { + Logger.error('Failed to store events to offline storage. Events will remain in memory queue.'); + } } Logger.verbose(`Queuing event: ${JSON.stringify(obfuscateData(event))}`); @@ -371,7 +375,11 @@ export class BatchUploader { this.eventsQueuedForProcessing = []; if (this.offlineStorageEnabled && this.eventVault) { - this.eventVault.store([]); + try { + this.eventVault.store([]); + } catch (error) { + this.mpInstance.Logger.error('Failed to clear events from offline storage.'); + } } let newBatches: Batch[] = []; @@ -423,10 +431,13 @@ export class BatchUploader { // therefore NOT overwrite Offline Storage when beacon returns, so that we can retry // uploading saved batches at a later time. Batches should only be removed from // Local Storage once we can confirm they are successfully uploaded. - this.batchVault.store(this.batchesQueuedForProcessing); - - // Clear batch queue since everything should be in Offline Storage - this.batchesQueuedForProcessing = []; + try { + this.batchVault.store(this.batchesQueuedForProcessing); + // Clear batch queue since everything should be in Offline Storage + this.batchesQueuedForProcessing = []; + } catch (error) { + this.mpInstance.Logger.error('Failed to store batches to offline storage. Batches will remain in memory queue.'); + } } if (triggerFuture) { diff --git a/src/foregroundTimeTracker.ts b/src/foregroundTimeTracker.ts index c64ca3eff..c872300f3 100644 --- a/src/foregroundTimeTracker.ts +++ b/src/foregroundTimeTracker.ts @@ -64,7 +64,11 @@ export default class ForegroundTimeTracker { public updateTimeInPersistence(): void { if (this.isTrackerActive) { - this.timerVault.store(Math.round(this.totalTime)); + try { + this.timerVault.store(Math.round(this.totalTime)); + } catch (error) { + // Silently fail - time tracking persistence is not critical for SDK functionality + } } } diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 3472d9f08..e37392be4 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -98,7 +98,11 @@ export const cacheIdentityRequest = ( status, expireTimestamp, }; - idCache.store(cache); + try { + idCache.store(cache); + } catch (error) { + // Silently fail - identity caching is an optimization, not critical for functionality + } }; // We need to ensure that identities are concatenated in a deterministic way, so @@ -233,7 +237,11 @@ export const removeExpiredIdentityCacheDates = ( } } - idCache.store(cache); + try { + idCache.store(cache); + } catch (error) { + // Silently fail - identity caching is an optimization, not critical for functionality + } }; export const tryCacheIdentity = ( diff --git a/src/roktManager.ts b/src/roktManager.ts index 44be40904..d294c2373 100644 --- a/src/roktManager.ts +++ b/src/roktManager.ts @@ -273,7 +273,7 @@ export default class RoktManager { }); } catch (error) { const errorMessage = error instanceof Error ? error.message : JSON.stringify(error); - this.logger.error('Failed to identify user with new email: ' + errorMessage); + this.logger.error('Failed to identify user with updated identities: ' + errorMessage); } } @@ -489,7 +489,7 @@ export default class RoktManager { return; } - this.logger?.verbose(`RoktManager: Processing queued message: ${message.methodName} with payload: ${JSON.stringify(message.payload)}`); + this.logger?.verbose(`RoktManager: Processing queued message: ${message.methodName} with payload: ${JSON.stringify(obfuscateData(message.payload))}`); // Capture resolve/reject functions before async processing const resolve = message.resolve; diff --git a/src/vault.ts b/src/vault.ts index ab40207f7..b680f5166 100644 --- a/src/vault.ts +++ b/src/vault.ts @@ -1,9 +1,5 @@ import { isEmpty, isNumber } from './utils'; -export interface IVaultOptions { - offlineStorageEnabled?: boolean; -} - export abstract class BaseVault { public contents: StorableItem; protected readonly _storageKey: string; @@ -12,13 +8,11 @@ export abstract class BaseVault { /** * * @param {string} storageKey the local storage key string - * @param {Storage} Web API Storage object that is being used - * @param {IVaultOptions} options A Dictionary of IVaultOptions + * @param {Storage} storageObject Web API Storage object that is being used */ constructor( storageKey: string, - storageObject: Storage, - options?: IVaultOptions + storageObject: Storage ) { this._storageKey = storageKey; this.storageObject = storageObject; @@ -75,13 +69,13 @@ export abstract class BaseVault { } export class LocalStorageVault extends BaseVault { - constructor(storageKey: string, options?: IVaultOptions) { - super(storageKey, window.localStorage, options); + constructor(storageKey: string) { + super(storageKey, window.localStorage); } } export class SessionStorageVault extends BaseVault { - constructor(storageKey: string, options?: IVaultOptions) { - super(storageKey, window.sessionStorage, options); + constructor(storageKey: string) { + super(storageKey, window.sessionStorage); } } \ No newline at end of file From a1ea8fed7547cc680d5a6a3ec4be48edfd9a0223 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Fri, 20 Feb 2026 15:40:26 -0500 Subject: [PATCH 3/3] fix: Address code review feedback on error handling Update vault storage error handling to log instead of throw to prevent breaking SDK flows when storage quota or security errors occur. Update test expectations to match corrected error message. --- src/vault.ts | 2 +- test/jest/roktManager.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vault.ts b/src/vault.ts index b680f5166..85868137d 100644 --- a/src/vault.ts +++ b/src/vault.ts @@ -38,7 +38,7 @@ export abstract class BaseVault { try { this.storageObject.setItem(this._storageKey, stringifiedItem); } catch (error) { - throw new Error('Cannot Save items to Storage'); + console.error('Cannot Save items to Storage'); } } diff --git a/test/jest/roktManager.spec.ts b/test/jest/roktManager.spec.ts index cd8fecab9..ab8bc507f 100644 --- a/test/jest/roktManager.spec.ts +++ b/test/jest/roktManager.spec.ts @@ -2196,7 +2196,7 @@ describe('RoktManager', () => { // Verify error was logged expect(mockMPInstance.Logger.error).toHaveBeenCalledWith( - 'Failed to identify user with new email: ' + JSON.stringify(mockError) + 'Failed to identify user with updated identities: ' + JSON.stringify(mockError) ); // Verify selectPlacements was still called