Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
489bf17
✨Add support for privacy plugin extracted data for masking
cy-moi Jun 23, 2025
c1e6461
fix: flaky test due to uncleared global var
cy-moi Jun 23, 2025
052a04b
fix: clear up dictionary and listeners; improve code; fix tests
cy-moi Jun 25, 2025
9fe8b89
fix: remove fallback for older version browsers
cy-moi Jun 27, 2025
549159f
fix: type
cy-moi Jun 27, 2025
6f20b2b
refactor: reduce bundle size a bit
cy-moi Jun 27, 2025
6e5063b
refactor: make allowlist masking compatible with dd-privacy tags
cy-moi Jun 30, 2025
69607f6
fix:lint
cy-moi Jun 30, 2025
5105112
fix:add test case
cy-moi Jul 1, 2025
da92015
fix: nits and regexes
cy-moi Jul 9, 2025
045d430
fix: lint and format
cy-moi Jul 9, 2025
71dfbe0
fix: check when tokens are not strings
cy-moi Jul 11, 2025
b220eae
add privacy level
cy-moi Jul 11, 2025
a7234ce
feat:add masking in session replay
cy-moi Jul 15, 2025
a4bd48c
fix:circular import
cy-moi Jul 15, 2025
86facff
fix: change action name masking to fixed length
cy-moi Jul 16, 2025
32c400e
Merge branch 'main' into congyao/RUM-10415-add-privacy-allowlist-support
cy-moi Jul 21, 2025
82927c8
feat: use only DD_ALLOW strict matching instead of tokenized dictionary
cy-moi Jul 21, 2025
febd0bb
fix: format and lint
cy-moi Jul 21, 2025
5196475
polish: improve code and add test cases
cy-moi Jul 22, 2025
4b749e2
Merge branch 'main' into congyao/RUM-10415-add-privacy-allowlist-support
cy-moi Jul 22, 2025
cf3170f
fix: change naming
cy-moi Jul 22, 2025
3ecbb66
Merge branch 'main' into congyao/RUM-10415-add-privacy-allowlist-support
cy-moi Aug 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 './actionNameConstants'

describe('actionCollection', () => {
const lifeCycle = new LifeCycle()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -145,7 +146,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,
})
Expand Down
12 changes: 9 additions & 3 deletions packages/rum-core/src/domain/action/actionCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ export function startActionCollection(
windowOpenObservable: Observable<void>,
configuration: RumConfiguration
) {
lifeCycle.subscribe(LifeCycleEventType.AUTO_ACTION_COMPLETED, (action) =>
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action))
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 => {
Expand Down Expand Up @@ -79,7 +82,10 @@ export function startActionCollection(
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action))
},
actionContexts,
stop,
stop: () => {
unsubscribeAutoActionCompleted()
stop()
},
}
}

Expand Down
18 changes: 18 additions & 0 deletions packages/rum-core/src/domain/action/actionNameConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Get the action name from the attribute 'data-dd-action-name' on the element or any of its parent.
* It can also be retrieved from a user defined attribute.
*/
export const DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE = 'data-dd-action-name'
export const ACTION_NAME_PLACEHOLDER = 'Masked Element'
export const enum ActionNameSource {
CUSTOM_ATTRIBUTE = 'custom_attribute',
MASK_PLACEHOLDER = 'mask_placeholder',
TEXT_CONTENT = 'text_content',
STANDARD_ATTRIBUTE = 'standard_attribute',
BLANK = 'blank',
MASK_DISALLOWED = 'mask_disallowed',
}
export interface ActionName {
name: string
nameSource: ActionNameSource
}
173 changes: 171 additions & 2 deletions packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { appendElement, mockRumConfiguration } from '../../../test'
import { NodePrivacyLevel } from '../privacy'
import { ActionNameSource, getActionNameFromElement } from './getActionNameFromElement'
import { NodePrivacyLevel } from '../privacyConstants'
import { getActionNameFromElement } from './getActionNameFromElement'
import { ActionNameSource } from './actionNameConstants'

const defaultConfiguration = mockRumConfiguration()

Expand Down Expand Up @@ -663,4 +664,172 @@ describe('getActionNameFromElement', () => {
})
})
})

