Skip to content

Commit dc19986

Browse files
[RUM-10415] [alt] add privacy allowlist support treewalker (#3803)
* Use treewalker to replace innerText * Add support for display and contentVisibility * Improve code and add test cases * Clean up * Update packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts Co-authored-by: sethfowler-datadog <[email protected]> * Use allowlist masking for action names * Pass down configurations to reduce args; fix listener leaks * Format code * Improve unit test for shouldMaskNode * Improve code * Refactor and add tests to preserve mask under mask-unless-allowlisted * Clean up global declare and test code * Require enablePrivacyForActionName: true * Update packages/rum/src/domain/record/serialization/serializeNode.spec.ts Co-authored-by: sethfowler-datadog <[email protected]> * Keep compatibility with current masking strategy * Update shouldMaskNode() to be compatible with input element * Added tests and improve code to adress comments * Update packages/rum/src/domain/record/serialization/serializeNode.spec.ts Co-authored-by: sethfowler-datadog <[email protected]> * fix-comment: test cases and unused parameter * fix-comment: fix getAttributes * fix-comment: fix test names --------- Co-authored-by: sethfowler-datadog <[email protected]>
1 parent 67b92ea commit dc19986

16 files changed

+1026
-123
lines changed

packages/core/src/domain/configuration/configuration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const DefaultPrivacyLevel = {
2525
ALLOW: 'allow',
2626
MASK: 'mask',
2727
MASK_USER_INPUT: 'mask-user-input',
28+
MASK_UNLESS_ALLOWLISTED: 'mask-unless-allowlisted',
2829
} as const
2930
export type DefaultPrivacyLevel = (typeof DefaultPrivacyLevel)[keyof typeof DefaultPrivacyLevel]
3031

packages/rum-core/src/domain/action/actionCollection.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createHooks } from '../hooks'
1111
import type { RumMutationRecord } from '../../browser/domMutationObservable'
1212
import type { ActionContexts } from './actionCollection'
1313
import { startActionCollection } from './actionCollection'
14+
import { ActionNameSource } from './actionNameConstants'
1415

1516
describe('actionCollection', () => {
1617
const lifeCycle = new LifeCycle()
@@ -50,7 +51,7 @@ describe('actionCollection', () => {
5051
duration: 100 as Duration,
5152
id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
5253
name: 'foo',
53-
nameSource: 'text_content',
54+
nameSource: ActionNameSource.TEXT_CONTENT,
5455
startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp },
5556
type: ActionType.CLICK,
5657
event,
@@ -145,7 +146,7 @@ describe('actionCollection', () => {
145146
frustrationTypes: [],
146147
id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
147148
name: 'foo',
148-
nameSource: 'text_content',
149+
nameSource: ActionNameSource.TEXT_CONTENT,
149150
startClocks: { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp },
150151
type: ActionType.CLICK,
151152
})

packages/rum-core/src/domain/action/actionCollection.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ export function startActionCollection(
3131
windowOpenObservable: Observable<void>,
3232
configuration: RumConfiguration
3333
) {
34-
lifeCycle.subscribe(LifeCycleEventType.AUTO_ACTION_COMPLETED, (action) =>
35-
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action))
34+
const { unsubscribe: unsubscribeAutoAction } = lifeCycle.subscribe(
35+
LifeCycleEventType.AUTO_ACTION_COMPLETED,
36+
(action) => {
37+
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action))
38+
}
3639
)
3740

3841
hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => {
@@ -79,7 +82,10 @@ export function startActionCollection(
7982
lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action))
8083
},
8184
actionContexts,
82-
stop,
85+
stop: () => {
86+
unsubscribeAutoAction()
87+
stop()
88+
},
8389
}
8490
}
8591

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Get the action name from the attribute 'data-dd-action-name' on the element or any of its parent.
3+
* It can also be retrieved from a user defined attribute.
4+
*/
5+
export const DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE = 'data-dd-action-name'
6+
export const ACTION_NAME_PLACEHOLDER = 'Masked Element'
7+
export const ACTION_NAME_MASK = 'xxx'
8+
9+
export const enum ActionNameSource {
10+
CUSTOM_ATTRIBUTE = 'custom_attribute',
11+
MASK_PLACEHOLDER = 'mask_placeholder',
12+
TEXT_CONTENT = 'text_content',
13+
STANDARD_ATTRIBUTE = 'standard_attribute',
14+
BLANK = 'blank',
15+
}
16+
17+
export interface ActionName {
18+
name: string
19+
nameSource: ActionNameSource
20+
}

packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { ExperimentalFeature } from '@datadog/browser-core'
22
import { mockExperimentalFeatures } from '../../../../core/test'
33
import { appendElement, mockRumConfiguration } from '../../../test'
4-
import { NodePrivacyLevel } from '../privacy'
5-
import { ActionNameSource, getActionNameFromElement } from './getActionNameFromElement'
4+
import { NodePrivacyLevel } from '../privacyConstants'
5+
import { getNodeSelfPrivacyLevel } from '../privacy'
6+
import { getActionNameFromElement } from './getActionNameFromElement'
7+
import { ActionNameSource } from './actionNameConstants'
68

79
const defaultConfiguration = mockRumConfiguration()
810

