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('**** **')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
/**
|
||||
* Pattern-based masking for PostHog session replay.
|
||||
* Deny-by-default 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:
|
||||
* Everything is masked unless it is app chrome (founder-approved 2026-08-17,
|
||||
* supersedes the 2026-08-06 pattern-based default where user content was
|
||||
* visible). Replays show layout, clicks and static UI text: headers, nav,
|
||||
* form labels, placeholders, buttons. They never show user data: not what a
|
||||
* user typed (input values are masked wholesale by rrweb, see
|
||||
* instrumentation-client.ts) and not user content rendered as text
|
||||
* (counterparty names, descriptions, amounts, 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.
|
||||
* What counts as chrome, i.e. renders readable in a replay:
|
||||
* - Any subtree tagged `data-ph-unmask`. Tags live on the shared UI
|
||||
* primitives (nav, PageHeader, Label, Button, tabs, dialog/sheet titles,
|
||||
* card titles, badges, tooltips, empty states), so page code gets readable
|
||||
* chrome without per-page tagging.
|
||||
* - `<th>` elements with NO explicit tag anywhere above them: table column
|
||||
* headers are static chrome, but the page-level dry-table pattern writes
|
||||
* raw `<th className={TH_CLASS}>` per page, so there is no shared
|
||||
* component to tag. An explicit data-ph-mask (on the th or any ancestor)
|
||||
* always wins over this fallback.
|
||||
*
|
||||
* 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.
|
||||
* Chrome is still pattern-scrubbed (belt and braces): an i18n string that
|
||||
* interpolates an amount or a person-/organisationsnummer into a title or
|
||||
* button label gets that span masked even inside an unmasked subtree.
|
||||
*
|
||||
* 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.
|
||||
* `data-ph-mask` force-masks a subtree and wins over `data-ph-unmask`: the
|
||||
* NEAREST tagged ancestor decides, and mask wins when both attributes land
|
||||
* on the same element. Use it where user data flows into a chrome primitive
|
||||
* (e.g. a Label interpolating the user's email, a dialog title carrying a
|
||||
* counterparty name).
|
||||
*
|
||||
* Untagged text is fully masked, so the failure mode for new UI is
|
||||
* over-masking (asterisks where chrome should be readable), never leaking
|
||||
* a user's books.
|
||||
*
|
||||
* Known limit: rrweb masks text nodes and input values, not ATTRIBUTES.
|
||||
* posthog-js exposes no attribute mask hook, so a title/aria-label/
|
||||
* placeholder attribute is recorded as-is. An element whose attributes
|
||||
* carry user data (e.g. a placeholder prefilled with an effective value)
|
||||
* must carry the `ph-no-capture` class instead: rrweb's blockClass removes
|
||||
* the whole element from the recording while the app UX is untouched. Do
|
||||
* not put user data in title or aria-label attributes.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -47,16 +62,6 @@ const AMOUNT_PATTERN = new RegExp(
|
||||
*/
|
||||
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. */
|
||||
@@ -70,7 +75,8 @@ function maskSpan(span: string): string {
|
||||
|
||||
/**
|
||||
* Masks currency amounts and separator-formatted identity numbers inside a
|
||||
* text node, leaving the surrounding text readable.
|
||||
* text node, leaving the surrounding text readable. Applied to CHROME text:
|
||||
* non-chrome text never gets here, it is masked wholesale.
|
||||
*/
|
||||
export function maskSensitiveText(text: string): string {
|
||||
return text.replace(AMOUNT_PATTERN, maskSpan).replace(IDENTITY_TEXT_PATTERN, maskSpan)
|
||||
@@ -79,31 +85,17 @@ export function maskSensitiveText(text: string): string {
|
||||
/**
|
||||
* `session_recording.maskTextFn`. Runs on EVERY text node because
|
||||
* `maskTextSelector: '*'` flags them all; this function then decides.
|
||||
* Default is masked; only chrome shows through, and even chrome is
|
||||
* pattern-scrubbed.
|
||||
*
|
||||
* Explicit tags are resolved FIRST, and only then the th fallback: a single
|
||||
* closest() over tags-plus-th would let a th nested inside a data-ph-mask
|
||||
* container win on DOM proximity and unmask it.
|
||||
*/
|
||||
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
|
||||
return tagged.hasAttribute('data-ph-mask') ? maskAll(text) : maskSensitiveText(text)
|
||||
}
|
||||
if (IDENTITY_INPUT_PATTERN.test(text)) {
|
||||
return maskAll(text)
|
||||
}
|
||||
return text
|
||||
return element?.closest?.('th') ? maskSensitiveText(text) : maskAll(text)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user