diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index af0ee016..fc70d177 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -605,6 +605,9 @@ processing_activities: data_categories: - user.contact.email # person property via identify(), aldrig i event-properties - user.name # profiles.full_name + # Sessionsinspelningen visar synlig text och tangentbordsinmatning, + # förutom maskerade fält (belopp, person-/orgnummer, lösenord samt + # data-ph-mask-taggade element), se security_measures nedan. - user.behavior # sidvisningar, klick, händelser, sessionsinspelning - user.device # user agent, skärmstorlek, IP (trunkeras av PostHog) # Supportärenden: fritext som användaren SJÄLV skriver till supporten via @@ -634,9 +637,14 @@ processing_activities: stored_in: - posthog_eu # externt hos biträdet; inget lagras i vår databas security_measures: - - session_replay_masks_all_text # maskTextSelector '*' utöver maskAllInputs - - session_replay_masks_all_inputs - - org_number_never_transmitted # låst av test i lib/analytics/__tests__ + # Mönsterbaserad maskering (2026-08-06, ersätter maskera-allt-läget): + # belopp (valutaformaterad text), person-/organisationsnummer (text och + # inmatning) och lösenord maskeras; övrig text och tangentbordsinmatning + # syns i inspelningen för felsökning. data-ph-mask tvingar maskering av + # utpekade PII-element; låst av test i lib/analytics/__tests__. + - session_replay_masks_amounts_and_identity_numbers # lib/analytics/replay-masking.ts + - session_replay_masks_password_inputs + - org_number_never_transmitted_as_group_property # låst av test i lib/analytics/__tests__ - no_pii_in_event_properties # PII endast som person properties via identify() # persistence: 'memory' -> ingen analysdata och inga kakor på enheten. # TVÅ undantag, båda funktionell UI-state utan personuppgifter, båda diff --git a/DECISIONS.md b/DECISIONS.md index 87ff11ee..fd5fb7cd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -799,5 +799,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-05] Dropped "ML 13 kap 8 §" cites for kontantmetoden VAT timing (comments/docs only): section is the old ML 1994:200 numbering; in ML 2023:200, 13 kap is input-VAT deduction. Rule stated without section cite until the current-law section is verified. [2026-08-05] The in-app assistant now reads invoice_inbox_items.channel_context (the answers a user gave in WhatsApp) as first-class underlag context, marked "uppgivna av användaren" and ranked above OCR output. Found in the field: the assistant asked for representation participants the user had typed into WhatsApp minutes earlier, because the intent's inbox query selected only document_id + extracted_data. Also backfilled by document_id, since a receipt can reach the intent through the document paths without the inbox row being matched to the transaction. [2026-08-05] The WhatsApp representation question now asks ONCE for a missing purpose instead of silently storing participants with purpose=null. Skatteverket wants participants AND purpose; accepting half and saying "Tack!" produced an undocumented deduction. Anti-loop: the follow-up fires only when no representation block exists yet, so a second incomplete answer is accepted as-is rather than nagging. +[2026-08-06] Session replay masking narrowed from mask-everything to pattern-based (founder-approved): fully masked replays were wall-to-wall asterisks and useless for support debugging. lib/analytics/replay-masking.ts masks currency-shaped text, person-/organisationsnummer (text and typed input) and password inputs; data-ph-mask still force-masks tagged PII, data-ph-unmask stays honored for chrome, and everything else including typed input is now visible in replays. Privacy policy and RoPA updated in the same change; supersedes the 2026-07-27 maskTextSelector '*' decision. [2026-08-06] ROT/RUT payout strings were placed in the invoice_editor namespace while RotRutPayoutDialog and the invoices page read useTranslations('invoices'), so all 44 labels rendered as raw "invoices.rot_rut_*" key paths in production since #1380. Moved the keys to invoices rather than repointing the components, since the dialog belongs to the invoice list, not the editor. Message files are edited textually, never via JSON.parse/stringify: they contain duplicate keys a round trip would silently drop. Same bug class fixed in TemplateBookDialog (bookkeeping) and Correction/StrikeLines dialogs (journal_detail) by adding the strings to the namespace each component reads, matching the existing precedent that toast_posted_* is duplicated across journal_list and journal_detail. Added i18n/__tests__/message-keys.test.ts, which resolves every literal t() key against both locales: next-intl has no build-time check and fails by rendering the key path, so nothing caught this before users did. [2026-08-06] The ROT/RUT payout button is hidden from the invoices header unless the company has an invoice with deduction_total > 0 or rot_rut_enabled is on in tax settings. ROT/RUT concerns only companies selling eligible work to consumers, and a payout can never precede the invoice that created the claim, so the derived signal cannot hide the action from someone who needs it. Read from the company_settings row the page already fetches for ore_rounding (no extra round trip); deliberately not scoped to the fiscal-year filter, since a begäran is claimed the year after payment. ?rot-rut=1 still opens the dialog, so the feature is hidden, not removed. diff --git a/app/(public)/privacy/page.tsx b/app/(public)/privacy/page.tsx index 31e89282..4f19a953 100644 --- a/app/(public)/privacy/page.tsx +++ b/app/(public)/privacy/page.tsx @@ -19,7 +19,7 @@ export default function PrivacyPolicyPage() { Integritetspolicy

- Senast uppdaterad: 2026-07-22 + Senast uppdaterad: 2026-08-06

@@ -157,10 +157,12 @@ export default function PrivacyPolicyPage() { enkäter och supportärenden. Överförda uppgifter: 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. All text i - sessionsinspelningar maskeras: vi spelar in var i - gränssnittet du klickar, aldrig vad som står i din - bokföring. Organisationsnummer överförs aldrig. + 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. 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 diff --git a/components/ui/label.tsx b/components/ui/label.tsx index 78d9efd9..14e7ac20 100644 --- a/components/ui/label.tsx +++ b/components/ui/label.tsx @@ -14,10 +14,10 @@ const Label = React.forwardRef< React.ComponentPropsWithoutRef & VariantProps >(({ className, ...props }, ref) => ( - // data-ph-unmask: form field labels are static i18n chrome, so session - // replays show WHICH field is being filled in while the typed value stays - // masked (see instrumentation-client.ts). A call site whose label text is - // user data must add data-ph-mask, which wins over this default. + // data-ph-unmask: form field labels are static i18n chrome, exempt from + // the pattern-based replay masking (see lib/analytics/replay-masking.ts). + // A call site whose label text is user data must add data-ph-mask, which + // wins over this default. { - const tagged = element?.closest('[data-ph-unmask],[data-ph-mask]') - if (!tagged || tagged.hasAttribute('data-ph-mask')) { - return maskText(text) - } - return text - }, + maskTextFn: replayMaskText, }, debug: process.env.NODE_ENV === 'development', }) diff --git a/lib/analytics/__tests__/replay-masking.test.ts b/lib/analytics/__tests__/replay-masking.test.ts new file mode 100644 index 00000000..20719b75 --- /dev/null +++ b/lib/analytics/__tests__/replay-masking.test.ts @@ -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('**** **') + }) +}) diff --git a/lib/analytics/replay-masking.ts b/lib/analytics/replay-masking.ts new file mode 100644 index 00000000..e8c70032 --- /dev/null +++ b/lib/analytics/replay-masking.ts @@ -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 = /(?