Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: mask emails with random strings instead of *** #53527

Merged
73 changes: 65 additions & 8 deletions src/libs/ExportOnyxState/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to do cleanup before the return of method maskOnyxState?


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<string, unknown>): Record<string, unknown> => {
const session = data.session as Session;
Expand All @@ -22,13 +51,30 @@ const maskSessionDetails = (data: Record<string, unknown>): Record<string, unkno
};
};

const maskEmail = (email: string) => {
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<string, unknown> | unknown[] | null, parentKey?: string): Record<string, unknown> | unknown[] | null => {
if (data === null) {
return data;
}

if (Array.isArray(data)) {
return data.map((item): unknown => (typeof item === 'object' ? maskFragileData(item as Record<string, unknown>, 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<string, unknown>, parentKey) : item;
});
}

const maskedData: Record<string, unknown> = {};
Expand All @@ -38,16 +84,26 @@ const maskFragileData = (data: Record<string, unknown> | 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<string, unknown>, key.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) ? key : parentKey);
maskedData[propertyName] = maskFragileData(value as Record<string, unknown>, propertyName.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) ? propertyName : parentKey);
} else {
maskedData[key] = value;
maskedData[propertyName] = value;
}
});

Expand All @@ -63,7 +119,8 @@ const maskOnyxState = (data: Record<string, unknown>, isMaskingFragileDataEnable
onyxState = maskFragileData(onyxState) as Record<string, unknown>;
}

emailMap.clear();
return onyxState;
};

export default {maskOnyxState};
export {maskOnyxState, emailRegex};
2 changes: 1 addition & 1 deletion src/libs/ExportOnyxState/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion src/libs/ExportOnyxState/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import CONST from '@src/CONST';
import ExportOnyxState from './common';
import * as ExportOnyxState from './common';

const readFromOnyxDatabase = () =>
new Promise<Record<string, unknown>>((resolve) => {
Expand Down
47 changes: 45 additions & 2 deletions tests/unit/ExportOnyxStateTest.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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<string, string[]>;

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<string, string>;

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<string, string>;
expect(result.emailString).not.toContain('user@example.com');
});
});
Loading