Files
accounted/lib/analytics/replay-masking.ts
T
Mattsson f8db38f989 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>
2026-08-17 11:32:45 +02:00

102 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Deny-by-default masking for PostHog session replay.
*
* 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).
*
* 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.
*
* 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.
*
* `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.
*/
/**
* 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
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. 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)
}
/**
* `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) : maskSensitiveText(text)
}
return element?.closest?.('th') ? maskSensitiveText(text) : maskAll(text)
}