describe('Mask with allowlist for action names', () => {
describe('when privacyEnabledActionName is false', () => {
beforeEach(() => {
window.$DD_ALLOW = new Set(['allowed text', 'button text'])
})

afterEach(() => {
window.$DD_ALLOW = undefined
})

it('applies maskActionName when privacy level is MASK_UNLESS_ALLOWLISTED', () => {
const { name, nameSource } = getActionNameFromElement(
appendElement('<button>secret text</button>'),
{
...defaultConfiguration,
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
enablePrivacyForActionName: false,
},
NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED
)
expect(name).toBe('Masked Element')
expect(nameSource).toBe('mask_disallowed')
})

it('preserves allowlisted text when privacy level is MASK_UNLESS_ALLOWLISTED', () => {
const { name, nameSource } = getActionNameFromElement(
appendElement('<button>allowed text</button>'),
{
...defaultConfiguration,
},
NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED
)
expect(name).toBe('allowed text')
expect(nameSource).toBe('text_content')
})

// TODO: remove this comment after we make enablePrivacyForActionName true by default
// This case should not happen in the field for now, because we do not set node privacy level to MASK
// when enablePrivacyForActionName is false in the configuration yet
it('apply mask placeholder when privacy level is MASK if DD_ALLOW is not empty', () => {
const { name, nameSource } = getActionNameFromElement(
appendElement('<button>any text</button>'),
{
...defaultConfiguration,
},
NodePrivacyLevel.MASK
)
expect(name).toBe('Masked Element')
expect(nameSource).toBe('mask_placeholder')
})

it('does not apply masking when privacy level is ALLOW', () => {
const { name, nameSource } = getActionNameFromElement(
appendElement('<button>any text</button>'),
{
...defaultConfiguration,
},
NodePrivacyLevel.ALLOW
)
expect(name).toBe('any text')
expect(nameSource).toBe('text_content')
})

it('handles empty allowlist with MASK_UNLESS_ALLOWLISTED privacy level', () => {
window.$DD_ALLOW = new Set()
const { name, nameSource } = getActionNameFromElement(
appendElement('<button>any text</button>'),
{
...defaultConfiguration,
enablePrivacyForActionName: false,
},
NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED
)
expect(name).toBe('Masked Element')
expect(nameSource).toBe('mask_disallowed')
})

it('handles undefined allowlist with MASK_UNLESS_ALLOWLISTED privacy level', () => {
window.$DD_ALLOW = undefined
const { name, nameSource } = getActionNameFromElement(
appendElement('<button>any text</button>'),
{
...defaultConfiguration,
enablePrivacyForActionName: false,
},
NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED
)
expect(name).toBe('Masked Element')
expect(nameSource).toBe('mask_disallowed')
})

it('applies masking to input button values when not allowlisted', () => {
const { name, nameSource } = getActionNameFromElement(
appendElement('<input type="button" value="secret button" />'),
{
...defaultConfiguration,
enablePrivacyForActionName: false,
},
NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED
)
expect(name).toBe('Masked Element')
expect(nameSource).toBe('mask_disallowed')
})

it('preserves input button values when allowlisted', () => {
const { name, nameSource } = getActionNameFromElement(
appendElement('<input type="button" value="button text" />'),
{
...defaultConfiguration,
enablePrivacyForActionName: false,
},
NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED
)
expect(name).toBe('button text')
expect(nameSource).toBe('text_content')
})

it('applies masking to aria-label when not allowlisted', () => {
const { name, nameSource } = getActionNameFromElement(
appendElement('<span aria-label="secret label" />'),
{
...defaultConfiguration,
enablePrivacyForActionName: false,
},
NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED
)
expect(name).toBe('Masked Element')
expect(nameSource).toBe('mask_disallowed')
})

it('preserves aria-label when allowlisted', () => {
const { name, nameSource } = getActionNameFromElement(
appendElement('<span aria-label="allowed text" />'),
{
...defaultConfiguration,
enablePrivacyForActionName: false,
},
NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED
)
expect(name).toBe('allowed text')
expect(nameSource).toBe('standard_attribute')
})
})

describe('when privacyEnabledActionName is true', () => {
beforeEach(() => {
window.$DD_ALLOW = new Set(['allowed text'])
})

afterEach(() => {
window.$DD_ALLOW = undefined
})

it('should use allowlist masking when nodePrivacyLevel is MASK_UNLESS_ALLOWLISTED', () => {
const { name, nameSource } = getActionNameFromElement(
appendElement('<button>secret text</button>'),
{
...defaultConfiguration,
enablePrivacyForActionName: true,
},
NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED
)
expect(name).toBe('Masked Element')
expect(nameSource).toBe('mask_disallowed')
})
})
})
})
44 changes: 22 additions & 22 deletions packages/rum-core/src/domain/action/getActionNameFromElement.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,17 @@
import { safeTruncate } from '@datadog/browser-core'
import { NodePrivacyLevel, getPrivacySelector } from '../privacy'
import { DefaultPrivacyLevel, safeTruncate } from '@datadog/browser-core'
import { NodePrivacyLevel, getPrivacySelector } from '../privacyConstants'
import type { RumConfiguration } from '../configuration'

