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
+9 -6
View File
@@ -360,12 +360,15 @@ function ArticlesPageInner() {
// The currency scope is part of why nothing matched, so it has to
// be named: otherwise the register reads as empty of the term
// when it is only empty inside the active scope.
currencyFilter === ALL_CURRENCIES
? t('no_search_results_description', { term: searchTerm })
: t('no_search_results_in_currency_description', {
term: searchTerm,
currency: currencyFilter,
})
// data-ph-mask: the search term is what the user typed.
<span data-ph-mask="">
{currencyFilter === ALL_CURRENCIES
? t('no_search_results_description', { term: searchTerm })
: t('no_search_results_in_currency_description', {
term: searchTerm,
currency: currencyFilter,
})}
</span>
}
/>
) : (
+2 -1
View File
@@ -277,7 +277,8 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
/>
<Card>
<CardHeader><CardTitle className="text-base">{asset.name}</CardTitle></CardHeader>
{/* data-ph-mask: the asset name is user data */}
<CardHeader><CardTitle data-ph-mask="" className="text-base">{asset.name}</CardTitle></CardHeader>
<CardContent className="space-y-2 p-6 pt-0 text-sm">
<SummaryRow label={t('acquisition_cost')} value={formatCurrency(Number(asset.acquisition_cost))} />
<SummaryRow label={t('acquired')} value={formatDate(asset.acquisition_date)} />
+4 -1
View File
@@ -441,11 +441,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const prefix = DIM_BADGE_PREFIX[dimNo] ?? dim?.name ?? `Dim ${dimNo}`
const hasName = !!value && value.name !== '' && value.name !== value.code
return (
// data-ph-mask: dimension names and codes are user data. No title
// attribute: replay masking covers text nodes, not attributes, so
// a title tooltip would ship the masked content in the clear.
<Badge
key={dimNo}
data-ph-mask=""
variant="outline"
className="font-mono text-[11px] font-normal"
title={`${dim?.name ?? prefix} ${code}${hasName ? `: ${value.name}` : ''}`}
>
{prefix}: {hasName ? value.name : code}
</Badge>
@@ -514,9 +514,11 @@ export default function ArsredovisningPage() {
<PageHeader
title={`Årsredovisning ${data.fiscal_period.name}`}
description={
data.company.org_number
? `${data.company.name} · ${data.company.org_number}`
: data.company.name
<span data-ph-mask="">
{data.company.org_number
? `${data.company.name} · ${data.company.org_number}`
: data.company.name}
</span>
}
action={
<Button variant="outline" asChild>
+1 -1
View File
@@ -314,7 +314,7 @@ function CustomersPageInner() {
<EmptyState
icon={Users}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
description={<span data-ph-mask="">{t('no_search_results_description', { term: searchTerm })}</span>}
/>
) : (
<EmptyCustomers onAction={() => setIsDialogOpen(true)} />
+1 -1
View File
@@ -381,7 +381,7 @@ export default function HelpPage() {
<EmptyState
icon={Search}
title={t('no_results_title')}
description={t('no_results', { query: searchQuery })}
description={<span data-ph-mask="">{t('no_results', { query: searchQuery })}</span>}
/>
) : (
filteredTerms.map((term) => (
@@ -250,7 +250,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
<Card>
<CardHeader>
<CardTitle>{t('preview_card_title')}</CardTitle>
<CardDescription>
{/* data-ph-mask: the invoice number is user data */}
<CardDescription data-ph-mask="">
{t('preview_card_description', { number: invoice.invoice_number ?? '' })}
</CardDescription>
</CardHeader>
@@ -346,7 +347,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
<CardTitle>{t('confirm_card_title')}</CardTitle>
<CardDescription>
{t('confirm_card_description_1')}
<span className="font-mono font-semibold text-foreground">{invoice.invoice_number}</span>
{/* data-ph-mask: the invoice number is user data */}
<span data-ph-mask="" className="font-mono font-semibold text-foreground">{invoice.invoice_number}</span>
{t('confirm_card_description_2')}
</CardDescription>
</CardHeader>
@@ -357,6 +359,9 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
placeholder={invoice.invoice_number ?? ''}
disabled={!invoice.invoice_number}
className={cn(
// ph-no-capture: the placeholder carries the invoice number, and
// replay masking covers input values, not attributes.
'ph-no-capture',
confirmText && confirmText !== invoice.invoice_number && 'border-destructive'
)}
/>
+2 -1
View File
@@ -1856,7 +1856,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{t('delete_dialog_desc_with_number_1')}
<strong>{t('delete_dialog_status_makulerad')}</strong>
{t('delete_dialog_desc_with_number_2')}
<span className="mt-2 block text-muted-foreground">
{/* data-ph-mask: interpolates the invoice number */}
<span data-ph-mask="" className="mt-2 block text-muted-foreground">
{t('delete_dialog_number_kept', { number: invoice.invoice_number })}
</span>
</>
+1 -1
View File
@@ -693,7 +693,7 @@ export default function InvoicesPage() {
<DataListEmpty
icon={<ReceiptText className="h-6 w-6" />}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
description={<span data-ph-mask="">{t('no_search_results_description', { term: searchTerm })}</span>}
/>
) : invoices.length === 0 ? (
<div className="animate-fade-in">
+2 -1
View File
@@ -1250,7 +1250,8 @@ export default function PendingOperationsPage() {
? t('reject_bulk_title', { count: selectedCount })
: 'Avvisa operation'}
</DialogTitle>
<DialogDescription>
{/* data-ph-mask: the operation title carries counterparty and amount */}
<DialogDescription data-ph-mask="">
{rejectTarget === 'bulk'
? t('reject_bulk_description')
: rejectTarget?.title}
+4 -2
View File
@@ -773,7 +773,8 @@ function SkattekontoRow({
unbooked deviates; upcoming rows are unbooked by nature. */}
{section === 'booked' && !isBooked && (
row.match_suggestion ? (
<Badge variant="warning" className="font-normal">
/* data-ph-mask: the voucher reference is user data */
<Badge variant="warning" className="font-normal" data-ph-mask="">
{t('chip_possible_duplicate', {
voucher:
row.match_suggestion.voucher_series && row.match_suggestion.voucher_number
@@ -857,7 +858,8 @@ function MatchDialog({
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Matcha mot befintligt verifikat</DialogTitle>
<DialogDescription>
{/* data-ph-mask: transaction text and amount are user data */}
<DialogDescription data-ph-mask="">
{row && (
<>
{formatDate(row.transaktionsdatum)} • {row.transaktionstext} •{' '}
+1 -1
View File
@@ -191,7 +191,7 @@ export default function SuppliersPage() {
<EmptyState
icon={Building2}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
description={<span data-ph-mask="">{t('no_search_results_description', { term: searchTerm })}</span>}
/>
) : (
<EmptyState
+2 -1
View File
@@ -186,7 +186,8 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
Fakturainformation
</CardTitle>
<CardDescription>
Till: {invoice.customerName}
{/* data-ph-mask: the customer name is user data */}
Till: <span data-ph-mask="">{invoice.customerName}</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
+8 -6
View File
@@ -19,7 +19,7 @@ export default function PrivacyPolicyPage() {
Integritetspolicy
</h1>
<p className="text-muted-foreground">
Senast uppdaterad: 2026-08-06
Senast uppdaterad: 2026-08-17
</p>
</div>
@@ -158,11 +158,13 @@ export default function PrivacyPolicyPage() {
användar-ID, e-postadress, namn och företagsnamn. Om du
själv skriver till supporten i appen skickas även ditt
meddelande dit som ett ärende, så att vi kan svara. I
sessionsinspelningar maskeras belopp, person- och
organisationsnummer samt lösenord; övrig text i
gränssnittet och det du själv skriver kan ingå i
inspelningen, så att vi kan felsöka problem du stöter
på. Organisationsnummer skickas aldrig som analysdata.
sessionsinspelningar maskeras allt du skriver och allt
innehåll i din bokföring (namn, beskrivningar, belopp,
person- och organisationsnummer); läsbart är endast
appens eget gränssnitt, som rubriker, knappar, menyer
och ledtexter, så att vi kan se var i appen du stöter
på problem utan att se dina uppgifter.
Organisationsnummer skickas aldrig som analysdata.
Identifiering sker endast för inloggade användare (ej
sandbox/demo). Inga kakor används, och själva analysdatan
lagras inte på din enhet. Två små tekniska värden sparas