chore(analytics): configure posthog session replay masking (#1428)

* chore(analytics): configure posthog session replay masking

Move session replay from the mask-everything default to pattern-based
masking in lib/analytics/replay-masking.ts: currency-shaped text,
person-/organisationsnummer (rendered and typed) and password inputs are
masked; other interface text and typed input is recorded for debugging.
data-ph-mask keeps force-masking tagged PII and data-ph-unmask is still
honored for chrome. Privacy policy, RoPA and decision log updated to
match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(analytics): address review comments on replay masking PR

Bump the privacy policy's visible last-updated date to 2026-08-06 and
add the conventional vi.clearAllMocks() beforeEach to the replay-masking
tests.

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-06 11:40:54 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 902b3ee986
commit a5c10e441a
7 changed files with 268 additions and 41 deletions
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { maskSensitiveText, replayMaskInput, 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.
beforeEach(() => {
vi.clearAllMocks()
})
/**
* Minimal stand-ins for the DOM elements rrweb hands to the masking
* functions (tests run in the node environment, no jsdom).
*/
function fakeElement(opts: { type?: string; tagged?: 'mask' | 'unmask' | 'both' | null } = {}): HTMLElement {
const attrs =
opts.tagged === 'mask'
? ['data-ph-mask']
: opts.tagged === 'unmask'
? ['data-ph-unmask']
: opts.tagged === 'both'
? ['data-ph-mask', 'data-ph-unmask']
: []
const tagged = attrs.length > 0 ? { hasAttribute: (name: string) => attrs.includes(name) } : null
return {
type: opts.type,
closest: (_selector: string) => tagged,
} 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('* ****** **')
})
it('masks negative amounts with both hyphen and the Intl minus sign', () => {
expect(maskSensitiveText('-500 kr')).toBe('**** **')
expect(maskSensitiveText('−1 234 kr')).toBe('** *** **')
})
it('masks the amount inside surrounding text', () => {
expect(maskSensitiveText('Totalt 1 234 kr att betala')).toBe('Totalt * *** ** att betala')
expect(maskSensitiveText('999 kr/mån')).toBe('*** **/mån')
})
it('masks other currency markers', () => {
expect(maskSensitiveText('12,00 €')).toBe('***** *')
expect(maskSensitiveText('10 US$')).toBe('** ***')
expect(maskSensitiveText('1 000 SEK')).toBe('* *** ***')
})
it('masks person- and organisationsnummer', () => {
expect(maskSensitiveText('556677-8899')).toBe('***********')
expect(maskSensitiveText('19850101-1234')).toBe('*************')
expect(maskSensitiveText('850101+1234')).toBe('***********')
})
it('leaves non-amount, non-identity text untouched', () => {
for (const text of [
'2026-08-06',
'Verifikat A-217',
'070-123 45 67',
'5050-1055',
'namn@exempel.se',
'10 kronor',
'E-postadress',
'Konto 1930',
]) {
expect(maskSensitiveText(text)).toBe(text)
}
})
})
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 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('**** **')
})
})
+109
View File
@@ -0,0 +1,109 @@
/**
* Pattern-based masking for PostHog session replay.
*
* Replays are visible by default so support can see WHERE a user gets stuck
* and WHAT they typed while getting there. What must never be readable in a
* replay is the content of a user's books and identity numbers:
*
* 1. Monetary amounts. Every amount in the app renders through
* `formatCurrency()` (Intl sv-SE currency style, e.g. "1 234,56 kr"), so a
* currency-shaped text pattern covers transactions, vouchers, reports,
* invoices and dashboards in one place, including future code, without
* tagging hundreds of render sites.
* 2. Person- and organisationsnummer. For an enskild firma the orgnr IS the
* owner's personnummer. Masked both as rendered text (formatOrgNumber()
* output, "556677-8899") and as typed input values.
* 3. Passwords. Always masked, never overridable.
*
* Tag overrides (nearest tagged ancestor wins, mask wins on a tie):
* - `data-ph-mask` force-masks an element's whole subtree (used on deliberate
* PII spots: company name / email in danger-zone labels, user-defined
* dimension names, nav count bubbles).
* - `data-ph-unmask` exempts a subtree from pattern masking (static chrome
* such as form labels and nav). It never unmasks a password input.
*
* Known limits, accepted deliberately: masking is length-preserving (star
* count reveals magnitude, layout stays stable in the replay), bare numbers
* without a currency marker stay visible, and an identity number rendered
* WITHOUT its separator is only caught on the input side.
*/
/**
* Currency-shaped text: optional sign (Intl sv-SE renders negative amounts
* with U+2212, hand-written strings use '-'), digits with space/nbsp grouping
* and a decimal part, then a currency marker. The trailing lookahead rejects
* letter continuations so "10 kronor" or "SEKTION" never match.
*/
const AMOUNT_PATTERN = new RegExp(
// − is the Unicode minus sign Intl sv-SE emits for negative amounts.
String.raw`[-−]?\d(?:[\d\s]|[.,](?=\d))*\s?(?:kr|sek|eur|usd|nok|dkk|gbp|chf|us\$|\$|€|£)(?![\p{L}\d])`,
'giu',
)
/**
* Person-/organisationsnummer rendered as text: 6 or 8 digits, separator,
* 4 digits ("556677-8899", "19850101-1234", "850101+1234"). The digit
* lookarounds keep bankgiro ("5050-1055"), phone numbers and dates out.
*/
const IDENTITY_TEXT_PATTERN = /(?<!\d)\d{6}(?:\d{2})?[-+]\d{4}(?!\d)/g
/**
* A typed input value that is (or is on its way to becoming) a person-/
* organisationsnummer: 6 or more leading digits, optionally a separator and
* up to 4 more. Matching from the 6th digit means intermediate keystroke
* snapshots never ship the birthdate prefix of a personnummer. Accepted
* over-masking: any bare 6-12 digit value (e.g. a raw amount over 99 999)
* is masked too; amounts with decimals or thousand separators stay visible.
*/
const IDENTITY_INPUT_PATTERN = /^\s*\d{6,8}[-+ ]?\d{0,4}\s*$/
const TAG_SELECTOR = '[data-ph-mask],[data-ph-unmask]'
/** Length-preserving mask: whitespace survives so table layout stays legible. */
function maskAll(text: string): string {
return text.replace(/\S/g, '*')
}
function maskSpan(span: string): string {
return maskAll(span)
}
/**
* Masks currency amounts and separator-formatted identity numbers inside a
* text node, leaving the surrounding text readable.
*/
export function maskSensitiveText(text: string): string {
return text.replace(AMOUNT_PATTERN, maskSpan).replace(IDENTITY_TEXT_PATTERN, maskSpan)
}
/**
* `session_recording.maskTextFn`. Runs on EVERY text node because
* `maskTextSelector: '*'` flags them all; this function then decides.
*/
export function replayMaskText(text: string, element?: HTMLElement): string {
const tagged = element?.closest(TAG_SELECTOR)
if (tagged) {
return tagged.hasAttribute('data-ph-mask') ? maskAll(text) : text
}
return maskSensitiveText(text)
}
/**
* `session_recording.maskInputFn`. rrweb only invokes this on inputs flagged
* by `maskInputOptions`, so the config sets `maskAllInputs: true` to flag
* every input and this function selectively passes values through. Password
* checks come first: not even `data-ph-unmask` may reveal one.
*/
export function replayMaskInput(text: string, element?: HTMLElement): string {
if ((element as HTMLInputElement | undefined)?.type === 'password') {
return maskAll(text)
}
const tagged = element?.closest?.(TAG_SELECTOR)
if (tagged) {
return tagged.hasAttribute('data-ph-mask') ? maskAll(text) : text
}
if (IDENTITY_INPUT_PATTERN.test(text)) {
return maskAll(text)
}
return text
}