/**
* Get the action name from the attribute 'data-dd-action-name' on the element or any of its parent.
* It can also be retrieved from a user defined attribute.
*/
export const DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE = 'data-dd-action-name'
export const ACTION_NAME_PLACEHOLDER = 'Masked Element'
export const enum ActionNameSource {
CUSTOM_ATTRIBUTE = 'custom_attribute',
MASK_PLACEHOLDER = 'mask_placeholder',
TEXT_CONTENT = 'text_content',
STANDARD_ATTRIBUTE = 'standard_attribute',
BLANK = 'blank',
}
interface ActionName {
name: string
nameSource: ActionNameSource
}
import { maskDisallowedActionName } from './privacy/maskWithAllowlist'
import {
ActionNameSource,
DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE,
ACTION_NAME_PLACEHOLDER,
} from './actionNameConstants'
import type { ActionName } from './actionNameConstants'

export function getActionNameFromElement(
element: Element,
{ enablePrivacyForActionName, actionNameAttribute: userProgrammaticAttribute }: RumConfiguration,
{ enablePrivacyForActionName, actionNameAttribute: userProgrammaticAttribute, defaultPrivacyLevel }: RumConfiguration,
nodePrivacyLevel?: NodePrivacyLevel
): ActionName {
// Proceed to get the action name in two steps:
Expand All @@ -47,12 +36,16 @@ export function getActionNameFromElement(
element,
userProgrammaticAttribute,
priorityStrategies,
defaultPrivacyLevel,
nodePrivacyLevel,
enablePrivacyForActionName
) ||
getActionNameFromElementForStrategies(
element,
userProgrammaticAttribute,
fallbackStrategies,
defaultPrivacyLevel,
nodePrivacyLevel,
enablePrivacyForActionName
) || { name: '', nameSource: ActionNameSource.BLANK }
)
Expand Down Expand Up @@ -141,6 +134,8 @@ function getActionNameFromElementForStrategies(
targetElement: Element,
userProgrammaticAttribute: string | undefined,
strategies: NameStrategy[],
defaultPrivacyLevel: DefaultPrivacyLevel,
nodeSelfPrivacy?: NodePrivacyLevel,
privacyEnabledActionName?: boolean
) {
let element: Element | null = targetElement
Expand All @@ -155,7 +150,12 @@ function getActionNameFromElementForStrategies(
for (const strategy of strategies) {
const actionName = strategy(element, userProgrammaticAttribute, privacyEnabledActionName)
if (actionName) {
const { name, nameSource } = actionName
const { name, nameSource } =
(defaultPrivacyLevel === DefaultPrivacyLevel.MASK_UNLESS_ALLOWLISTED &&
nodeSelfPrivacy !== NodePrivacyLevel.ALLOW) ||
nodeSelfPrivacy === NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED
? maskDisallowedActionName(actionName)
: actionName
const trimmedName = name && name.trim()
if (trimmedName) {
return { name: truncate(normalizeWhitespace(trimmedName)), nameSource }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ActionType } from '../../../rawRumEvent.types'
import { ActionNameSource } from '../actionNameConstants'
import type { ClickActionBase } from '../trackClickActions'
import { maskDisallowedActionName } from './maskWithAllowlist'

const TEST_STRINGS = {
COMPLEX_MIXED: 'test-team-name:💥$$$',
PARAGRAPH_MIXED: '✅ This is an action name in allowlist',
}

describe('maskWithAllowlist', () => {
const clickActionBase: ClickActionBase = {
type: ActionType.CLICK,
name: '',
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])
})

afterEach(() => {
window.$DD_ALLOW = undefined
})

it('should fail close if $DD_ALLOW is not defined', () => {
window.$DD_ALLOW = undefined as any
clickActionBase.name = 'mask-feature-on'
const testString = maskDisallowedActionName(clickActionBase)
expect(testString.name).toBe('Masked Element')
expect(testString.nameSource).toBe(ActionNameSource.MASK_DISALLOWED)
})

it('masks words not in allowlist (with dictionary from $DD_ALLOW)', () => {
clickActionBase.name = 'This is an action name in allowlist'
const testString1 = maskDisallowedActionName(clickActionBase)
expect(testString1.name).toBe('Masked Element')
expect(testString1.nameSource).toBe(ActionNameSource.MASK_DISALLOWED)

clickActionBase.name = 'any unallowed string'
const testString2 = maskDisallowedActionName(clickActionBase)
expect(testString2.name).toBe('Masked Element')
expect(testString2.nameSource).toBe(ActionNameSource.MASK_DISALLOWED)
})

it('handles empty string', () => {
clickActionBase.name = ''
const result = maskDisallowedActionName(clickActionBase)
expect(result.name).toBe('')
expect(result.nameSource).toBe(ActionNameSource.MASK_DISALLOWED)
})
})
Loading