diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 46a47528f1fe..9cc38aad3346 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -3,6 +3,35 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Session} from '@src/types/onyx'; const MASKING_PATTERN = '***'; +const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/; + +const emailMap = new Map(); + +const getRandomLetter = () => String.fromCharCode(97 + Math.floor(Math.random() * 26)); + +function stringContainsEmail(text: string) { + return emailRegex.test(text); +} + +function extractEmail(text: string) { + const match = text.match(emailRegex); + return match ? match[0] : null; // Return the email if found, otherwise null +} + +const randomizeEmail = (email: string): string => { + const [localPart, domain] = email.split('@'); + const [domainName, tld] = domain.split('.'); + + const randomizePart = (part: string) => [...part].map((c) => (/[a-zA-Z0-9]/.test(c) ? getRandomLetter() : c)).join(''); + const randomLocal = randomizePart(localPart); + const randomDomain = randomizePart(domainName); + + return `${randomLocal}@${randomDomain}.${tld}`; +}; + +function replaceEmailInString(text: string, emailReplacement: string) { + return text.replace(emailRegex, emailReplacement); +} const maskSessionDetails = (data: Record): Record => { const session = data.session as Session; @@ -22,13 +51,30 @@ const maskSessionDetails = (data: Record): Record { + let maskedEmail = ''; + if (!emailMap.has(email)) { + maskedEmail = randomizeEmail(email); + emailMap.set(email, maskedEmail); + } else { + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + maskedEmail = emailMap.get(email) as string; + } + return maskedEmail; +}; + const maskFragileData = (data: Record | unknown[] | null, parentKey?: string): Record | unknown[] | null => { if (data === null) { return data; } if (Array.isArray(data)) { - return data.map((item): unknown => (typeof item === 'object' ? maskFragileData(item as Record, parentKey) : item)); + return data.map((item): unknown => { + if (typeof item === 'string' && Str.isValidEmail(item)) { + return maskEmail(item); + } + return typeof item === 'object' ? maskFragileData(item as Record, parentKey) : item; + }); } const maskedData: Record = {}; @@ -38,16 +84,26 @@ const maskFragileData = (data: Record | unknown[] | null, paren return; } - const value = data[key]; + // loginList is an object that contains emails as keys, the keys should be masked as well + let propertyName = ''; + if (Str.isValidEmail(key)) { + propertyName = maskEmail(key); + } else { + propertyName = key; + } + + const value = data[propertyName]; if (typeof value === 'string' && Str.isValidEmail(value)) { - maskedData[key] = MASKING_PATTERN; - } else if (parentKey && parentKey.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) && (key === 'text' || key === 'html')) { - maskedData[key] = MASKING_PATTERN; + maskedData[propertyName] = maskEmail(value); + } else if (typeof value === 'string' && stringContainsEmail(value)) { + maskedData[propertyName] = replaceEmailInString(value, maskEmail(extractEmail(value) ?? '')); + } else if (parentKey && parentKey.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) && (propertyName === 'text' || propertyName === 'html')) { + maskedData[propertyName] = MASKING_PATTERN; } else if (typeof value === 'object') { - maskedData[key] = maskFragileData(value as Record, key.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) ? key : parentKey); + maskedData[propertyName] = maskFragileData(value as Record, propertyName.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) ? propertyName : parentKey); } else { - maskedData[key] = value; + maskedData[propertyName] = value; } }); @@ -63,7 +119,8 @@ const maskOnyxState = (data: Record, isMaskingFragileDataEnable onyxState = maskFragileData(onyxState) as Record; } + emailMap.clear(); return onyxState; }; -export default {maskOnyxState}; +export {maskOnyxState, emailRegex}; diff --git a/src/libs/ExportOnyxState/index.native.ts b/src/libs/ExportOnyxState/index.native.ts index 2ad9af0bf54c..eb1bb879c32d 100644 --- a/src/libs/ExportOnyxState/index.native.ts +++ b/src/libs/ExportOnyxState/index.native.ts @@ -2,7 +2,7 @@ import RNFS from 'react-native-fs'; import {open} from 'react-native-quick-sqlite'; import Share from 'react-native-share'; import CONST from '@src/CONST'; -import ExportOnyxState from './common'; +import * as ExportOnyxState from './common'; const readFromOnyxDatabase = () => new Promise((resolve) => { diff --git a/src/libs/ExportOnyxState/index.ts b/src/libs/ExportOnyxState/index.ts index 66fa6744ecdc..f04ce8d5c90b 100644 --- a/src/libs/ExportOnyxState/index.ts +++ b/src/libs/ExportOnyxState/index.ts @@ -1,5 +1,5 @@ import CONST from '@src/CONST'; -import ExportOnyxState from './common'; +import * as ExportOnyxState from './common'; const readFromOnyxDatabase = () => new Promise>((resolve) => { diff --git a/tests/unit/ExportOnyxStateTest.ts b/tests/unit/ExportOnyxStateTest.ts index 70faa061cb2a..cb4e4b846218 100644 --- a/tests/unit/ExportOnyxStateTest.ts +++ b/tests/unit/ExportOnyxStateTest.ts @@ -1,4 +1,4 @@ -import ExportOnyxState from '@libs/ExportOnyxState/common'; +import * as ExportOnyxState from '@libs/ExportOnyxState/common'; import type * as OnyxTypes from '@src/types/onyx'; type ExampleOnyxState = { @@ -40,6 +40,49 @@ describe('maskOnyxState', () => { expect(result.session.authToken).toBe('***'); expect(result.session.encryptedAuthToken).toBe('***'); - expect(result.session.email).toBe('***'); + }); + + it('should mask emails as a string value in property with a random email', () => { + const input = { + session: mockSession, + }; + + const result = ExportOnyxState.maskOnyxState(input) as ExampleOnyxState; + + expect(result.session.email).toMatch(ExportOnyxState.emailRegex); + }); + + it('should mask array of emails with random emails', () => { + const input = { + session: mockSession, + emails: ['user@example.com', 'user2@example.com'], + }; + + const result = ExportOnyxState.maskOnyxState(input, true) as Record; + + expect(result.emails.at(0)).toMatch(ExportOnyxState.emailRegex); + expect(result.emails.at(1)).toMatch(ExportOnyxState.emailRegex); + }); + + it('should mask emails in keys of objects', () => { + const input = { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'user@example.com': 'value', + session: mockSession, + }; + + const result = ExportOnyxState.maskOnyxState(input, true) as Record; + + expect(Object.keys(result).at(0)).toMatch(ExportOnyxState.emailRegex); + }); + + it('should mask emails that are part of a string', () => { + const input = { + session: mockSession, + emailString: 'user@example.com is a test string', + }; + + const result = ExportOnyxState.maskOnyxState(input, true) as Record; + expect(result.emailString).not.toContain('user@example.com'); }); });