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:
Mattsson
2026-08-17 11:32:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1bb423b2b3
commit f8db38f989
74 changed files with 366 additions and 224 deletions
+43 -38
View File
@@ -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('**** **')
})
})