fix(analytics): mask session replays by default, chrome-only unmask (#1639)
* fix(analytics): mask session replays by default, chrome-only unmask Invert PostHog session-replay masking from visible-by-default with pattern masking to deny-by-default: every input value is masked wholesale (rrweb maskAllInputs, no maskInputFn) and every text node is masked unless it sits under data-ph-unmask chrome or a table column header (th). Chrome tags live on the shared UI primitives (PageHeader, Label, Button except combobox triggers, TabsTrigger, Badge, Card/Dialog/Sheet titles, tooltips, help popovers, empty states, settings labels), and tagged chrome is still pattern-scrubbed for amounts and person-/organisationsnummer. data-ph-mask beats data-ph-unmask, so call sites that interpolate user data into chrome stay masked; a very-thorough audit swept every unmasked primitive and each found site got a call-site mask. Confirm-dialog wrappers and toasts stay masked centrally: their copy describes user objects by design. Untagged new UI over-masks instead of leaking. Privacy policy, RoPA and decision log updated in the same change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): tag detail-section chrome merged from main The register-detail primitives landed on main after the replay-masking audit ran: kickers and DefRow labels are static i18n chrome, values stay masked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): close skeptic and review findings on replay masking Explicit data-ph tags now resolve before the th chrome fallback, so a th nested inside a data-ph-mask container masks correctly (regression test added). Seven missed text-leak sites get call-site masks: delete-invoice and credit-page invoice numbers, IB-correction voucher reference, TIC orgnr (served unnormalized, so the separator-based scrub cannot be relied on), articles search-term empty state, dimension segment labels, and activate-account buttons. The attribute channel is closed with rrweb's blockClass: inputs whose placeholder carries an effective user value (salary overrides, correction description, danger-zone confirms, credit confirm) get ph-no-capture, removing the element from recordings while the prefill UX stays intact; the pivot-th title attribute is dropped. Privacy-policy effective date bumped to 2026-08-17. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1bb423b2b3
commit
f8db38f989
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { maskSensitiveText, replayMaskInput, replayMaskText } from '@/lib/analytics/replay-masking'
|
||||
import { maskSensitiveText, replayMaskText } from '@/lib/analytics/replay-masking'
|
||||
|
||||
// Repo test convention. eventBus.clear() is deliberately absent: these are
|
||||
// pure functions and importing the bus would only add module side effects.
|
||||
@@ -9,9 +9,13 @@ beforeEach(() => {
|
||||
|
||||
/**
|
||||
* Minimal stand-ins for the DOM elements rrweb hands to the masking
|
||||
* functions (tests run in the node environment, no jsdom).
|
||||
* functions (tests run in the node environment, no jsdom). `closest` is
|
||||
* called once with the explicit-tag selector and, when that misses, once
|
||||
* with 'th'; the fake answers each selector like a real DOM lookup would.
|
||||
*/
|
||||
function fakeElement(opts: { type?: string; tagged?: 'mask' | 'unmask' | 'both' | null } = {}): HTMLElement {
|
||||
function fakeElement(
|
||||
opts: { tagged?: 'mask' | 'unmask' | 'both' | null; th?: boolean } = {}
|
||||
): HTMLElement {
|
||||
const attrs =
|
||||
opts.tagged === 'mask'
|
||||
? ['data-ph-mask']
|
||||
@@ -19,18 +23,18 @@ function fakeElement(opts: { type?: string; tagged?: 'mask' | 'unmask' | 'both'
|
||||
? ['data-ph-unmask']
|
||||
: opts.tagged === 'both'
|
||||
? ['data-ph-mask', 'data-ph-unmask']
|
||||
: []
|
||||
const tagged = attrs.length > 0 ? { hasAttribute: (name: string) => attrs.includes(name) } : null
|
||||
: null
|
||||
const tagged = attrs ? { hasAttribute: (name: string) => attrs.includes(name) } : null
|
||||
const thAncestor = opts.th ? { hasAttribute: () => false } : null
|
||||
return {
|
||||
type: opts.type,
|
||||
closest: (_selector: string) => tagged,
|
||||
closest: (selector: string) => (selector.includes('data-ph') ? tagged : thAncestor),
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
describe('maskSensitiveText', () => {
|
||||
it('masks sv-SE formatted amounts, preserving length and whitespace', () => {
|
||||
// First variant groups thousands with U+00A0 (what Intl sv-SE emits), the second with a regular space.
|
||||
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
|
||||
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
|
||||
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
|
||||
})
|
||||
|
||||
@@ -73,43 +77,44 @@ describe('maskSensitiveText', () => {
|
||||
})
|
||||
|
||||
describe('replayMaskText', () => {
|
||||
it('pattern-masks when the node has no tagged ancestor', () => {
|
||||
expect(replayMaskText('Saldo 1 234 kr', fakeElement())).toBe('Saldo * *** **')
|
||||
expect(replayMaskText('Saldo 1 234 kr', undefined)).toBe('Saldo * *** **')
|
||||
it('masks everything when the node has no chrome ancestor', () => {
|
||||
expect(replayMaskText('Acme AB', fakeElement())).toBe('**** **')
|
||||
expect(replayMaskText('Kaffe till kontoret', fakeElement())).toBe('***** **** ********')
|
||||
expect(replayMaskText('Acme AB', undefined)).toBe('**** **')
|
||||
})
|
||||
|
||||
it('masks everything when rrweb passes an element without closest (text node parents can be non-Element)', () => {
|
||||
expect(replayMaskText('Acme AB', {} as unknown as HTMLElement)).toBe('**** **')
|
||||
})
|
||||
|
||||
it('shows chrome text under data-ph-unmask', () => {
|
||||
expect(replayMaskText('Bokför och godkänn', fakeElement({ tagged: 'unmask' }))).toBe(
|
||||
'Bokför och godkänn'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows table column headers (th) without a tag', () => {
|
||||
expect(replayMaskText('Datum', fakeElement({ th: true }))).toBe('Datum')
|
||||
})
|
||||
|
||||
it('lets an explicit data-ph-mask beat the th fallback (th inside a masked container, or masked th)', () => {
|
||||
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'mask', th: true }))).toBe('**** **')
|
||||
})
|
||||
|
||||
it('pattern-scrubs amounts and identity numbers even inside chrome', () => {
|
||||
expect(replayMaskText('Betala 1 234 kr nu', fakeElement({ tagged: 'unmask' }))).toBe(
|
||||
'Betala * *** ** nu'
|
||||
)
|
||||
expect(replayMaskText('Ta bort 556677-8899', fakeElement({ tagged: 'unmask' }))).toBe(
|
||||
'Ta bort ***********'
|
||||
)
|
||||
})
|
||||
|
||||
it('masks everything under data-ph-mask', () => {
|
||||
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'mask' }))).toBe('**** **')
|
||||
})
|
||||
|
||||
it('passes everything through under data-ph-unmask', () => {
|
||||
expect(replayMaskText('Belopp i kr', fakeElement({ tagged: 'unmask' }))).toBe('Belopp i kr')
|
||||
})
|
||||
|
||||
it('lets mask win when both attributes land on the same element', () => {
|
||||
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'both' }))).toBe('**** **')
|
||||
})
|
||||
})
|
||||
|
||||
describe('replayMaskInput', () => {
|
||||
it('always masks password inputs, even under data-ph-unmask', () => {
|
||||
expect(replayMaskInput('hunter2', fakeElement({ type: 'password' }))).toBe('*******')
|
||||
expect(replayMaskInput('hunter2', fakeElement({ type: 'password', tagged: 'unmask' }))).toBe('*******')
|
||||
})
|
||||
|
||||
it('masks identity-number-shaped values, including partial typing', () => {
|
||||
expect(replayMaskInput('556677-8899', fakeElement({ type: 'text' }))).toBe('***********')
|
||||
expect(replayMaskInput('19850101-1234', fakeElement({ type: 'text' }))).toBe('*************')
|
||||
expect(replayMaskInput('5566778', fakeElement({ type: 'text' }))).toBe('*******')
|
||||
})
|
||||
|
||||
it('passes ordinary typed values through', () => {
|
||||
for (const value of ['1234,56', 'Kaffe till kontoret', 'namn@exempel.se', '1930', 'Acme AB']) {
|
||||
expect(replayMaskInput(value, fakeElement({ type: 'text' }))).toBe(value)
|
||||
}
|
||||
})
|
||||
|
||||
it('honors data-ph-mask on inputs', () => {
|
||||
expect(replayMaskInput('Acme AB', fakeElement({ type: 'text', tagged: 'mask' }))).toBe('**** **')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user