Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions packages/core/src/domain/telemetry/telemetryEvent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,10 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & {
* Whether the request origins list to ignore when computing the page activity is used
*/
use_excluded_activity_urls?: boolean
/**
* Whether the attribute unmask allowlist is used for Session Replay
*/
use_attr_unmask_allowlist?: boolean
/**
* Whether the Worker is loaded from an external URL
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ describe('serializeRumConfiguration', () => {
traceSampleRate: 50,
traceContextInjection: TraceContextInjection.ALL,
defaultPrivacyLevel: 'allow',
attrUnmaskAllowlist: [],
enablePrivacyForActionName: false,
subdomain: 'foo',
sessionReplaySampleRate: 60,
Expand All @@ -639,6 +640,7 @@ describe('serializeRumConfiguration', () => {
| 'workerUrl'
| 'allowedTracingUrls'
| 'excludedActivityUrls'
| 'attrUnmaskAllowlist'
| 'remoteConfigurationProxy'
| 'allowedGraphQlUrls'
? `use_${CamelToSnakeCase<Key>}`
Expand Down Expand Up @@ -675,6 +677,7 @@ describe('serializeRumConfiguration', () => {
start_session_replay_recording_manually: true,
action_name_attribute: 'test-id',
default_privacy_level: 'allow',
use_attr_unmask_allowlist: false,
enable_privacy_for_action_name: false,
track_resources: true,
track_long_task: true,
Expand Down
16 changes: 16 additions & 0 deletions packages/rum-core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,15 @@ export interface RumInitConfiguration extends InitConfiguration {
*/
defaultPrivacyLevel?: DefaultPrivacyLevel | undefined

/**
* List of attribute names that should not be masked when privacy level is mask or mask-unless-allowlisted.
* Attributes in this list will be recorded with their original values in Session Replay.
*
* @category Privacy
* @defaultValue []
*/
attrUnmaskAllowlist?: string[] | undefined

/**
* If you are accessing Datadog through a custom subdomain, you can set `subdomain` to include your custom domain in the `getSessionReplayLink()` returned URL .
*
Expand Down Expand Up @@ -305,6 +314,7 @@ export interface RumConfiguration extends Configuration {
compressIntakeRequests: boolean
applicationId: string
defaultPrivacyLevel: DefaultPrivacyLevel
attrUnmaskAllowlist: string[]
enablePrivacyForActionName: boolean
sessionReplaySampleRate: number
startSessionReplayRecordingManually: boolean
Expand Down Expand Up @@ -351,6 +361,10 @@ export function validateAndBuildRumConfiguration(
return
}

if (initConfiguration.attrUnmaskAllowlist !== undefined && !Array.isArray(initConfiguration.attrUnmaskAllowlist)) {
display.warn('attrUnmaskAllowlist should be an array')
}

const allowedTracingUrls = validateAndBuildTracingOptions(initConfiguration)
if (!allowedTracingUrls) {
return
Expand Down Expand Up @@ -391,6 +405,7 @@ export function validateAndBuildRumConfiguration(
defaultPrivacyLevel: objectHasValue(DefaultPrivacyLevel, initConfiguration.defaultPrivacyLevel)
? initConfiguration.defaultPrivacyLevel
: DefaultPrivacyLevel.MASK,
attrUnmaskAllowlist: Array.isArray(initConfiguration.attrUnmaskAllowlist) ? initConfiguration.attrUnmaskAllowlist : [],
enablePrivacyForActionName: !!initConfiguration.enablePrivacyForActionName,
traceContextInjection: objectHasValue(TraceContextInjection, initConfiguration.traceContextInjection)
? initConfiguration.traceContextInjection
Expand Down Expand Up @@ -531,6 +546,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {
selected_tracing_propagators: getSelectedTracingPropagators(configuration),
default_privacy_level: configuration.defaultPrivacyLevel,
enable_privacy_for_action_name: configuration.enablePrivacyForActionName,
use_attr_unmask_allowlist: isNonEmptyArray(configuration.attrUnmaskAllowlist),
use_excluded_activity_urls: isNonEmptyArray(configuration.excludedActivityUrls),
use_worker_url: !!configuration.workerUrl,
compress_intake_requests: configuration.compressIntakeRequests,
Expand Down
54 changes: 54 additions & 0 deletions packages/rum-core/src/domain/privacy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
reducePrivacyLevel,
getNodePrivacyLevel,
shouldMaskNode,
shouldMaskAttribute,
maskDisallowedTextContent,
} from './privacy'
import { ACTION_NAME_MASK } from './action/actionNameConstants'
import { mockRumConfiguration } from '../../test'

describe('getNodePrivacyLevel', () => {
it('returns the element privacy mode if it has one', () => {
Expand Down Expand Up @@ -489,6 +491,58 @@ describe('shouldMaskNode', () => {
})
})

describe('shouldMaskAttribute', () => {
it('does not mask attributes in attrUnmaskAllowlist when privacy level is MASK', () => {
const configuration = mockRumConfiguration({ attrUnmaskAllowlist: ['title', 'data-testid'] })
expect(
shouldMaskAttribute('DIV', 'title', 'some title', NodePrivacyLevel.MASK, configuration)
).toBeFalse()
expect(
shouldMaskAttribute('DIV', 'data-testid', 'my-element', NodePrivacyLevel.MASK, configuration)
).toBeFalse()
})

it('does not mask attributes in attrUnmaskAllowlist when privacy level is MASK_UNLESS_ALLOWLISTED', () => {
const configuration = mockRumConfiguration({ attrUnmaskAllowlist: ['alt', 'placeholder'] })
expect(
shouldMaskAttribute('IMG', 'alt', 'description', NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED, configuration)
).toBeFalse()
expect(
shouldMaskAttribute('INPUT', 'placeholder', 'Enter text', NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED, configuration)
).toBeFalse()
})

it('masks attributes not in allowlist according to normal rules when privacy level is MASK', () => {
const configuration = mockRumConfiguration({ attrUnmaskAllowlist: ['data-allowlisted'] })
expect(shouldMaskAttribute('DIV', 'title', 'secret', NodePrivacyLevel.MASK, configuration)).toBeTrue()
expect(shouldMaskAttribute('A', 'href', 'https://example.com', NodePrivacyLevel.MASK, configuration)).toBeTrue()
expect(
shouldMaskAttribute('DIV', 'data-allowlisted', 'value', NodePrivacyLevel.MASK, configuration)
).toBeFalse()
})

it('preserves default masking behavior when attrUnmaskAllowlist is empty', () => {
const configuration = mockRumConfiguration({ attrUnmaskAllowlist: [] })
expect(shouldMaskAttribute('DIV', 'title', 'secret', NodePrivacyLevel.MASK, configuration)).toBeTrue()
expect(shouldMaskAttribute('IMG', 'alt', 'desc', NodePrivacyLevel.MASK, configuration)).toBeTrue()
expect(shouldMaskAttribute('A', 'href', 'https://x.com', NodePrivacyLevel.MASK, configuration)).toBeTrue()
})

it('does not mask standard sensitive attributes when they are in allowlist', () => {
const configuration = mockRumConfiguration({
attrUnmaskAllowlist: ['title', 'alt', 'placeholder', 'aria-label', 'name', 'href', 'srcdoc', 'src'],
})
expect(shouldMaskAttribute('DIV', 'title', 'visible', NodePrivacyLevel.MASK, configuration)).toBeFalse()
expect(shouldMaskAttribute('IMG', 'alt', 'visible', NodePrivacyLevel.MASK, configuration)).toBeFalse()
expect(shouldMaskAttribute('INPUT', 'placeholder', 'visible', NodePrivacyLevel.MASK, configuration)).toBeFalse()
expect(shouldMaskAttribute('DIV', 'aria-label', 'visible', NodePrivacyLevel.MASK, configuration)).toBeFalse()
expect(shouldMaskAttribute('INPUT', 'name', 'visible', NodePrivacyLevel.MASK, configuration)).toBeFalse()
expect(shouldMaskAttribute('A', 'href', 'https://example.com', NodePrivacyLevel.MASK, configuration)).toBeFalse()
expect(shouldMaskAttribute('IFRAME', 'srcdoc', '<p>html</p>', NodePrivacyLevel.MASK, configuration)).toBeFalse()
expect(shouldMaskAttribute('IMG', 'src', 'https://img.com/x.png', NodePrivacyLevel.MASK, configuration)).toBeFalse()
})
})

const TEST_STRINGS = {
COMPLEX_MIXED: 'test-team-name:💥$$$',
PARAGRAPH_MIXED: '✅ This is an action name in allowlist',
Expand Down
6 changes: 6 additions & 0 deletions packages/rum-core/src/domain/privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ export function shouldMaskAttribute(
if (nodePrivacyLevel !== NodePrivacyLevel.MASK && nodePrivacyLevel !== NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED) {
return false
}

const attrUnmaskAllowlist: string[] = configuration.attrUnmaskAllowlist ?? []
if (attrUnmaskAllowlist.includes(attributeName)) {
return false
}

if (
attributeName === PRIVACY_ATTR_NAME ||
STABLE_ATTRIBUTES.includes(attributeName) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '@datadog/browser-rum-core'
import { serializeAttribute, MAX_ATTRIBUTE_VALUE_CHAR_LENGTH } from './serializeAttribute'

const DEFAULT_CONFIGURATION = {} as RumConfiguration
const DEFAULT_CONFIGURATION = { attrUnmaskAllowlist: [] as string[] } as RumConfiguration

describe('serializeAttribute', () => {
it('truncates "data:" URIs after long string length', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { NodePrivacyLevel } from '@datadog/browser-rum-core'
import type { RumConfiguration } from '@datadog/browser-rum-core'

export const DEFAULT_CONFIGURATION = { defaultPrivacyLevel: NodePrivacyLevel.ALLOW } as RumConfiguration
export const DEFAULT_CONFIGURATION = {
defaultPrivacyLevel: NodePrivacyLevel.ALLOW,
attrUnmaskAllowlist: [] as string[],
} as RumConfiguration