@@ -504,6 +506,162 @@ describe('getActionNameFromElement', () => {
504506
expect(nameSource).toBe('text_content')
505507
})
506508
})
509+
describe('with allowlist and enablePrivacyForActionName is true', () => {
510+
interface BrowserWindow extends Window {
511+
$DD_ALLOW?: Set<string>
512+
}
513+
514+
it('preserves privacy level of the element when defaultPrivacyLevel is mask-unless-allowlisted', () => {
515+
mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
516+
const { name, nameSource } = getActionNameFromElement(
517+
appendElement(`
518+
<div data-dd-privacy="mask">
519+
<span target>bar</span>
520+
</div>
521+
`),
522+
{
523+
...defaultConfiguration,
524+
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
525+
enablePrivacyForActionName: true,
526+
},
527+
NodePrivacyLevel.MASK
528+
)
529+
expect(name).toBe('Masked Element')
530+
expect(nameSource).toBe('mask_placeholder')
531+
})
532+
533+
it('preserves privacy level of the element when node privacy level is mask-unless-allowlisted', () => {
534+
const testCases = [
535+
{
536+
html: `
537+
<div data-dd-privacy="mask-unless-allowlisted" target>
538+
<span>foo</span>
539+
<div data-dd-privacy="mask">
540+
<span>bar</span>
541+
<div data-dd-privacy="allow">
542+
<span>baz</span>
543+
</div>
544+
</div>
545+
</div>
546+
`,
547+
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
548+
expectedName: '',
549+
expectedNameSource: 'blank',
550+
},
551+
{
552+
html: `
553+
<div data-dd-privacy="mask-unless-allowlisted" target>
554+
<span>foo</span>
555+
<div data-dd-privacy="mask">
556+
<span>bar</span>
557+
<div data-dd-privacy="allow">
558+
<span>baz</span>
559+
</div>
560+
</div>
561+
</div>
562+
`,
563+
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
564+
allowlist: ['foo'],
565+
expectedName: 'foo',
566+
expectedNameSource: 'text_content',
567+
},
568+
{
569+
html: `
570+
<div data-dd-privacy="mask-unless-allowlisted" target>
571+
<span>foo</span>
572+
<div data-dd-privacy="allow">
573+
<span>baz</span>
574+
</div>
575+
</div>
576+
`,
577+
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
578+
expectedName: 'baz',
579+
expectedNameSource: 'text_content',
580+
},
581+
{
582+
html: `
583+
<div data-dd-privacy="mask" target>
584+
<span>foo</span>
585+
<div data-dd-privacy="mask-unless-allowlisted">
586+
<span>bar</span>
587+
<div data-dd-privacy="allow">
588+
<span>baz</span>
589+
</div>
590+
</div>
591+
</div>
592+
`,
593+
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
594+
expectedName: 'Masked Element',
595+
expectedNameSource: 'mask_placeholder',
596+
},
597+
{
598+
html: `
599+
<div data-dd-privacy="allow" target>
600+
<span>foo</span>
601+
<div data-dd-privacy="mask-unless-allowlisted">
602+
<span>bar</span>
603+
<div data-dd-privacy="allow">
604+
<span>baz</span>
605+
</div>
606+
</div>
607+
</div>
608+
`,
609+
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
610+
expectedName: 'foo baz',
611+
expectedNameSource: 'text_content',
612+
},
613+
{
614+
html: `
615+
<div data-dd-privacy="allow" target>
616+
<span>foo</span>
617+
<div data-dd-privacy="mask-unless-allowlisted">
618+
<span>bar</span>
619+
<div data-dd-privacy="mask">
620+
<span>baz</span>
621+
</div>
622+
</div>
623+
</div>
624+
`,
625+
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
626+
expectedName: 'foo',
627+
expectedNameSource: 'text_content',
628+
},
629+
{
630+
html: `
631+
<div data-dd-privacy="allow" target>
632+
<span>foo</span>
633+
<div data-dd-privacy="mask-unless-allowlisted">
634+
<span>bar</span>
635+
<div data-dd-privacy="allow">
636+
<span>baz</span>
637+
</div>
638+
</div>
639+
</div>
640+
`,
641+
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
642+
allowlist: ['bar'],
643+
expectedName: 'foo bar baz',
644+
expectedNameSource: 'text_content',
645+
},
646+
]
647+
testCases.forEach(({ html, defaultPrivacyLevel, allowlist, expectedName, expectedNameSource }) => {
648+
mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
649+
;(window as BrowserWindow).$DD_ALLOW = new Set(allowlist)
650+
const target = appendElement(html)
651+
const { name, nameSource } = getActionNameFromElement(
652+
target,
653+
{
654+
...defaultConfiguration,
655+
defaultPrivacyLevel,
656+
enablePrivacyForActionName: true,
657+
},
658+
getNodeSelfPrivacyLevel(target)
659+
)
660+
expect(name).toBe(expectedName)
661+
expect(nameSource).toBe(expectedNameSource)
662+
})
663+
})
664+
})
507665

508666
describe('with privacyEnabledForActionName', () => {
509667
it('extracts attribute text when privacyEnabledActionName is false', () => {

0 commit comments

Comments
 (0)