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
@@ -269,6 +269,9 @@ export function AccountDangerZone() {
onChange={(e) => setConfirmText(e.target.value)}
placeholder={email ?? ''}
autoComplete="off"
// ph-no-capture: the placeholder is the user's email, and
// replay masking covers values, not attributes.
className="ph-no-capture"
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
+5 -1
View File
@@ -120,7 +120,8 @@ export function CompanyDangerZone() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('danger_dialog_title', { companyName: company.name })}</DialogTitle>
{/* data-ph-mask: the title interpolates the company name */}
<DialogTitle data-ph-mask="">{t('danger_dialog_title', { companyName: company.name })}</DialogTitle>
<DialogDescription>
{t('danger_dialog_description', { appName: branding.appName.toLowerCase() })}
</DialogDescription>
@@ -139,6 +140,9 @@ export function CompanyDangerZone() {
onChange={(e) => setConfirmText(e.target.value)}
placeholder={company.name}
autoComplete="off"
// ph-no-capture: the placeholder is the company name, and
// replay masking covers values, not attributes.
className="ph-no-capture"
/>
</div>
<DialogFooter>
+12 -7
View File
@@ -36,19 +36,23 @@ export function SettingsSectionHeader({ title, intro, action, mark }: SettingsSe
<div className="flex items-baseline justify-between gap-4">
<div className="flex items-center gap-2.5">
{mark ? <span className="shrink-0 leading-none">{mark}</span> : null}
<h2 className="font-display text-xl tracking-tight">{title}</h2>
{/* data-ph-unmask: settings chrome (titles, labels) is static i18n
text in session replays; values and controls stay masked. */}
<h2 data-ph-unmask="" className="font-display text-xl tracking-tight">{title}</h2>
</div>
{action ? <div className="flex shrink-0 items-center gap-3">{action}</div> : null}
</div>
{intro ? (
<p className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">{intro}</p>
<p data-ph-unmask="" className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">{intro}</p>
) : null}
</header>
)
}
interface SettingsGroupProps {
label?: string
/** Chrome in session replays (data-ph-unmask): wrap any user data (e.g. a
team name) in a data-ph-mask element. */
label?: React.ReactNode
/** Group-level help ("?" right after the eyebrow) for guidance that spans the rows. */
help?: React.ReactNode
children: React.ReactNode
@@ -60,7 +64,7 @@ export function SettingsGroup({ label, help, children, className }: SettingsGrou
<section className={cn('pt-8 first:pt-6', className)}>
{label ? (
<p className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{label}</span>
<span data-ph-unmask="">{label}</span>
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</p>
) : null}
@@ -104,11 +108,11 @@ export function SettingsRow({
>
<div className="flex w-full shrink-0 items-center gap-2 md:w-44">
{htmlFor ? (
<label htmlFor={htmlFor} className={labelClass}>
<label htmlFor={htmlFor} data-ph-unmask="" className={labelClass}>
{label}
</label>
) : (
<span className={labelClass}>{label}</span>
<span data-ph-unmask="" className={labelClass}>{label}</span>
)}
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</div>
@@ -241,6 +245,7 @@ export function SettingsSeg<T extends string>({
disabled={disabled}
aria-pressed={o.value === value}
onClick={() => onChange(o.value)}
data-ph-unmask=""
className={cn(
'rounded-sm px-3 py-1 text-xs transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-60',
o.value === value
@@ -259,7 +264,7 @@ export function SettingsSeg<T extends string>({
export function SettingsDangerZone({ label, children }: { label: string; children: React.ReactNode }) {
return (
<section className="mt-10 border-t border-destructive/30 pt-3">
<p className="px-1 text-[11px] font-medium uppercase tracking-wider text-destructive/80">
<p data-ph-unmask="" className="px-1 text-[11px] font-medium uppercase tracking-wider text-destructive/80">
{label}
</p>
<div>{children}</div>
+1 -1
View File
@@ -116,7 +116,7 @@ export function TeamPanel() {
}
return (
<SettingsGroup label={teamName || t('team_fallback')}>
<SettingsGroup label={<span data-ph-mask="">{teamName || t('team_fallback')}</span>}>
{/* Read-only member roster: flat hairline rows, no cards. */}
{members.map((member) => (
<div