diff --git a/packages/core/src/domain/configuration/configuration.ts b/packages/core/src/domain/configuration/configuration.ts index 7a92c71a1b..e13eb0662f 100644 --- a/packages/core/src/domain/configuration/configuration.ts +++ b/packages/core/src/domain/configuration/configuration.ts @@ -20,6 +20,7 @@ export const DefaultPrivacyLevel = { ALLOW: 'allow', MASK: 'mask', MASK_USER_INPUT: 'mask-user-input', + MASK_UNLESS_ALLOWLISTED: 'mask-unless-allowlisted', } as const export type DefaultPrivacyLevel = (typeof DefaultPrivacyLevel)[keyof typeof DefaultPrivacyLevel] diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 6caeeb03e3..b5b3488756 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -11,6 +11,7 @@ import { createHooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ActionContexts } from './actionCollection' import { startActionCollection } from './actionCollection' +import { ActionNameSource } from './getActionNameFromElement' describe('actionCollection', () => { const lifeCycle = new LifeCycle() @@ -50,7 +51,7 @@ describe('actionCollection', () => { duration: 100 as Duration, id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', name: 'foo', - nameSource: 'text_content', + nameSource: ActionNameSource.TEXT_CONTENT, startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, type: ActionType.CLICK, event, @@ -142,7 +143,7 @@ describe('actionCollection', () => { frustrationTypes: [], id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', name: 'foo', - nameSource: 'text_content', + nameSource: ActionNameSource.TEXT_CONTENT, startClocks: { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, type: ActionType.CLICK, }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index f5e865737e..20671bfbce 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -11,6 +11,7 @@ import type { DefaultRumEventAttributes, Hooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ActionContexts, ClickAction } from './trackClickActions' import { trackClickActions } from './trackClickActions' +import { createActionAllowList, actionNameDictionary } from './privacy/allowedDictionary' export type { ActionContexts } @@ -31,8 +32,13 @@ export function startActionCollection( windowOpenObservable: Observable, configuration: RumConfiguration ) { - lifeCycle.subscribe(LifeCycleEventType.AUTO_ACTION_COMPLETED, (action) => - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) + createActionAllowList() + + const { unsubscribe: unsubscribeAutoActionCompleted } = lifeCycle.subscribe( + LifeCycleEventType.AUTO_ACTION_COMPLETED, + (action) => { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) + } ) hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => { @@ -63,7 +69,8 @@ export function startActionCollection( lifeCycle, domMutationObservable, windowOpenObservable, - configuration + configuration, + actionNameDictionary )) } @@ -72,7 +79,11 @@ export function startActionCollection( lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) }, actionContexts, - stop, + stop: () => { + actionNameDictionary?.clear() + unsubscribeAutoActionCompleted() + stop() + }, } } @@ -104,6 +115,7 @@ function processAction(action: AutoAction | CustomAction): RawRumEventCollectedD }, } : undefined + const actionEvent: RawRumActionEvent = combine( { action: { id: generateUUID(), target: { name: action.name }, type: action.type }, diff --git a/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts b/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts index ccced2bd9e..790c302119 100644 --- a/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts +++ b/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts @@ -1,5 +1,5 @@ import { appendElement, mockRumConfiguration } from '../../../test' -import { NodePrivacyLevel } from '../privacy' +import { NodePrivacyLevel } from '../privacyConstants' import { ActionNameSource, getActionNameFromElement } from './getActionNameFromElement' const defaultConfiguration = mockRumConfiguration() diff --git a/packages/rum-core/src/domain/action/getActionNameFromElement.ts b/packages/rum-core/src/domain/action/getActionNameFromElement.ts index ad54f187dc..22e8a3309d 100644 --- a/packages/rum-core/src/domain/action/getActionNameFromElement.ts +++ b/packages/rum-core/src/domain/action/getActionNameFromElement.ts @@ -1,5 +1,5 @@ import { safeTruncate } from '@datadog/browser-core' -import { NodePrivacyLevel, getPrivacySelector } from '../privacy' +import { NodePrivacyLevel, getPrivacySelector } from '../privacyConstants' import type { RumConfiguration } from '../configuration' /** @@ -14,8 +14,9 @@ export const enum ActionNameSource { TEXT_CONTENT = 'text_content', STANDARD_ATTRIBUTE = 'standard_attribute', BLANK = 'blank', + MASK_DISALLOWED = 'mask_disallowed', } -type ActionName = { +export type ActionName = { name: string nameSource: ActionNameSource } diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts new file mode 100644 index 0000000000..f37cc644c6 --- /dev/null +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts @@ -0,0 +1,257 @@ +import { ActionType } from '../../../rawRumEvent.types' +import { NodePrivacyLevel } from '../../privacyConstants' +import { ActionNameSource, ACTION_NAME_PLACEHOLDER } from '../getActionNameFromElement' +import type { ClickActionBase } from '../trackClickActions' +import { + createActionAllowList, + processRawAllowList, + maskActionName, + tokenize, + isBrowserSupported, +} from './allowedDictionary' +import type { AllowedDictionary } from './allowedDictionary' + +const TEST_STRINGS = { + COMPLEX_MIXED: 'test-user-name:💥$$$, test-user-id:hello>=42@world?', + PARAGRAPH_MIXED: "This isn't a sentence, it's RUM's test: 💥, $$$ = 1 + 2 + 3, and more.", +} + +const LANGUAGES_TEST_STRINGS = { + FRENCH_MIXED_SENTENCE: "C'est pas un test, c'est RUM's test: 💥, $$$ = 1 + 2 + 3, et plus.", + SPANISH_MIXED_SENTENCE: "Este no es un test, es RUM's test: 💥, $$$ = 1 + 2 + 3, y más.", + GERMAN_MIXED_SENTENCE: "Das ist kein Test, das ist RUM's Test: 💥, $$$ = 1 + 2 + 3, und mehr.", + ITALIAN_MIXED_SENTENCE: "Questo non è un test, questo è RUM's test: 💥, $$$ = 1 + 2 + 3, e altro.", + PORTUGUESE_MIXED_SENTENCE: "Este não é um teste, este é RUM's test: 💥, $$$ = 1 + 2 + 3, e mais.", +} +if (isBrowserSupported()) { + describe('Test tokenize', () => { + it('should handle emojis when Browser supports unicode regex', () => { + const paragraphMixedTokens = tokenize(TEST_STRINGS.PARAGRAPH_MIXED) + expect(paragraphMixedTokens).toContain('💥') + expect(paragraphMixedTokens).not.toContain('$$$') + expect(paragraphMixedTokens).not.toContain('1') + expect(paragraphMixedTokens).not.toContain('2') + expect(paragraphMixedTokens).not.toContain('3') + }) + + it('should return empty array for whitespace-only strings', () => { + expect(tokenize(' ')).toEqual([]) + expect(tokenize(' ')).toEqual([]) + expect(tokenize('\t')).toEqual([]) + expect(tokenize('\n')).toEqual([]) + expect(tokenize('\r')).toEqual([]) + expect(tokenize(' \t\n\r ')).toEqual([]) + }) + + /** + * This test is to ensure that the match regex is working as expected in all browsers. + * With unicode regex, we can support symbols and emojis OOTB. + * But in older versions of browsers, we need to use a minimal fallback regex which does + * not support many symbols, to avoid bloating the bundle size. + * + * Only European languages (Except Russian) are tested here. + * We can't test Russian because it's not supported by the fallback regex. + * Asian languages are not supported by our current tokenizer strategy. + */ + it('Tokenized results matches words and symbols in TEST_STRINGS', () => { + const expectedParagraphMixed = [ + 'This', + "isn't", + 'a', + 'sentence', + "it's", + "RUM's", + 'test', + '💥', + '=', + '+', + '+', + 'and', + 'more', + ] + expect(tokenize(TEST_STRINGS.PARAGRAPH_MIXED).sort()).toEqual(expectedParagraphMixed.sort()) + + const expectedFrench = ["C'est", 'pas', 'un', 'test', "c'est", "RUM's", 'test', '💥', '=', '+', '+', 'et', 'plus'] + expect(tokenize(LANGUAGES_TEST_STRINGS.FRENCH_MIXED_SENTENCE).sort()).toEqual(expectedFrench.sort()) + + const expectedSpanish = ['Este', 'no', 'es', 'un', 'test', 'es', "RUM's", 'test', '💥', '=', '+', '+', 'y', 'más'] + expect(tokenize(LANGUAGES_TEST_STRINGS.SPANISH_MIXED_SENTENCE).sort()).toEqual(expectedSpanish.sort()) + + const expectedGerman = [ + 'Das', + 'ist', + 'kein', + 'Test', + 'das', + 'ist', + "RUM's", + 'Test', + '💥', + '=', + '+', + '+', + 'und', + 'mehr', + ] + expect(tokenize(LANGUAGES_TEST_STRINGS.GERMAN_MIXED_SENTENCE).sort()).toEqual(expectedGerman.sort()) + + const expectedPortuguese = [ + 'Este', + 'não', + 'é', + 'um', + 'teste', + 'este', + 'é', + "RUM's", + 'test', + '💥', + '=', + '+', + '+', + 'e', + 'mais', + ] + expect(tokenize(LANGUAGES_TEST_STRINGS.PORTUGUESE_MIXED_SENTENCE).sort()).toEqual(expectedPortuguese.sort()) + }) + }) +} + +describe('createActionAllowList', () => { + beforeAll(() => { + window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) + }) + + afterAll(() => { + window.$DD_ALLOW = undefined + }) + + it('should create an action name dictionary and clear it', () => { + const actionNameDictionary = createActionAllowList() + if (!isBrowserSupported()) { + expect(actionNameDictionary.allowlist.size).toBe(0) + expect(actionNameDictionary.rawStringIterator).toBeDefined() + return + } + expect(actionNameDictionary.allowlist.size).toBeGreaterThan(0) + expect(actionNameDictionary.rawStringIterator).toBeDefined() + actionNameDictionary.clear() + expect(actionNameDictionary.allowlist.size).toBe(0) + expect(actionNameDictionary.rawStringIterator).toBeUndefined() + }) + + it('should handle when $DD_ALLOW is undefined and redefined later', () => { + window.$DD_ALLOW = undefined + const actionNameDictionary = createActionAllowList() + expect(actionNameDictionary.rawStringIterator).toBeUndefined() + + window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) + // Trigger the observer manually + window.$DD_ALLOW_OBSERVERS?.forEach((observer) => observer()) + expect(actionNameDictionary.rawStringIterator).toBeDefined() + actionNameDictionary.clear() + }) +}) + +if (isBrowserSupported()) { + describe('actionNameDictionary processing', () => { + let actionNameDictionary: AllowedDictionary + let clearActionNameDictionary: () => void + + beforeEach(() => { + window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) + actionNameDictionary = createActionAllowList() + clearActionNameDictionary = actionNameDictionary.clear + }) + + afterEach(() => { + window.$DD_ALLOW = undefined + clearActionNameDictionary() + }) + + it('initializes allowlist with normalized words from $DD_ALLOW', () => { + expect(actionNameDictionary.allowlist.has('test')).toBeTrue() + expect(actionNameDictionary.allowlist.has('hello')).toBeTrue() + expect(actionNameDictionary.allowlist.has('world')).toBeTrue() + }) + + it('updates dictionary when $DD_ALLOW changes', () => { + const initialAllowlistSize = actionNameDictionary.allowlist.size + + // Simulate a change in $DD_ALLOW + window.$DD_ALLOW?.add('new-Word') + window.$DD_ALLOW?.add('another-Word') + // Trigger the observer manually + processRawAllowList(window.$DD_ALLOW, actionNameDictionary) + + // Verify dictionary is updated with new words + expect(actionNameDictionary.allowlist.has('word')).toBeTrue() + expect(actionNameDictionary.allowlist.has('new')).toBeTrue() + expect(actionNameDictionary.allowlist.has('another')).toBeTrue() + // Old words should still be present + expect(actionNameDictionary.allowlist.size).toBe(initialAllowlistSize + 3) + }) + }) +} + +describe('createActionNameDictionary and maskActionName', () => { + let actionNameDictionary: AllowedDictionary + let clearActionNameDictionary: () => void + const clickActionBase: ClickActionBase = { + type: ActionType.CLICK, + name: 'test-💥-xxxxxx-xxx', + nameSource: ActionNameSource.MASK_DISALLOWED, + target: { + selector: 'button', + width: 100, + height: 100, + }, + position: { x: 0, y: 0 }, + } + + beforeEach(() => { + window.$DD_ALLOW = new Set([TEST_STRINGS.PARAGRAPH_MIXED]) + actionNameDictionary = createActionAllowList() + clearActionNameDictionary = actionNameDictionary.clear + }) + + afterEach(() => { + window.$DD_ALLOW = undefined + clearActionNameDictionary() + }) + + it('should not run if $DD_ALLOW is not defined', () => { + window.$DD_ALLOW = undefined as any + clickActionBase.name = 'mask-feature-off' + const testString = maskActionName(clickActionBase, NodePrivacyLevel.ALLOW, actionNameDictionary.allowlist) + expect(testString.name).toBe('mask-feature-off') + expect(testString.nameSource).toBe(ActionNameSource.MASK_DISALLOWED) + }) + + it('masks words not in allowlist (with dictionary from $DD_ALLOW)', () => { + clickActionBase.name = "test this: if 💥 isn't pii" + let expected = "test this: xxx 💥 isn't xxx" + if (!isBrowserSupported()) { + expected = ACTION_NAME_PLACEHOLDER + } + const testString1 = maskActionName(clickActionBase, NodePrivacyLevel.MASK, actionNameDictionary.allowlist) + expect(testString1.name).toBe(expected) + expect(testString1.nameSource).toBe(ActionNameSource.MASK_DISALLOWED) + + clickActionBase.name = 'test-💥+123*hello wild' + expected = 'test-💥+xxx xxx' + if (!isBrowserSupported()) { + expected = ACTION_NAME_PLACEHOLDER + } + const testString2 = maskActionName(clickActionBase, NodePrivacyLevel.MASK, actionNameDictionary.allowlist) + expect(testString2.name).toBe(expected) + expect(testString2.nameSource).toBe(ActionNameSource.MASK_DISALLOWED) + }) + + it('handles empty string', () => { + clickActionBase.name = '' + const result = maskActionName(clickActionBase, NodePrivacyLevel.ALLOW, actionNameDictionary.allowlist) + expect(result.name).toBe('') + expect(result.nameSource).toBe(ActionNameSource.MASK_DISALLOWED) + }) +}) diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts new file mode 100644 index 0000000000..a214861c54 --- /dev/null +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -0,0 +1,169 @@ +import { NodePrivacyLevel, TEXT_MASKING_CHAR, FIXED_MASKING_STRING } from '../../privacyConstants' +import { ACTION_NAME_PLACEHOLDER, ActionNameSource } from '../getActionNameFromElement' +import type { ClickActionBase } from '../trackClickActions' + +declare global { + interface Window { + $DD_ALLOW?: Set + $DD_ALLOW_OBSERVERS?: Set<() => void> + } +} + +export const actionNameDictionary: AllowedDictionary = { + rawStringCounter: 0, + allowlist: new Set(), + rawStringIterator: undefined, + clear: () => { + actionNameDictionary.allowlist.clear() + actionNameDictionary.rawStringCounter = 0 + actionNameDictionary.rawStringIterator = undefined + }, +} + +type UnicodeRegexes = { + matchRegex: RegExp + splitRegex: RegExp +} + +// Cache regex compilation and browser support detection +let cachedRegexes: UnicodeRegexes | undefined + +export function getOrInitRegexes(): UnicodeRegexes | undefined { + if (cachedRegexes !== undefined) { + return cachedRegexes + } + + try { + cachedRegexes = { + // Split on separators, control characters, and selected punctuation + splitRegex: new RegExp('[^\\p{Separator}\\p{Cc}\\p{Sm}!"(),-./:;?[\\]`_{|}]+', 'gu'), + // Match letters (including apostrophes), emojis, and mathematical symbols + matchRegex: new RegExp("[\\p{Letter}’']+|[\\p{Emoji_Presentation}]+|[\\p{Sm}]+", 'gu'), + } + } catch { + cachedRegexes = undefined + } + + return cachedRegexes +} + +export function tokenize(str: string): string[] { + if (typeof str !== 'string' || !str.trim()) { + return [] + } + + if (!getOrInitRegexes() || !cachedRegexes) { + return [] + } + + const { matchRegex } = cachedRegexes + return str.match(matchRegex) || [] +} + +export function isBrowserSupported(): boolean { + return getOrInitRegexes() !== undefined +} + +export type AllowedDictionary = { + rawStringCounter: number + allowlist: Set + rawStringIterator: IterableIterator | undefined + clear: () => void +} + +export function createActionAllowList(): AllowedDictionary { + actionNameDictionary.clear = () => { + actionNameDictionary.allowlist.clear() + actionNameDictionary.rawStringCounter = 0 + actionNameDictionary.rawStringIterator = undefined + window.$DD_ALLOW_OBSERVERS?.delete(observer) + } + + const observer = () => processRawAllowList(window.$DD_ALLOW, actionNameDictionary) + processRawAllowList(window.$DD_ALLOW, actionNameDictionary) + + // Add observer + if (!window.$DD_ALLOW_OBSERVERS) { + window.$DD_ALLOW_OBSERVERS = new Set<() => void>() + } + window.$DD_ALLOW_OBSERVERS.add(observer) + + return actionNameDictionary +} + +export function processRawAllowList(rawAllowlist: Set | undefined, dictionary: AllowedDictionary): void { + if (!rawAllowlist?.size) { + return + } + + if (!dictionary.rawStringIterator) { + dictionary.rawStringIterator = rawAllowlist.values() + } + + const currentSize = rawAllowlist.size + while (dictionary.rawStringCounter < currentSize) { + const nextItem = dictionary.rawStringIterator.next() + dictionary.rawStringCounter++ + + if (nextItem.value) { + // Process raw string tokens directly + const tokens = tokenize(nextItem.value) + for (const token of tokens) { + dictionary.allowlist.add(token.toLowerCase()) + } + } + } +} + +export function maskTextContent( + text: string, + processedAllowlist: Set, + regexes: UnicodeRegexes, + fixedMask?: string +): { maskedText: string; hasBeenMasked: boolean } { + let hasBeenMasked = false + + const maskedText = text.replace(regexes.splitRegex, (segment: string) => { + if (!processedAllowlist.has(segment.toLowerCase())) { + hasBeenMasked = true + return fixedMask ?? TEXT_MASKING_CHAR.repeat(segment.length) + } + return segment + }) + + return { maskedText, hasBeenMasked } +} + +export function maskActionName( + actionName: ClickActionBase, + nodeSelfPrivacy: NodePrivacyLevel, + processedAllowlist: Set +): ClickActionBase { + if (nodeSelfPrivacy === NodePrivacyLevel.ALLOW) { + return actionName + } else if ( + nodeSelfPrivacy !== NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED && + (!window.$DD_ALLOW || !window.$DD_ALLOW.size) + ) { + return actionName + } // if the privacy level is MASK or MASK_USER_INPUT and the allowlist is present, we continue of masking the action name + + const { name, nameSource } = actionName + const regexes = getOrInitRegexes() + + if (!regexes || !window.$DD_ALLOW || !window.$DD_ALLOW.size) { + return { + ...actionName, + name: name ? ACTION_NAME_PLACEHOLDER : '', + nameSource: name ? ActionNameSource.MASK_DISALLOWED : nameSource, + } + } + + const maskedName = maskTextContent(name, processedAllowlist, regexes, FIXED_MASKING_STRING) + + return { + ...actionName, + name: maskedName.maskedText, + nameSource: maskedName.hasBeenMasked ? ActionNameSource.MASK_DISALLOWED : nameSource, + } +} diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index 582e88dd8d..047540bb32 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -21,6 +21,8 @@ import type { ClickAction } from './trackClickActions' import { finalizeClicks, trackClickActions } from './trackClickActions' import { MAX_DURATION_BETWEEN_CLICKS } from './clickChain' import { getInteractionSelector, CLICK_ACTION_MAX_DURATION } from './interactionSelectorCache' +import { ActionNameSource } from './getActionNameFromElement' +import { createActionAllowList } from './privacy/allowedDictionary' // Used to wait some time after the creation of an action const BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY = PAGE_ACTIVITY_VALIDATION_DELAY * 0.8 @@ -61,7 +63,8 @@ describe('trackClickActions', () => { lifeCycle, domMutationObservable, windowOpenObservable, - mockRumConfiguration(partialConfig) + mockRumConfiguration(partialConfig), + createActionAllowList() ) findActionId = trackClickActionsResult.actionContexts.findActionId @@ -117,7 +120,7 @@ describe('trackClickActions', () => { duration: BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY as Duration, id: jasmine.any(String), name: 'Click me', - nameSource: 'text_content', + nameSource: ActionNameSource.TEXT_CONTENT, startClocks: { relative: addDuration(pointerDownClocks.relative, EMULATED_CLICK_DURATION), timeStamp: addDuration(pointerDownClocks.timeStamp, EMULATED_CLICK_DURATION), @@ -447,6 +450,73 @@ describe('trackClickActions', () => { }) }) + describe('maskActionName', () => { + beforeAll(() => { + window.$DD_ALLOW = new Set(['foo-bar']) + // notify the observer to process the allowlist + window.$DD_ALLOW_OBSERVERS?.forEach((observer) => observer()) + }) + + afterAll(() => { + window.$DD_ALLOW = undefined + }) + + it('should mask action name when defaultPrivacyLevel is mask_unless_allowlisted and not in allowlist', () => { + startClickActionsTracking({ + defaultPrivacyLevel: DefaultPrivacyLevel.MASK_UNLESS_ALLOWLISTED, + }) + + emulateClick({ activity: {} }) + expect(findActionId()).not.toBeUndefined() + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].name).toBe('xxxxx xx') + expect(events[0].nameSource).toBe(ActionNameSource.MASK_DISALLOWED) + }) + + it('should not mask action name when defaultPrivacyLevel is allow', () => { + startClickActionsTracking({ + defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, + }) + + emulateClick({ activity: {} }) + expect(findActionId()).not.toBeUndefined() + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].name).toBe('Click me') + }) + + it('should not use allowlist masking when enablePrivacyForActionName is true', () => { + startClickActionsTracking({ + defaultPrivacyLevel: DefaultPrivacyLevel.MASK, + enablePrivacyForActionName: true, + }) + + emulateClick({ activity: {} }) + expect(findActionId()).not.toBeUndefined() + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].name).toBe('Masked Element') + }) + + it('should not mask action name when defaultPrivacyLevel is mask but dd-privacy is allow', () => { + button.setAttribute('data-dd-privacy', 'allow') + startClickActionsTracking({ + defaultPrivacyLevel: DefaultPrivacyLevel.MASK, + }) + + emulateClick({ activity: {} }) + expect(findActionId()).not.toBeUndefined() + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].name).toBe('Click me') + }) + }) + function emulateClick({ target = button, activity, diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index 58bc50341d..4e1e441a0c 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -17,16 +17,20 @@ import { LifeCycleEventType } from '../lifeCycle' import { trackEventCounts } from '../trackEventCounts' import { PAGE_ACTIVITY_VALIDATION_DELAY, waitPageActivityEnd } from '../waitPageActivityEnd' import { getSelectorFromElement } from '../getSelectorFromElement' -import { getNodePrivacyLevel, NodePrivacyLevel } from '../privacy' +import { getNodePrivacyLevel } from '../privacy' +import { NodePrivacyLevel } from '../privacyConstants' import type { RumConfiguration } from '../configuration' import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ClickChain } from './clickChain' import { createClickChain } from './clickChain' import { getActionNameFromElement } from './getActionNameFromElement' +import type { ActionNameSource } from './getActionNameFromElement' import type { MouseEventOnElement, UserActivity } from './listenActionEvents' import { listenActionEvents } from './listenActionEvents' import { computeFrustration } from './computeFrustration' import { CLICK_ACTION_MAX_DURATION, updateInteractionSelector } from './interactionSelectorCache' +import { maskActionName } from './privacy/allowedDictionary' +import type { AllowedDictionary } from './privacy/allowedDictionary' interface ActionCounts { errorCount: number @@ -38,7 +42,7 @@ export interface ClickAction { type: ActionType.CLICK id: string name: string - nameSource: string + nameSource: ActionNameSource target?: { selector: string | undefined width: number @@ -65,7 +69,8 @@ export function trackClickActions( lifeCycle: LifeCycle, domMutationObservable: Observable, windowOpenObservable: Observable, - configuration: RumConfiguration + configuration: RumConfiguration, + actionNameDictionary: AllowedDictionary ) { const history: ClickActionIdHistory = createValueHistory({ expireDelay: ACTION_CONTEXT_TIME_OUT_DELAY }) const stopObservable = new Observable() @@ -87,7 +92,14 @@ export function trackClickActions( hadActivityOnPointerDown: () => boolean }>(configuration, { onPointerDown: (pointerDownEvent) => - processPointerDown(configuration, lifeCycle, domMutationObservable, pointerDownEvent, windowOpenObservable), + processPointerDown( + configuration, + lifeCycle, + domMutationObservable, + pointerDownEvent, + windowOpenObservable, + actionNameDictionary + ), onPointerUp: ({ clickActionBase, hadActivityOnPointerDown }, startEvent, getUserActivity) => { startClickAction( configuration, @@ -139,17 +151,22 @@ function processPointerDown( lifeCycle: LifeCycle, domMutationObservable: Observable, pointerDownEvent: MouseEventOnElement, - windowOpenObservable: Observable + windowOpenObservable: Observable, + actionNameDictionary: AllowedDictionary ) { - const nodePrivacyLevel = configuration.enablePrivacyForActionName - ? getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel) - : NodePrivacyLevel.ALLOW + const nodeSelfPrivacy = getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel) + const nodePrivacyLevel = configuration.enablePrivacyForActionName ? nodeSelfPrivacy : NodePrivacyLevel.ALLOW if (nodePrivacyLevel === NodePrivacyLevel.HIDDEN) { return undefined } - const clickActionBase = computeClickActionBase(pointerDownEvent, nodePrivacyLevel, configuration) + let clickActionBase = computeClickActionBase(pointerDownEvent, nodePrivacyLevel, configuration) + + // mask with allowlist when enablePrivacyForActionName is not set to true + if (!configuration.enablePrivacyForActionName) { + clickActionBase = maskActionName(clickActionBase, nodeSelfPrivacy, actionNameDictionary.allowlist) + } let hadActivityOnPointerDown = false @@ -231,7 +248,7 @@ function startClickAction( }) } -type ClickActionBase = Pick +export type ClickActionBase = Pick function computeClickActionBase( event: MouseEventOnElement, @@ -243,7 +260,8 @@ function computeClickActionBase( if (selector) { updateInteractionSelector(event.timeStamp, selector) } - const actionName = getActionNameFromElement(event.target, configuration, nodePrivacyLevel) + + const { name, nameSource } = getActionNameFromElement(event.target, configuration, nodePrivacyLevel) return { type: ActionType.CLICK, @@ -257,8 +275,8 @@ function computeClickActionBase( x: Math.round(event.clientX - rect.left), y: Math.round(event.clientY - rect.top), }, - name: actionName.name, - nameSource: actionName.nameSource, + name, + nameSource, } } diff --git a/packages/rum-core/src/domain/privacy.spec.ts b/packages/rum-core/src/domain/privacy.spec.ts index 5f5c9cb3fd..5b664bc953 100644 --- a/packages/rum-core/src/domain/privacy.spec.ts +++ b/packages/rum-core/src/domain/privacy.spec.ts @@ -4,11 +4,8 @@ import { PRIVACY_ATTR_VALUE_HIDDEN, PRIVACY_ATTR_VALUE_MASK, PRIVACY_ATTR_VALUE_MASK_USER_INPUT, - getNodeSelfPrivacyLevel, - reducePrivacyLevel, - getNodePrivacyLevel, - shouldMaskNode, -} from './privacy' +} from './privacyConstants' +import { getNodeSelfPrivacyLevel, reducePrivacyLevel, getNodePrivacyLevel, shouldMaskNode } from './privacy' describe('getNodePrivacyLevel', () => { it('returns the element privacy mode if it has one', () => { diff --git a/packages/rum-core/src/domain/privacy.ts b/packages/rum-core/src/domain/privacy.ts index ef90843fbe..dad05ff4f3 100644 --- a/packages/rum-core/src/domain/privacy.ts +++ b/packages/rum-core/src/domain/privacy.ts @@ -1,41 +1,12 @@ -import { DefaultPrivacyLevel } from '@datadog/browser-core' import { isElementNode, getParentNode, isTextNode } from '../browser/htmlDomUtils' - -export const NodePrivacyLevel = { - IGNORE: 'ignore', - HIDDEN: 'hidden', - ALLOW: DefaultPrivacyLevel.ALLOW, - MASK: DefaultPrivacyLevel.MASK, - MASK_USER_INPUT: DefaultPrivacyLevel.MASK_USER_INPUT, -} as const -export type NodePrivacyLevel = (typeof NodePrivacyLevel)[keyof typeof NodePrivacyLevel] - -export const PRIVACY_ATTR_NAME = 'data-dd-privacy' - -// Privacy Attrs -export const PRIVACY_ATTR_VALUE_ALLOW = 'allow' -export const PRIVACY_ATTR_VALUE_MASK = 'mask' -export const PRIVACY_ATTR_VALUE_MASK_USER_INPUT = 'mask-user-input' -export const PRIVACY_ATTR_VALUE_HIDDEN = 'hidden' - -// Privacy Classes - not all customers can set plain HTML attributes, so support classes too -export const PRIVACY_CLASS_PREFIX = 'dd-privacy-' - -// Private Replacement Templates -export const CENSORED_STRING_MARK = '***' -export const CENSORED_IMG_MARK = 'data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==' - -export const FORM_PRIVATE_TAG_NAMES: { [tagName: string]: true } = { - INPUT: true, - OUTPUT: true, - TEXTAREA: true, - SELECT: true, - OPTION: true, - DATALIST: true, - OPTGROUP: true, -} - -const TEXT_MASKING_CHAR = 'x' +import { getOrInitRegexes, maskTextContent, actionNameDictionary } from './action/privacy/allowedDictionary' +import { + NodePrivacyLevel, + FORM_PRIVATE_TAG_NAMES, + TEXT_MASKING_CHAR, + CENSORED_STRING_MARK, + getPrivacySelector, +} from './privacyConstants' export type NodePrivacyLevelCache = Map @@ -82,6 +53,7 @@ export function reducePrivacyLevel( case NodePrivacyLevel.ALLOW: case NodePrivacyLevel.MASK: case NodePrivacyLevel.MASK_USER_INPUT: + case NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED: case NodePrivacyLevel.HIDDEN: case NodePrivacyLevel.IGNORE: return childPrivacyLevel @@ -133,6 +105,10 @@ export function getNodeSelfPrivacyLevel(node: Node): NodePrivacyLevel | undefine return NodePrivacyLevel.MASK_USER_INPUT } + if (node.matches(getPrivacySelector(NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED))) { + return NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED + } + if (node.matches(getPrivacySelector(NodePrivacyLevel.ALLOW))) { return NodePrivacyLevel.ALLOW } @@ -158,6 +134,7 @@ export function shouldMaskNode(node: Node, privacyLevel: NodePrivacyLevel) { case NodePrivacyLevel.MASK: case NodePrivacyLevel.HIDDEN: case NodePrivacyLevel.IGNORE: + case NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED: return true case NodePrivacyLevel.MASK_USER_INPUT: return isTextNode(node) ? isFormElement(node.parentNode) : isFormElement(node) @@ -226,6 +203,11 @@ export function getTextContent( } else if (parentTagName === 'OPTION') { //