From 835f86edc3efa99266ddef460cbb783970f18a4f Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Tue, 25 Aug 2026 21:57:11 +0200 Subject: [PATCH 01/64] docs: add Privacy at Capture v2 simplification design spec Design for reworking the merged privacy feature onto rrweb's existing masking primitives, adopting field-proven mechanisms from PostHog, Highlight, Sentry, Amplitude, and Mixpanel. Resolves all 24 confirmed review findings structurally. Co-Authored-By: Claude Fable 5 --- ...-08-25-privacy-v2-simplification-design.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md diff --git a/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md b/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md new file mode 100644 index 0000000000..e7b354eed2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md @@ -0,0 +1,253 @@ +# Privacy at Capture v2 — Simplification Design + +**Date:** 2026-08-25 +**Status:** Approved (design), pending implementation plan +**Branch:** `privacy-v2-simplification` (off `main` @ `41c22825`) + +## Context + +The Privacy at Capture feature (merged via PR #1, `37a946a5..main`) introduced a +versioned privacy policy, selector rules, heuristic PII detectors with +user-supplied regex patterns, canvas masking, and URL sanitization. A +high-effort code review confirmed 24 defects, including silent privacy leaks +(canvas command stream, detector candidate skipping, URL credentials, plugin +no-op), fail-open selector handling, a bypassable ReDoS validator, CSS +destruction under `strict`, and a default-path performance regression from an +uncached per-node ancestor-walk engine. + +A source-level survey of the five major session-replay vendors built on rrweb +(PostHog, Highlight, Sentry, Amplitude, Mixpanel) showed the confirmed bugs +cluster exactly where this feature diverges from field-proven practice: +sub-range regex masking of DOM text and user-supplied detector patterns, which +no vendor ships. + +**Goal:** an upstreamable privacy layer that vendors can adopt in place of +their forks and wrapper layers, maintained by the community. + +## Governing principles + +1. **Fail closed.** Every ambiguity — invalid config, thrown exception, + unreachable mask path — resolves toward masking or not capturing. +2. **Proven mechanisms only.** No mechanism ships that no vendor has run in + production. Where vendors disagree, adopt the safest variant. +3. **Legacy is sacred.** With no `privacyPolicy` and no privacy plugin + loaded, behavior and performance are byte-identical to rrweb before this + feature. (Loading the detectors plugin is an explicit opt-in and does + change behavior — see §6.) + +## Decisions (approved) + +- **Detectors:** fixed set only (email, phone, Luhn card, SSN, IPv4), each + individually toggleable. **No user-supplied regex patterns.** Any hit masks + the **whole text node / input value**, not character ranges (Highlight + model). Pattern set is derived from PostHog's network-side patterns + (delimited digit runs, Luhn validation, SSN invalid-group exclusions), not + Highlight's (which contain unescaped-dot bugs). +- **Architecture:** `compilePrivacyPolicy` compiles presets and rules down + onto rrweb's **existing masking primitives** (`maskTextSelector`, + `maskAllInputs`, `maskInputOptions`, `blockSelector`, inherited `needsMask` + propagation), extended minimally. The parallel `getPrivacyAction` + ancestor-walk engine is **deleted**. + +## Design + +### 1. Policy surface + +```ts +privacyPolicy: { + version: 1, + preset: 'legacy' | 'balanced' | 'strict', + rules?: { selector: string; action: 'mask' | 'unmask' | 'exclude' | 'allow' }[], + blockedQueryParameters?: string[], + allowedQueryParameters?: string[], +} +``` + +Removed from the schema: custom detector patterns, `minimumLength`, +`maximumMatchLength`, `maskStyle`, `classification` (dead or dangerous per +review). The `privacy-policy.schema.json` file is **deleted**; TypeScript +types plus runtime validation are the single source of truth (fixes the +three-way schema/types/runtime drift that let schema-valid policies crash +`record()`). + +### 2. Compilation + +`compilePrivacyPolicy(policy)` returns a bundle of existing rrweb options plus +merged selector lists. No rule engine. + +- `legacy` → exactly today's defaults. Zero added cost on the default path. +- `balanced` → `maskAllInputs: true`, `maskInputOptions.password: true` + **forced regardless of user config** (PostHog), masked attributes + `['title', 'placeholder', 'aria-label']` (Sentry's default list), URL + sanitization on. +- `strict` → balanced + `maskTextSelector: '*'` (mask-all-text posture, + Sentry/Mixpanel), media blocking (`img, video, audio, source`, Sentry's + `blockAllMedia`), `recordCanvas` forced off, URL sanitization. +- Rules and the three `data-privacy` attribute selectors compile into the + mask / unmask / block selector lists. +- Cross-vendor mask classes recognized in compiled defaults: + `.rr-mask, .mp-mask, .fs-mask, .amp-mask, .ph-mask` and block equivalents + (Mixpanel precedent) — eases vendor adoption of upstream. +- **Per-selector validation at compile:** each selector is probed with + `fragment.querySelector(sel)` in try/catch (Amplitude); invalid selectors + are dropped with a `console.warn` naming the selector. A selector is never + merged unvalidated, so one bad selector cannot poison the merged list. +- Error handling: a user-supplied invalid policy throws at `record()` call + time (programmer error, matches rrweb conventions). A **plugin-transformed** + policy that fails to compile falls back to compiling the user's own policy, + with `console.error`. + +### 3. Text and CSS + +- The single decision channel is the existing inherited `needsMask` + propagation (checked once at subtree root, short-circuits for descendants — + PostHog's tri-state mechanism is the reference). +- `unmaskTextSelector` is added to the core `needsMask` check, + nearest-ancestor-wins (Sentry's `maskDistance <= unmaskDistance` tie-break). +- CSS is **never masked** (unanimous vendor precedent): the `!isStyle` + exemption applies to all paths, including mutation/characterData (fixing + the inconsistency Sentry's own fork still has). +- `maskTextFn` composition unchanged under `legacy`. + +### 4. Inputs + +- All input masking routes through `maskInputValue` + `maskInputOptions`; + presets set the options. +- `legacy`: `maskInputFn` behaves exactly as today. +- `balanced`/`strict`: defense-in-depth (Sentry): the user fn runs, then its + output is star-replaced — the fn controls length, never content. Neither + the preset nor the fn can silently weaken the other. + +### 5. Attributes and URLs + +- **One** attribute finalization pass in one shared helper, used by both + `serializeElementNode` and the mutation emit path (deletes the snapshot + double-masking; mutation-added nodes stop bypassing + `maskAllElementAttributes`/`maskAttributeFn`). +- The four copy-pasted `legacyMask` forks collapse into that helper. +- `style`/`_cssText` are removed from `SENSITIVE_ATTRIBUTES`. +- `maskAllElementAttributes` and `maskAttributeFn` are mutually exclusive; + the fn is dropped with a warning (PostHog fail-closed rationale). +- Generated-attribute safety: trust the serializer's own `isGenerated` flag; + delete the `SAFE_GENERATED_ATTRIBUTES` static list, the per-element Set + bookkeeping, and mutation.ts's `generatedAttributes` WeakMap. +- `sanitizeUrl`: additionally clears `url.username`/`url.password` + (ahead of all five vendors); lowercased blocked/allowed sets precomputed at + compile time. + +### 6. Detectors plugin (`@rrweb/rrweb-plugin-privacy-detectors`) + +- Fixed detectors: email, phone, Luhn payment card, SSN, IPv4. Per-detector + boolean toggles only. +- Scan is `regex.test(value)` per enabled detector with short-circuit; any + hit masks the **entire** text node or input value through the same masking + path as everything else. `mergeMatches`, `maskSensitiveRanges`, + `SensitiveMatch`, `scanCustomPattern`, and `validateCustomDetector` are + deleted. +- Detection runs **independent of preset early-returns**: loading the plugin + with no `privacyPolicy` detects under `legacy` (fixes the silent no-op; + makes the plugin README true). +- Patterns are bounded/linear (audited); Luhn for cards, invalid-group + exclusions for SSN (`(?!000|666)…`), delimiter-aware digit runs to avoid + the UUID/long-number false-positive classes PostHog documents. + +### 7. Canvas + +- Fail closed: when `canvasMasking` is configured, canvas is captured only + via the FPS/worker path where mask regions apply. If `sampling.canvas` is + not numeric, it is forced to a low default (with a `console.warn`) instead + of letting the unmasked mutation-mode command stream run. +- Mask region scaling uses content-box math (`getBoundingClientRect` minus + padding/border), not `clientWidth`; a hidden canvas (0 dimensions) skips + capture rather than assuming backing-store coordinates. +- `strict` keeps `recordCanvas` forced off. + +### 8. Hardening + +- Mask-decision paths are wrapped fail-closed (Mixpanel): decision variable + initialized to *masked*; any throw logs and masks. +- One untainted `tagName` accessor in `@rrweb/utils` + (`getUntaintedAccessor('Element', el, 'tagName')`) replaces the two + divergent one-off shadowing fixes and is used at every `tagName` read in + privacy-relevant paths. Same for the shadow-root walk (`isShadowRoot` + + `dom.host`) and password detection (`getInputType`). +- `ImageBitmapDataURLWorkerParams` union change is declared in the changeset + as a breaking change to `@rrweb/types`. + +### 9. Deletions summary + +`getPrivacyAction` engine and all call sites; range-masking machinery; +custom-pattern validator; `maskStyle`/`classification`/`MASK_STYLES`; +`privacy-policy.schema.json`; `SAFE_GENERATED_ATTRIBUTES` dual mechanism; +`generatedAttributes` WeakMap; duplicated CSS-mask helpers +(`maskAdoptedRule` folds into shared `maskCssForRecord`); the four +`legacyMask` copy-paste forks. Expected: `privacy.ts` shrinks from ~936 to +roughly ~300 lines; all 10 reported review findings and the overflow items +are resolved structurally. + +### 10. Testing + +- Existing privacy/detector/recorder suites adapted to the new shapes. +- New regression tests pinning each confirmed failure mode: + - Detector adjacency: `call 5551234567 4111 1111 1111 1111 now` → node + masked (was: Visa in cleartext). + - Invalid selector in a rule → dropped with warning; other selectors still + enforced; blocked elements stay blocked. + - `

secret

', strict); + expect(out).toContain('body{color:red}'); + expect(out).not.toContain('secret'); + }); + it('unmask selector wins for its subtree, nearest ancestor decides', () => { + const out = serialize( + '

visible

hidden

', + strict, + ); + expect(out).toContain('visible'); + expect(out).not.toContain('hidden'); + }); + it('detectors mask the whole text node under legacy when configured', () => { + const withDet = compilePrivacyPolicy({ + version: 1, preset: 'legacy', detectors: { paymentCard: true, phone: true }, + }); + const out = serialize('

call 5551234567 4111 1111 1111 1111 now

', withDet); + expect(out).not.toContain('4111 1111 1111 1111'); + }); + it('legacy without detectors leaves text untouched', () => { + const legacy = compilePrivacyPolicy(undefined); + expect(serialize('

bob@example.com

', legacy)).toContain('bob@example.com'); + }); +}); +``` + +- [ ] **Step 2: Run, verify FAIL** — `npx vitest run test/privacy-integration.test.ts`. + +- [ ] **Step 3: Implement.** In `utils.ts`, extend the existing needs-mask helper (keep its inheritance/`checkAncestors` contract intact): + +```ts +export function needsMaskingText( + node: Node, + maskTextClass: string | RegExp, + maskTextSelector: string | null, + unmaskTextSelector: string | null, + checkAncestors: boolean, +): boolean { + try { + const el: HTMLElement | null = + node.nodeType === node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement; + if (el === null) return false; + let current: HTMLElement | null = el; + while (current) { + if (unmaskTextSelector && current.matches(unmaskTextSelector)) return false; + if (classMatchesMaskTextClass(current, maskTextClass)) return true; // reuse existing class check + if (maskTextSelector && current.matches(maskTextSelector)) return true; + if (!checkAncestors) break; + current = current.parentElement; + } + return false; + } catch { + return true; // fail closed: an error in the mask decision masks + } +} +``` + +Nearest-ancestor-wins falls out of walking upward and returning on first hit. In `serializeTextNode`: restore the single pre-feature shape — `if (!isStyle && !isScript && textContent && needsMask) { textContent = maskTextFn ? maskTextFn(textContent, parentEl) : textContent.replace(/[\S]/g, '*'); }` — and delete the `if (privacy)` branch entirely. Then add the detector hook after it: + +```ts +if (!isStyle && !isScript && textContent && !needsMask && privacy && + detectSensitiveValue(textContent, privacy)) { + textContent = textContent.replace(/[\S]/g, '*'); +} +``` + +Thread `unmaskTextSelector` through the same option paths `maskTextSelector` already travels (grep for `maskTextSelector` in `snapshot.ts` and mirror each occurrence). Under strict (`maskTextSelector === '*'`), the unmask check must still run per node even when needsMask was inherited: pass `needsMask && !unmaskTextSelector` as the short-circuit condition where the code currently reuses inherited `needsMask`. + +- [ ] **Step 4: Run, verify PASS.** Also run the package's full suite; adapt existing snapshot tests that passed the old `privacy` object expecting engine behavior. +- [ ] **Step 5: Commit** — `git commit -am "feat(privacy): unmask selector + detector hook in core text masking, CSS exempt everywhere"` + +--- + +### Task 5: Input masking composition + +**Files:** +- Modify: `packages/rrweb-snapshot/src/utils.ts` (`maskInputValue`, `getInputType`) +- Modify: `packages/rrweb-snapshot/src/privacy.ts` (`isProtectedInput` → exported, reusing `getInputType`) +- Modify: `packages/rrweb-snapshot/src/snapshot.ts`, `packages/rrweb/src/record/mutation.ts` (~583-690), `packages/rrweb/src/record/observer.ts` (~425-445) +- Test: `packages/rrweb-snapshot/test/privacy-integration.test.ts` + +**Interfaces:** +- Produces (single entry point; the four legacyMask forks collapse into it): + +```ts +export function maskInput({ + element, tagName, type, value, maskInputOptions, maskInputFn, privacy, +}: { + element: HTMLElement; tagName: string; type: string | null; value: string; + maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; + privacy: CompiledPrivacyPolicy | undefined; +}): string; +export function isProtectedInput(element: HTMLElement): boolean; // password/hidden/data-rr-is-password/cc-* autocomplete +``` + +- Behavior table (encode in tests): protected input → always `'*'.repeat(len)` regardless of everything. Legacy preset: mask iff legacy options say so; `maskInputFn` output trusted (today's behavior). Balanced/strict: always mask; if `maskInputFn` present, run it then star-replace its output (`'*'.repeat(fnOutput.length)`) — fn controls length only. +- Deletes: `shouldMaskInputWithPrivacy`, `maskInputWithPrivacy`, `replacePreservingShape` usage for inputs (function itself deleted once Task 6 removes its last use). + +- [ ] **Step 1: Write the failing tests:** + +```ts +import { maskInput, isProtectedInput } from '../src/utils'; +describe('maskInput v2', () => { + const balanced = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); + const legacy = compilePrivacyPolicy(undefined); + const input = (attrs = '') => { + document.body.innerHTML = ``; + return document.querySelector('input') as HTMLInputElement; + }; + it('balanced masks all inputs shape-free (stars, not digits)', () => { + const out = maskInput({ element: input(), tagName: 'input', type: 'text', + value: '4111 1111 1111 1111', maskInputOptions: {}, privacy: balanced }); + expect(out).toBe('*'.repeat(19)); + }); + it('balanced + maskInputFn: fn controls length only, never content', () => { + const out = maskInput({ element: input(), tagName: 'input', type: 'text', + value: 'secret', maskInputOptions: {}, + maskInputFn: () => '[redacted]', privacy: balanced }); + expect(out).toBe('*'.repeat('[redacted]'.length)); + }); + it('legacy + maskInputFn trusted verbatim when legacy options mask', () => { + const out = maskInput({ element: input(), tagName: 'input', type: 'text', + value: 'secret', maskInputOptions: { text: true }, + maskInputFn: () => '[redacted]', privacy: legacy }); + expect(out).toBe('[redacted]'); + }); + it('legacy without options passes value through', () => { + expect(maskInput({ element: input(), tagName: 'input', type: 'text', + value: 'plain', maskInputOptions: {}, privacy: legacy })).toBe('plain'); + }); + it('protected inputs always mask, even legacy with no options', () => { + expect(maskInput({ element: input('type="password"'), tagName: 'input', type: 'password', + value: 'pw', maskInputOptions: {}, privacy: legacy })).toBe('**'); + expect(isProtectedInput(input('autocomplete="cc-number"'))).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run, verify FAIL.** +- [ ] **Step 3: Implement** `maskInput` in `utils.ts` wrapping the existing `maskInputValue` legacy logic: + +```ts +export function maskInput(args: {/* as Interfaces */}): string { + const { element, tagName, type, value, maskInputOptions, maskInputFn, privacy } = args; + if (isProtectedInput(element)) return '*'.repeat(value.length); + const legacyWantsMask = Boolean( + maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || + (type && maskInputOptions[type.toLowerCase() as keyof MaskInputOptions]), + ); + const presetWantsMask = !!privacy && privacy.maskAllInputs; + if (!legacyWantsMask && !presetWantsMask) return value; + let masked = maskInputFn ? maskInputFn(value, element) : '*'.repeat(value.length); + if (presetWantsMask && maskInputFn) masked = '*'.repeat(masked.length); // fn controls length only + if (presetWantsMask && !maskInputFn) masked = '*'.repeat(value.length); + return masked; +} +``` + +Move `isProtectedInput` from `privacy.ts` into `utils.ts` built on `getInputType` (covers the password-revealed-as-text case) plus the `PROTECTED_AUTOCOMPLETE` set. Replace all four call sites (`snapshot.ts` serializeElementNode value handling, `mutation.ts` genTextAreaValueMutation + processMutation value branch, `observer.ts` eventHandler) with single `maskInput` calls — delete each site's local `legacyMask` computation and its `if (privacy) … else …` fork. In `observer.ts`, also delete the outer `shouldMaskInputWithPrivacy` guard (redundant; `maskInput` decides). + +- [ ] **Step 4: Run package suites** (`rrweb-snapshot` fully; `cd packages/rrweb && npx vitest run test/record` for the record paths). Adapt tests asserting `replacePreservingShape` digit-preserving output (`'0000 0000…'`) to expect stars. +- [ ] **Step 5: Commit** — `git commit -am "feat(privacy): single maskInput entry point, Sentry-style fn composition"` + +--- + +### Task 6: Attribute finalization — one pass, one helper + +**Files:** +- Modify: `packages/rrweb-snapshot/src/privacy.ts` (`protectSerializedAttribute`, `maskAttributeWithPrivacy` deleted, `SENSITIVE_ATTRIBUTES` trimmed) +- Modify: `packages/rrweb-snapshot/src/snapshot.ts` (attribute loop ~lines 620-900) +- Modify: `packages/rrweb/src/record/mutation.ts` (pushAdd ~329-370; emit attribute loop ~510-530; delete `generatedAttributes` WeakMap ~152/526/559/809) +- Test: `packages/rrweb-snapshot/test/privacy-integration.test.ts`, `packages/rrweb/test/record/privacy.test.ts` (adapt existing) + +**Interfaces:** +- Produces (replaces both `maskAttributeWithPrivacy` and old `protectSerializedAttribute`): + +```ts +export function finalizeAttribute({ + element, name, value, privacy, maskAllElementAttributes, maskAttributeFn, isGenerated, +}: { + element: Element; name: string; value: string | null; + privacy: CompiledPrivacyPolicy | undefined; + maskAllElementAttributes?: boolean; maskAttributeFn?: MaskAttributeFn; + isGenerated?: boolean; +}): string | null; +``` + +- Decision order inside: (1) `isGenerated` → return value untouched (serializer-produced, safe by construction; `rr_dataURL` is intentionally NOT flagged generated). (2) `maskAllElementAttributes` → `'*'.repeat(len)`; when it is set, `maskAttributeFn` is ignored with a one-time `console.warn` (mutually exclusive, PostHog). (3) `maskAttributeFn` → run in try/catch, catch → stars. (4) policy: strict media source attrs → null; URL attrs → `sanitizeUrl`; `privacy.maskedAttributes` list (`title`/`placeholder`/`aria-label`) → stars; `value` attribute on form tags under strict → stars. `style`/`_cssText` are never touched. +- mutation.ts `pushAdd` gains `maskAllElementAttributes: this.maskAllElementAttributes, maskAttributeFn: this.maskAttributeFn` in its serializeNodeWithId options (fixes the added-node bypass). `SAFE_GENERATED_ATTRIBUTES` and the `generatedAttributes` WeakMap are deleted; the single `rr_open_mode` write site passes `isGenerated: true` directly. + +- [ ] **Step 1: Write the failing tests:** + +```ts +describe('finalizeAttribute', () => { + const strict = compilePrivacyPolicy({ version: 1, preset: 'strict' }); + const el = () => { document.body.innerHTML = ''; return document.querySelector('img')!; }; + it('never masks style, even under strict', () => { + expect(finalizeAttribute({ element: el(), name: 'style', value: 'color:red', privacy: strict })).toBe('color:red'); + }); + it('masks listed attributes under strict/balanced', () => { + expect(finalizeAttribute({ element: el(), name: 'title', value: 'Bob', privacy: strict })).toBe('***'); + }); + it('strict nulls media sources; URLs sanitized elsewhere', () => { + expect(finalizeAttribute({ element: el(), name: 'src', value: 'https://a.com/i.png', privacy: strict })).toBeNull(); + }); + it('maskAllElementAttributes stars everything except generated', () => { + expect(finalizeAttribute({ element: el(), name: 'title', value: 'Bob', privacy: undefined, maskAllElementAttributes: true })).toBe('***'); + expect(finalizeAttribute({ element: el(), name: 'rr_open_mode', value: 'modal', privacy: undefined, maskAllElementAttributes: true, isGenerated: true })).toBe('modal'); + }); + it('maskAttributeFn throw fails closed to stars; fn ignored under maskAll', () => { + expect(finalizeAttribute({ element: el(), name: 'title', value: 'Bob', privacy: undefined, + maskAttributeFn: () => { throw new Error('boom'); } })).toBe('***'); + }); +}); +``` + +Plus a recorder-level test in `packages/rrweb/test/record/privacy.test.ts` (adapt existing harness): record with `maskAllElementAttributes: true`, append a new `
` after recording starts, flush, assert the emitted add's attributes are starred (the review's added-node bypass regression). + +- [ ] **Step 2: Run, verify FAIL.** +- [ ] **Step 3: Implement** `finalizeAttribute` per the decision order above (single function, ~40 lines; `MEDIA_TAGS`/`MEDIA_SOURCE_ATTRIBUTES`/`URL_ATTRIBUTES`/`FORM_VALUE_TAGS` sets stay; `SENSITIVE_ATTRIBUTES` becomes the compiled `maskedAttributes` list, drop the module-level set). In `snapshot.ts`: delete the per-attribute `maskAttributeWithPrivacy` call in the collection loop; keep exactly ONE finalization sweep at the end of `serializeElementNode` calling `finalizeAttribute` for every entry (including `_cssText`, which it passes through untouched), with `isGenerated` set for serializer-written attributes (`rr_width`, `rr_height`, `rr_scrollLeft`, `rr_scrollTop`, `rr_mediaState`, `rr_open_mode` — not `rr_dataURL`). In `mutation.ts`: emit path uses `finalizeAttribute` (delete its parallel guarded sweep + per-attribute `maskAttributeWithPrivacy` at ~741), `pushAdd` passes the two missing options, WeakMap deleted. +- [ ] **Step 4: Run** `rrweb-snapshot` and `rrweb` record suites; verify PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(privacy): single attribute finalization pass, added-node coverage, CSS attrs exempt"` + +--- + +### Task 7: Delete CSS masking call sites + +**Files:** +- Modify: `packages/rrweb/src/record/observer.ts` (delete `maskCssForRecord` + `stylesheetOwnerElement` ~597-616 and the maskTextWithPrivacy calls at ~650, 730, 762, 830, 995) +- Modify: `packages/rrweb/src/record/stylesheet-manager.ts` (delete `maskAdoptedRule` ~97-106 and its call at ~80) +- Modify: `packages/rrweb/src/record/mutation.ts` (delete styleDiff masking ~763-786) +- Test: `packages/rrweb/test/record/stylesheet-manager.test.ts`, `packages/rrweb/test/record/style.test.ts` (adapt) + +**Interfaces:** none new. CSS text (insertRule/replace/replaceSync/setProperty/styleDiff/adopted sheets) is recorded verbatim — the unanimous vendor behavior. Blocked subtrees are already excluded wholesale by `blockSelector`. + +- [ ] **Step 1: Adapt tests** — the PR-added assertions in `stylesheet-manager.test.ts` (~32 lines) and any styleDiff masking tests now assert the INVERSE: adopted-sheet rules and style mutations are recorded unmodified even under `preset: 'strict'`. Write those assertions first. +- [ ] **Step 2: Run, verify FAIL** (masking still active). +- [ ] **Step 3: Delete** the helpers and call sites listed above; remove now-unused `privacy` parameters from the touched signatures (`StylesheetManager` constructor arg, observer param threading) ONLY where nothing else consumes them — `observer.ts` still needs `privacy` for input masking (Task 5). +- [ ] **Step 4: Run** the `rrweb` record suite; verify PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(privacy): CSS is never masked; delete stylesheet masking call sites"` + +--- + +### Task 8: Canvas fail-closed + region scaling + +**Files:** +- Modify: `packages/rrweb/src/record/index.ts` (canvas wiring ~120-130) +- Modify: `packages/rrweb/src/record/observers/canvas/canvas-manager.ts` (constructor ~85-100; `getCanvas`/`search` ~190-215) +- Modify: `packages/rrweb/src/record/observers/canvas/canvas-mask.ts` (~40-70) +- Test: `packages/rrweb/test/record/canvas-mask.test.ts` (adapt existing canvas tests) + +**Interfaces:** +- record/index.ts rule (encode as a pure helper so it is unit-testable): + +```ts +export function resolveCanvasSampling( + requestedSampling: number | 'all' | undefined, + canvasMaskingConfigured: boolean, +): number | 'all' | undefined { + if (!canvasMaskingConfigured) return requestedSampling; + if (typeof requestedSampling === 'number') return requestedSampling; + console.warn('[rrweb] canvasMasking requires FPS canvas capture; forcing sampling.canvas = 4'); + return 4; +} +``` + +- canvas-mask.ts: scale factors come from `canvas.getBoundingClientRect()` minus computed padding/border (content box), falling back to SKIPPING capture (return no frame) when the content box has zero area — never silently reinterpret regions as backing-store pixels. +- canvas-manager FPS discovery: replace the per-tick `querySelectorAll('*')` recursion with `win.document.querySelectorAll('canvas')` plus canvases from a `trackedShadowRoots: Set` the manager exposes (`addShadowRoot(root)` / `removeShadowRoot(root)`), called by the existing shadow-DOM manager where it already observes attachShadow. + +- [ ] **Step 1: Write the failing tests:** + +```ts +import { resolveCanvasSampling } from '../../src/record'; +describe('canvas fail-closed', () => { + it('forces numeric sampling when masking configured', () => { + expect(resolveCanvasSampling('all', true)).toBe(4); + expect(resolveCanvasSampling(undefined, true)).toBe(4); + expect(resolveCanvasSampling(15, true)).toBe(15); + expect(resolveCanvasSampling('all', false)).toBe('all'); + }); +}); +``` + +Plus in the existing canvas mask test file: a region-scaling case with a padded canvas (`style="padding:20px"`, canvas 100×100 backing store, content box 100×100 → scale 1 even though `clientWidth` is 140), asserting the mask rect coordinates passed to the worker. + +- [ ] **Step 2: Run, verify FAIL.** +- [ ] **Step 3: Implement** the three changes. In `record/index.ts`, apply `resolveCanvasSampling` before constructing `CanvasManager`, so `initCanvasMutationObserver` is unreachable when masking is configured. +- [ ] **Step 4: Run** canvas suites (`npx vitest run test/record` filtered to canvas files); verify PASS. +- [ ] **Step 5: Commit** — `git commit -am "fix(canvas): masking forces FPS capture path; content-box region scaling; cheap canvas discovery"` + +--- + +### Task 9: Wiring hardening — plugin fallback, untainted tagName, plugin package + +**Files:** +- Modify: `packages/rrweb/src/record/index.ts` (~109-130) +- Modify: `packages/utils/src/index.ts` (add `untaintedTagName`) +- Modify: `packages/rrweb/src/record/mutation.ts` (~663-665 raw tagName reads), `packages/rrweb-snapshot/src/snapshot.ts` (~533-539 inline guard), `packages/rrweb-snapshot/src/privacy.ts` (delete `nativeElementTagName`, `parentElementAcrossShadowRoot` — no remaining callers after Tasks 4-6) +- Modify: `packages/plugins/rrweb-plugin-privacy-detectors/src/index.ts`, its `README.md`, `test/` +- Test: `packages/plugins/rrweb-plugin-privacy-detectors/test/index.test.ts`, `packages/rrweb/test/record/privacy.test.ts` + +**Interfaces:** +- `@rrweb/utils` produces: `export function untaintedTagName(element: Element | null | undefined): string` — returns `''` for null; uses the element's own `tagName` when it is a string, else the untainted `Element.prototype` getter via the existing `getUntaintedAccessor` machinery; uppercased. Every privacy-relevant `element.tagName` read in `mutation.ts`/`snapshot.ts` touched by this feature goes through it. +- record/index.ts plugin fallback: + +```ts +let privacy: CompiledPrivacyPolicy; +try { + privacy = compilePrivacyPolicy(portablePrivacyPolicy); +} catch (error) { + if (portablePrivacyPolicy !== privacyPolicy) { + console.error('[rrweb] plugin-transformed privacy policy failed to compile; using the user policy', error); + privacy = compilePrivacyPolicy(privacyPolicy); // user's own invalid policy still throws (programmer error) + } else { + throw error; + } +} +``` + +- Plugin: `applyPrivacyDetectors(undefined, opts)` keeps base `{version: 1, preset: 'legacy'}` — and now genuinely detects, because `compilePrivacyPolicy` populates `detectors` regardless of preset and the Task 4 hook runs under legacy. README updated to state exactly that. + +- [ ] **Step 1: Write the failing tests:** + +```ts +// plugin package +it('plugin with no user policy yields a legacy policy whose compiled detectors are active', () => { + const plugin = getRecordPrivacyDetectorsPlugin(); + const policy = plugin.applyPrivacyPolicy!(undefined) as PrivacyPolicy; + expect(policy.preset).toBe('legacy'); + const compiled = compilePrivacyPolicy(policy); + expect(compiled.detectors.length).toBeGreaterThan(0); + expect(detectSensitiveValue('bob@example.com', compiled)).toBe(true); +}); +// rrweb record suite +it('a plugin returning a malformed policy falls back to the user policy instead of throwing', () => { + const badPlugin = { name: 'bad@1', applyPrivacyPolicy: () => ({ nonsense: true }) }; + expect(() => + record({ emit: () => {}, plugins: [badPlugin as never] }), + ).not.toThrow(); +}); +it('untaintedTagName survives
', () => { + document.body.innerHTML = '
'; + expect(untaintedTagName(document.querySelector('form'))).toBe('FORM'); +}); +``` + +- [ ] **Step 2: Run, verify FAIL** (the malformed-plugin case throws today). +- [ ] **Step 3: Implement** the three changes; replace the raw `target.tagName.toLowerCase()` at `mutation.ts:665` and the inline typeof guard at `snapshot.ts:533-539` with `untaintedTagName(...)`; delete `nativeElementTagName`/`parentElementAcrossShadowRoot` from `privacy.ts`. +- [ ] **Step 4: Run** plugin + rrweb suites; verify PASS. +- [ ] **Step 5: Commit** — `git commit -am "fix(privacy): plugin compile fallback, shared untainted tagName, plugin detects under legacy"` + +--- + +### Task 10: Types package, changeset, docs + +**Files:** +- Modify: `packages/types/src/index.ts` (mirror Task 1 type removals for the public `@rrweb/types` copies; keep the `ImageBitmapDataURLWorkerParams` union but document it) +- Modify: `guide.md` (privacy section ~lines 270-300), `packages/plugins/rrweb-plugin-privacy-detectors/README.md` +- Create: `.changeset/privacy-v2-simplification.md` +- Test: `npx tsc -b tsconfig.json` (workspace type-check) as the verification step + +**Interfaces:** none new; this task reconciles public types and docs with Tasks 1-9. + +- [ ] **Step 1: Sync `packages/types`** with the rrweb-snapshot type changes (remove `PrivacyMaskStyle`, `custom` detectors, rule `style`/`classification`/`attributes`; preset union loses `'custom'`). +- [ ] **Step 2: Write the changeset:** + +```md +--- +'rrweb-snapshot': minor +'rrweb': minor +'@rrweb/types': major +'@rrweb/rrweb-plugin-privacy-detectors': minor +'@rrweb/utils': minor +--- + +Privacy at Capture v2: policies now compile onto rrweb's existing masking +primitives; heuristic detectors are a fixed whole-value set (custom regex +patterns removed); CSS is never masked; canvas masking forces the FPS capture +path; selector and config errors fail closed. BREAKING (@rrweb/types): +`ImageBitmapDataURLWorkerParams` is a union; privacy rule `style`, +`classification`, custom detectors, and the `'custom'` preset are removed. +``` + +- [ ] **Step 3: Update `guide.md`:** preset table now states exactly what Task 1 compiles (balanced: inputs + `title`/`placeholder`/`aria-label` + URL sanitization; strict: + all text, media blocked, canvas off; CSS never masked; detectors only via the plugin, active under any preset). Fix the line "Existing masking options are still applied when a policy does not make an explicit decision" to the Task 5 truth: "Under `balanced`/`strict`, `maskInputFn` output is star-replaced — the callback controls length, never content." Update plugin README per Task 9. +- [ ] **Step 4: Verify** — `npx tsc -b tsconfig.json` clean; `git grep -l "maskSensitiveRanges\|getPrivacyAction\|detectSensitiveText\|maskTextWithPrivacy\|maskAttributeWithPrivacy\|maskInputWithPrivacy\|shouldMaskInputWithPrivacy\|SAFE_GENERATED_ATTRIBUTES\|privacy-policy.schema"` returns nothing outside this plan/spec. +- [ ] **Step 5: Commit** — `git commit -am "docs(privacy): v2 types sync, changeset, guide"` + +--- + +### Task 11: Full verification sweep + +**Files:** none created; runs everything. + +- [ ] **Step 1:** `npx yarn@1.22.19 install` if not yet done, then repo-root `npx turbo run test --filter=rrweb-snapshot --filter=rrweb --filter=@rrweb/rrweb-plugin-privacy-detectors --filter=@rrweb/utils` (fall back to per-package `npx vitest run` if turbo is unavailable). Expected: all green. +- [ ] **Step 2: Perf smoke** — add `packages/rrweb-snapshot/test/privacy-perf.test.ts`: + +```ts +it('legacy snapshot performs no privacy selector matching', () => { + const spy = vi.spyOn(Element.prototype, 'matches'); + document.body.innerHTML = '
'.repeat(200) + 'deep text' + '
'.repeat(200); + snapshot(document, { privacy: compilePrivacyPolicy(undefined) }); + const privacyCalls = spy.mock.calls.filter(([sel]) => + typeof sel === 'string' && sel.includes('data-privacy')); + expect(privacyCalls.length).toBe(0); + spy.mockRestore(); +}); +``` + +- [ ] **Step 3:** Type-check (`npx tsc -b tsconfig.json`) and lint the touched packages (`npx turbo run lint --filter=...` if configured). +- [ ] **Step 4: Commit** — `git commit -am "test(privacy): perf smoke + full sweep"` — then report results (including any deviations) back for review before any push. + +--- + +## Self-review notes + +- Spec §1-§9 → Tasks 1-10 (coverage: §1→T1/T10, §2→T1, §3→T4/T7, §4→T5, §5→T3/T6, §6→T2/T9, §7→T8, §8→T9, §9 deletions distributed, §10→every task + T11). +- Type consistency: `CompiledPrivacyPolicy`, `CompiledDetector`, `finalizeAttribute`, `maskInput`, `needsMaskingText`, `untaintedTagName`, `resolveCanvasSampling` are each defined once in an Interfaces block and consumed by name in later tasks. +- Known judgment calls an implementer may hit: exact current line numbers may have drifted a few lines — anchor on symbol names, not line numbers; existing test harness names (`test/privacy.test.ts` structure) may require merging the new describes into existing files rather than replacing wholesale. From 0880e2bfa62cd4b7268af8d3f0a29cb63f372cac Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 11:13:58 +0200 Subject: [PATCH 03/64] feat(privacy): compile policy onto selector lists and rrweb options Replace the rule-engine CompiledPrivacyPolicy with a v2 shape that compiles a PrivacyPolicy into plain rrweb masking options (selector lists, maskAllInputs, maskedAttributes, blockMedia, sanitizeUrls, precomputed query-parameter sets). Presets are now strict/balanced/legacy only ('custom' removed), actions drop style/classification, and 'unmask' is an alias of 'allow'. Vendor privacy classes (rr-/mp-/fs-/amp-/ ph-/sentry-) are compiled into the mask/unmask/block selectors for every non-legacy preset. Delete the old rule-matching and heuristic-detector-compilation engine (getPrivacyAction, maskTextWithPrivacy, maskInputWithPrivacy, maskAttributeWithPrivacy, protectSerializedAttribute, detectSensitiveText, and related helpers) along with the JSON policy schema; detector compilation and masking behavior land in later tasks. snapshot.ts and the rrweb recorder fall back to their pre-privacy masking paths in the interim (plain needsMask/maskInputValue/attribute passthrough); sanitizeUrl is a passthrough stub pending Task 3. Co-Authored-By: Claude Fable 5 --- packages/rrweb-snapshot/package.json | 4 +- .../rrweb-snapshot/privacy-policy.schema.json | 115 --- packages/rrweb-snapshot/src/privacy.ts | 944 ++---------------- packages/rrweb-snapshot/src/snapshot.ts | 108 +- packages/rrweb-snapshot/src/types.ts | 86 +- packages/rrweb-snapshot/test/privacy.test.ts | 768 ++------------ packages/rrweb/src/index.ts | 2 - packages/rrweb/src/record/mutation.ts | 165 +-- packages/rrweb/src/record/observer.ts | 73 +- .../rrweb/src/record/stylesheet-manager.ts | 23 +- 10 files changed, 267 insertions(+), 2021 deletions(-) delete mode 100644 packages/rrweb-snapshot/privacy-policy.schema.json diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index 3db3a8d87b..a6dbad9681 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -46,13 +46,11 @@ "types": "./dist/index.d.cts", "default": "./dist/rrweb-snapshot.umd.cjs" } - }, - "./privacy-policy.schema.json": "./privacy-policy.schema.json" + } }, "files": [ "umd", "dist", - "privacy-policy.schema.json", "package.json" ], "sideEffects": false, diff --git a/packages/rrweb-snapshot/privacy-policy.schema.json b/packages/rrweb-snapshot/privacy-policy.schema.json deleted file mode 100644 index 6c323eac9a..0000000000 --- a/packages/rrweb-snapshot/privacy-policy.schema.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://rrweb.io/schemas/privacy-policy-v1.json", - "title": "Privacy at Capture Policy v1", - "type": "object", - "additionalProperties": false, - "required": ["version", "preset"], - "properties": { - "version": { "const": 1 }, - "preset": { - "enum": ["strict", "balanced", "custom", "legacy"] - }, - "rules": { - "type": "array", - "items": { "$ref": "#/$defs/rule" } - }, - "detectors": { "$ref": "#/$defs/detectors" }, - "url": { "$ref": "#/$defs/url" } - }, - "$defs": { - "sensitiveDataKind": { - "enum": [ - "credential", - "payment", - "identity", - "contact", - "location", - "custom" - ] - }, - "target": { - "type": "object", - "additionalProperties": false, - "required": ["type", "selector"], - "properties": { - "type": { "const": "selector" }, - "selector": { "type": "string", "minLength": 1 }, - "attributes": { - "type": "array", - "items": { "type": "string", "minLength": 1 }, - "uniqueItems": true - } - } - }, - "rule": { - "type": "object", - "additionalProperties": false, - "required": ["target", "action"], - "properties": { - "target": { "$ref": "#/$defs/target" }, - "action": { "enum": ["allow", "mask", "exclude"] }, - "style": { - "enum": ["replacement", "solid", "blur", "pixelate", "shuffle"] - }, - "classification": { "$ref": "#/$defs/sensitiveDataKind" } - } - }, - "customDetector": { - "type": "object", - "additionalProperties": false, - "required": ["name", "pattern"], - "properties": { - "name": { "type": "string", "minLength": 1, "pattern": ".*\\S.*" }, - "pattern": { "type": "string", "minLength": 1, "maxLength": 256 }, - "flags": { - "type": "string", - "pattern": "^[dgimsuv]{0,7}$" - }, - "classification": { "$ref": "#/$defs/sensitiveDataKind" }, - "minimumLength": { - "type": "integer", - "minimum": 1, - "maximum": 1024 - }, - "maximumMatchLength": { - "type": "integer", - "minimum": 1, - "maximum": 1024 - } - } - }, - "detectors": { - "type": "object", - "additionalProperties": false, - "properties": { - "email": { "type": "boolean" }, - "phone": { "type": "boolean" }, - "paymentCard": { "type": "boolean" }, - "ssn": { "type": "boolean" }, - "ipAddress": { "type": "boolean" }, - "custom": { - "type": "array", - "items": { "$ref": "#/$defs/customDetector" } - } - } - }, - "url": { - "type": "object", - "additionalProperties": false, - "properties": { - "blockedQueryParameters": { - "type": "array", - "items": { "type": "string" }, - "uniqueItems": true - }, - "allowedQueryParameters": { - "type": "array", - "items": { "type": "string" }, - "uniqueItems": true - }, - "removeHash": { "type": "boolean" } - } - } - } -} diff --git a/packages/rrweb-snapshot/src/privacy.ts b/packages/rrweb-snapshot/src/privacy.ts index 22f8b9806f..17f72b7cb6 100644 --- a/packages/rrweb-snapshot/src/privacy.ts +++ b/packages/rrweb-snapshot/src/privacy.ts @@ -1,75 +1,42 @@ import type { CompiledPrivacyPolicy, - PrivacyAction, PrivacyDetectorOptions, PrivacyPolicy, - SensitiveDataKind, - MaskAttributeFn, } from './types'; -import dom from '@rrweb/utils'; -const ACTION_PRIORITY: Record = { - allow: 0, - mask: 1, - exclude: 2, -}; - -const DATA_PRIVACY_RULES: CompiledPrivacyPolicy['rules'] = [ - { - action: 'allow', - selector: '[data-privacy="allow"]', - }, - { - action: 'mask', - selector: '[data-privacy="mask"]', - }, - { - action: 'exclude', - selector: '[data-privacy="exclude"]', - }, -]; - -const PRIVACY_PRESETS = new Set(['strict', 'balanced', 'custom', 'legacy']); -const MASK_STYLES = new Set([ - 'replacement', - 'solid', - 'blur', - 'pixelate', - 'shuffle', -]); - -const SENSITIVE_ATTRIBUTES = new Set([ - 'alt', - 'aria-description', - 'aria-label', - 'placeholder', - 'style', - 'title', - '_csstext', -]); +const VENDOR_MASK_CLASSES = + '.rr-mask,.mp-mask,.fs-mask,.amp-mask,.ph-mask,.sentry-mask,[data-sentry-mask]'; +const VENDOR_UNMASK_CLASSES = + '.rr-unmask,.amp-unmask,.sentry-unmask,[data-sentry-unmask]'; +const VENDOR_BLOCK_CLASSES = + '.rr-block,.mp-block,.fs-exclude,.amp-block,.ph-no-capture,.sentry-block'; +const PRIVACY_PRESETS = new Set(['strict', 'balanced', 'legacy']); +const MASKED_ATTRIBUTE_DEFAULTS = ['title', 'placeholder', 'aria-label']; -const URL_ATTRIBUTES = new Set([ - 'action', - 'background', - 'data', - 'formaction', - 'href', - 'poster', - 'src', - 'xlink:href', +const PROTECTED_AUTOCOMPLETE = new Set([ + 'cc-csc', + 'cc-exp', + 'cc-exp-month', + 'cc-exp-year', + 'cc-name', + 'cc-number', + 'current-password', + 'new-password', + 'one-time-code', ]); -const MEDIA_SOURCE_ATTRIBUTES = new Set([ - 'background', - 'data', - 'poster', - 'src', - 'srcset', -]); +const DEFAULT_BLOCKED_QUERY_PARAMETERS = [ + 'access_token', + 'auth', + 'code', + 'key', + 'password', + 'secret', + 'session', + 'token', +]; -export const DEFAULT_PRIVACY_DETECTORS: Required< - Omit -> = { +export const DEFAULT_PRIVACY_DETECTORS: Required = { email: true, phone: true, paymentCard: true, @@ -97,216 +64,79 @@ export function applyPrivacyDetectors( }; } -const DETECTOR_SCAN_CHUNK_SIZE = 8_192; -const CUSTOM_DETECTOR_SCAN_CHUNK_SIZE = 512; -const MAX_DETECTOR_MATCHES = 1_000; -const MAX_CUSTOM_PATTERN_LENGTH = 256; -const MAX_CUSTOM_MATCH_LENGTH = 1_024; -const MAX_CUSTOM_QUANTIFIERS = 12; -const DEFAULT_CUSTOM_MATCH_LENGTH = 256; - -const PROTECTED_AUTOCOMPLETE = new Set([ - 'cc-csc', - 'cc-exp', - 'cc-exp-month', - 'cc-exp-year', - 'cc-name', - 'cc-number', - 'current-password', - 'new-password', - 'one-time-code', -]); - -const DEFAULT_BLOCKED_QUERY_PARAMETERS = [ - 'access_token', - 'auth', - 'code', - 'key', - 'password', - 'secret', - 'session', - 'token', -]; - -const FORM_VALUE_TAGS = new Set(['INPUT', 'OPTION', 'SELECT', 'TEXTAREA']); - -const MEDIA_TAGS = new Set([ - 'AUDIO', - 'EMBED', - 'IFRAME', - 'IMG', - 'OBJECT', - 'SOURCE', - 'VIDEO', -]); - -const SAFE_GENERATED_ATTRIBUTES = new Set([ - 'rr_width', - 'rr_height', - 'rr_left', - 'rr_top', - 'rr_position', - 'rr_transform', - 'rr_display', - 'rr_scrollleft', - 'rr_scrolltop', - 'rr_mediastate', - 'rr_open_mode', -]); - -export type SensitiveMatch = { - start: number; - end: number; - kind: SensitiveDataKind; - detector: string; -}; - -export function compilePrivacyPolicy( - policy: PrivacyPolicy | undefined, -): CompiledPrivacyPolicy { - const effectivePolicy: PrivacyPolicy = policy || { - version: 1, - preset: 'legacy', - }; - if (effectivePolicy.version !== 1) { - throw new Error( - `Unsupported Privacy at Capture policy version: ${String( - effectivePolicy.version, - )}`, - ); - } - if (!PRIVACY_PRESETS.has(effectivePolicy.preset)) { - throw new Error( - `Unsupported privacy preset: ${String(effectivePolicy.preset)}`, - ); +export function validateSelector(selector: string): boolean { + try { + document.createDocumentFragment().querySelector(selector); + return true; + } catch { + return false; } +} - const policyRules = (effectivePolicy.rules || []).map((rule) => { - if (!rule.target || rule.target.type !== 'selector') { - throw new Error( - `Unsupported privacy target type: ${String(rule.target?.type)}`, - ); - } - if (!rule.target.selector) - throw new Error('Privacy rule selector cannot be empty'); - if (!(rule.action in ACTION_PRIORITY)) { - throw new Error(`Unsupported privacy action: ${String(rule.action)}`); - } - if (rule.style && !MASK_STYLES.has(rule.style)) { - throw new Error(`Unsupported privacy mask style: ${String(rule.style)}`); +function joinSelectors(selectors: Array): string | null { + const kept: string[] = []; + for (const s of selectors) { + if (!s) continue; + if (!validateSelector(s)) { + console.warn(`[rrweb privacy] dropping invalid selector: ${s}`); + continue; } - return { - action: rule.action, - style: rule.style, - classification: rule.classification, - selector: rule.target.selector, - attributes: rule.target.attributes - ? new Set( - rule.target.attributes.map((attribute) => attribute.toLowerCase()), - ) - : undefined, - }; - }); - const rules = [...DATA_PRIVACY_RULES, ...policyRules]; + kept.push(s); + } + return kept.join(',') || null; +} - const detectorOptions = { - ...effectivePolicy.detectors, - }; - const detectors: CompiledPrivacyPolicy['detectors'] = []; +export function compilePrivacyPolicy(policy?: PrivacyPolicy): CompiledPrivacyPolicy { + const effective: PrivacyPolicy = policy || { version: 1, preset: 'legacy' }; + if (effective.version !== 1) + throw new Error(`Unsupported Privacy at Capture policy version: ${String(effective.version)}`); + if (!PRIVACY_PRESETS.has(effective.preset)) + throw new Error(`Unsupported privacy preset: ${String(effective.preset)}`); + const preset = effective.preset; + const nonLegacy = preset !== 'legacy'; - if (detectorOptions.email) { - detectors.push({ - name: 'email', - regex: - /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[a-zA-Z0-9-]{1,63}(?:\.[a-zA-Z0-9-]{1,63})+/g, - classification: 'contact', - minimumLength: 6, - maximumMatchLength: 320, - scanChunkSize: DETECTOR_SCAN_CHUNK_SIZE, - validate: (candidate) => candidate.length <= 254, - }); - } - if (detectorOptions.phone) { - detectors.push({ - name: 'phone', - regex: /(?:\+?\d[\d ().-]{7,29}\d)/g, - classification: 'contact', - minimumLength: 10, - maximumMatchLength: 32, - scanChunkSize: DETECTOR_SCAN_CHUNK_SIZE, - validate: (candidate) => { - const length = candidate.replace(/\D/g, '').length; - return length >= 10 && length <= 15; - }, - }); - } - if (detectorOptions.paymentCard) { - detectors.push({ - name: 'payment-card', - regex: /(?:\d[ -]?){12,18}\d/g, - classification: 'payment', - minimumLength: 13, - maximumMatchLength: 37, - scanChunkSize: DETECTOR_SCAN_CHUNK_SIZE, - validate: passesLuhn, - }); - } - if (detectorOptions.ssn) { - detectors.push({ - name: 'ssn', - regex: /\b\d{3}-?\d{2}-?\d{4}\b/g, - classification: 'identity', - minimumLength: 9, - maximumMatchLength: 11, - scanChunkSize: DETECTOR_SCAN_CHUNK_SIZE, - }); - } - if (detectorOptions.ipAddress) { - detectors.push({ - name: 'ip-address', - regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, - classification: 'location', - minimumLength: 7, - maximumMatchLength: 15, - scanChunkSize: DETECTOR_SCAN_CHUNK_SIZE, - validate: (candidate) => - candidate.split('.').every((part) => Number(part) <= 255), - }); - } - for (const detector of detectorOptions.custom || []) { - const minimumLength = detector.minimumLength ?? 1; - const maximumMatchLength = - detector.maximumMatchLength ?? DEFAULT_CUSTOM_MATCH_LENGTH; - validateCustomDetector( - detector.name, - detector.pattern, - detector.flags, - minimumLength, - maximumMatchLength, - ); - detectors.push({ - name: detector.name, - regex: new RegExp(detector.pattern, ensureGlobalFlag(detector.flags)), - classification: detector.classification || 'custom', - minimumLength, - maximumMatchLength, - scanChunkSize: CUSTOM_DETECTOR_SCAN_CHUNK_SIZE, - }); + const bySelector = { mask: [] as string[], unmask: [] as string[], exclude: [] as string[] }; + for (const rule of effective.rules || []) { + if (!rule.target || rule.target.type !== 'selector' || !rule.target.selector) + throw new Error('Privacy rules require a non-empty selector target'); + const action = rule.action === 'allow' ? 'unmask' : rule.action; + if (!(action in bySelector)) + throw new Error(`Unsupported privacy action: ${String(rule.action)}`); + bySelector[action as keyof typeof bySelector].push(rule.target.selector); } return { - policy: effectivePolicy, - rules, - detectors, - minimumDetectorLength: - detectors.length > 0 - ? Math.min(...detectors.map((detector) => detector.minimumLength)) - : Number.POSITIVE_INFINITY, - blockSelector: - rules - .filter((rule) => rule.action === 'exclude' && !rule.attributes) - .map((rule) => rule.selector) - .join(',') || null, + policy: effective, + preset, + maskTextSelector: nonLegacy + ? preset === 'strict' + ? '*' + : joinSelectors(['[data-privacy="mask"]', VENDOR_MASK_CLASSES, ...bySelector.mask]) + : joinSelectors(bySelector.mask.length ? ['[data-privacy="mask"]', ...bySelector.mask] : []), + unmaskTextSelector: joinSelectors( + nonLegacy + ? ['[data-privacy="allow"]', VENDOR_UNMASK_CLASSES, ...bySelector.unmask] + : bySelector.unmask, + ), + blockSelector: joinSelectors( + nonLegacy + ? ['[data-privacy="exclude"]', VENDOR_BLOCK_CLASSES, ...bySelector.exclude] + : bySelector.exclude.length ? ['[data-privacy="exclude"]', ...bySelector.exclude] : [], + ), + maskAllInputs: nonLegacy, + maskedAttributes: nonLegacy ? [...MASKED_ATTRIBUTE_DEFAULTS] : [], + blockMedia: preset === 'strict', + sanitizeUrls: nonLegacy, + blockedQueryParameters: new Set( + [...DEFAULT_BLOCKED_QUERY_PARAMETERS, ...(effective.url?.blockedQueryParameters || [])].map( + (n) => n.toLowerCase(), + ), + ), + allowedQueryParameters: effective.url?.allowedQueryParameters + ? new Set(effective.url.allowedQueryParameters.map((n) => n.toLowerCase())) + : null, + removeHash: effective.url?.removeHash !== false, + detectors: [], // populated by applyPrivacyDetectors (Task 2) }; } @@ -319,334 +149,6 @@ export function mergeBlockSelectors( ); } -export function getPrivacyAction( - element: Element | null, - privacy: CompiledPrivacyPolicy | undefined, - attribute?: string, - requireExplicitAttribute = false, -): PrivacyAction | undefined { - if (!element || !privacy) return undefined; - - let best: - | { action: PrivacyAction; distance: number; priority: number } - | undefined; - let current: Element | null = element; - let distance = 0; - - while (current) { - for (const rule of privacy.rules) { - if (requireExplicitAttribute && !rule.attributes) continue; - if ( - attribute && - rule.attributes && - !rule.attributes.has(attribute.toLowerCase()) - ) { - continue; - } - if (!attribute && rule.attributes) continue; - - let matches = false; - try { - matches = current.matches(rule.selector); - } catch { - continue; - } - if (!matches) continue; - - const priority = ACTION_PRIORITY[rule.action]; - if ( - !best || - distance < best.distance || - (distance === best.distance && priority > best.priority) - ) { - best = { action: rule.action, distance, priority }; - } - } - current = parentElementAcrossShadowRoot(current); - distance += 1; - } - - return best?.action; -} - -export function maskTextWithPrivacy( - value: string, - element: HTMLElement | null, - privacy: CompiledPrivacyPolicy | undefined, - legacyMask: boolean, - legacyMaskFn?: (text: string, element: HTMLElement | null) => string, -): string { - if (!privacy) { - return legacyMask ? applyLegacyMask(value, element, legacyMaskFn) : value; - } - - if (nativeElementTagName(element) === 'SCRIPT') { - return 'SCRIPT_PLACEHOLDER'; - } - - const action = getPrivacyAction(element, privacy); - if (privacy.policy.preset === 'legacy' && !action) { - return legacyMask ? applyLegacyMask(value, element, legacyMaskFn) : value; - } - if (action === 'allow') return value; - if (action === 'exclude') return ''; - if (action === 'mask' || privacy.policy.preset === 'strict') { - return replacePreservingShape(value); - } - if (legacyMask) return applyLegacyMask(value, element, legacyMaskFn); - if (privacy.detectors.length > 0) { - return maskSensitiveRanges(value, detectSensitiveText(value, privacy)); - } - return value; -} - -export function shouldMaskInputWithPrivacy( - element: HTMLElement, - privacy: CompiledPrivacyPolicy | undefined, - legacyMask: boolean, -): boolean { - if (!privacy) return legacyMask; - - const action = getPrivacyAction(element, privacy); - if (privacy.policy.preset === 'legacy' && !action) return legacyMask; - if (isProtectedInput(element)) return true; - if (action === 'mask') return true; - if (action === 'exclude') return true; - if (action === 'allow') return false; - if (privacy.policy.preset === 'strict') return true; - if (privacy.policy.preset === 'balanced') return true; - return legacyMask; -} - -export function maskInputWithPrivacy( - value: string, - element: HTMLElement, - privacy: CompiledPrivacyPolicy | undefined, - legacyMask: boolean, - legacyMaskFn?: (text: string, element: HTMLElement) => string, -): string { - if (!shouldMaskInputWithPrivacy(element, privacy, legacyMask)) return value; - const action = getPrivacyAction(element, privacy); - if (!privacy || (privacy.policy.preset === 'legacy' && !action)) { - return legacyMaskFn - ? legacyMaskFn(value, element) - : '*'.repeat(value.length); - } - return replacePreservingShape(value); -} - -export function maskAttributeWithPrivacy( - element: HTMLElement, - name: string, - value: string | null, - privacy: CompiledPrivacyPolicy | undefined, -): string | null { - if (!value || !privacy) return value; - - const normalizedName = name.toLowerCase(); - const standardSensitiveAttribute = - normalizedName === 'value' || - SENSITIVE_ATTRIBUTES.has(normalizedName) || - URL_ATTRIBUTES.has(normalizedName); - const action = getPrivacyAction( - element, - privacy, - normalizedName, - !standardSensitiveAttribute, - ); - if (privacy.policy.preset === 'legacy' && !action) return value; - if (normalizedName === 'value' && isProtectedInput(element)) { - return replacePreservingShape(value); - } - if (action === 'allow') return value; - if (action === 'exclude') return null; - if (action === 'mask') return replacePreservingShape(value); - if ( - privacy.policy.preset === 'strict' && - normalizedName === 'value' && - FORM_VALUE_TAGS.has(nativeElementTagName(element)) - ) { - return replacePreservingShape(value); - } - - if ( - privacy.policy.preset === 'strict' && - MEDIA_TAGS.has(nativeElementTagName(element)) && - MEDIA_SOURCE_ATTRIBUTES.has(normalizedName) - ) { - return null; - } - if (URL_ATTRIBUTES.has(normalizedName)) { - return sanitizeUrl(value, privacy); - } - if (SENSITIVE_ATTRIBUTES.has(normalizedName)) { - return privacy.policy.preset === 'strict' - ? replacePreservingShape(value) - : maskSensitiveRanges(value, detectSensitiveText(value, privacy)); - } - return value; -} - -/** - * Apply runtime attribute escape hatches without allowing them to undo the - * portable policy. The coarse mode wins over the callback, and the policy is - * always the final authority. - */ -export function protectSerializedAttribute({ - element, - name, - value, - privacy, - maskAllElementAttributes = false, - maskAttributeFn, - isGenerated = false, -}: { - element: Element; - name: string; - value: string | null; - privacy: CompiledPrivacyPolicy | undefined; - maskAllElementAttributes?: boolean; - maskAttributeFn?: MaskAttributeFn; - isGenerated?: boolean; -}): string | null { - if (!value) return value; - - let protectedValue = value; - if (maskAllElementAttributes) { - protectedValue = - isGenerated && SAFE_GENERATED_ATTRIBUTES.has(name.toLowerCase()) - ? value - : '*'.repeat(value.length); - } else if (maskAttributeFn) { - try { - protectedValue = maskAttributeFn(name, value, element); - } catch { - // A masking callback is part of the privacy boundary; failure must not - // silently publish the original value. - protectedValue = '*'.repeat(value.length); - } - } - - return maskAttributeWithPrivacy( - element as HTMLElement, - name, - protectedValue, - privacy, - ); -} - -export function sanitizeUrl( - value: string, - privacy: CompiledPrivacyPolicy | undefined, -): string { - if (!privacy || privacy.policy.preset === 'legacy') return value; - try { - const url = new URL(value, 'https://rrweb.invalid'); - const allowed = privacy.policy.url?.allowedQueryParameters?.map((name) => - name.toLowerCase(), - ); - const blocked = new Set( - [ - ...DEFAULT_BLOCKED_QUERY_PARAMETERS, - ...(privacy.policy.url?.blockedQueryParameters || []), - ].map((name) => name.toLowerCase()), - ); - - for (const [name, parameterValue] of url.searchParams) { - if ( - (privacy.policy.preset === 'strict' && !allowed) || - (allowed && !allowed.includes(name.toLowerCase())) || - blocked.has(name.toLowerCase()) || - detectSensitiveText(parameterValue, privacy).length > 0 - ) { - url.searchParams.set(name, '*'); - } - } - url.pathname = maskSensitiveRanges( - url.pathname, - detectSensitiveText(url.pathname, privacy), - ); - if (privacy.policy.url?.removeHash !== false) url.hash = ''; - - if (url.origin === 'https://rrweb.invalid') { - return `${url.pathname}${url.search}${url.hash}`; - } - return url.toString(); - } catch { - return maskSensitiveRanges(value, detectSensitiveText(value, privacy)); - } -} - -export function detectSensitiveText( - value: string, - privacy: CompiledPrivacyPolicy, -): SensitiveMatch[] { - if ( - privacy.detectors.length === 0 || - value.length < privacy.minimumDetectorLength - ) { - return []; - } - - const matches: SensitiveMatch[] = []; - for (const detector of privacy.detectors) { - if (value.length < detector.minimumLength) continue; - - for ( - let offset = 0; - offset < value.length; - offset += detector.scanChunkSize - ) { - const primaryEnd = Math.min( - offset + detector.scanChunkSize, - value.length, - ); - const scanEnd = Math.min( - primaryEnd + detector.maximumMatchLength - 1, - value.length, - ); - const chunk = value.slice(offset, scanEnd); - detector.regex.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = detector.regex.exec(chunk))) { - const start = offset + match.index; - if (start >= primaryEnd) break; - if (match[0].length > detector.maximumMatchLength) { - matches.push({ - start: 0, - end: value.length, - kind: detector.classification, - detector: `${detector.name}:oversize`, - }); - return mergeMatches(matches); - } - if (!detector.validate || detector.validate(match[0])) { - matches.push({ - start, - end: start + match[0].length, - kind: detector.classification, - detector: detector.name, - }); - if (matches.length >= MAX_DETECTOR_MATCHES) { - // Detection is a privacy boundary. If a hostile value produces an - // unreasonable number of matches, mask the complete value because - // later detectors have not necessarily scanned its prefix. - matches.push({ - start: 0, - end: value.length, - kind: detector.classification, - detector: `${detector.name}:overflow`, - }); - return mergeMatches(matches); - } - } - if (match[0].length === 0) detector.regex.lastIndex += 1; - } - } - } - return mergeMatches(matches); -} - export function passesLuhn(candidate: string): boolean { const digits = candidate.replace(/[ -]/g, ''); if (!/^\d{13,19}$/.test(digits) || /^(\d)\1+$/.test(digits)) return false; @@ -665,14 +167,8 @@ export function passesLuhn(candidate: string): boolean { return sum % 10 === 0; } -export function replacePreservingShape(value: string): string { - return value.replace(/[\p{L}\p{N}]/gu, (character) => - /\p{N}/u.test(character) ? '0' : 'x', - ); -} - -function isProtectedInput(element: HTMLElement): boolean { - if (nativeElementTagName(element) !== 'INPUT') return false; +export function isProtectedInput(element: HTMLElement): boolean { + if (element.tagName !== 'INPUT') return false; const input = element as HTMLInputElement; if ( input.type === 'password' || @@ -687,250 +183,10 @@ function isProtectedInput(element: HTMLElement): boolean { .some((token) => PROTECTED_AUTOCOMPLETE.has(token)); } -function validateCustomDetector( - name: string, - pattern: string, - flags: string | undefined, - minimumLength: number, - maximumMatchLength: number, -): void { - const label = name || ''; - if (!name.trim()) throw new Error('Custom detector name cannot be empty'); - if (!pattern || pattern.length > MAX_CUSTOM_PATTERN_LENGTH) { - throw new Error( - `Custom detector "${label}" pattern must be 1-${MAX_CUSTOM_PATTERN_LENGTH} characters`, - ); - } - if ( - !Number.isInteger(minimumLength) || - minimumLength < 1 || - minimumLength > MAX_CUSTOM_MATCH_LENGTH - ) { - throw new Error( - `Custom detector "${label}" minimumLength must be an integer from 1-${MAX_CUSTOM_MATCH_LENGTH}`, - ); - } - if ( - !Number.isInteger(maximumMatchLength) || - maximumMatchLength < 1 || - maximumMatchLength > MAX_CUSTOM_MATCH_LENGTH - ) { - throw new Error( - `Custom detector "${label}" maximumMatchLength must be an integer from 1-${MAX_CUSTOM_MATCH_LENGTH}`, - ); - } - if (minimumLength > maximumMatchLength) { - throw new Error( - `Custom detector "${label}" minimumLength cannot exceed maximumMatchLength`, - ); - } - if ( - flags && - (!/^[dgimsuv]*$/.test(flags) || new Set(flags).size !== flags.length) - ) { - throw new Error(`Custom detector "${label}" has unsupported regex flags`); - } - if ( - /\\[1-9]/.test(pattern) || - /\\k MAX_CUSTOM_QUANTIFIERS) { - throw new Error(`Custom detector "${label}" contains too many quantifiers`); - } - - let regex: RegExp; - try { - regex = new RegExp(pattern, ensureGlobalFlag(flags)); - } catch { - throw new Error(`Custom detector "${label}" contains an invalid regex`); - } - regex.lastIndex = 0; - if (regex.test('')) { - throw new Error(`Custom detector "${label}" cannot match empty text`); - } -} - -function quantifierLength(pattern: string, index: number): number { - const character = pattern[index]; - if (character === '*' || character === '+' || character === '?') { - return pattern[index + 1] === '?' ? 2 : 1; - } - if (character === '{') { - const match = pattern.slice(index).match(/^\{\d+(?:,\d*)?\}/); - if (!match) return 0; - return match[0].length + (pattern[index + match[0].length] === '?' ? 1 : 0); - } - return 0; -} - -function hasLookaroundOrNamedGroup(pattern: string): boolean { - let inCharacterClass = false; - let escaped = false; - - for (let index = 0; index < pattern.length; index += 1) { - const character = pattern[index]; - if (escaped) { - escaped = false; - continue; - } - if (character === '\\') { - escaped = true; - continue; - } - if (character === '[') { - inCharacterClass = true; - continue; - } - if (character === ']' && inCharacterClass) { - inCharacterClass = false; - continue; - } - if (inCharacterClass || character !== '(') continue; - if (pattern[index + 1] !== '?') continue; - if (pattern[index + 2] === ':') continue; - return true; - } - return false; -} - -function scanCustomPattern(pattern: string): { - nestedRepetition: boolean; - quantifiers: number; -} { - const groups: Array<{ repeated: boolean; alternation: boolean }> = []; - let inCharacterClass = false; - let escaped = false; - let quantifiers = 0; - - for (let index = 0; index < pattern.length; index += 1) { - const character = pattern[index]; - if (escaped) { - escaped = false; - continue; - } - if (character === '\\') { - escaped = true; - continue; - } - if (character === '[') { - inCharacterClass = true; - continue; - } - if (character === ']' && inCharacterClass) { - inCharacterClass = false; - continue; - } - if (inCharacterClass) continue; - - if (character === '(') { - groups.push({ repeated: false, alternation: false }); - if (pattern[index + 1] === '?' && pattern[index + 2] === ':') { - index += 2; - } - continue; - } - if (character === '|') { - const group = groups[groups.length - 1]; - if (group) group.alternation = true; - continue; - } - - const length = quantifierLength(pattern, index); - if (length > 0) { - quantifiers += 1; - const group = groups[groups.length - 1]; - if (group) group.repeated = true; - index += length - 1; - continue; - } - - if (character !== ')') continue; - - const group = groups.pop(); - if (!group) continue; - const outerLength = quantifierLength(pattern, index + 1); - if (outerLength > 0 && (group.repeated || group.alternation)) { - return { nestedRepetition: true, quantifiers }; - } - const parent = groups[groups.length - 1]; - if (parent && (group.repeated || outerLength > 0)) { - parent.repeated = true; - } - } - - return { nestedRepetition: false, quantifiers }; -} - -function applyLegacyMask( +export function sanitizeUrl( value: string, - element: HTMLElement | null, - maskFn?: (text: string, element: HTMLElement | null) => string, + _privacy: CompiledPrivacyPolicy | undefined, ): string { - return maskFn ? maskFn(value, element) : value.replace(/[\S]/g, '*'); -} - -function maskSensitiveRanges(value: string, matches: SensitiveMatch[]): string { - if (!matches.length) return value; - let result = ''; - let cursor = 0; - for (const match of matches) { - result += value.slice(cursor, match.start); - result += replacePreservingShape(value.slice(match.start, match.end)); - cursor = match.end; - } - return result + value.slice(cursor); -} - -function mergeMatches(matches: SensitiveMatch[]): SensitiveMatch[] { - const sorted = matches.sort((a, b) => a.start - b.start || b.end - a.end); - const result: SensitiveMatch[] = []; - for (const match of sorted) { - const previous = result[result.length - 1]; - if (previous && match.start <= previous.end) { - previous.end = Math.max(previous.end, match.end); - continue; - } - result.push({ ...match }); - } - return result; -} - -function ensureGlobalFlag(flags = ''): string { - return flags.includes('g') ? flags : `${flags}g`; -} - -function nativeElementTagName(element: Element | null | undefined): string { - if (!element) return ''; - const tagName = element.tagName; - if (typeof tagName === 'string') return tagName.toUpperCase(); - // HTMLFormElement (and similar) expose named controls as own properties, - // so `` shadows the prototype getter. - try { - const native: unknown = Object.getOwnPropertyDescriptor( - Element.prototype, - 'tagName', - )?.get?.call(element); - return typeof native === 'string' ? native.toUpperCase() : ''; - } catch { - return ''; - } -} - -function parentElementAcrossShadowRoot(element: Element): Element | null { - const parent = dom.parentElement(element); - if (parent) return parent; - const root = dom.getRootNode(element); - if (!root || !('host' in root) || !('mode' in root)) return null; - return dom.host(root as ShadowRoot); + // Task 3 reimplements + return value; } diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index df476d7c31..405874df05 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -34,14 +34,7 @@ import { absolutifyURLs, markCssSplits, } from './snapshot-utils'; -import { - compilePrivacyPolicy, - maskAttributeWithPrivacy, - maskInputWithPrivacy, - maskTextWithPrivacy, - mergeBlockSelectors, - protectSerializedAttribute, -} from './privacy'; +import { compilePrivacyPolicy, mergeBlockSelectors } from './privacy'; import dom from '@rrweb/utils'; let _id = 1; @@ -528,7 +521,7 @@ function serializeTextNode( privacy?: CompiledPrivacyPolicy; }, ): serializedNode { - const { needsMask, maskTextFn, rootId, cssCaptured, privacy } = options; + const { needsMask, maskTextFn, rootId, cssCaptured } = options; // The parent node may not be a html element which has a tagName attribute. // Named form controls can also shadow `tagName` (e.g. ). // So just let it be undefined which is ok in this use case. @@ -553,15 +546,7 @@ function serializeTextNode( } } if (!isScript && textContent) { - if (privacy) { - textContent = maskTextWithPrivacy( - textContent, - dom.parentElement(n), - privacy, - needsMask, - maskTextFn, - ); - } else if (!isStyle && needsMask) { + if (!isStyle && needsMask) { textContent = maskTextFn ? maskTextFn(textContent, dom.parentElement(n)) : textContent.replace(/[\S]/g, '*'); @@ -606,8 +591,6 @@ function serializeElementNode( inlineStylesheet, maskInputOptions = {}, maskInputFn, - maskAllElementAttributes, - maskAttributeFn, dataURLOptions = {}, inlineImages, recordCanvas, @@ -620,34 +603,16 @@ function serializeElementNode( const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); let attributes: attributes = {}; - const generatedAttributeNames = new Set(); - let serializationComplete = false; - const protectLateAttribute = (name: string, value: string) => - serializationComplete - ? protectSerializedAttribute({ - element: n, - name, - value, - privacy, - maskAllElementAttributes, - maskAttributeFn, - isGenerated: generatedAttributeNames.has(name), - }) - : value; const len = n.attributes.length; for (let i = 0; i < len; i++) { const attr = n.attributes[i]; if (!ignoreAttribute(tagName, attr.name, attr.value)) { - const transformed = transformAttribute( + attributes[attr.name] = transformAttribute( doc, tagName, toLowerCase(attr.name), attr.value, ); - const protectedValue = privacy - ? maskAttributeWithPrivacy(n, attr.name, transformed, privacy) - : transformed; - if (protectedValue !== null) attributes[attr.name] = protectedValue; } } // remote css @@ -689,28 +654,14 @@ function serializeElementNode( value ) { const type = getInputType(n); - if (privacy) { - const legacyMask = Boolean( - maskInputOptions[tagName as keyof MaskInputOptions] || - (type && maskInputOptions[type as keyof MaskInputOptions]), - ); - attributes.value = maskInputWithPrivacy( - value, - n, - privacy, - legacyMask, - maskInputFn, - ); - } else { - attributes.value = maskInputValue({ - element: n, - type, - tagName, - value, - maskInputOptions, - maskInputFn, - }); - } + attributes.value = maskInputValue({ + element: n, + type, + tagName, + value, + maskInputOptions, + maskInputFn, + }); } else if (checked) { attributes.checked = checked; } @@ -732,7 +683,6 @@ function serializeElementNode( (attributes as DialogAttributes).rr_open_mode = n.matches('dialog:modal') ? 'modal' : 'non-modal'; - generatedAttributeNames.add('rr_open_mode'); } // canvas image data @@ -792,9 +742,9 @@ function serializeElementNode( canvasService!.width = image.naturalWidth; canvasService!.height = image.naturalHeight; canvasCtx!.drawImage(image, 0, 0); - attributes.rr_dataURL = protectLateAttribute( - 'rr_dataURL', - canvasService!.toDataURL(dataURLOptions.type, dataURLOptions.quality), + attributes.rr_dataURL = canvasService!.toDataURL( + dataURLOptions.type, + dataURLOptions.quality, ); } catch (err) { if (image.crossOrigin !== 'anonymous') { @@ -811,10 +761,7 @@ function serializeElementNode( } if (image.crossOrigin === 'anonymous') { priorCrossOrigin - ? (attributes.crossOrigin = protectLateAttribute( - 'crossOrigin', - priorCrossOrigin, - )) + ? (attributes.crossOrigin = priorCrossOrigin) : image.removeAttribute('crossorigin'); } }; @@ -833,7 +780,6 @@ function serializeElementNode( mediaAttributes.rr_mediaMuted = (n as HTMLMediaElement).muted; mediaAttributes.rr_mediaLoop = (n as HTMLMediaElement).loop; mediaAttributes.rr_mediaVolume = (n as HTMLMediaElement).volume; - generatedAttributeNames.add('rr_mediaState'); } // Scroll if (!newlyAddedElement) { @@ -843,11 +789,9 @@ function serializeElementNode( // So we can safely skip the `scrollTop/Left` calls for newly added elements if (n.scrollLeft) { attributes.rr_scrollLeft = n.scrollLeft; - generatedAttributeNames.add('rr_scrollLeft'); } if (n.scrollTop) { attributes.rr_scrollTop = n.scrollTop; - generatedAttributeNames.add('rr_scrollTop'); } } // block element @@ -858,8 +802,6 @@ function serializeElementNode( rr_width: `${width}px`, rr_height: `${height}px`, }; - generatedAttributeNames.add('rr_width'); - generatedAttributeNames.add('rr_height'); } // iframe if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) { @@ -871,24 +813,6 @@ function serializeElementNode( delete attributes.src; // prevent auto loading } - // Apply runtime attribute controls to the final representation, after - // synthesized form/layout values. The portable policy remains the last - // authority inside protectSerializedAttribute. - for (const [name, value] of Object.entries(attributes)) { - if (typeof value === 'string' || value === null) { - attributes[name] = protectSerializedAttribute({ - element: n, - name, - value, - privacy, - maskAllElementAttributes, - maskAttributeFn, - isGenerated: generatedAttributeNames.has(name), - }); - } - } - serializationComplete = true; - let isCustomElement: true | undefined; try { if (customElements.get(tagName)) isCustomElement = true; diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index bdd3d2aefa..99a8973e8d 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -83,46 +83,20 @@ export type PrivacyPolicy = { url?: PrivacyUrlOptions; }; -export type PrivacyPreset = 'strict' | 'balanced' | 'custom' | 'legacy'; - -export type PrivacyAction = 'allow' | 'mask' | 'exclude'; - -/** - * `blur`, `pixelate`, and `shuffle` are portable policy vocabulary reserved - * for visual recorders. DOM text is currently rendered with `replacement`. - */ -export type PrivacyMaskStyle = - | 'replacement' - | 'solid' - | 'blur' - | 'pixelate' - | 'shuffle'; - -export type SensitiveDataKind = - | 'credential' - | 'payment' - | 'identity' - | 'contact' - | 'location' - | 'custom'; +export type PrivacyPreset = 'strict' | 'balanced' | 'legacy'; + +/** `unmask` is an alias of `allow`. */ +export type PrivacyAction = 'allow' | 'unmask' | 'mask' | 'exclude'; export type PrivacyRule = { target: PrivacyTarget; action: PrivacyAction; - style?: PrivacyMaskStyle; - classification?: SensitiveDataKind; }; export type PrivacyTarget = { type: 'selector'; /** A CSS selector. Rules also apply to descendants of the matched node. */ selector: string; - /** - * Restrict an element rule to these attributes. Without this field, a rule - * applies to element text, form values, and the standard sensitive - * attributes. - */ - attributes?: string[]; }; export type PrivacyDetectorOptions = Partial<{ @@ -136,20 +110,6 @@ export type PrivacyDetectorOptions = Partial<{ paymentCard: boolean; ssn: boolean; ipAddress: boolean; - custom: Array<{ - name: string; - pattern: string; - flags?: string; - classification?: SensitiveDataKind; - /** Skip this detector for shorter values. Defaults to 1. */ - minimumLength?: number; - /** - * Maximum possible match length. Used as overlap when scanning long values - * in bounded chunks. Defaults to 256 and cannot exceed 1,024. Must be at - * least `minimumLength`. - */ - maximumMatchLength?: number; - }>; }>; export type PrivacyUrlOptions = { @@ -163,26 +123,32 @@ export type PrivacyUrlOptions = { removeHash?: boolean; }; +export type CompiledDetector = { name: string; test: (value: string) => boolean }; + /** @internal Runtime form shared by snapshot and incremental observers. */ export type CompiledPrivacyPolicy = { policy: PrivacyPolicy; - rules: Array< - Omit & { - selector: string; - attributes?: Set; - } - >; - detectors: Array<{ - name: string; - regex: RegExp; - classification: SensitiveDataKind; - minimumLength: number; - maximumMatchLength: number; - scanChunkSize: number; - validate?: (candidate: string) => boolean; - }>; - minimumDetectorLength: number; + preset: PrivacyPreset; + /** 'mask' rules + [data-privacy="mask"] + vendor classes (+ '*' under strict) */ + maskTextSelector: string | null; + /** 'allow'/'unmask' rules + [data-privacy="allow"] + vendor unmask classes */ + unmaskTextSelector: string | null; + /** 'exclude' rules + [data-privacy="exclude"] + vendor block classes */ blockSelector: string | null; + /** true under balanced/strict */ + maskAllInputs: boolean; + /** ['title','placeholder','aria-label'] under balanced/strict, else [] */ + maskedAttributes: string[]; + /** true under strict */ + blockMedia: boolean; + /** true under balanced/strict */ + sanitizeUrls: boolean; + /** precomputed, lowercased */ + blockedQueryParameters: Set; + allowedQueryParameters: Set | null; + removeHash: boolean; + /** populated by applyPrivacyDetectors (Task 2); [] here */ + detectors: CompiledDetector[]; }; export type KeepIframeSrcFn = (src: string) => boolean; diff --git a/packages/rrweb-snapshot/test/privacy.test.ts b/packages/rrweb-snapshot/test/privacy.test.ts index 3498873c92..d40e6cbdb4 100644 --- a/packages/rrweb-snapshot/test/privacy.test.ts +++ b/packages/rrweb-snapshot/test/privacy.test.ts @@ -1,709 +1,87 @@ /** * @vitest-environment jsdom */ -import { beforeEach, describe, expect, it } from 'vitest'; -import snapshot from '../src/snapshot'; -import { - applyPrivacyDetectors, - compilePrivacyPolicy, - detectSensitiveText, - getPrivacyAction, - maskInputWithPrivacy, - maskTextWithPrivacy, - passesLuhn, - sanitizeUrl, -} from '../src/privacy'; - -const balanced = () => - compilePrivacyPolicy( - applyPrivacyDetectors({ version: 1, preset: 'balanced' }), - )!; - -describe('privacy policy', () => { - beforeEach(() => { - document.documentElement.innerHTML = ''; - }); - - it('detects cards with Luhn instead of masking every long number', () => { - const privacy = balanced(); - const value = 'valid 4111 1111 1111 1111 invalid 4111 1111 1111 1112'; - const matches = detectSensitiveText(value, privacy); - - expect(passesLuhn('4111 1111 1111 1111')).toBe(true); - expect(passesLuhn('4111 1111 1111 1112')).toBe(false); - expect( - matches.filter((match) => match.detector === 'payment-card'), - ).toEqual([ - expect.objectContaining({ - start: value.indexOf('4111 1111 1111 1111'), - end: value.indexOf('4111 1111 1111 1111') + 19, - }), - ]); - }); - - it('masks only detected ranges in balanced text', () => { - const element = document.createElement('p'); - expect( - maskTextWithPrivacy( - 'Contact person@example.com about order 12345', - element, - balanced(), - false, - ), - ).toBe('Contact xxxxxx@xxxxxxx.xxx about order 12345'); - }); - - it('does not enable heuristic detectors from the balanced preset alone', () => { - const element = document.createElement('p'); - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'balanced', - }); - expect( - maskTextWithPrivacy( - 'Contact person@example.com about order 12345', - element, - privacy, - false, - ), - ).toBe('Contact person@example.com about order 12345'); - expect(privacy.detectors).toEqual([]); - }); - - it('lets applyPrivacyDetectors opt into heuristic matching', () => { - expect( - applyPrivacyDetectors( - { - version: 1, - preset: 'balanced', - detectors: { email: false }, - }, - { email: true }, - ).detectors, - ).toMatchObject({ - email: false, - phone: true, - paymentCard: true, - }); - }); - - it('runs configured detectors in custom policies', () => { - const element = document.createElement('p'); - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [{ name: 'account-id', pattern: 'acct_[0-9]+' }], - }, - }); - - expect( - maskTextWithPrivacy( - 'Account acct_12345 is active', - element, - privacy, - false, - ), - ).toBe('Account xxxx_00000 is active'); - }); - - it('rejects unsafe or unbounded custom detector configurations', () => { - const policyWith = (pattern: string, flags?: string) => () => - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [{ name: 'unsafe', pattern, flags }], - }, - }); - - expect(policyWith('(a+)+$')).toThrow('ambiguous nested repetition'); - expect(policyWith('(a|aa)+$')).toThrow('ambiguous nested repetition'); - expect(policyWith('(a+){1,20}')).toThrow('ambiguous nested repetition'); - expect(policyWith('(a{1,10})+')).toThrow('ambiguous nested repetition'); - expect(policyWith('(?:a+)+')).toThrow('ambiguous nested repetition'); - expect(policyWith('(a)\\1')).toThrow('backreferences'); - expect(policyWith('(?a)\\k')).toThrow('backreferences'); - expect(policyWith('(?=secret)')).toThrow('lookaround'); - expect(policyWith('(?!secret)')).toThrow('lookaround'); - expect(policyWith('a*')).toThrow('cannot match empty text'); - expect(policyWith('account', 'y')).toThrow('unsupported regex flags'); - expect(policyWith('a'.repeat(257))).toThrow('must be 1-256 characters'); - expect(policyWith('a?'.repeat(13))).toThrow('too many quantifiers'); - expect(() => - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [ - { - name: 'too-wide', - pattern: 'account_[0-9]+', - maximumMatchLength: 1_025, - }, - ], - }, - }), - ).toThrow('maximumMatchLength'); - expect(() => - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [ - { - name: 'inverted', - pattern: 'account_[0-9]+', - minimumLength: 32, - maximumMatchLength: 8, - }, - ], - }, - }), - ).toThrow('minimumLength cannot exceed maximumMatchLength'); - - expect(() => - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [ - { name: 'account-id', pattern: 'acct_[0-9]+' }, - { name: 'optional-colour', pattern: 'colou?r' }, - { name: 'grouped', pattern: '(?:acct_)[0-9]{4,12}' }, - { name: 'repeated-atom', pattern: '(foo)+' }, - ], - }, - }), - ).not.toThrow(); - }); - - it('uses detector length fast paths and finds matches across scan chunks', () => { - const custom = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [ - { - name: 'account-id', - pattern: 'acct_[0-9]+', - minimumLength: 12, - maximumMatchLength: 32, - }, - ], - }, - }); - expect(detectSensitiveText('acct_1', custom)).toEqual([]); - const customBoundaryValue = `${'x'.repeat(508)}acct_12345`; - expect(detectSensitiveText(customBoundaryValue, custom)).toEqual([ - expect.objectContaining({ start: 508, end: customBoundaryValue.length }), - ]); - - const value = `${'x'.repeat(8_187)}4111 1111 1111 1111`; - expect( - detectSensitiveText(value, balanced()).some( - (match) => match.detector === 'payment-card' && match.start === 8_187, - ), - ).toBe(true); - }); - - it('fails closed when a value produces too many detector matches', () => { - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [ - { - name: 'digit', - pattern: '[0-9]', - maximumMatchLength: 1, - }, - ], - }, - }); - const value = '1'.repeat(1_500); - expect(detectSensitiveText(value, privacy)).toEqual([ - expect.objectContaining({ start: 0, end: value.length }), - ]); - - const oversize = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [ - { - name: 'bounded-digits', - pattern: '[0-9]+', - maximumMatchLength: 4, - }, - ], - }, - }); - const oversizeValue = 'public 12345 trailing text'; - expect(detectSensitiveText(oversizeValue, oversize)).toEqual([ - expect.objectContaining({ start: 0, end: oversizeValue.length }), - ]); - }); - - it('masks style text under policy while keeping script placeholders', () => { - const style = document.createElement('style'); - const script = document.createElement('script'); - expect( - maskTextWithPrivacy( - '.person@example.com { color: red }', - style, - balanced(), - false, - ), - ).toBe('.xxxxxx@xxxxxxx.xxx { color: red }'); - expect( - maskTextWithPrivacy( - '.person@example.com { color: red }', - style, - compilePrivacyPolicy({ version: 1, preset: 'strict' }), - false, - ), - ).toBe('.xxxxxx@xxxxxxx.xxx { xxxxx: xxx }'); - expect( - maskTextWithPrivacy( - 'window.secret = "person@example.com"', - script, - balanced(), - false, - ), - ).toBe('SCRIPT_PLACEHOLDER'); - }); - - it('detects emails with more than four domain labels', () => { - const value = 'Contact first.last@sub.mail.company.co.uk today'; - expect( - detectSensitiveText(value, balanced()).some( - (match) => - match.detector === 'email' && - value.slice(match.start, match.end) === - 'first.last@sub.mail.company.co.uk', - ), - ).toBe(true); - }); - - it('uses nearest explicit rules and safer action for ties', () => { - document.body.innerHTML = ` -
-
secret
-
`; - const target = document.querySelector('#target')!; - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'custom', +import { describe, it, expect, vi } from 'vitest'; +import { compilePrivacyPolicy, validateSelector, mergeBlockSelectors } from '../src/privacy'; + +describe('compilePrivacyPolicy v2', () => { + it('legacy preset compiles to inert options', () => { + const c = compilePrivacyPolicy(undefined); + expect(c.preset).toBe('legacy'); + expect(c.maskTextSelector).toBeNull(); + expect(c.blockSelector).toBeNull(); + expect(c.maskAllInputs).toBe(false); + expect(c.maskedAttributes).toEqual([]); + expect(c.sanitizeUrls).toBe(false); + expect(c.detectors).toEqual([]); + }); + it('balanced masks inputs, attributes and URLs but not text', () => { + const c = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); + expect(c.maskAllInputs).toBe(true); + expect(c.maskedAttributes).toEqual(['title', 'placeholder', 'aria-label']); + expect(c.sanitizeUrls).toBe(true); + expect(c.maskTextSelector).not.toContain('*'); + expect(c.maskTextSelector).toContain('[data-privacy="mask"]'); + expect(c.maskTextSelector).toContain('.ph-mask'); // cross-vendor classes + expect(c.blockSelector).toContain('[data-privacy="exclude"]'); + }); + it('strict masks all text and blocks media', () => { + const c = compilePrivacyPolicy({ version: 1, preset: 'strict' }); + expect(c.maskTextSelector).toBe('*'); + expect(c.blockMedia).toBe(true); + }); + it('compiles rules into selector lists, unmask as alias of allow', () => { + const c = compilePrivacyPolicy({ + version: 1, preset: 'balanced', rules: [ - { - target: { type: 'selector', selector: '.allow' }, - action: 'allow', - }, - { - target: { type: 'selector', selector: '.mask' }, - action: 'mask', - }, + { target: { type: 'selector', selector: '.pii' }, action: 'mask' }, + { target: { type: 'selector', selector: '.safe' }, action: 'unmask' }, + { target: { type: 'selector', selector: '.gone' }, action: 'exclude' }, ], }); - - expect(getPrivacyAction(target, privacy)).toBe('allow'); - expect( - getPrivacyAction( - document.querySelector('main'), - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.allow' }, - action: 'allow', - }, - { - target: { type: 'selector', selector: '.mask' }, - action: 'mask', - }, - ], - }), - ), - ).toBe('mask'); - }); - - it('recognizes data-privacy without recorder configuration', () => { - document.body.innerHTML = ` -
-

Private customer Public label

- - -
-
Excluded account 12345
`; - - const payload = JSON.stringify(snapshot(document)); - - expect(payload).not.toContain('Private title'); - expect(payload).not.toContain('Private customer'); - expect(payload).not.toContain('Private input'); - expect(payload).not.toContain('secret-password'); - expect(payload).not.toContain('Excluded account 12345'); - expect(payload).toContain('Public label'); - }); - - it('inherits past invalid data-privacy values and resolves ties safely', () => { - document.body.innerHTML = ` -
- Private - Private -
`; - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.policy-mask' }, - action: 'mask', - }, - ], - }); - - expect(getPrivacyAction(document.querySelector('#invalid'), privacy)).toBe( - 'mask', - ); - expect(getPrivacyAction(document.querySelector('#tie'), privacy)).toBe( - 'mask', - ); - }); - - it('maps exclude policy rules to rrweb blocking', () => { - document.body.innerHTML = - '
Excluded by policy
'; - const privacyPolicy = { - version: 1 as const, - preset: 'custom' as const, + expect(c.maskTextSelector).toContain('.pii'); + expect(c.unmaskTextSelector).toContain('.safe'); + expect(c.blockSelector).toContain('.gone'); + }); + it('drops invalid selectors individually with a warning, keeps the rest', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const c = compilePrivacyPolicy({ + version: 1, preset: 'balanced', rules: [ - { - target: { type: 'selector' as const, selector: '.private' }, - action: 'exclude' as const, - }, + { target: { type: 'selector', selector: ':::garbage' }, action: 'exclude' }, + { target: { type: 'selector', selector: '.valid' }, action: 'exclude' }, ], - }; - const privacy = compilePrivacyPolicy(privacyPolicy); - const payload = JSON.stringify(snapshot(document, { privacyPolicy })); - - expect(privacy.blockSelector).toContain('.private'); - expect(payload).not.toContain('Excluded by policy'); - }); - - it('preserves structural attributes unless they are explicitly targeted', () => { - document.body.innerHTML = ` - `; - const payload = JSON.stringify( - snapshot(document, { - privacyPolicy: { - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.private' }, - action: 'mask', - }, - { - target: { - type: 'selector', - selector: '.private', - attributes: ['data-secret'], - }, - action: 'mask', - }, - ], - }, - }), - ); - - expect(payload).toContain('private layout'); - expect(payload).toContain('"type":"text"'); - expect(payload).not.toContain('account-123'); - expect(payload).not.toContain('Private value'); - }); - - it('does not allow protected inputs to be unmasked', () => { - const input = document.createElement('input'); - input.type = 'password'; - input.className = 'record'; - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.record' }, - action: 'allow', - }, - ], - }); - - expect( - maskInputWithPrivacy('secret', input, privacy, false, () => 'secret'), - ).toBe('xxxxxx'); - - input.type = 'text'; - input.setAttribute('data-rr-is-password', 'true'); - expect( - maskInputWithPrivacy('visible', input, privacy, false, () => 'visible'), - ).toBe('xxxxxxx'); - - input.type = 'password'; - input.removeAttribute('data-rr-is-password'); - input.setAttribute('data-privacy', 'allow'); - expect( - maskInputWithPrivacy( - 'protected', - input, - compilePrivacyPolicy(undefined), - false, - () => 'protected', - ), - ).toBe('xxxxxxxxx'); - }); - - it('removes sensitive URL values while retaining routing context', () => { - expect( - sanitizeUrl( - 'https://example.com/account?tab=billing&token=secret#profile', - balanced(), - ), - ).toBe('https://example.com/account?tab=billing&token=*'); - - expect( - sanitizeUrl( - 'https://example.com/account?tab=billing', - compilePrivacyPolicy({ version: 1, preset: 'strict' }), - ), - ).toBe('https://example.com/account?tab=*'); - }); - - it('inherits rules across a shadow-root boundary', () => { - const host = document.createElement('div'); - host.className = 'private'; - const shadow = host.attachShadow({ mode: 'open' }); - const child = document.createElement('span'); - shadow.appendChild(child); - document.body.appendChild(host); - - expect( - getPrivacyAction( - child, - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.private' }, - action: 'mask', - }, - ], - }), - ), - ).toBe('mask'); - }); - - it('masks CSS text, inline style, and stylesheet snapshots', () => { - document.body.innerHTML = ` - -
`; - - const balancedPayload = JSON.stringify( - snapshot(document, { - privacyPolicy: applyPrivacyDetectors({ - version: 1, - preset: 'balanced', - }), - }), - ); - expect(balancedPayload).not.toContain('person@example.com'); - expect(balancedPayload).toContain('xxxxxx@xxxxxxx.xxx'); - - const strictPayload = JSON.stringify( - snapshot(document, { - privacyPolicy: { version: 1, preset: 'strict' }, - }), - ); - expect(strictPayload).not.toContain('person@example.com'); - }); - - it('applies policy before a snapshot is serialized', () => { - document.body.innerHTML = ` -

Contact person@example.com

- - - Account`; - - const serialized = snapshot(document, { - privacyPolicy: applyPrivacyDetectors({ - version: 1, - preset: 'balanced', - rules: [ - { - target: { type: 'selector', selector: '.record' }, - action: 'allow', - }, - ], - }), }); - const payload = JSON.stringify(serialized); - - expect(payload).not.toContain('person@example.com'); - expect(payload).not.toContain('private input'); - expect(payload).not.toContain('secret-password'); - expect(payload).not.toContain('token=secret'); - expect(payload).toContain('Contact xxxxxx@xxxxxxx.xxx'); - expect(payload).toContain('token=*'); - }); - - it('masks every form value attribute in strict mode', () => { - document.body.innerHTML = ` - - `; - const payload = JSON.stringify( - snapshot(document, { - privacyPolicy: { version: 1, preset: 'strict' }, - }), - ); - - expect(payload).not.toContain('private-radio-value'); - expect(payload).not.toContain('private-option-value'); - expect(payload).not.toContain('Private option'); - }); - - it('supports coarse masking of final source attributes', () => { - document.body.innerHTML = ` -
- `; - const payload = JSON.stringify( - snapshot(document, { maskAllElementAttributes: true }), - ); - - expect(payload).not.toContain('customer-name'); - expect(payload).not.toContain('person@example.com'); - expect(payload).not.toContain('private synthesized value'); - }); - - it('lets a callback mask final attributes but never override policy', () => { - document.body.innerHTML = ` -
`; - const payload = JSON.stringify( - snapshot(document, { - privacyPolicy: applyPrivacyDetectors({ - version: 1, - preset: 'balanced', - }), - maskAttributeFn: (name, value) => - name === 'data-owner' ? '[OWNER]' : value, - }), - ); - - expect(payload).toContain('[OWNER]'); - expect(payload).not.toContain('person@example.com'); - expect(payload).toContain('xxxxxx@xxxxxxx.xxx'); - }); - - it('fails closed when an attribute callback throws', () => { - document.body.innerHTML = '
'; - const payload = JSON.stringify( - snapshot(document, { - maskAttributeFn: () => { - throw new Error('boom'); - }, - }), - ); - - expect(payload).not.toContain('private-title'); - }); - - it('suppresses full-snapshot canvas pixels while region masking is configured', () => { - const canvas = document.createElement('canvas'); - (canvas as HTMLCanvasElement & { __context?: string }).__context = '2d'; - canvas.getContext = (() => ({ - getImageData: () => ({ data: new Uint8ClampedArray([255, 0, 0, 255]) }), - })) as unknown as typeof canvas.getContext; - canvas.toDataURL = () => 'data:image/webp;base64,unmasked-pixels'; - document.body.appendChild(canvas); - - const unprotected = JSON.stringify( - snapshot(document, { - recordCanvas: true, - canvasMaskingConfigured: () => false, - }), - ); - const protectedSnapshot = JSON.stringify( - snapshot(document, { - recordCanvas: true, - canvasMaskingConfigured: () => true, - }), - ); - - expect(unprotected).toContain('rr_dataURL'); - expect(protectedSnapshot).not.toContain('rr_dataURL'); - }); - - it('does not throw when a form control shadows HTMLFormElement.tagName', () => { - const form = document.createElement('form'); - const input = document.createElement('input'); - input.setAttribute('name', 'tagName'); - const text = document.createTextNode('visible email person@example.com'); - form.appendChild(input); - form.appendChild(text); - document.body.appendChild(form); - + expect(c.blockSelector).toContain('.valid'); + expect(c.blockSelector).not.toContain(':::garbage'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(':::garbage')); + warn.mockRestore(); + }); + it('throws on bad version/preset/empty selector', () => { + expect(() => compilePrivacyPolicy({ version: 2 as never, preset: 'legacy' })).toThrow(); + expect(() => compilePrivacyPolicy({ version: 1, preset: 'custom' as never })).toThrow(); expect(() => - maskTextWithPrivacy( - 'visible email person@example.com', - form, - balanced(), - false, - ), - ).not.toThrow(); - expect(() => snapshot(document)).not.toThrow(); + compilePrivacyPolicy({ version: 1, preset: 'balanced', + rules: [{ target: { type: 'selector', selector: '' }, action: 'mask' }] }), + ).toThrow(); }); - - it('walks ancestors when getRootNode has been monkey-patched', () => { - const originalGetRootNode = Node.prototype.getRootNode; - Node.prototype.getRootNode = function () { - throw new Error('getRootNode was hijacked by framework'); - }; - try { - document.body.innerHTML = - '
secret
'; - const target = document.querySelector('#target')!; - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'balanced', - rules: [ - { - target: { type: 'selector', selector: '[data-privacy="mask"]' }, - action: 'mask', - }, - ], - }); - expect(() => getPrivacyAction(target, privacy)).not.toThrow(); - expect(getPrivacyAction(target, privacy)).toBe('mask'); - expect(() => snapshot(document)).not.toThrow(); - } finally { - Node.prototype.getRootNode = originalGetRootNode; - } + it('precomputes lowercased query parameter sets', () => { + const c = compilePrivacyPolicy({ version: 1, preset: 'strict', + url: { blockedQueryParameters: ['SessionID'] } }); + expect(c.blockedQueryParameters.has('sessionid')).toBe(true); + expect(c.blockedQueryParameters.has('token')).toBe(true); // default list }); - - it('keeps the legacy path unchanged when no policy is supplied', () => { - document.body.innerHTML = - '

Visible text

'; - const payload = JSON.stringify(snapshot(document)); - expect(payload).toContain('Visible text'); - expect(payload).toContain('legacy-hidden-value'); - expect( - maskInputWithPrivacy( - 'legacy value', - document.createElement('input'), - undefined, - true, - ), - ).toBe('************'); +}); +describe('validateSelector', () => { + it('accepts valid, rejects invalid', () => { + expect(validateSelector('.a > [data-x="1"]')).toBe(true); + expect(validateSelector(':::nope')).toBe(false); + }); +}); +describe('mergeBlockSelectors', () => { + it('joins legacy selector with compiled blockSelector', () => { + const c = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); + expect(mergeBlockSelectors('.legacy', c)).toContain('.legacy'); + expect(mergeBlockSelectors('.legacy', c)).toContain('[data-privacy="exclude"]'); }); }); diff --git a/packages/rrweb/src/index.ts b/packages/rrweb/src/index.ts index 3ae4ae05aa..0ce40746c8 100644 --- a/packages/rrweb/src/index.ts +++ b/packages/rrweb/src/index.ts @@ -26,13 +26,11 @@ export type { recordOptions, ReplayPlugin } from './types'; export type { PrivacyAction, PrivacyDetectorOptions, - PrivacyMaskStyle, PrivacyPolicy, PrivacyPreset, PrivacyRule, PrivacyTarget, PrivacyUrlOptions, - SensitiveDataKind, } from 'rrweb-snapshot'; const { addCustomEvent } = record; diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 4aa072069c..2d523ac978 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -5,12 +5,7 @@ import { ignoreAttribute, isShadowRoot, needMaskingText, - maskAttributeWithPrivacy, - protectSerializedAttribute, - getPrivacyAction, - maskInputWithPrivacy, maskInputValue, - maskTextWithPrivacy, Mirror, isNativeShadowDom, getInputType, @@ -149,7 +144,6 @@ export default class MutationBuffer { private texts: textCursor[] = []; private attributes: attributeCursor[] = []; private attributeMap = new WeakMap(); - private generatedAttributes = new WeakMap>(); private removes: removedNodeMutation[] = []; private mapRemoves: Node[] = []; @@ -186,8 +180,6 @@ export default class MutationBuffer { private maskInputOptions: observerParam['maskInputOptions']; private maskTextFn: observerParam['maskTextFn']; private maskInputFn: observerParam['maskInputFn']; - private maskAllElementAttributes: observerParam['maskAllElementAttributes']; - private maskAttributeFn: observerParam['maskAttributeFn']; private privacy: observerParam['privacy']; private keepIframeSrcFn: observerParam['keepIframeSrcFn']; private recordCanvas: observerParam['recordCanvas']; @@ -216,8 +208,6 @@ export default class MutationBuffer { 'maskInputOptions', 'maskTextFn', 'maskInputFn', - 'maskAllElementAttributes', - 'maskAttributeFn', 'privacy', 'keepIframeSrcFn', 'recordCanvas', @@ -485,19 +475,7 @@ export default class MutationBuffer { attributes: this.attributes .map((attribute) => { const { attributes } = attribute; - const styleAction = getPrivacyAction( - attribute.node as Element, - this.privacy, - 'style', - true, - ); - if ( - !this.maskAllElementAttributes && - !this.maskAttributeFn && - styleAction !== 'mask' && - styleAction !== 'exclude' && - typeof attributes.style === 'string' - ) { + if (typeof attributes.style === 'string') { const diffAsStr = JSON.stringify(attribute.styleDiff); const unchangedAsStr = JSON.stringify(attribute._unchangedStyles); // check if the style diff is actually shorter than the regular string based mutation @@ -513,23 +491,6 @@ export default class MutationBuffer { } } } - if (this.maskAllElementAttributes || this.maskAttributeFn) { - for (const [name, value] of Object.entries(attributes)) { - if (typeof value === 'string' || value === null) { - attributes[name] = protectSerializedAttribute({ - element: attribute.node as Element, - name, - value, - privacy: this.privacy, - maskAllElementAttributes: this.maskAllElementAttributes, - maskAttributeFn: this.maskAttributeFn, - isGenerated: this.generatedAttributes - .get(attribute.node) - ?.has(name), - }); - } - } - } return { id: this.mirror.getId(attribute.node), attributes: attributes, @@ -556,7 +517,6 @@ export default class MutationBuffer { this.texts = []; this.attributes = []; this.attributeMap = new WeakMap(); - this.generatedAttributes = new WeakMap>(); this.removes = []; this.addedSet = new Set(); this.movedSet = new Set(); @@ -584,29 +544,14 @@ export default class MutationBuffer { (cn) => dom.textContent(cn) || '', ).join(''); const type = getInputType(textarea); - if (this.privacy) { - const legacyMask = Boolean( - this.maskInputOptions.textarea || - (type && - this.maskInputOptions[type as keyof typeof this.maskInputOptions]), - ); - item.attributes.value = maskInputWithPrivacy( - value, - textarea, - this.privacy, - legacyMask, - this.maskInputFn, - ); - } else { - item.attributes.value = maskInputValue({ - element: textarea, - maskInputOptions: this.maskInputOptions, - tagName: textarea.tagName, - type, - value, - maskInputFn: this.maskInputFn, - }); - } + item.attributes.value = maskInputValue({ + element: textarea, + maskInputOptions: this.maskInputOptions, + tagName: textarea.tagName, + type, + value, + maskInputFn: this.maskInputFn, + }); }; private processMutation = (m: mutationRecord) => { @@ -623,25 +568,12 @@ export default class MutationBuffer { ) { this.texts.push({ value: - value && this.privacy - ? maskTextWithPrivacy( - value, - closestElementOfNode(m.target), - this.privacy, - needMaskingText( - m.target, - this.maskTextClass, - this.maskTextSelector, - true, // checkAncestors - ), - this.maskTextFn, - ) - : needMaskingText( - m.target, - this.maskTextClass, - this.maskTextSelector, - true, // checkAncestors - ) && value + needMaskingText( + m.target, + this.maskTextClass, + this.maskTextSelector, + true, // checkAncestors + ) && value ? this.maskTextFn ? this.maskTextFn(value, closestElementOfNode(m.target)) : value.replace(/[\S]/g, '*') @@ -659,33 +591,14 @@ export default class MutationBuffer { if (attributeName === 'value') { const type = getInputType(target); - if (this.privacy) { - const legacyMask = Boolean( - this.maskInputOptions[ - target.tagName.toLowerCase() as keyof typeof this.maskInputOptions - ] || - (type && - this.maskInputOptions[ - type as keyof typeof this.maskInputOptions - ]), - ); - value = maskInputWithPrivacy( - value || '', - target, - this.privacy, - legacyMask, - this.maskInputFn, - ); - } else { - value = maskInputValue({ - element: target, - maskInputOptions: this.maskInputOptions, - tagName: target.tagName, - type, - value, - maskInputFn: this.maskInputFn, - }); - } + value = maskInputValue({ + element: target, + maskInputOptions: this.maskInputOptions, + tagName: target.tagName, + type, + value, + maskInputFn: this.maskInputFn, + }); } if ( isBlocked(m.target, this.blockClass, this.blockSelector, false) || @@ -731,20 +644,12 @@ export default class MutationBuffer { if (!ignoreAttribute(target.tagName, attributeName, value)) { // overwrite attribute if the mutations was triggered in same time - const transformed = transformAttribute( + item.attributes[attributeName] = transformAttribute( this.doc, toLowerCase(target.tagName), toLowerCase(attributeName), value, ); - item.attributes[attributeName] = this.privacy - ? maskAttributeWithPrivacy( - target, - attributeName, - transformed, - this.privacy, - ) - : transformed; if (attributeName === 'style') { if (!this.unattachedDoc) { try { @@ -768,21 +673,9 @@ export default class MutationBuffer { newPriority !== old.style.getPropertyPriority(pname) ) { if (newPriority === '') { - item.styleDiff[pname] = this.privacy - ? maskTextWithPrivacy(newValue, target, this.privacy, false) - : newValue; + item.styleDiff[pname] = newValue; } else { - item.styleDiff[pname] = [ - this.privacy - ? maskTextWithPrivacy( - newValue, - target, - this.privacy, - false, - ) - : newValue, - newPriority, - ]; + item.styleDiff[pname] = [newValue, newPriority]; } } else { // for checking @@ -801,12 +694,6 @@ export default class MutationBuffer { } else { item.attributes['rr_open_mode'] = 'non-modal'; } - let generated = this.generatedAttributes.get(m.target); - if (!generated) { - generated = new Set(); - this.generatedAttributes.set(m.target, generated); - } - generated.add('rr_open_mode'); } } break; diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 99e476b1ee..32fb2c3097 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -1,8 +1,6 @@ import { type MaskInputOptions, - maskInputWithPrivacy, - maskTextWithPrivacy, - shouldMaskInputWithPrivacy, + maskInputValue, Mirror, getInputType, toLowerCase, @@ -391,7 +389,6 @@ function initInputObserver({ ignoreSelector, maskInputOptions, maskInputFn, - privacy, sampling, userTriggeredOnInput, }: observerParam): listenerHandler { @@ -428,20 +425,18 @@ function initInputObserver({ if (type === 'radio' || type === 'checkbox') { isChecked = (target as HTMLInputElement).checked; - } else { - const legacyMask = Boolean( - maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || - maskInputOptions[type as keyof MaskInputOptions], - ); - if (shouldMaskInputWithPrivacy(target, privacy, legacyMask)) { - text = maskInputWithPrivacy( - text, - target, - privacy, - legacyMask, - maskInputFn, - ); - } + } else if ( + maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || + maskInputOptions[type as keyof MaskInputOptions] + ) { + text = maskInputValue({ + element: target, + maskInputOptions, + tagName, + type, + value: text, + maskInputFn, + }); } cbWithDedup( target, @@ -594,29 +589,8 @@ function getIdAndStyleId( }; } -function stylesheetOwnerElement( - sheet: CSSStyleSheet | null | undefined, -): HTMLElement | null { - const owner = sheet?.ownerNode; - return owner instanceof Element ? (owner as HTMLElement) : null; -} - -function maskCssForRecord( - value: string, - sheet: CSSStyleSheet | null | undefined, - privacy: observerParam['privacy'], -): string { - if (!value || !privacy) return value; - return maskTextWithPrivacy( - value, - stylesheetOwnerElement(sheet), - privacy, - false, - ); -} - function initStyleSheetObserver( - { styleSheetRuleCb, mirror, stylesheetManager, privacy }: observerParam, + { styleSheetRuleCb, mirror, stylesheetManager }: observerParam, { win }: { win: IWindow }, ): listenerHandler { if (!win.CSSStyleSheet || !win.CSSStyleSheet.prototype) { @@ -647,7 +621,7 @@ function initStyleSheetObserver( styleSheetRuleCb({ id, styleId, - adds: [{ rule: maskCssForRecord(rule, thisArg, privacy), index }], + adds: [{ rule, index }], }); } return target.apply(thisArg, argumentsList); @@ -727,7 +701,7 @@ function initStyleSheetObserver( styleSheetRuleCb({ id, styleId, - replace: maskCssForRecord(text, thisArg, privacy), + replace: text, }); } return target.apply(thisArg, argumentsList); @@ -759,7 +733,7 @@ function initStyleSheetObserver( styleSheetRuleCb({ id, styleId, - replaceSync: maskCssForRecord(text, thisArg, privacy), + replaceSync: text, }); } return target.apply(thisArg, argumentsList); @@ -827,11 +801,7 @@ function initStyleSheetObserver( styleId, adds: [ { - rule: maskCssForRecord( - rule, - thisArg.parentStyleSheet, - privacy, - ), + rule, index: [ ...getNestedCSSRulePositions(thisArg), index || 0, // defaults to 0 @@ -962,7 +932,6 @@ function initStyleDeclarationObserver( mirror, ignoreCSSAttributes, stylesheetManager, - privacy, }: observerParam, { win }: { win: IWindow }, ): listenerHandler { @@ -992,11 +961,7 @@ function initStyleDeclarationObserver( styleId, set: { property, - value: maskCssForRecord( - value, - thisArg.parentRule?.parentStyleSheet, - privacy, - ), + value, priority, }, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index 258c16548e..4b0697fbf5 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -1,8 +1,4 @@ -import { - maskTextWithPrivacy, - stringifyRule, - type CompiledPrivacyPolicy, -} from 'rrweb-snapshot'; +import { stringifyRule, type CompiledPrivacyPolicy } from 'rrweb-snapshot'; import type { elementNode, serializedNodeWithId, @@ -17,17 +13,16 @@ export class StylesheetManager { private trackedLinkElements: WeakSet = new WeakSet(); private mutationCb: mutationCallBack; private adoptedStyleSheetCb: adoptedStyleSheetCallback; - private privacy: CompiledPrivacyPolicy | undefined; public styleMirror = new StyleSheetMirror(); constructor(options: { mutationCb: mutationCallBack; adoptedStyleSheetCb: adoptedStyleSheetCallback; + // Plumbed through for later tasks; not yet consumed here. privacy?: CompiledPrivacyPolicy; }) { this.mutationCb = options.mutationCb; this.adoptedStyleSheetCb = options.adoptedStyleSheetCb; - this.privacy = options.privacy; } public attachLinkElement( @@ -77,7 +72,7 @@ export class StylesheetManager { rules: Array.from( sheet.cssRules || sheet.rules || [], (r, index) => ({ - rule: this.maskAdoptedRule(stringifyRule(r, sheet.href), sheet), + rule: this.maskAdoptedRule(stringifyRule(r, sheet.href)), index, }), ), @@ -94,15 +89,9 @@ export class StylesheetManager { this.trackedLinkElements = new WeakSet(); } - private maskAdoptedRule(rule: string, sheet: CSSStyleSheet): string { - if (!rule || !this.privacy) return rule; - const owner = sheet.ownerNode; - return maskTextWithPrivacy( - rule, - owner instanceof Element ? (owner as HTMLElement) : null, - this.privacy, - false, - ); + private maskAdoptedRule(rule: string): string { + // Task 3 reimplements text masking against the compiled policy. + return rule; } // TODO: take snapshot on stylesheet reload by applying event listener From 54798a2211f5937281b9a5e4c73337712fc8ff6a Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 11:35:54 +0200 Subject: [PATCH 04/64] fix(privacy): restore interim maskAllElementAttributes/maskAttributeFn masking Task 1's deletion of protectSerializedAttribute silently stopped honoring the public maskAllElementAttributes/maskAttributeFn recording options, since nothing consumed them after the deletion even though snapshot.ts and MutationBuffer kept threading them through. Restore a minimal final sweep over string attribute values in both serializeElementNode (full snapshots) and MutationBuffer's attribute-mutation emit path (masking takes precedence over the compact-style-mutation optimization, which would otherwise leak unmasked style fragments through a styleDiff object). Both are marked for replacement by Task 6's finalizeAttribute. Also harden isProtectedInput to fail closed (treat as protected) when tagName is shadowed to a non-string value, instead of silently treating it as not an input. Co-Authored-By: Claude Fable 5 --- packages/rrweb-snapshot/src/privacy.ts | 6 ++++- packages/rrweb-snapshot/src/snapshot.ts | 30 +++++++++++++++++++++++++ packages/rrweb/src/record/mutation.ts | 29 +++++++++++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/rrweb-snapshot/src/privacy.ts b/packages/rrweb-snapshot/src/privacy.ts index 17f72b7cb6..e04b82370c 100644 --- a/packages/rrweb-snapshot/src/privacy.ts +++ b/packages/rrweb-snapshot/src/privacy.ts @@ -168,7 +168,11 @@ export function passesLuhn(candidate: string): boolean { } export function isProtectedInput(element: HTMLElement): boolean { - if (element.tagName !== 'INPUT') return false; + // Task 9 replaces with untaintedTagName. A shadowed/non-string `tagName` + // (e.g. ) fails closed: treat as protected. + const t: unknown = element.tagName; + if (typeof t !== 'string') return true; + if (t !== 'INPUT') return false; const input = element as HTMLInputElement; if ( input.type === 'password' || diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 405874df05..fcf250db3c 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -591,6 +591,8 @@ function serializeElementNode( inlineStylesheet, maskInputOptions = {}, maskInputFn, + maskAllElementAttributes, + maskAttributeFn, dataURLOptions = {}, inlineImages, recordCanvas, @@ -603,6 +605,10 @@ function serializeElementNode( const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); let attributes: attributes = {}; + // Task 6 replaces this with finalizeAttribute: names the serializer itself + // generated, exempt from `maskAllElementAttributes` (rr_dataURL is NOT + // exempt: it can contain real page pixels). + const generatedAttributeNames = new Set(); const len = n.attributes.length; for (let i = 0; i < len; i++) { const attr = n.attributes[i]; @@ -683,6 +689,7 @@ function serializeElementNode( (attributes as DialogAttributes).rr_open_mode = n.matches('dialog:modal') ? 'modal' : 'non-modal'; + generatedAttributeNames.add('rr_open_mode'); } // canvas image data @@ -780,6 +787,7 @@ function serializeElementNode( mediaAttributes.rr_mediaMuted = (n as HTMLMediaElement).muted; mediaAttributes.rr_mediaLoop = (n as HTMLMediaElement).loop; mediaAttributes.rr_mediaVolume = (n as HTMLMediaElement).volume; + generatedAttributeNames.add('rr_mediaState'); } // Scroll if (!newlyAddedElement) { @@ -789,9 +797,11 @@ function serializeElementNode( // So we can safely skip the `scrollTop/Left` calls for newly added elements if (n.scrollLeft) { attributes.rr_scrollLeft = n.scrollLeft; + generatedAttributeNames.add('rr_scrollLeft'); } if (n.scrollTop) { attributes.rr_scrollTop = n.scrollTop; + generatedAttributeNames.add('rr_scrollTop'); } } // block element @@ -802,6 +812,8 @@ function serializeElementNode( rr_width: `${width}px`, rr_height: `${height}px`, }; + generatedAttributeNames.add('rr_width'); + generatedAttributeNames.add('rr_height'); } // iframe if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) { @@ -813,6 +825,24 @@ function serializeElementNode( delete attributes.src; // prevent auto loading } + // Task 6 replaces this with finalizeAttribute. + if (maskAllElementAttributes || maskAttributeFn) { + for (const [name, value] of Object.entries(attributes)) { + if (typeof value !== 'string') continue; + if (maskAllElementAttributes) { + if (!generatedAttributeNames.has(name)) { + attributes[name] = '*'.repeat(value.length); + } + } else if (maskAttributeFn) { + try { + attributes[name] = maskAttributeFn(name, value, n); + } catch { + attributes[name] = '*'.repeat(value.length); + } + } + } + } + let isCustomElement: true | undefined; try { if (customElements.get(tagName)) isCustomElement = true; diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 2d523ac978..01fb2e63d2 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -180,6 +180,8 @@ export default class MutationBuffer { private maskInputOptions: observerParam['maskInputOptions']; private maskTextFn: observerParam['maskTextFn']; private maskInputFn: observerParam['maskInputFn']; + private maskAllElementAttributes: observerParam['maskAllElementAttributes']; + private maskAttributeFn: observerParam['maskAttributeFn']; private privacy: observerParam['privacy']; private keepIframeSrcFn: observerParam['keepIframeSrcFn']; private recordCanvas: observerParam['recordCanvas']; @@ -208,6 +210,8 @@ export default class MutationBuffer { 'maskInputOptions', 'maskTextFn', 'maskInputFn', + 'maskAllElementAttributes', + 'maskAttributeFn', 'privacy', 'keepIframeSrcFn', 'recordCanvas', @@ -475,7 +479,11 @@ export default class MutationBuffer { attributes: this.attributes .map((attribute) => { const { attributes } = attribute; - if (typeof attributes.style === 'string') { + if ( + !this.maskAllElementAttributes && + !this.maskAttributeFn && + typeof attributes.style === 'string' + ) { const diffAsStr = JSON.stringify(attribute.styleDiff); const unchangedAsStr = JSON.stringify(attribute._unchangedStyles); // check if the style diff is actually shorter than the regular string based mutation @@ -491,6 +499,25 @@ export default class MutationBuffer { } } } + // Task 6 replaces this with finalizeAttribute. + if (this.maskAllElementAttributes || this.maskAttributeFn) { + for (const [name, value] of Object.entries(attributes)) { + if (typeof value !== 'string') continue; + if (this.maskAllElementAttributes) { + attributes[name] = '*'.repeat(value.length); + } else if (this.maskAttributeFn) { + try { + attributes[name] = this.maskAttributeFn( + name, + value, + attribute.node as Element, + ); + } catch { + attributes[name] = '*'.repeat(value.length); + } + } + } + } return { id: this.mirror.getId(attribute.node), attributes: attributes, From 01fbc2e6f928dcc978cbe62277a6c24c6112549f Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 11:49:56 +0200 Subject: [PATCH 05/64] feat(privacy): fixed whole-value detectors, delete range and custom-pattern machinery Co-Authored-By: Claude Fable 5 --- .../rrweb-plugin-network-record/tsconfig.json | 13 +- .../rrweb-plugin-network-replay/tsconfig.json | 14 +- .../tsconfig.json | 14 +- .../rrweb-player/.svelte-kit/ambient.d.ts | 760 +++++++----------- packages/rrweb-snapshot/src/privacy.ts | 58 +- packages/rrweb-snapshot/test/privacy.test.ts | 42 +- 6 files changed, 414 insertions(+), 487 deletions(-) diff --git a/packages/plugins/rrweb-plugin-network-record/tsconfig.json b/packages/plugins/rrweb-plugin-network-record/tsconfig.json index b70866e1ab..fc5391c651 100644 --- a/packages/plugins/rrweb-plugin-network-record/tsconfig.json +++ b/packages/plugins/rrweb-plugin-network-record/tsconfig.json @@ -1,7 +1,13 @@ { "extends": "../../../tsconfig.base.json", - "include": ["src"], - "exclude": ["vite.config.ts", "vitest.config.ts", "test"], + "include": [ + "src" + ], + "exclude": [ + "vite.config.ts", + "vitest.config.ts", + "test" + ], "compilerOptions": { "rootDir": "src", "tsBuildInfoFile": "./tsconfig.tsbuildinfo" @@ -12,6 +18,9 @@ }, { "path": "../../utils" + }, + { + "path": "../../rrweb" } ] } diff --git a/packages/plugins/rrweb-plugin-network-replay/tsconfig.json b/packages/plugins/rrweb-plugin-network-replay/tsconfig.json index dd9ef0fc92..8d73f951ee 100644 --- a/packages/plugins/rrweb-plugin-network-replay/tsconfig.json +++ b/packages/plugins/rrweb-plugin-network-replay/tsconfig.json @@ -1,17 +1,25 @@ { "extends": "../../../tsconfig.base.json", - "include": ["src"], - "exclude": ["vite.config.ts", "test"], + "include": [ + "src" + ], + "exclude": [ + "vite.config.ts", + "test" + ], "compilerOptions": { "rootDir": "src", "tsBuildInfoFile": "./tsconfig.tsbuildinfo" }, "references": [ + { + "path": "../rrweb-plugin-network-record" + }, { "path": "../../types" }, { - "path": "../rrweb-plugin-network-record" + "path": "../../rrweb" } ] } diff --git a/packages/plugins/rrweb-plugin-privacy-detectors/tsconfig.json b/packages/plugins/rrweb-plugin-privacy-detectors/tsconfig.json index 3412c6c7aa..dee9f4ff89 100644 --- a/packages/plugins/rrweb-plugin-privacy-detectors/tsconfig.json +++ b/packages/plugins/rrweb-plugin-privacy-detectors/tsconfig.json @@ -1,17 +1,23 @@ { "extends": "../../../tsconfig.base.json", - "include": ["src"], - "exclude": ["vite.config.ts", "vitest.config.ts", "test"], + "include": [ + "src" + ], + "exclude": [ + "vite.config.ts", + "vitest.config.ts", + "test" + ], "compilerOptions": { "rootDir": "src", "tsBuildInfoFile": "./tsconfig.tsbuildinfo" }, "references": [ { - "path": "../../rrweb-snapshot" + "path": "../../types" }, { - "path": "../../types" + "path": "../../rrweb-snapshot" } ] } diff --git a/packages/rrweb-player/.svelte-kit/ambient.d.ts b/packages/rrweb-player/.svelte-kit/ambient.d.ts index baa9b47d92..0982243585 100644 --- a/packages/rrweb-player/.svelte-kit/ambient.d.ts +++ b/packages/rrweb-player/.svelte-kit/ambient.d.ts @@ -26,261 +26,167 @@ * ``` */ declare module '$env/static/private' { - export const SUDO_GID: string; - export const GITHUB_STATE: string; - export const COPILOT_AGENT_ACTION: string; + export const CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES: string; + export const CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: string; + export const npm_package_exports___node_polyfills_types: string; + export const CLAUDE_CODE_MESSAGING_TOKEN: string; + export const NoDefaultCurrentDirectoryInExePath: string; export const npm_package_scripts_test_cross_platform_build: string; - export const npm_package_devDependencies_rollup: string; - export const npm_package_devDependencies__types_node: string; - export const COPILOT_AGENT_START_TIME_SEC: string; - export const CURL_CA_BUNDLE: string; - export const DOTNET_NOLOGO: string; - export const npm_package_devDependencies_vitest: string; - export const MAIL: string; - export const NODE_EXTRA_CA_CERTS: string; - export const USER: string; - export const npm_package_bin_svelte_kit: string; - export const npm_package_dependencies_sirv: string; - export const npm_package_dependencies_sade: string; - export const npm_package_dependencies_mrmime: string; - export const npm_package_dependencies_magic_string: string; - export const npm_config_version_commit_hooks: string; - export const npm_config_user_agent: string; - export const SHOULD_CONTINUE: string; - export const CI: string; - export const npm_package_scripts_generate_version: string; - export const npm_package_dependencies__types_cookie: string; - export const npm_config_bin_links: string; - export const XDG_SESSION_TYPE: string; - export const RUNNER_ENVIRONMENT: string; - export const GITHUB_ENV: string; - export const COPILOT_AGENT_ONLINE_EVALUATION_DISABLED: string; - export const PIPX_HOME: string; - export const npm_node_execpath: string; - export const npm_package_devDependencies_vite: string; - export const npm_package_devDependencies__sveltejs_vite_plugin_svelte: string; - export const npm_config_init_version: string; - export const JAVA_HOME_8_X64: string; - export const SHLVL: string; - export const npm_package_exports___node_types: string; - export const npm_package_files_0: string; - export const COPILOT_AGENT_RUNTIME_VERSION: string; - export const HOME: string; - export const OLDPWD: string; - export const npm_package_files_1: string; - export const npm_package_repository_directory: string; - export const RUNNER_TEMP: string; - export const GITHUB_EVENT_PATH: string; - export const CAROOT: string; - export const COPILOT_AGENT_FIREWALL_RULESET_ALLOW_LIST: string; - export const npm_package_files_2: string; - export const JAVA_HOME_11_X64: string; - export const COPILOT_AGENT_MCP_SERVER_TEMP: string; - export const PIPX_BIN_DIR: string; - export const GITHUB_REPOSITORY_OWNER: string; - export const npm_package_engines_node: string; + export const CLAUDE_EFFORT: string; + export const CLAUDE_CODE_ENTRYPOINT: string; export const npm_package_exports___vite_import: string; - export const npm_package_files_3: string; - export const npm_package_devDependencies_svelte_preprocess: string; - export const npm_config_init_license: string; - export const GRADLE_HOME: string; - export const ANDROID_NDK_LATEST_HOME: string; - export const JAVA_HOME_21_X64: string; - export const GITHUB_RETENTION_DAYS: string; - export const npm_package_files_4: string; - export const npm_config_version_tag_prefix: string; - export const GITHUB_REPOSITORY_OWNER_ID: string; - export const POWERSHELL_DISTRIBUTION_CHANNEL: string; - export const SSL_CERT_FILE: string; - export const AZURE_EXTENSION_DIR: string; - export const GITHUB_HEAD_REF: string; - export const npm_package_scripts_check: string; - export const npm_package_files_5: string; - export const npm_package_dependencies_tiny_glob: string; - export const SYSTEMD_EXEC_PID: string; - export const DBUS_SESSION_BUS_ADDRESS: string; - export const npm_package_scripts_postinstall: string; - export const npm_package_files_6: string; - export const GITHUB_GRAPHQL_URL: string; - export const GITHUB_DOWNLOADS_URL: string; + export const npm_package_exports___hooks_import: string; + export const NODE: string; + export const npm_package_dependencies_sade: string; + export const INIT_CWD: string; export const npm_package_devDependencies_typescript: string; - export const npm_package_devDependencies__types_connect: string; - export const npm_package_description: string; - export const JAVA_HOME_25_X64: string; - export const NVM_DIR: string; - export const npm_package_readmeFilename: string; - export const npm_package_types: string; export const npm_package_homepage: string; - export const DOTNET_SKIP_FIRST_TIME_EXPERIENCE: string; - export const COPILOT_JOB_EVENT_TYPE: string; - export const JAVA_HOME_17_X64: string; - export const ImageVersion: string; - export const SUDO_UID: string; - export const npm_package_exports___hooks_types: string; - export const npm_package_devDependencies__playwright_test: string; - export const BLACKBIRD_MODE: string; - export const LOGNAME: string; - export const COPILOT_AGENT_PR_COMMIT_COUNT: string; - export const RUNNER_OS: string; - export const GITHUB_API_URL: string; - export const GOROOT_1_22_X64: string; - export const COPILOT_AGENT_COMMIT_LOGIN: string; - export const SWIFT_PATH: string; - export const npm_package_type: string; - export const COPILOT_USE_SESSIONS: string; - export const CHROMEWEBDRIVER: string; - export const COPILOT_AGENT_CONTENT_FILTER_MODE: string; - export const GOROOT_1_23_X64: string; - export const JOURNAL_STREAM: string; - export const GITHUB_WORKFLOW: string; - export const _: string; - export const COPILOT_AGENT_BRANCH_NAME: string; - export const MEMORY_PRESSURE_WATCH: string; - export const XDG_SESSION_CLASS: string; - export const GOROOT_1_24_X64: string; + export const npm_config_version_git_tag: string; + export const BAGGAGE: string; + export const CLAUDE_CODE_HOST_SESSION_ID: string; + export const CLAUDE_PREVIEW_CLASSIFIER_FLOOR: string; + export const CLAUDE_CODE_OAUTH_SCOPES: string; + export const SHELL: string; + export const npm_package_devDependencies_vite: string; + export const npm_package_dependencies_devalue: string; + export const CLAUDE_PID: string; + export const CLAUDE_CODE_CHILD_SESSION: string; + export const CLAUDE_CODE_EAGER_FLUSH: string; + export const TMPDIR: string; + export const npm_config_global_prefix: string; export const npm_package_scripts_lint: string; + export const npm_config_init_license: string; + export const npm_package_dependencies_set_cookie_parser: string; + export const npm_package_dependencies_cookie: string; + export const CLAUDE_AGENT_SDK_VERSION: string; + export const MallocNanoZone: string; + export const COLOR: string; + export const USE_LOCAL_OAUTH: string; + export const npm_config_noproxy: string; + export const CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH: string; + export const npm_package_devDependencies_svelte_preprocess: string; export const npm_config_registry: string; - export const ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: string; - export const COPILOT_AGENT_FIREWALL_ENABLE_RULESET_ALLOW_LIST: string; - export const GOROOT_1_25_X64: string; - export const GITHUB_RUN_ID: string; - export const TERM: string; - export const XDG_SESSION_ID: string; - export const GITHUB_REF_TYPE: string; - export const BOOTSTRAP_HASKELL_NONINTERACTIVE: string; - export const GITHUB_WORKFLOW_SHA: string; - export const GITHUB_BASE_REF: string; - export const ImageOS: string; - export const COPILOT_MCP_ENABLED: string; - export const npm_package_exports___import: string; - export const npm_package_devDependencies_dts_buddy: string; - export const npm_package_dependencies_kleur: string; - export const npm_package_dependencies_devalue: string; - export const npm_config_ignore_scripts: string; - export const COPILOT_AGENT_CALLBACK_URL: string; - export const GITHUB_WORKFLOW_REF: string; - export const GITHUB_ACTION_REPOSITORY: string; - export const ENABLE_RUNNER_TRACING: string; + export const npm_config_local_prefix: string; + export const npm_package_dependencies_import_meta_resolve: string; + export const npm_package_repository_url: string; + export const GIT_EDITOR: string; + export const AI_AGENT: string; + export const npm_package_readmeFilename: string; + export const USER: string; + export const npm_package_exports___node_import: string; + export const npm_package_description: string; export const npm_package_exports___package_json: string; - export const npm_package_peerDependencies_svelte: string; + export const npm_package_dependencies_esm_env: string; + export const npm_package_license: string; + export const API_TIMEOUT_MS: string; + export const COMMAND_MODE: string; + export const npm_config_globalconfig: string; + export const npm_package_exports___import: string; + export const npm_package_repository_directory: string; + export const SSH_AUTH_SOCK: string; + export const __CF_USER_TEXT_ENCODING: string; + export const npm_package_bin_svelte_kit: string; + export const npm_execpath: string; + export const npm_package_devDependencies__types_sade: string; + export const npm_package_peerDependencies__sveltejs_vite_plugin_svelte: string; + export const npm_package_devDependencies_svelte: string; + export const YARN_IGNORE_PATH: string; + export const CLAUDE_CODE_REPORT_FINDINGS: string; export const PATH: string; - export const NODE: string; - export const COPILOT_AGENT_INJECTED_SECRET_NAMES: string; - export const ANT_HOME: string; - export const DOTNET_MULTILEVEL_LOOKUP: string; - export const RUNNER_TRACKING_ID: string; - export const INVOCATION_ID: string; - export const RUNNER_TOOL_CACHE: string; - export const GITHUB_UPLOADS_URL: string; - export const REQUESTS_CA_BUNDLE: string; - export const npm_package_repository_type: string; + export const npm_config_argv: string; + export const npm_package_scripts_postinstall: string; + export const MCP_CONNECTION_NONBLOCKING: string; + export const npm_package_devDependencies_rollup: string; + export const npm_package_dependencies_magic_string: string; + export const npm_package_json: string; + export const _: string; + export const npm_config_userconfig: string; + export const npm_config_init_module: string; + export const COREPACK_ENABLE_DOWNLOAD_PROMPT: string; + export const __CFBundleIdentifier: string; + export const npm_command: string; + export const PWD: string; + export const npm_lifecycle_event: string; + export const EDITOR: string; export const npm_package_name: string; - export const GITHUB_ACTION: string; - export const GITHUB_RUN_NUMBER: string; - export const GITHUB_TRIGGERING_ACTOR: string; - export const COPILOT_EXPERIMENTS: string; - export const RUNNER_ARCH: string; - export const XDG_RUNTIME_DIR: string; - export const AGENT_TOOLSDIRECTORY: string; + export const npm_package_types: string; + export const npm_package_devDependencies__sveltejs_vite_plugin_svelte: string; + export const npm_package_repository_type: string; + export const npm_package_scripts_generate_types: string; export const npm_package_scripts_test_integration: string; + export const npm_package_devDependencies__types_connect: string; export const npm_package_exports___node_polyfills_import: string; - export const npm_package_devDependencies__types_set_cookie_parser: string; - export const SSL_CERT_DIR: string; - export const npm_package_scripts_test_unit: string; - export const npm_package_exports___vite_types: string; - export const npm_config_ignore_path: string; - export const LANG: string; - export const VCPKG_INSTALLATION_ROOT: string; - export const CONDA: string; - export const RUNNER_NAME: string; - export const XDG_CONFIG_HOME: string; - export const GITHUB_REF_NAME: string; - export const GITHUB_REPOSITORY: string; - export const npm_lifecycle_script: string; + export const npm_package_exports___types: string; + export const npm_config_version_commit_hooks: string; + export const npm_config_npm_version: string; + export const NODE_USE_SYSTEM_CA: string; + export const XPC_FLAGS: string; export const npm_package_scripts_test_cross_platform_dev: string; - export const SUDO_COMMAND: string; - export const ANDROID_NDK_ROOT: string; - export const GITHUB_ACTION_REF: string; - export const DEBIAN_FRONTEND: string; - export const npm_package_scripts_test: string; - export const npm_package_dependencies_esm_env: string; - export const npm_config_version_git_message: string; - export const SHELL: string; - export const GITHUB_REPOSITORY_ID: string; - export const GITHUB_ACTIONS: string; - export const CPD_SAVE_TRAJECTORY_OUTPUT: string; - export const npm_lifecycle_event: string; - export const npm_package_repository_url: string; + export const npm_package_devDependencies_vitest: string; + export const npm_package_dependencies_tiny_glob: string; + export const npm_config_bin_links: string; + export const npm_package_engines_node: string; + export const npm_package_dependencies_sirv: string; + export const npm_config_node_gyp: string; + export const XPC_SERVICE_NAME: string; export const npm_package_version: string; - export const GITHUB_REF_PROTECTED: string; - export const npm_config_argv: string; - export const npm_package_scripts_generate_types: string; + export const npm_config_yes: string; + export const SHLVL: string; + export const HOME: string; + export const npm_package_type: string; + export const CLAUDE_CODE_DISABLE_CRON: string; + export const ANTHROPIC_BASE_URL: string; + export const npm_package_scripts_generate_version: string; + export const npm_package_scripts_test: string; export const npm_package_scripts_check_all: string; - export const npm_package_devDependencies_svelte: string; - export const npm_package_dependencies_cookie: string; - export const GITHUB_WORKSPACE: string; - export const SUDO_USER: string; - export const ACCEPT_EULA: string; - export const DOTNET_SYSTEM_NET_DISABLEIPV6: string; - export const GITHUB_JOB: string; - export const YARN_IGNORE_PATH: string; - export const npm_package_exports___node_import: string; - export const GITHUB_SHA: string; - export const GITHUB_RUN_ATTEMPT: string; - export const COPILOT_AGENT_DEBUG: string; - export const npm_package_devDependencies__types_sade: string; - export const npm_config_version_git_tag: string; - export const npm_config_version_git_sign: string; - export const GITHUB_REF: string; - export const COPILOT_AGENT_ISSUE_NUMBER: string; - export const COPILOT_AGENT_SOURCE_ENVIRONMENT: string; - export const GITHUB_ACTOR: string; - export const FIREWALL_RULESET_CONTENT: string; - export const ANDROID_SDK_ROOT: string; - export const npm_package_license: string; + export const CLAUDE_CODE_EXECPATH: string; + export const npm_package_exports___vite_types: string; + export const npm_package_exports___hooks_types: string; + export const npm_config_save_prefix: string; export const npm_config_strict_ssl: string; + export const DISABLE_MICROCOMPACT: string; + export const MCP_SERVER_CONNECTION_BATCH_SIZE: string; + export const npm_config_version_git_message: string; + export const npm_config_cache: string; + export const LOGNAME: string; export const npm_package_scripts_format: string; - export const GITHUB_PATH: string; - export const JAVA_HOME: string; - export const PWD: string; - export const GITHUB_ACTOR_ID: string; - export const RUNNER_WORKSPACE: string; - export const npm_execpath: string; - export const npm_package_dependencies_set_cookie_parser: string; - export const COPILOT_AGENT_PR_NUMBER: string; - export const HOMEBREW_CLEANUP_PERIODIC_FULL_DAYS: string; - export const GITHUB_EVENT_NAME: string; - export const HOMEBREW_NO_AUTO_UPDATE: string; - export const ANDROID_HOME: string; - export const GITHUB_SERVER_URL: string; - export const GECKOWEBDRIVER: string; - export const GHCUP_INSTALL_BASE_PREFIX: string; - export const GITHUB_OUTPUT: string; - export const npm_package_exports___types: string; - export const EDGEWEBDRIVER: string; - export const COPILOT_EXPERIMENT_ASSIGNMENT_CONTEXT: string; export const npm_package_peerDependencies_vite: string; - export const npm_package_peerDependencies__sveltejs_vite_plugin_svelte: string; - export const npm_config_save_prefix: string; + export const npm_lifecycle_script: string; + export const npm_package_peerDependencies_svelte: string; + export const npm_config_ignore_path: string; + export const COREPACK_ENABLE_AUTO_PIN: string; + export const npm_package_devDependencies__types_set_cookie_parser: string; + export const npm_config_user_agent: string; + export const CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH: string; + export const npm_package_files_3: string; + export const npm_package_dependencies__types_cookie: string; + export const npm_config_version_git_sign: string; + export const npm_config_ignore_scripts: string; + export const CLAUDE_CODE_SESSION_ID: string; + export const DISABLE_AUTOUPDATER: string; + export const npm_package_files_2: string; + export const npm_package_devDependencies__types_node: string; + export const npm_package_devDependencies__playwright_test: string; + export const npm_package_files_1: string; + export const npm_package_devDependencies_dts_buddy: string; + export const OSLogRateLimit: string; + export const npm_package_files_0: string; + export const npm_package_dependencies_mrmime: string; + export const npm_package_dependencies_kleur: string; + export const npm_config_init_version: string; export const npm_config_ignore_optional: string; - export const ANDROID_NDK: string; - export const SGX_AESM_ADDR: string; - export const CHROME_BIN: string; - export const PUPPETEER_SKIP_DOWNLOAD: string; - export const SELENIUM_JAR_PATH: string; - export const MEMORY_PRESSURE_WRITE: string; - export const COPILOT_AGENT_COMMIT_EMAIL: string; - export const COPILOT_AGENT_FIREWALL_LOG_FILE: string; - export const COPILOT_FEATURE_FLAGS: string; - export const npm_package_exports___node_polyfills_types: string; - export const INIT_CWD: string; - export const COPILOT_API_URL: string; - export const ANDROID_NDK_HOME: string; - export const GITHUB_STEP_SUMMARY: string; - export const COPILOT_AGENT_BASE_COMMIT: string; - export const COPILOT_AGENT_TIMEOUT_MIN: string; - export const npm_package_exports___hooks_import: string; - export const npm_package_dependencies_import_meta_resolve: string; + export const CLAUDECODE: string; + export const CLAUDE_CODE_MESSAGING_SOCKET: string; + export const npm_package_exports___node_types: string; + export const npm_package_files_6: string; + export const npm_package_scripts_check: string; + export const npm_package_files_5: string; + export const npm_node_execpath: string; + export const npm_config_prefix: string; + export const USE_STAGING_OAUTH: string; + export const npm_package_scripts_test_unit: string; + export const npm_package_files_4: string; + export const npm_config_version_tag_prefix: string; } /** @@ -312,261 +218,167 @@ declare module '$env/static/public' { */ declare module '$env/dynamic/private' { export const env: { - SUDO_GID: string; - GITHUB_STATE: string; - COPILOT_AGENT_ACTION: string; + CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES: string; + CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: string; + npm_package_exports___node_polyfills_types: string; + CLAUDE_CODE_MESSAGING_TOKEN: string; + NoDefaultCurrentDirectoryInExePath: string; npm_package_scripts_test_cross_platform_build: string; - npm_package_devDependencies_rollup: string; - npm_package_devDependencies__types_node: string; - COPILOT_AGENT_START_TIME_SEC: string; - CURL_CA_BUNDLE: string; - DOTNET_NOLOGO: string; - npm_package_devDependencies_vitest: string; - MAIL: string; - NODE_EXTRA_CA_CERTS: string; - USER: string; - npm_package_bin_svelte_kit: string; - npm_package_dependencies_sirv: string; - npm_package_dependencies_sade: string; - npm_package_dependencies_mrmime: string; - npm_package_dependencies_magic_string: string; - npm_config_version_commit_hooks: string; - npm_config_user_agent: string; - SHOULD_CONTINUE: string; - CI: string; - npm_package_scripts_generate_version: string; - npm_package_dependencies__types_cookie: string; - npm_config_bin_links: string; - XDG_SESSION_TYPE: string; - RUNNER_ENVIRONMENT: string; - GITHUB_ENV: string; - COPILOT_AGENT_ONLINE_EVALUATION_DISABLED: string; - PIPX_HOME: string; - npm_node_execpath: string; - npm_package_devDependencies_vite: string; - npm_package_devDependencies__sveltejs_vite_plugin_svelte: string; - npm_config_init_version: string; - JAVA_HOME_8_X64: string; - SHLVL: string; - npm_package_exports___node_types: string; - npm_package_files_0: string; - COPILOT_AGENT_RUNTIME_VERSION: string; - HOME: string; - OLDPWD: string; - npm_package_files_1: string; - npm_package_repository_directory: string; - RUNNER_TEMP: string; - GITHUB_EVENT_PATH: string; - CAROOT: string; - COPILOT_AGENT_FIREWALL_RULESET_ALLOW_LIST: string; - npm_package_files_2: string; - JAVA_HOME_11_X64: string; - COPILOT_AGENT_MCP_SERVER_TEMP: string; - PIPX_BIN_DIR: string; - GITHUB_REPOSITORY_OWNER: string; - npm_package_engines_node: string; + CLAUDE_EFFORT: string; + CLAUDE_CODE_ENTRYPOINT: string; npm_package_exports___vite_import: string; - npm_package_files_3: string; - npm_package_devDependencies_svelte_preprocess: string; - npm_config_init_license: string; - GRADLE_HOME: string; - ANDROID_NDK_LATEST_HOME: string; - JAVA_HOME_21_X64: string; - GITHUB_RETENTION_DAYS: string; - npm_package_files_4: string; - npm_config_version_tag_prefix: string; - GITHUB_REPOSITORY_OWNER_ID: string; - POWERSHELL_DISTRIBUTION_CHANNEL: string; - SSL_CERT_FILE: string; - AZURE_EXTENSION_DIR: string; - GITHUB_HEAD_REF: string; - npm_package_scripts_check: string; - npm_package_files_5: string; - npm_package_dependencies_tiny_glob: string; - SYSTEMD_EXEC_PID: string; - DBUS_SESSION_BUS_ADDRESS: string; - npm_package_scripts_postinstall: string; - npm_package_files_6: string; - GITHUB_GRAPHQL_URL: string; - GITHUB_DOWNLOADS_URL: string; + npm_package_exports___hooks_import: string; + NODE: string; + npm_package_dependencies_sade: string; + INIT_CWD: string; npm_package_devDependencies_typescript: string; - npm_package_devDependencies__types_connect: string; - npm_package_description: string; - JAVA_HOME_25_X64: string; - NVM_DIR: string; - npm_package_readmeFilename: string; - npm_package_types: string; npm_package_homepage: string; - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: string; - COPILOT_JOB_EVENT_TYPE: string; - JAVA_HOME_17_X64: string; - ImageVersion: string; - SUDO_UID: string; - npm_package_exports___hooks_types: string; - npm_package_devDependencies__playwright_test: string; - BLACKBIRD_MODE: string; - LOGNAME: string; - COPILOT_AGENT_PR_COMMIT_COUNT: string; - RUNNER_OS: string; - GITHUB_API_URL: string; - GOROOT_1_22_X64: string; - COPILOT_AGENT_COMMIT_LOGIN: string; - SWIFT_PATH: string; - npm_package_type: string; - COPILOT_USE_SESSIONS: string; - CHROMEWEBDRIVER: string; - COPILOT_AGENT_CONTENT_FILTER_MODE: string; - GOROOT_1_23_X64: string; - JOURNAL_STREAM: string; - GITHUB_WORKFLOW: string; - _: string; - COPILOT_AGENT_BRANCH_NAME: string; - MEMORY_PRESSURE_WATCH: string; - XDG_SESSION_CLASS: string; - GOROOT_1_24_X64: string; + npm_config_version_git_tag: string; + BAGGAGE: string; + CLAUDE_CODE_HOST_SESSION_ID: string; + CLAUDE_PREVIEW_CLASSIFIER_FLOOR: string; + CLAUDE_CODE_OAUTH_SCOPES: string; + SHELL: string; + npm_package_devDependencies_vite: string; + npm_package_dependencies_devalue: string; + CLAUDE_PID: string; + CLAUDE_CODE_CHILD_SESSION: string; + CLAUDE_CODE_EAGER_FLUSH: string; + TMPDIR: string; + npm_config_global_prefix: string; npm_package_scripts_lint: string; + npm_config_init_license: string; + npm_package_dependencies_set_cookie_parser: string; + npm_package_dependencies_cookie: string; + CLAUDE_AGENT_SDK_VERSION: string; + MallocNanoZone: string; + COLOR: string; + USE_LOCAL_OAUTH: string; + npm_config_noproxy: string; + CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH: string; + npm_package_devDependencies_svelte_preprocess: string; npm_config_registry: string; - ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: string; - COPILOT_AGENT_FIREWALL_ENABLE_RULESET_ALLOW_LIST: string; - GOROOT_1_25_X64: string; - GITHUB_RUN_ID: string; - TERM: string; - XDG_SESSION_ID: string; - GITHUB_REF_TYPE: string; - BOOTSTRAP_HASKELL_NONINTERACTIVE: string; - GITHUB_WORKFLOW_SHA: string; - GITHUB_BASE_REF: string; - ImageOS: string; - COPILOT_MCP_ENABLED: string; - npm_package_exports___import: string; - npm_package_devDependencies_dts_buddy: string; - npm_package_dependencies_kleur: string; - npm_package_dependencies_devalue: string; - npm_config_ignore_scripts: string; - COPILOT_AGENT_CALLBACK_URL: string; - GITHUB_WORKFLOW_REF: string; - GITHUB_ACTION_REPOSITORY: string; - ENABLE_RUNNER_TRACING: string; + npm_config_local_prefix: string; + npm_package_dependencies_import_meta_resolve: string; + npm_package_repository_url: string; + GIT_EDITOR: string; + AI_AGENT: string; + npm_package_readmeFilename: string; + USER: string; + npm_package_exports___node_import: string; + npm_package_description: string; npm_package_exports___package_json: string; - npm_package_peerDependencies_svelte: string; + npm_package_dependencies_esm_env: string; + npm_package_license: string; + API_TIMEOUT_MS: string; + COMMAND_MODE: string; + npm_config_globalconfig: string; + npm_package_exports___import: string; + npm_package_repository_directory: string; + SSH_AUTH_SOCK: string; + __CF_USER_TEXT_ENCODING: string; + npm_package_bin_svelte_kit: string; + npm_execpath: string; + npm_package_devDependencies__types_sade: string; + npm_package_peerDependencies__sveltejs_vite_plugin_svelte: string; + npm_package_devDependencies_svelte: string; + YARN_IGNORE_PATH: string; + CLAUDE_CODE_REPORT_FINDINGS: string; PATH: string; - NODE: string; - COPILOT_AGENT_INJECTED_SECRET_NAMES: string; - ANT_HOME: string; - DOTNET_MULTILEVEL_LOOKUP: string; - RUNNER_TRACKING_ID: string; - INVOCATION_ID: string; - RUNNER_TOOL_CACHE: string; - GITHUB_UPLOADS_URL: string; - REQUESTS_CA_BUNDLE: string; - npm_package_repository_type: string; + npm_config_argv: string; + npm_package_scripts_postinstall: string; + MCP_CONNECTION_NONBLOCKING: string; + npm_package_devDependencies_rollup: string; + npm_package_dependencies_magic_string: string; + npm_package_json: string; + _: string; + npm_config_userconfig: string; + npm_config_init_module: string; + COREPACK_ENABLE_DOWNLOAD_PROMPT: string; + __CFBundleIdentifier: string; + npm_command: string; + PWD: string; + npm_lifecycle_event: string; + EDITOR: string; npm_package_name: string; - GITHUB_ACTION: string; - GITHUB_RUN_NUMBER: string; - GITHUB_TRIGGERING_ACTOR: string; - COPILOT_EXPERIMENTS: string; - RUNNER_ARCH: string; - XDG_RUNTIME_DIR: string; - AGENT_TOOLSDIRECTORY: string; + npm_package_types: string; + npm_package_devDependencies__sveltejs_vite_plugin_svelte: string; + npm_package_repository_type: string; + npm_package_scripts_generate_types: string; npm_package_scripts_test_integration: string; + npm_package_devDependencies__types_connect: string; npm_package_exports___node_polyfills_import: string; - npm_package_devDependencies__types_set_cookie_parser: string; - SSL_CERT_DIR: string; - npm_package_scripts_test_unit: string; - npm_package_exports___vite_types: string; - npm_config_ignore_path: string; - LANG: string; - VCPKG_INSTALLATION_ROOT: string; - CONDA: string; - RUNNER_NAME: string; - XDG_CONFIG_HOME: string; - GITHUB_REF_NAME: string; - GITHUB_REPOSITORY: string; - npm_lifecycle_script: string; + npm_package_exports___types: string; + npm_config_version_commit_hooks: string; + npm_config_npm_version: string; + NODE_USE_SYSTEM_CA: string; + XPC_FLAGS: string; npm_package_scripts_test_cross_platform_dev: string; - SUDO_COMMAND: string; - ANDROID_NDK_ROOT: string; - GITHUB_ACTION_REF: string; - DEBIAN_FRONTEND: string; - npm_package_scripts_test: string; - npm_package_dependencies_esm_env: string; - npm_config_version_git_message: string; - SHELL: string; - GITHUB_REPOSITORY_ID: string; - GITHUB_ACTIONS: string; - CPD_SAVE_TRAJECTORY_OUTPUT: string; - npm_lifecycle_event: string; - npm_package_repository_url: string; + npm_package_devDependencies_vitest: string; + npm_package_dependencies_tiny_glob: string; + npm_config_bin_links: string; + npm_package_engines_node: string; + npm_package_dependencies_sirv: string; + npm_config_node_gyp: string; + XPC_SERVICE_NAME: string; npm_package_version: string; - GITHUB_REF_PROTECTED: string; - npm_config_argv: string; - npm_package_scripts_generate_types: string; + npm_config_yes: string; + SHLVL: string; + HOME: string; + npm_package_type: string; + CLAUDE_CODE_DISABLE_CRON: string; + ANTHROPIC_BASE_URL: string; + npm_package_scripts_generate_version: string; + npm_package_scripts_test: string; npm_package_scripts_check_all: string; - npm_package_devDependencies_svelte: string; - npm_package_dependencies_cookie: string; - GITHUB_WORKSPACE: string; - SUDO_USER: string; - ACCEPT_EULA: string; - DOTNET_SYSTEM_NET_DISABLEIPV6: string; - GITHUB_JOB: string; - YARN_IGNORE_PATH: string; - npm_package_exports___node_import: string; - GITHUB_SHA: string; - GITHUB_RUN_ATTEMPT: string; - COPILOT_AGENT_DEBUG: string; - npm_package_devDependencies__types_sade: string; - npm_config_version_git_tag: string; - npm_config_version_git_sign: string; - GITHUB_REF: string; - COPILOT_AGENT_ISSUE_NUMBER: string; - COPILOT_AGENT_SOURCE_ENVIRONMENT: string; - GITHUB_ACTOR: string; - FIREWALL_RULESET_CONTENT: string; - ANDROID_SDK_ROOT: string; - npm_package_license: string; + CLAUDE_CODE_EXECPATH: string; + npm_package_exports___vite_types: string; + npm_package_exports___hooks_types: string; + npm_config_save_prefix: string; npm_config_strict_ssl: string; + DISABLE_MICROCOMPACT: string; + MCP_SERVER_CONNECTION_BATCH_SIZE: string; + npm_config_version_git_message: string; + npm_config_cache: string; + LOGNAME: string; npm_package_scripts_format: string; - GITHUB_PATH: string; - JAVA_HOME: string; - PWD: string; - GITHUB_ACTOR_ID: string; - RUNNER_WORKSPACE: string; - npm_execpath: string; - npm_package_dependencies_set_cookie_parser: string; - COPILOT_AGENT_PR_NUMBER: string; - HOMEBREW_CLEANUP_PERIODIC_FULL_DAYS: string; - GITHUB_EVENT_NAME: string; - HOMEBREW_NO_AUTO_UPDATE: string; - ANDROID_HOME: string; - GITHUB_SERVER_URL: string; - GECKOWEBDRIVER: string; - GHCUP_INSTALL_BASE_PREFIX: string; - GITHUB_OUTPUT: string; - npm_package_exports___types: string; - EDGEWEBDRIVER: string; - COPILOT_EXPERIMENT_ASSIGNMENT_CONTEXT: string; npm_package_peerDependencies_vite: string; - npm_package_peerDependencies__sveltejs_vite_plugin_svelte: string; - npm_config_save_prefix: string; + npm_lifecycle_script: string; + npm_package_peerDependencies_svelte: string; + npm_config_ignore_path: string; + COREPACK_ENABLE_AUTO_PIN: string; + npm_package_devDependencies__types_set_cookie_parser: string; + npm_config_user_agent: string; + CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH: string; + npm_package_files_3: string; + npm_package_dependencies__types_cookie: string; + npm_config_version_git_sign: string; + npm_config_ignore_scripts: string; + CLAUDE_CODE_SESSION_ID: string; + DISABLE_AUTOUPDATER: string; + npm_package_files_2: string; + npm_package_devDependencies__types_node: string; + npm_package_devDependencies__playwright_test: string; + npm_package_files_1: string; + npm_package_devDependencies_dts_buddy: string; + OSLogRateLimit: string; + npm_package_files_0: string; + npm_package_dependencies_mrmime: string; + npm_package_dependencies_kleur: string; + npm_config_init_version: string; npm_config_ignore_optional: string; - ANDROID_NDK: string; - SGX_AESM_ADDR: string; - CHROME_BIN: string; - PUPPETEER_SKIP_DOWNLOAD: string; - SELENIUM_JAR_PATH: string; - MEMORY_PRESSURE_WRITE: string; - COPILOT_AGENT_COMMIT_EMAIL: string; - COPILOT_AGENT_FIREWALL_LOG_FILE: string; - COPILOT_FEATURE_FLAGS: string; - npm_package_exports___node_polyfills_types: string; - INIT_CWD: string; - COPILOT_API_URL: string; - ANDROID_NDK_HOME: string; - GITHUB_STEP_SUMMARY: string; - COPILOT_AGENT_BASE_COMMIT: string; - COPILOT_AGENT_TIMEOUT_MIN: string; - npm_package_exports___hooks_import: string; - npm_package_dependencies_import_meta_resolve: string; + CLAUDECODE: string; + CLAUDE_CODE_MESSAGING_SOCKET: string; + npm_package_exports___node_types: string; + npm_package_files_6: string; + npm_package_scripts_check: string; + npm_package_files_5: string; + npm_node_execpath: string; + npm_config_prefix: string; + USE_STAGING_OAUTH: string; + npm_package_scripts_test_unit: string; + npm_package_files_4: string; + npm_config_version_tag_prefix: string; [key: `PUBLIC_${string}`]: undefined; [key: `${string}`]: string | undefined; } diff --git a/packages/rrweb-snapshot/src/privacy.ts b/packages/rrweb-snapshot/src/privacy.ts index e04b82370c..b5d8076e59 100644 --- a/packages/rrweb-snapshot/src/privacy.ts +++ b/packages/rrweb-snapshot/src/privacy.ts @@ -1,4 +1,5 @@ import type { + CompiledDetector, CompiledPrivacyPolicy, PrivacyDetectorOptions, PrivacyPolicy, @@ -36,6 +37,15 @@ const DEFAULT_BLOCKED_QUERY_PARAMETERS = [ 'token', ]; +// Detector patterns (from posthog-js autocapture-utils.ts) +const CARD_CANDIDATE = /(?:^|[^0-9-])((?:\d[ -]?){12,18}\d)(?:$|[^0-9-])/; +const SSN_PATTERN = /\b(?!000|666|9\d{2})\d{3}-?(?!00)\d{2}-?(?!0000)\d{4}\b/; +const EMAIL_PATTERN = + /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[a-zA-Z0-9-]{1,63}(?:\.[a-zA-Z0-9-]{1,63})+/; +const PHONE_PATTERN = /(?:^|\s)\+?\d[\d().\-]{7,18}\d(?:$|\s)/; +const IPV4_PATTERN = /\b(?:\d{1,3}\.){3}\d{1,3}\b/; +const MAX_SCAN_LENGTH = 10_000; + export const DEFAULT_PRIVACY_DETECTORS: Required = { email: true, phone: true, @@ -51,9 +61,9 @@ export const DEFAULT_PRIVACY_DETECTORS: Required = { */ export function applyPrivacyDetectors( policy: PrivacyPolicy | undefined, - options?: Partial, + options?: PrivacyDetectorOptions, ): PrivacyPolicy { - const base: PrivacyPolicy = policy || { version: 1, preset: 'balanced' }; + const base: PrivacyPolicy = policy || { version: 1, preset: 'legacy' }; return { ...base, detectors: { @@ -64,6 +74,48 @@ export function applyPrivacyDetectors( }; } +export function buildDetectors(options: PrivacyDetectorOptions | undefined): CompiledDetector[] { + const opts = options || {}; + const detectors: CompiledDetector[] = []; + if (opts.email) + detectors.push({ name: 'email', test: (v) => EMAIL_PATTERN.test(v) }); + if (opts.phone) + detectors.push({ + name: 'phone', + test: (v) => { + const m = PHONE_PATTERN.exec(v); + if (!m) return false; + const digits = m[0].replace(/\D/g, ''); + return digits.length >= 10 && digits.length <= 15; + }, + }); + if (opts.paymentCard) + detectors.push({ + name: 'payment-card', + test: (v) => { + const m = CARD_CANDIDATE.exec(v); + return !!m && passesLuhn(m[1]); + }, + }); + if (opts.ssn) detectors.push({ name: 'ssn', test: (v) => SSN_PATTERN.test(v) }); + if (opts.ipAddress) + detectors.push({ + name: 'ip-address', + test: (v) => { + const m = IPV4_PATTERN.exec(v); + return !!m && m[0].split('.').every((p) => Number(p) <= 255); + }, + }); + return detectors; +} + +export function detectSensitiveValue(value: string, privacy: CompiledPrivacyPolicy): boolean { + if (!privacy.detectors.length || !value) return false; + // Fail closed on absurd inputs instead of scanning them. + if (value.length > MAX_SCAN_LENGTH) return true; + return privacy.detectors.some((d) => d.test(value)); +} + export function validateSelector(selector: string): boolean { try { document.createDocumentFragment().querySelector(selector); @@ -136,7 +188,7 @@ export function compilePrivacyPolicy(policy?: PrivacyPolicy): CompiledPrivacyPol ? new Set(effective.url.allowedQueryParameters.map((n) => n.toLowerCase())) : null, removeHash: effective.url?.removeHash !== false, - detectors: [], // populated by applyPrivacyDetectors (Task 2) + detectors: buildDetectors(effective.detectors), }; } diff --git a/packages/rrweb-snapshot/test/privacy.test.ts b/packages/rrweb-snapshot/test/privacy.test.ts index d40e6cbdb4..aa372ef74b 100644 --- a/packages/rrweb-snapshot/test/privacy.test.ts +++ b/packages/rrweb-snapshot/test/privacy.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { describe, it, expect, vi } from 'vitest'; -import { compilePrivacyPolicy, validateSelector, mergeBlockSelectors } from '../src/privacy'; +import { compilePrivacyPolicy, validateSelector, mergeBlockSelectors, detectSensitiveValue, buildDetectors } from '../src/privacy'; describe('compilePrivacyPolicy v2', () => { it('legacy preset compiles to inert options', () => { @@ -85,3 +85,43 @@ describe('mergeBlockSelectors', () => { expect(mergeBlockSelectors('.legacy', c)).toContain('[data-privacy="exclude"]'); }); }); + +describe('detectSensitiveValue', () => { + const withDetectors = compilePrivacyPolicy({ + version: 1, + preset: 'legacy', + detectors: { email: true, phone: true, paymentCard: true, ssn: true, ipAddress: true }, + }); + + it('detects a Luhn-valid card adjacent to other digits (review regression)', () => { + expect(detectSensitiveValue('call 5551234567 4111 1111 1111 1111 now', withDetectors)).toBe(true); + }); + + it('detects email, ssn, ip; passes clean prose', () => { + expect(detectSensitiveValue('contact bob@example.com', withDetectors)).toBe(true); + expect(detectSensitiveValue('ssn 123-45-6789', withDetectors)).toBe(true); + expect(detectSensitiveValue('host 192.168.0.1', withDetectors)).toBe(true); + expect(detectSensitiveValue('the quick brown fox', withDetectors)).toBe(false); + }); + + it('rejects UUIDs and version strings as cards/ssns (false-positive guard)', () => { + expect(detectSensitiveValue('id 550e8400-e29b-41d4-a716-446655440000', withDetectors)).toBe(false); + expect(detectSensitiveValue('v1.2.3.4000 build', withDetectors)).toBe(false); + }); + + it('detects regardless of preset (works under legacy)', () => { + expect(withDetectors.preset).toBe('legacy'); + expect(detectSensitiveValue('4111 1111 1111 1111', withDetectors)).toBe(true); + }); + + it('no detectors configured -> never detects', () => { + const none = compilePrivacyPolicy({ version: 1, preset: 'strict' }); + expect(detectSensitiveValue('bob@example.com', none)).toBe(false); + }); + + it('per-detector toggles work', () => { + const emailOff = buildDetectors({ email: false, phone: false, paymentCard: true, ssn: false, ipAddress: false }); + expect(emailOff.some((d) => d.name === 'email')).toBe(false); + expect(emailOff.some((d) => d.name === 'payment-card')).toBe(true); + }); +}); From 2b253f2ed96dd4ff641ce134138a676fd08c216c Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 11:57:49 +0200 Subject: [PATCH 06/64] fix(privacy): tighten phone pattern quantifier for whole-value semantics, add spaced/dashed formats Fixes two issues from review: 1. Phone pattern quantifier changed from 7-18 to 7-13 to prevent matching across digit runs separated by spaces (e.g. '5551234567 4111 1111' no longer matches as single run). Restores space in character class [\d ().-] to support spaced formats ('555 123 4567'), dashed ('555-123-4567'), and mixed formatting. 2. Restore 4 files accidentally committed in prior commit from 54798a22: - packages/plugins/rrweb-plugin-network-record/tsconfig.json - packages/plugins/rrweb-plugin-network-replay/tsconfig.json - packages/plugins/rrweb-plugin-privacy-detectors/tsconfig.json (tsconfig reformatting) - packages/rrweb-player/.svelte-kit/ambient.d.ts (SvelteKit machine-local regeneration) Adds test assertions for spaced and dashed phone detection to prevent future regression. Tool note: tsconfig.json files are auto-reformatted by TypeScript build processes; .svelte-kit/ambient.d.ts is regenerated by SvelteKit and embeds local environment variables - both must be kept out of version control. Co-Authored-By: Claude Fable 5 --- .../rrweb-plugin-network-record/tsconfig.json | 13 +- .../rrweb-plugin-network-replay/tsconfig.json | 14 +- .../tsconfig.json | 14 +- .../rrweb-player/.svelte-kit/ambient.d.ts | 760 +++++++++++------- packages/rrweb-snapshot/src/privacy.ts | 2 +- packages/rrweb-snapshot/test/privacy.test.ts | 8 + 6 files changed, 492 insertions(+), 319 deletions(-) diff --git a/packages/plugins/rrweb-plugin-network-record/tsconfig.json b/packages/plugins/rrweb-plugin-network-record/tsconfig.json index fc5391c651..b70866e1ab 100644 --- a/packages/plugins/rrweb-plugin-network-record/tsconfig.json +++ b/packages/plugins/rrweb-plugin-network-record/tsconfig.json @@ -1,13 +1,7 @@ { "extends": "../../../tsconfig.base.json", - "include": [ - "src" - ], - "exclude": [ - "vite.config.ts", - "vitest.config.ts", - "test" - ], + "include": ["src"], + "exclude": ["vite.config.ts", "vitest.config.ts", "test"], "compilerOptions": { "rootDir": "src", "tsBuildInfoFile": "./tsconfig.tsbuildinfo" @@ -18,9 +12,6 @@ }, { "path": "../../utils" - }, - { - "path": "../../rrweb" } ] } diff --git a/packages/plugins/rrweb-plugin-network-replay/tsconfig.json b/packages/plugins/rrweb-plugin-network-replay/tsconfig.json index 8d73f951ee..dd9ef0fc92 100644 --- a/packages/plugins/rrweb-plugin-network-replay/tsconfig.json +++ b/packages/plugins/rrweb-plugin-network-replay/tsconfig.json @@ -1,25 +1,17 @@ { "extends": "../../../tsconfig.base.json", - "include": [ - "src" - ], - "exclude": [ - "vite.config.ts", - "test" - ], + "include": ["src"], + "exclude": ["vite.config.ts", "test"], "compilerOptions": { "rootDir": "src", "tsBuildInfoFile": "./tsconfig.tsbuildinfo" }, "references": [ - { - "path": "../rrweb-plugin-network-record" - }, { "path": "../../types" }, { - "path": "../../rrweb" + "path": "../rrweb-plugin-network-record" } ] } diff --git a/packages/plugins/rrweb-plugin-privacy-detectors/tsconfig.json b/packages/plugins/rrweb-plugin-privacy-detectors/tsconfig.json index dee9f4ff89..3412c6c7aa 100644 --- a/packages/plugins/rrweb-plugin-privacy-detectors/tsconfig.json +++ b/packages/plugins/rrweb-plugin-privacy-detectors/tsconfig.json @@ -1,23 +1,17 @@ { "extends": "../../../tsconfig.base.json", - "include": [ - "src" - ], - "exclude": [ - "vite.config.ts", - "vitest.config.ts", - "test" - ], + "include": ["src"], + "exclude": ["vite.config.ts", "vitest.config.ts", "test"], "compilerOptions": { "rootDir": "src", "tsBuildInfoFile": "./tsconfig.tsbuildinfo" }, "references": [ { - "path": "../../types" + "path": "../../rrweb-snapshot" }, { - "path": "../../rrweb-snapshot" + "path": "../../types" } ] } diff --git a/packages/rrweb-player/.svelte-kit/ambient.d.ts b/packages/rrweb-player/.svelte-kit/ambient.d.ts index 0982243585..baa9b47d92 100644 --- a/packages/rrweb-player/.svelte-kit/ambient.d.ts +++ b/packages/rrweb-player/.svelte-kit/ambient.d.ts @@ -26,167 +26,261 @@ * ``` */ declare module '$env/static/private' { - export const CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES: string; - export const CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: string; - export const npm_package_exports___node_polyfills_types: string; - export const CLAUDE_CODE_MESSAGING_TOKEN: string; - export const NoDefaultCurrentDirectoryInExePath: string; + export const SUDO_GID: string; + export const GITHUB_STATE: string; + export const COPILOT_AGENT_ACTION: string; export const npm_package_scripts_test_cross_platform_build: string; - export const CLAUDE_EFFORT: string; - export const CLAUDE_CODE_ENTRYPOINT: string; - export const npm_package_exports___vite_import: string; - export const npm_package_exports___hooks_import: string; - export const NODE: string; + export const npm_package_devDependencies_rollup: string; + export const npm_package_devDependencies__types_node: string; + export const COPILOT_AGENT_START_TIME_SEC: string; + export const CURL_CA_BUNDLE: string; + export const DOTNET_NOLOGO: string; + export const npm_package_devDependencies_vitest: string; + export const MAIL: string; + export const NODE_EXTRA_CA_CERTS: string; + export const USER: string; + export const npm_package_bin_svelte_kit: string; + export const npm_package_dependencies_sirv: string; export const npm_package_dependencies_sade: string; - export const INIT_CWD: string; + export const npm_package_dependencies_mrmime: string; + export const npm_package_dependencies_magic_string: string; + export const npm_config_version_commit_hooks: string; + export const npm_config_user_agent: string; + export const SHOULD_CONTINUE: string; + export const CI: string; + export const npm_package_scripts_generate_version: string; + export const npm_package_dependencies__types_cookie: string; + export const npm_config_bin_links: string; + export const XDG_SESSION_TYPE: string; + export const RUNNER_ENVIRONMENT: string; + export const GITHUB_ENV: string; + export const COPILOT_AGENT_ONLINE_EVALUATION_DISABLED: string; + export const PIPX_HOME: string; + export const npm_node_execpath: string; + export const npm_package_devDependencies_vite: string; + export const npm_package_devDependencies__sveltejs_vite_plugin_svelte: string; + export const npm_config_init_version: string; + export const JAVA_HOME_8_X64: string; + export const SHLVL: string; + export const npm_package_exports___node_types: string; + export const npm_package_files_0: string; + export const COPILOT_AGENT_RUNTIME_VERSION: string; + export const HOME: string; + export const OLDPWD: string; + export const npm_package_files_1: string; + export const npm_package_repository_directory: string; + export const RUNNER_TEMP: string; + export const GITHUB_EVENT_PATH: string; + export const CAROOT: string; + export const COPILOT_AGENT_FIREWALL_RULESET_ALLOW_LIST: string; + export const npm_package_files_2: string; + export const JAVA_HOME_11_X64: string; + export const COPILOT_AGENT_MCP_SERVER_TEMP: string; + export const PIPX_BIN_DIR: string; + export const GITHUB_REPOSITORY_OWNER: string; + export const npm_package_engines_node: string; + export const npm_package_exports___vite_import: string; + export const npm_package_files_3: string; + export const npm_package_devDependencies_svelte_preprocess: string; + export const npm_config_init_license: string; + export const GRADLE_HOME: string; + export const ANDROID_NDK_LATEST_HOME: string; + export const JAVA_HOME_21_X64: string; + export const GITHUB_RETENTION_DAYS: string; + export const npm_package_files_4: string; + export const npm_config_version_tag_prefix: string; + export const GITHUB_REPOSITORY_OWNER_ID: string; + export const POWERSHELL_DISTRIBUTION_CHANNEL: string; + export const SSL_CERT_FILE: string; + export const AZURE_EXTENSION_DIR: string; + export const GITHUB_HEAD_REF: string; + export const npm_package_scripts_check: string; + export const npm_package_files_5: string; + export const npm_package_dependencies_tiny_glob: string; + export const SYSTEMD_EXEC_PID: string; + export const DBUS_SESSION_BUS_ADDRESS: string; + export const npm_package_scripts_postinstall: string; + export const npm_package_files_6: string; + export const GITHUB_GRAPHQL_URL: string; + export const GITHUB_DOWNLOADS_URL: string; export const npm_package_devDependencies_typescript: string; + export const npm_package_devDependencies__types_connect: string; + export const npm_package_description: string; + export const JAVA_HOME_25_X64: string; + export const NVM_DIR: string; + export const npm_package_readmeFilename: string; + export const npm_package_types: string; export const npm_package_homepage: string; - export const npm_config_version_git_tag: string; - export const BAGGAGE: string; - export const CLAUDE_CODE_HOST_SESSION_ID: string; - export const CLAUDE_PREVIEW_CLASSIFIER_FLOOR: string; - export const CLAUDE_CODE_OAUTH_SCOPES: string; - export const SHELL: string; - export const npm_package_devDependencies_vite: string; - export const npm_package_dependencies_devalue: string; - export const CLAUDE_PID: string; - export const CLAUDE_CODE_CHILD_SESSION: string; - export const CLAUDE_CODE_EAGER_FLUSH: string; - export const TMPDIR: string; - export const npm_config_global_prefix: string; + export const DOTNET_SKIP_FIRST_TIME_EXPERIENCE: string; + export const COPILOT_JOB_EVENT_TYPE: string; + export const JAVA_HOME_17_X64: string; + export const ImageVersion: string; + export const SUDO_UID: string; + export const npm_package_exports___hooks_types: string; + export const npm_package_devDependencies__playwright_test: string; + export const BLACKBIRD_MODE: string; + export const LOGNAME: string; + export const COPILOT_AGENT_PR_COMMIT_COUNT: string; + export const RUNNER_OS: string; + export const GITHUB_API_URL: string; + export const GOROOT_1_22_X64: string; + export const COPILOT_AGENT_COMMIT_LOGIN: string; + export const SWIFT_PATH: string; + export const npm_package_type: string; + export const COPILOT_USE_SESSIONS: string; + export const CHROMEWEBDRIVER: string; + export const COPILOT_AGENT_CONTENT_FILTER_MODE: string; + export const GOROOT_1_23_X64: string; + export const JOURNAL_STREAM: string; + export const GITHUB_WORKFLOW: string; + export const _: string; + export const COPILOT_AGENT_BRANCH_NAME: string; + export const MEMORY_PRESSURE_WATCH: string; + export const XDG_SESSION_CLASS: string; + export const GOROOT_1_24_X64: string; export const npm_package_scripts_lint: string; - export const npm_config_init_license: string; - export const npm_package_dependencies_set_cookie_parser: string; - export const npm_package_dependencies_cookie: string; - export const CLAUDE_AGENT_SDK_VERSION: string; - export const MallocNanoZone: string; - export const COLOR: string; - export const USE_LOCAL_OAUTH: string; - export const npm_config_noproxy: string; - export const CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH: string; - export const npm_package_devDependencies_svelte_preprocess: string; export const npm_config_registry: string; - export const npm_config_local_prefix: string; - export const npm_package_dependencies_import_meta_resolve: string; - export const npm_package_repository_url: string; - export const GIT_EDITOR: string; - export const AI_AGENT: string; - export const npm_package_readmeFilename: string; - export const USER: string; - export const npm_package_exports___node_import: string; - export const npm_package_description: string; - export const npm_package_exports___package_json: string; - export const npm_package_dependencies_esm_env: string; - export const npm_package_license: string; - export const API_TIMEOUT_MS: string; - export const COMMAND_MODE: string; - export const npm_config_globalconfig: string; + export const ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: string; + export const COPILOT_AGENT_FIREWALL_ENABLE_RULESET_ALLOW_LIST: string; + export const GOROOT_1_25_X64: string; + export const GITHUB_RUN_ID: string; + export const TERM: string; + export const XDG_SESSION_ID: string; + export const GITHUB_REF_TYPE: string; + export const BOOTSTRAP_HASKELL_NONINTERACTIVE: string; + export const GITHUB_WORKFLOW_SHA: string; + export const GITHUB_BASE_REF: string; + export const ImageOS: string; + export const COPILOT_MCP_ENABLED: string; export const npm_package_exports___import: string; - export const npm_package_repository_directory: string; - export const SSH_AUTH_SOCK: string; - export const __CF_USER_TEXT_ENCODING: string; - export const npm_package_bin_svelte_kit: string; - export const npm_execpath: string; - export const npm_package_devDependencies__types_sade: string; - export const npm_package_peerDependencies__sveltejs_vite_plugin_svelte: string; - export const npm_package_devDependencies_svelte: string; - export const YARN_IGNORE_PATH: string; - export const CLAUDE_CODE_REPORT_FINDINGS: string; + export const npm_package_devDependencies_dts_buddy: string; + export const npm_package_dependencies_kleur: string; + export const npm_package_dependencies_devalue: string; + export const npm_config_ignore_scripts: string; + export const COPILOT_AGENT_CALLBACK_URL: string; + export const GITHUB_WORKFLOW_REF: string; + export const GITHUB_ACTION_REPOSITORY: string; + export const ENABLE_RUNNER_TRACING: string; + export const npm_package_exports___package_json: string; + export const npm_package_peerDependencies_svelte: string; export const PATH: string; - export const npm_config_argv: string; - export const npm_package_scripts_postinstall: string; - export const MCP_CONNECTION_NONBLOCKING: string; - export const npm_package_devDependencies_rollup: string; - export const npm_package_dependencies_magic_string: string; - export const npm_package_json: string; - export const _: string; - export const npm_config_userconfig: string; - export const npm_config_init_module: string; - export const COREPACK_ENABLE_DOWNLOAD_PROMPT: string; - export const __CFBundleIdentifier: string; - export const npm_command: string; - export const PWD: string; - export const npm_lifecycle_event: string; - export const EDITOR: string; - export const npm_package_name: string; - export const npm_package_types: string; - export const npm_package_devDependencies__sveltejs_vite_plugin_svelte: string; + export const NODE: string; + export const COPILOT_AGENT_INJECTED_SECRET_NAMES: string; + export const ANT_HOME: string; + export const DOTNET_MULTILEVEL_LOOKUP: string; + export const RUNNER_TRACKING_ID: string; + export const INVOCATION_ID: string; + export const RUNNER_TOOL_CACHE: string; + export const GITHUB_UPLOADS_URL: string; + export const REQUESTS_CA_BUNDLE: string; export const npm_package_repository_type: string; - export const npm_package_scripts_generate_types: string; + export const npm_package_name: string; + export const GITHUB_ACTION: string; + export const GITHUB_RUN_NUMBER: string; + export const GITHUB_TRIGGERING_ACTOR: string; + export const COPILOT_EXPERIMENTS: string; + export const RUNNER_ARCH: string; + export const XDG_RUNTIME_DIR: string; + export const AGENT_TOOLSDIRECTORY: string; export const npm_package_scripts_test_integration: string; - export const npm_package_devDependencies__types_connect: string; export const npm_package_exports___node_polyfills_import: string; - export const npm_package_exports___types: string; - export const npm_config_version_commit_hooks: string; - export const npm_config_npm_version: string; - export const NODE_USE_SYSTEM_CA: string; - export const XPC_FLAGS: string; + export const npm_package_devDependencies__types_set_cookie_parser: string; + export const SSL_CERT_DIR: string; + export const npm_package_scripts_test_unit: string; + export const npm_package_exports___vite_types: string; + export const npm_config_ignore_path: string; + export const LANG: string; + export const VCPKG_INSTALLATION_ROOT: string; + export const CONDA: string; + export const RUNNER_NAME: string; + export const XDG_CONFIG_HOME: string; + export const GITHUB_REF_NAME: string; + export const GITHUB_REPOSITORY: string; + export const npm_lifecycle_script: string; export const npm_package_scripts_test_cross_platform_dev: string; - export const npm_package_devDependencies_vitest: string; - export const npm_package_dependencies_tiny_glob: string; - export const npm_config_bin_links: string; - export const npm_package_engines_node: string; - export const npm_package_dependencies_sirv: string; - export const npm_config_node_gyp: string; - export const XPC_SERVICE_NAME: string; - export const npm_package_version: string; - export const npm_config_yes: string; - export const SHLVL: string; - export const HOME: string; - export const npm_package_type: string; - export const CLAUDE_CODE_DISABLE_CRON: string; - export const ANTHROPIC_BASE_URL: string; - export const npm_package_scripts_generate_version: string; + export const SUDO_COMMAND: string; + export const ANDROID_NDK_ROOT: string; + export const GITHUB_ACTION_REF: string; + export const DEBIAN_FRONTEND: string; export const npm_package_scripts_test: string; + export const npm_package_dependencies_esm_env: string; + export const npm_config_version_git_message: string; + export const SHELL: string; + export const GITHUB_REPOSITORY_ID: string; + export const GITHUB_ACTIONS: string; + export const CPD_SAVE_TRAJECTORY_OUTPUT: string; + export const npm_lifecycle_event: string; + export const npm_package_repository_url: string; + export const npm_package_version: string; + export const GITHUB_REF_PROTECTED: string; + export const npm_config_argv: string; + export const npm_package_scripts_generate_types: string; export const npm_package_scripts_check_all: string; - export const CLAUDE_CODE_EXECPATH: string; - export const npm_package_exports___vite_types: string; - export const npm_package_exports___hooks_types: string; - export const npm_config_save_prefix: string; + export const npm_package_devDependencies_svelte: string; + export const npm_package_dependencies_cookie: string; + export const GITHUB_WORKSPACE: string; + export const SUDO_USER: string; + export const ACCEPT_EULA: string; + export const DOTNET_SYSTEM_NET_DISABLEIPV6: string; + export const GITHUB_JOB: string; + export const YARN_IGNORE_PATH: string; + export const npm_package_exports___node_import: string; + export const GITHUB_SHA: string; + export const GITHUB_RUN_ATTEMPT: string; + export const COPILOT_AGENT_DEBUG: string; + export const npm_package_devDependencies__types_sade: string; + export const npm_config_version_git_tag: string; + export const npm_config_version_git_sign: string; + export const GITHUB_REF: string; + export const COPILOT_AGENT_ISSUE_NUMBER: string; + export const COPILOT_AGENT_SOURCE_ENVIRONMENT: string; + export const GITHUB_ACTOR: string; + export const FIREWALL_RULESET_CONTENT: string; + export const ANDROID_SDK_ROOT: string; + export const npm_package_license: string; export const npm_config_strict_ssl: string; - export const DISABLE_MICROCOMPACT: string; - export const MCP_SERVER_CONNECTION_BATCH_SIZE: string; - export const npm_config_version_git_message: string; - export const npm_config_cache: string; - export const LOGNAME: string; export const npm_package_scripts_format: string; + export const GITHUB_PATH: string; + export const JAVA_HOME: string; + export const PWD: string; + export const GITHUB_ACTOR_ID: string; + export const RUNNER_WORKSPACE: string; + export const npm_execpath: string; + export const npm_package_dependencies_set_cookie_parser: string; + export const COPILOT_AGENT_PR_NUMBER: string; + export const HOMEBREW_CLEANUP_PERIODIC_FULL_DAYS: string; + export const GITHUB_EVENT_NAME: string; + export const HOMEBREW_NO_AUTO_UPDATE: string; + export const ANDROID_HOME: string; + export const GITHUB_SERVER_URL: string; + export const GECKOWEBDRIVER: string; + export const GHCUP_INSTALL_BASE_PREFIX: string; + export const GITHUB_OUTPUT: string; + export const npm_package_exports___types: string; + export const EDGEWEBDRIVER: string; + export const COPILOT_EXPERIMENT_ASSIGNMENT_CONTEXT: string; export const npm_package_peerDependencies_vite: string; - export const npm_lifecycle_script: string; - export const npm_package_peerDependencies_svelte: string; - export const npm_config_ignore_path: string; - export const COREPACK_ENABLE_AUTO_PIN: string; - export const npm_package_devDependencies__types_set_cookie_parser: string; - export const npm_config_user_agent: string; - export const CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH: string; - export const npm_package_files_3: string; - export const npm_package_dependencies__types_cookie: string; - export const npm_config_version_git_sign: string; - export const npm_config_ignore_scripts: string; - export const CLAUDE_CODE_SESSION_ID: string; - export const DISABLE_AUTOUPDATER: string; - export const npm_package_files_2: string; - export const npm_package_devDependencies__types_node: string; - export const npm_package_devDependencies__playwright_test: string; - export const npm_package_files_1: string; - export const npm_package_devDependencies_dts_buddy: string; - export const OSLogRateLimit: string; - export const npm_package_files_0: string; - export const npm_package_dependencies_mrmime: string; - export const npm_package_dependencies_kleur: string; - export const npm_config_init_version: string; + export const npm_package_peerDependencies__sveltejs_vite_plugin_svelte: string; + export const npm_config_save_prefix: string; export const npm_config_ignore_optional: string; - export const CLAUDECODE: string; - export const CLAUDE_CODE_MESSAGING_SOCKET: string; - export const npm_package_exports___node_types: string; - export const npm_package_files_6: string; - export const npm_package_scripts_check: string; - export const npm_package_files_5: string; - export const npm_node_execpath: string; - export const npm_config_prefix: string; - export const USE_STAGING_OAUTH: string; - export const npm_package_scripts_test_unit: string; - export const npm_package_files_4: string; - export const npm_config_version_tag_prefix: string; + export const ANDROID_NDK: string; + export const SGX_AESM_ADDR: string; + export const CHROME_BIN: string; + export const PUPPETEER_SKIP_DOWNLOAD: string; + export const SELENIUM_JAR_PATH: string; + export const MEMORY_PRESSURE_WRITE: string; + export const COPILOT_AGENT_COMMIT_EMAIL: string; + export const COPILOT_AGENT_FIREWALL_LOG_FILE: string; + export const COPILOT_FEATURE_FLAGS: string; + export const npm_package_exports___node_polyfills_types: string; + export const INIT_CWD: string; + export const COPILOT_API_URL: string; + export const ANDROID_NDK_HOME: string; + export const GITHUB_STEP_SUMMARY: string; + export const COPILOT_AGENT_BASE_COMMIT: string; + export const COPILOT_AGENT_TIMEOUT_MIN: string; + export const npm_package_exports___hooks_import: string; + export const npm_package_dependencies_import_meta_resolve: string; } /** @@ -218,167 +312,261 @@ declare module '$env/static/public' { */ declare module '$env/dynamic/private' { export const env: { - CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES: string; - CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: string; - npm_package_exports___node_polyfills_types: string; - CLAUDE_CODE_MESSAGING_TOKEN: string; - NoDefaultCurrentDirectoryInExePath: string; + SUDO_GID: string; + GITHUB_STATE: string; + COPILOT_AGENT_ACTION: string; npm_package_scripts_test_cross_platform_build: string; - CLAUDE_EFFORT: string; - CLAUDE_CODE_ENTRYPOINT: string; - npm_package_exports___vite_import: string; - npm_package_exports___hooks_import: string; - NODE: string; + npm_package_devDependencies_rollup: string; + npm_package_devDependencies__types_node: string; + COPILOT_AGENT_START_TIME_SEC: string; + CURL_CA_BUNDLE: string; + DOTNET_NOLOGO: string; + npm_package_devDependencies_vitest: string; + MAIL: string; + NODE_EXTRA_CA_CERTS: string; + USER: string; + npm_package_bin_svelte_kit: string; + npm_package_dependencies_sirv: string; npm_package_dependencies_sade: string; - INIT_CWD: string; + npm_package_dependencies_mrmime: string; + npm_package_dependencies_magic_string: string; + npm_config_version_commit_hooks: string; + npm_config_user_agent: string; + SHOULD_CONTINUE: string; + CI: string; + npm_package_scripts_generate_version: string; + npm_package_dependencies__types_cookie: string; + npm_config_bin_links: string; + XDG_SESSION_TYPE: string; + RUNNER_ENVIRONMENT: string; + GITHUB_ENV: string; + COPILOT_AGENT_ONLINE_EVALUATION_DISABLED: string; + PIPX_HOME: string; + npm_node_execpath: string; + npm_package_devDependencies_vite: string; + npm_package_devDependencies__sveltejs_vite_plugin_svelte: string; + npm_config_init_version: string; + JAVA_HOME_8_X64: string; + SHLVL: string; + npm_package_exports___node_types: string; + npm_package_files_0: string; + COPILOT_AGENT_RUNTIME_VERSION: string; + HOME: string; + OLDPWD: string; + npm_package_files_1: string; + npm_package_repository_directory: string; + RUNNER_TEMP: string; + GITHUB_EVENT_PATH: string; + CAROOT: string; + COPILOT_AGENT_FIREWALL_RULESET_ALLOW_LIST: string; + npm_package_files_2: string; + JAVA_HOME_11_X64: string; + COPILOT_AGENT_MCP_SERVER_TEMP: string; + PIPX_BIN_DIR: string; + GITHUB_REPOSITORY_OWNER: string; + npm_package_engines_node: string; + npm_package_exports___vite_import: string; + npm_package_files_3: string; + npm_package_devDependencies_svelte_preprocess: string; + npm_config_init_license: string; + GRADLE_HOME: string; + ANDROID_NDK_LATEST_HOME: string; + JAVA_HOME_21_X64: string; + GITHUB_RETENTION_DAYS: string; + npm_package_files_4: string; + npm_config_version_tag_prefix: string; + GITHUB_REPOSITORY_OWNER_ID: string; + POWERSHELL_DISTRIBUTION_CHANNEL: string; + SSL_CERT_FILE: string; + AZURE_EXTENSION_DIR: string; + GITHUB_HEAD_REF: string; + npm_package_scripts_check: string; + npm_package_files_5: string; + npm_package_dependencies_tiny_glob: string; + SYSTEMD_EXEC_PID: string; + DBUS_SESSION_BUS_ADDRESS: string; + npm_package_scripts_postinstall: string; + npm_package_files_6: string; + GITHUB_GRAPHQL_URL: string; + GITHUB_DOWNLOADS_URL: string; npm_package_devDependencies_typescript: string; + npm_package_devDependencies__types_connect: string; + npm_package_description: string; + JAVA_HOME_25_X64: string; + NVM_DIR: string; + npm_package_readmeFilename: string; + npm_package_types: string; npm_package_homepage: string; - npm_config_version_git_tag: string; - BAGGAGE: string; - CLAUDE_CODE_HOST_SESSION_ID: string; - CLAUDE_PREVIEW_CLASSIFIER_FLOOR: string; - CLAUDE_CODE_OAUTH_SCOPES: string; - SHELL: string; - npm_package_devDependencies_vite: string; - npm_package_dependencies_devalue: string; - CLAUDE_PID: string; - CLAUDE_CODE_CHILD_SESSION: string; - CLAUDE_CODE_EAGER_FLUSH: string; - TMPDIR: string; - npm_config_global_prefix: string; + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: string; + COPILOT_JOB_EVENT_TYPE: string; + JAVA_HOME_17_X64: string; + ImageVersion: string; + SUDO_UID: string; + npm_package_exports___hooks_types: string; + npm_package_devDependencies__playwright_test: string; + BLACKBIRD_MODE: string; + LOGNAME: string; + COPILOT_AGENT_PR_COMMIT_COUNT: string; + RUNNER_OS: string; + GITHUB_API_URL: string; + GOROOT_1_22_X64: string; + COPILOT_AGENT_COMMIT_LOGIN: string; + SWIFT_PATH: string; + npm_package_type: string; + COPILOT_USE_SESSIONS: string; + CHROMEWEBDRIVER: string; + COPILOT_AGENT_CONTENT_FILTER_MODE: string; + GOROOT_1_23_X64: string; + JOURNAL_STREAM: string; + GITHUB_WORKFLOW: string; + _: string; + COPILOT_AGENT_BRANCH_NAME: string; + MEMORY_PRESSURE_WATCH: string; + XDG_SESSION_CLASS: string; + GOROOT_1_24_X64: string; npm_package_scripts_lint: string; - npm_config_init_license: string; - npm_package_dependencies_set_cookie_parser: string; - npm_package_dependencies_cookie: string; - CLAUDE_AGENT_SDK_VERSION: string; - MallocNanoZone: string; - COLOR: string; - USE_LOCAL_OAUTH: string; - npm_config_noproxy: string; - CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH: string; - npm_package_devDependencies_svelte_preprocess: string; npm_config_registry: string; - npm_config_local_prefix: string; - npm_package_dependencies_import_meta_resolve: string; - npm_package_repository_url: string; - GIT_EDITOR: string; - AI_AGENT: string; - npm_package_readmeFilename: string; - USER: string; - npm_package_exports___node_import: string; - npm_package_description: string; - npm_package_exports___package_json: string; - npm_package_dependencies_esm_env: string; - npm_package_license: string; - API_TIMEOUT_MS: string; - COMMAND_MODE: string; - npm_config_globalconfig: string; + ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: string; + COPILOT_AGENT_FIREWALL_ENABLE_RULESET_ALLOW_LIST: string; + GOROOT_1_25_X64: string; + GITHUB_RUN_ID: string; + TERM: string; + XDG_SESSION_ID: string; + GITHUB_REF_TYPE: string; + BOOTSTRAP_HASKELL_NONINTERACTIVE: string; + GITHUB_WORKFLOW_SHA: string; + GITHUB_BASE_REF: string; + ImageOS: string; + COPILOT_MCP_ENABLED: string; npm_package_exports___import: string; - npm_package_repository_directory: string; - SSH_AUTH_SOCK: string; - __CF_USER_TEXT_ENCODING: string; - npm_package_bin_svelte_kit: string; - npm_execpath: string; - npm_package_devDependencies__types_sade: string; - npm_package_peerDependencies__sveltejs_vite_plugin_svelte: string; - npm_package_devDependencies_svelte: string; - YARN_IGNORE_PATH: string; - CLAUDE_CODE_REPORT_FINDINGS: string; + npm_package_devDependencies_dts_buddy: string; + npm_package_dependencies_kleur: string; + npm_package_dependencies_devalue: string; + npm_config_ignore_scripts: string; + COPILOT_AGENT_CALLBACK_URL: string; + GITHUB_WORKFLOW_REF: string; + GITHUB_ACTION_REPOSITORY: string; + ENABLE_RUNNER_TRACING: string; + npm_package_exports___package_json: string; + npm_package_peerDependencies_svelte: string; PATH: string; - npm_config_argv: string; - npm_package_scripts_postinstall: string; - MCP_CONNECTION_NONBLOCKING: string; - npm_package_devDependencies_rollup: string; - npm_package_dependencies_magic_string: string; - npm_package_json: string; - _: string; - npm_config_userconfig: string; - npm_config_init_module: string; - COREPACK_ENABLE_DOWNLOAD_PROMPT: string; - __CFBundleIdentifier: string; - npm_command: string; - PWD: string; - npm_lifecycle_event: string; - EDITOR: string; - npm_package_name: string; - npm_package_types: string; - npm_package_devDependencies__sveltejs_vite_plugin_svelte: string; + NODE: string; + COPILOT_AGENT_INJECTED_SECRET_NAMES: string; + ANT_HOME: string; + DOTNET_MULTILEVEL_LOOKUP: string; + RUNNER_TRACKING_ID: string; + INVOCATION_ID: string; + RUNNER_TOOL_CACHE: string; + GITHUB_UPLOADS_URL: string; + REQUESTS_CA_BUNDLE: string; npm_package_repository_type: string; - npm_package_scripts_generate_types: string; + npm_package_name: string; + GITHUB_ACTION: string; + GITHUB_RUN_NUMBER: string; + GITHUB_TRIGGERING_ACTOR: string; + COPILOT_EXPERIMENTS: string; + RUNNER_ARCH: string; + XDG_RUNTIME_DIR: string; + AGENT_TOOLSDIRECTORY: string; npm_package_scripts_test_integration: string; - npm_package_devDependencies__types_connect: string; npm_package_exports___node_polyfills_import: string; - npm_package_exports___types: string; - npm_config_version_commit_hooks: string; - npm_config_npm_version: string; - NODE_USE_SYSTEM_CA: string; - XPC_FLAGS: string; + npm_package_devDependencies__types_set_cookie_parser: string; + SSL_CERT_DIR: string; + npm_package_scripts_test_unit: string; + npm_package_exports___vite_types: string; + npm_config_ignore_path: string; + LANG: string; + VCPKG_INSTALLATION_ROOT: string; + CONDA: string; + RUNNER_NAME: string; + XDG_CONFIG_HOME: string; + GITHUB_REF_NAME: string; + GITHUB_REPOSITORY: string; + npm_lifecycle_script: string; npm_package_scripts_test_cross_platform_dev: string; - npm_package_devDependencies_vitest: string; - npm_package_dependencies_tiny_glob: string; - npm_config_bin_links: string; - npm_package_engines_node: string; - npm_package_dependencies_sirv: string; - npm_config_node_gyp: string; - XPC_SERVICE_NAME: string; - npm_package_version: string; - npm_config_yes: string; - SHLVL: string; - HOME: string; - npm_package_type: string; - CLAUDE_CODE_DISABLE_CRON: string; - ANTHROPIC_BASE_URL: string; - npm_package_scripts_generate_version: string; + SUDO_COMMAND: string; + ANDROID_NDK_ROOT: string; + GITHUB_ACTION_REF: string; + DEBIAN_FRONTEND: string; npm_package_scripts_test: string; + npm_package_dependencies_esm_env: string; + npm_config_version_git_message: string; + SHELL: string; + GITHUB_REPOSITORY_ID: string; + GITHUB_ACTIONS: string; + CPD_SAVE_TRAJECTORY_OUTPUT: string; + npm_lifecycle_event: string; + npm_package_repository_url: string; + npm_package_version: string; + GITHUB_REF_PROTECTED: string; + npm_config_argv: string; + npm_package_scripts_generate_types: string; npm_package_scripts_check_all: string; - CLAUDE_CODE_EXECPATH: string; - npm_package_exports___vite_types: string; - npm_package_exports___hooks_types: string; - npm_config_save_prefix: string; + npm_package_devDependencies_svelte: string; + npm_package_dependencies_cookie: string; + GITHUB_WORKSPACE: string; + SUDO_USER: string; + ACCEPT_EULA: string; + DOTNET_SYSTEM_NET_DISABLEIPV6: string; + GITHUB_JOB: string; + YARN_IGNORE_PATH: string; + npm_package_exports___node_import: string; + GITHUB_SHA: string; + GITHUB_RUN_ATTEMPT: string; + COPILOT_AGENT_DEBUG: string; + npm_package_devDependencies__types_sade: string; + npm_config_version_git_tag: string; + npm_config_version_git_sign: string; + GITHUB_REF: string; + COPILOT_AGENT_ISSUE_NUMBER: string; + COPILOT_AGENT_SOURCE_ENVIRONMENT: string; + GITHUB_ACTOR: string; + FIREWALL_RULESET_CONTENT: string; + ANDROID_SDK_ROOT: string; + npm_package_license: string; npm_config_strict_ssl: string; - DISABLE_MICROCOMPACT: string; - MCP_SERVER_CONNECTION_BATCH_SIZE: string; - npm_config_version_git_message: string; - npm_config_cache: string; - LOGNAME: string; npm_package_scripts_format: string; + GITHUB_PATH: string; + JAVA_HOME: string; + PWD: string; + GITHUB_ACTOR_ID: string; + RUNNER_WORKSPACE: string; + npm_execpath: string; + npm_package_dependencies_set_cookie_parser: string; + COPILOT_AGENT_PR_NUMBER: string; + HOMEBREW_CLEANUP_PERIODIC_FULL_DAYS: string; + GITHUB_EVENT_NAME: string; + HOMEBREW_NO_AUTO_UPDATE: string; + ANDROID_HOME: string; + GITHUB_SERVER_URL: string; + GECKOWEBDRIVER: string; + GHCUP_INSTALL_BASE_PREFIX: string; + GITHUB_OUTPUT: string; + npm_package_exports___types: string; + EDGEWEBDRIVER: string; + COPILOT_EXPERIMENT_ASSIGNMENT_CONTEXT: string; npm_package_peerDependencies_vite: string; - npm_lifecycle_script: string; - npm_package_peerDependencies_svelte: string; - npm_config_ignore_path: string; - COREPACK_ENABLE_AUTO_PIN: string; - npm_package_devDependencies__types_set_cookie_parser: string; - npm_config_user_agent: string; - CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH: string; - npm_package_files_3: string; - npm_package_dependencies__types_cookie: string; - npm_config_version_git_sign: string; - npm_config_ignore_scripts: string; - CLAUDE_CODE_SESSION_ID: string; - DISABLE_AUTOUPDATER: string; - npm_package_files_2: string; - npm_package_devDependencies__types_node: string; - npm_package_devDependencies__playwright_test: string; - npm_package_files_1: string; - npm_package_devDependencies_dts_buddy: string; - OSLogRateLimit: string; - npm_package_files_0: string; - npm_package_dependencies_mrmime: string; - npm_package_dependencies_kleur: string; - npm_config_init_version: string; + npm_package_peerDependencies__sveltejs_vite_plugin_svelte: string; + npm_config_save_prefix: string; npm_config_ignore_optional: string; - CLAUDECODE: string; - CLAUDE_CODE_MESSAGING_SOCKET: string; - npm_package_exports___node_types: string; - npm_package_files_6: string; - npm_package_scripts_check: string; - npm_package_files_5: string; - npm_node_execpath: string; - npm_config_prefix: string; - USE_STAGING_OAUTH: string; - npm_package_scripts_test_unit: string; - npm_package_files_4: string; - npm_config_version_tag_prefix: string; + ANDROID_NDK: string; + SGX_AESM_ADDR: string; + CHROME_BIN: string; + PUPPETEER_SKIP_DOWNLOAD: string; + SELENIUM_JAR_PATH: string; + MEMORY_PRESSURE_WRITE: string; + COPILOT_AGENT_COMMIT_EMAIL: string; + COPILOT_AGENT_FIREWALL_LOG_FILE: string; + COPILOT_FEATURE_FLAGS: string; + npm_package_exports___node_polyfills_types: string; + INIT_CWD: string; + COPILOT_API_URL: string; + ANDROID_NDK_HOME: string; + GITHUB_STEP_SUMMARY: string; + COPILOT_AGENT_BASE_COMMIT: string; + COPILOT_AGENT_TIMEOUT_MIN: string; + npm_package_exports___hooks_import: string; + npm_package_dependencies_import_meta_resolve: string; [key: `PUBLIC_${string}`]: undefined; [key: `${string}`]: string | undefined; } diff --git a/packages/rrweb-snapshot/src/privacy.ts b/packages/rrweb-snapshot/src/privacy.ts index b5d8076e59..b6128d3bb0 100644 --- a/packages/rrweb-snapshot/src/privacy.ts +++ b/packages/rrweb-snapshot/src/privacy.ts @@ -42,7 +42,7 @@ const CARD_CANDIDATE = /(?:^|[^0-9-])((?:\d[ -]?){12,18}\d)(?:$|[^0-9-])/; const SSN_PATTERN = /\b(?!000|666|9\d{2})\d{3}-?(?!00)\d{2}-?(?!0000)\d{4}\b/; const EMAIL_PATTERN = /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[a-zA-Z0-9-]{1,63}(?:\.[a-zA-Z0-9-]{1,63})+/; -const PHONE_PATTERN = /(?:^|\s)\+?\d[\d().\-]{7,18}\d(?:$|\s)/; +const PHONE_PATTERN = /(?:^|\s)\+?\d[\d ().-]{7,13}\d(?:$|\s)/; const IPV4_PATTERN = /\b(?:\d{1,3}\.){3}\d{1,3}\b/; const MAX_SCAN_LENGTH = 10_000; diff --git a/packages/rrweb-snapshot/test/privacy.test.ts b/packages/rrweb-snapshot/test/privacy.test.ts index aa372ef74b..cf6e8d38e1 100644 --- a/packages/rrweb-snapshot/test/privacy.test.ts +++ b/packages/rrweb-snapshot/test/privacy.test.ts @@ -124,4 +124,12 @@ describe('detectSensitiveValue', () => { expect(emailOff.some((d) => d.name === 'email')).toBe(false); expect(emailOff.some((d) => d.name === 'payment-card')).toBe(true); }); + + it('detects spaced phone format (fix regression)', () => { + expect(detectSensitiveValue('call 555 123 4567 now', withDetectors)).toBe(true); + }); + + it('detects dashed phone format (fix regression)', () => { + expect(detectSensitiveValue('555-123-4567', withDetectors)).toBe(true); + }); }); From ec48742451e6c84c342d4e5731a1cc9a27308901 Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 12:01:58 +0200 Subject: [PATCH 07/64] feat(privacy): sanitizeUrl strips userinfo, uses precompiled sets, fails closed Co-Authored-By: Claude Fable 5 --- packages/rrweb-snapshot/src/privacy.ts | 29 ++++++++++++++++---- packages/rrweb-snapshot/test/privacy.test.ts | 26 +++++++++++++++++- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/rrweb-snapshot/src/privacy.ts b/packages/rrweb-snapshot/src/privacy.ts index b6128d3bb0..2215c00945 100644 --- a/packages/rrweb-snapshot/src/privacy.ts +++ b/packages/rrweb-snapshot/src/privacy.ts @@ -239,10 +239,27 @@ export function isProtectedInput(element: HTMLElement): boolean { .some((token) => PROTECTED_AUTOCOMPLETE.has(token)); } -export function sanitizeUrl( - value: string, - _privacy: CompiledPrivacyPolicy | undefined, -): string { - // Task 3 reimplements - return value; +export function sanitizeUrl(value: string, privacy: CompiledPrivacyPolicy | undefined): string { + if (!privacy || !privacy.sanitizeUrls) return value; + try { + const url = new URL(value, 'https://rrweb.invalid'); + url.username = ''; + url.password = ''; + for (const [name] of url.searchParams) { + const lower = name.toLowerCase(); + if ( + (privacy.preset === 'strict' && !privacy.allowedQueryParameters) || + (privacy.allowedQueryParameters && !privacy.allowedQueryParameters.has(lower)) || + privacy.blockedQueryParameters.has(lower) + ) { + url.searchParams.set(name, '*'); + } + } + if (privacy.removeHash) url.hash = ''; + if (url.origin === 'https://rrweb.invalid') + return `${url.pathname}${url.search}${url.hash}`; + return url.toString(); + } catch { + return ''; // fail closed: an unparseable URL is not recorded + } } diff --git a/packages/rrweb-snapshot/test/privacy.test.ts b/packages/rrweb-snapshot/test/privacy.test.ts index cf6e8d38e1..64b13afd4e 100644 --- a/packages/rrweb-snapshot/test/privacy.test.ts +++ b/packages/rrweb-snapshot/test/privacy.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { describe, it, expect, vi } from 'vitest'; -import { compilePrivacyPolicy, validateSelector, mergeBlockSelectors, detectSensitiveValue, buildDetectors } from '../src/privacy'; +import { compilePrivacyPolicy, validateSelector, mergeBlockSelectors, detectSensitiveValue, buildDetectors, sanitizeUrl } from '../src/privacy'; describe('compilePrivacyPolicy v2', () => { it('legacy preset compiles to inert options', () => { @@ -133,3 +133,27 @@ describe('detectSensitiveValue', () => { expect(detectSensitiveValue('555-123-4567', withDetectors)).toBe(true); }); }); + +describe('sanitizeUrl v2', () => { + const strict = compilePrivacyPolicy({ version: 1, preset: 'strict' }); + const balanced = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); + const legacy = compilePrivacyPolicy(undefined); + it('strips userinfo credentials', () => { + expect(sanitizeUrl('https://alice:hunter2@api.example.com/x', balanced)).toBe('https://api.example.com/x'); + }); + it('masks blocked query parameters, case-insensitively', () => { + expect(sanitizeUrl('https://a.com/?Token=abc&ok=1', balanced)).toBe('https://a.com/?Token=*&ok=1'); + }); + it('strict masks all params unless allowlisted', () => { + const allow = compilePrivacyPolicy({ version: 1, preset: 'strict', url: { allowedQueryParameters: ['page'] } }); + expect(sanitizeUrl('https://a.com/?page=2&q=x', strict)).toBe('https://a.com/?page=*&q=*'); + expect(sanitizeUrl('https://a.com/?page=2&q=x', allow)).toBe('https://a.com/?page=2&q=*'); + }); + it('removes hash unless disabled; legacy passes through untouched', () => { + expect(sanitizeUrl('https://a.com/x#frag', balanced)).toBe('https://a.com/x'); + expect(sanitizeUrl('https://alice:pw@a.com/?token=x#f', legacy)).toBe('https://alice:pw@a.com/?token=x#f'); + }); + it('unparseable value under non-legacy fails closed to empty string', () => { + expect(sanitizeUrl('http://[broken', balanced)).toBe(''); + }); +}); From 90036fea71b183a28bab82b8ee4787e5d1c4b0bb Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 12:19:24 +0200 Subject: [PATCH 08/64] feat(privacy): unmask selector + detector hook in core text masking, CSS exempt everywhere Co-Authored-By: Claude Fable 5 --- packages/rrweb-snapshot/src/privacy.ts | 19 ++ packages/rrweb-snapshot/src/snapshot.ts | 164 +++++++++++++----- .../test/privacy-integration.test.ts | 58 +++++++ packages/rrweb/src/record/index.ts | 12 +- packages/rrweb/src/record/mutation.ts | 4 + packages/rrweb/src/types.ts | 2 + 6 files changed, 218 insertions(+), 41 deletions(-) create mode 100644 packages/rrweb-snapshot/test/privacy-integration.test.ts diff --git a/packages/rrweb-snapshot/src/privacy.ts b/packages/rrweb-snapshot/src/privacy.ts index 2215c00945..48522ebfa7 100644 --- a/packages/rrweb-snapshot/src/privacy.ts +++ b/packages/rrweb-snapshot/src/privacy.ts @@ -201,6 +201,25 @@ export function mergeBlockSelectors( ); } +export function mergeMaskTextSelectors( + legacySelector: string | null, + privacy: CompiledPrivacyPolicy | undefined, +): string | null { + return ( + [legacySelector, privacy?.maskTextSelector].filter(Boolean).join(',') || null + ); +} + +export function mergeUnmaskTextSelectors( + legacySelector: string | null, + privacy: CompiledPrivacyPolicy | undefined, +): string | null { + return ( + [legacySelector, privacy?.unmaskTextSelector].filter(Boolean).join(',') || + null + ); +} + export function passesLuhn(candidate: string): boolean { const digits = candidate.replace(/[ -]/g, ''); if (!/^\d{13,19}$/.test(digits) || /^(\d)\1+$/.test(digits)) return false; diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index fcf250db3c..40e513abc8 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -34,7 +34,13 @@ import { absolutifyURLs, markCssSplits, } from './snapshot-utils'; -import { compilePrivacyPolicy, mergeBlockSelectors } from './privacy'; +import { + compilePrivacyPolicy, + detectSensitiveValue, + mergeBlockSelectors, + mergeMaskTextSelectors, + mergeUnmaskTextSelectors, +} from './privacy'; import dom from '@rrweb/utils'; let _id = 1; @@ -265,47 +271,93 @@ export function classMatchesRegex( return classMatchesRegex(dom.parentNode(node), regex, checkAncestors); } +/** + * `'*'` (compiled from the `strict` preset) is a mask-everything default rather + * than an explicit per-element rule: it must lose to an `unmaskTextSelector` + * ancestor, and it must not shadow a nearer explicit mask ancestor. So it is + * split out of the selector list and only applied once the ancestor walk has + * found nothing explicit. + */ +const maskAllSelectorCache = new Map< + string, + { maskAll: boolean; selector: string | null } +>(); + +function splitMaskAllSelector(maskTextSelector: string): { + maskAll: boolean; + selector: string | null; +} { + const cached = maskAllSelectorCache.get(maskTextSelector); + if (cached) return cached; + let maskAll = false; + const kept: string[] = []; + for (const part of maskTextSelector.split(',')) { + if (part.trim() === '*') maskAll = true; + else kept.push(part); + } + const parsed = { + maskAll, + selector: maskAll ? kept.join(',') || null : maskTextSelector, + }; + if (maskAllSelectorCache.size < 100) + maskAllSelectorCache.set(maskTextSelector, parsed); + return parsed; +} + +function classMatchesMaskTextClass( + el: Element, + maskTextClass: string | RegExp, +): boolean { + if (typeof maskTextClass === 'string') + return el.classList.contains(maskTextClass); + for (let index = el.classList.length; index--; ) { + if (maskTextClass.test(el.classList[index])) return true; + } + return false; +} + export function needMaskingText( node: Node, maskTextClass: string | RegExp, maskTextSelector: string | null, + unmaskTextSelector: string | null, checkAncestors: boolean, ): boolean { - let el: Element; - if (isElement(node)) { - el = node; - if (!dom.childNodes(el).length) { - // optimisation: we can avoid any of the below checks on leaf elements - // as masking is applied to child text nodes only - return false; - } - } else if (dom.parentElement(node) === null) { - // should warn? maybe a text node isn't attached to a parent node yet? - return false; - } else { - el = dom.parentElement(node)!; - } try { - if (typeof maskTextClass === 'string') { - if (checkAncestors) { - if (el.closest(`.${maskTextClass}`)) return true; - } else { - if (el.classList.contains(maskTextClass)) return true; + let el: Element; + if (isElement(node)) { + el = node; + if (!dom.childNodes(el).length) { + // optimisation: we can avoid any of the below checks on leaf elements + // as masking is applied to child text nodes only + return false; } + } else if (dom.parentElement(node) === null) { + // should warn? maybe a text node isn't attached to a parent node yet? + return false; } else { - if (classMatchesRegex(el, maskTextClass, checkAncestors)) return true; + el = dom.parentElement(node)!; } - if (maskTextSelector) { - if (checkAncestors) { - if (el.closest(maskTextSelector)) return true; - } else { - if (el.matches(maskTextSelector)) return true; - } + const { maskAll, selector } = maskTextSelector + ? splitMaskAllSelector(maskTextSelector) + : { maskAll: false, selector: null }; + // fast path: nothing can overrule a mask-everything policy + if (maskAll && !unmaskTextSelector) return true; + let current: Element | null = el; + while (current) { + // nearest ancestor wins: the first explicit decision going upwards + if (unmaskTextSelector && current.matches(unmaskTextSelector)) + return false; + if (classMatchesMaskTextClass(current, maskTextClass)) return true; + if (selector && current.matches(selector)) return true; + if (!checkAncestors) break; + current = dom.parentElement(current); } + return maskAll; } catch (e) { - // + // fail closed: an error in the mask decision masks + return true; } - return false; } // https://stackoverflow.com/a/36155560 @@ -521,7 +573,7 @@ function serializeTextNode( privacy?: CompiledPrivacyPolicy; }, ): serializedNode { - const { needsMask, maskTextFn, rootId, cssCaptured } = options; + const { needsMask, maskTextFn, rootId, cssCaptured, privacy } = options; // The parent node may not be a html element which has a tagName attribute. // Named form controls can also shadow `tagName` (e.g. ). // So just let it be undefined which is ok in this use case. @@ -545,12 +597,22 @@ function serializeTextNode( textContent = absolutifyURLs(textContent, getHref(options.doc)); } } - if (!isScript && textContent) { - if (!isStyle && needsMask) { - textContent = maskTextFn - ? maskTextFn(textContent, dom.parentElement(n)) - : textContent.replace(/[\S]/g, '*'); - } + if (!isStyle && !isScript && textContent && needsMask) { + textContent = maskTextFn + ? maskTextFn(textContent, dom.parentElement(n)) + : textContent.replace(/[\S]/g, '*'); + } + // Detectors are policy-independent and mask the whole text node when they + // find anything sensitive. CSS and scripts are never scanned or masked. + if ( + !isStyle && + !isScript && + textContent && + !needsMask && + privacy && + detectSensitiveValue(textContent, privacy) + ) { + textContent = textContent.replace(/[\S]/g, '*'); } return { @@ -998,6 +1060,7 @@ export function serializeNodeWithId( blockSelector: string | null; maskTextClass: string | RegExp; maskTextSelector: string | null; + unmaskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; newlyAddedElement?: boolean; @@ -1036,6 +1099,7 @@ export function serializeNodeWithId( blockSelector, maskTextClass, maskTextSelector, + unmaskTextSelector, skipChild = false, inlineStylesheet = true, maskInputOptions = {}, @@ -1061,13 +1125,19 @@ export function serializeNodeWithId( let { needsMask } = options; let { preserveWhiteSpace = true } = options; - if (!needsMask) { - // perf: if needsMask = true, children won't also need to check - const checkAncestors = needsMask === undefined; // if false, we've already checked ancestors + // perf: if needsMask = true, children won't also need to check — unless an + // unmaskTextSelector is configured, in which case a descendant can still + // escape a masked (or mask-everything) ancestor and must check for itself. + if (!needsMask || unmaskTextSelector) { + // if false, we've already checked ancestors (unless unmasking is in play, + // where the nearest-ancestor decision has to be recomputed per node) + const checkAncestors = + needsMask === undefined || Boolean(unmaskTextSelector); needsMask = needMaskingText( n as Element, maskTextClass, maskTextSelector, + unmaskTextSelector, checkAncestors, ); } @@ -1155,6 +1225,7 @@ export function serializeNodeWithId( needsMask, maskTextClass, maskTextSelector, + unmaskTextSelector, skipChild, inlineStylesheet, maskInputOptions, @@ -1235,6 +1306,7 @@ export function serializeNodeWithId( needsMask, maskTextClass, maskTextSelector, + unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, @@ -1291,6 +1363,7 @@ export function serializeNodeWithId( needsMask, maskTextClass, maskTextSelector, + unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, @@ -1336,6 +1409,7 @@ function snapshot( blockSelector?: string | null; maskTextClass?: string | RegExp; maskTextSelector?: string | null; + unmaskTextSelector?: string | null; inlineStylesheet?: boolean; maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; @@ -1368,7 +1442,8 @@ function snapshot( blockClass = 'rr-block', blockSelector: legacyBlockSelector = null, maskTextClass = 'rr-mask', - maskTextSelector = null, + maskTextSelector: legacyMaskTextSelector = null, + unmaskTextSelector: legacyUnmaskTextSelector = null, inlineStylesheet = true, inlineImages = false, recordCanvas = false, @@ -1391,6 +1466,14 @@ function snapshot( } = options || {}; const privacy = compilePrivacyPolicy(privacyPolicy); const blockSelector = mergeBlockSelectors(legacyBlockSelector, privacy); + const maskTextSelector = mergeMaskTextSelectors( + legacyMaskTextSelector, + privacy, + ); + const unmaskTextSelector = mergeUnmaskTextSelectors( + legacyUnmaskTextSelector, + privacy, + ); const maskInputOptions: MaskInputOptions = maskAllInputs === true ? { @@ -1425,6 +1508,7 @@ function snapshot( blockSelector, maskTextClass, maskTextSelector, + unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, diff --git a/packages/rrweb-snapshot/test/privacy-integration.test.ts b/packages/rrweb-snapshot/test/privacy-integration.test.ts new file mode 100644 index 0000000000..49050c1f97 --- /dev/null +++ b/packages/rrweb-snapshot/test/privacy-integration.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from 'vitest'; +import snapshot from '../src/snapshot'; +import type { PrivacyPolicy } from '../src/types'; + +function serialize(html: string, privacyPolicy?: PrivacyPolicy): string { + document.body.innerHTML = html; + return JSON.stringify(snapshot(document, { privacyPolicy })); +} + +describe('text masking v2', () => { + const strict: PrivacyPolicy = { version: 1, preset: 'strict' }; + + it('strict masks page text', () => { + expect(serialize('

hello world

', strict)).not.toContain( + 'hello world', + ); + }); + + it('never masks

secret

', + strict, + ); + expect(out).toMatch(/body\s*\{\s*color:\s*red/); + expect(out).not.toContain('secret'); + }); + + it('unmask selector wins for its subtree, nearest ancestor decides', () => { + const out = serialize( + '

visible

hidden

', + strict, + ); + expect(out).toContain('visible'); + expect(out).not.toContain('hidden'); + }); + + it('detectors mask the whole text node under legacy when configured', () => { + const withDetectors: PrivacyPolicy = { + version: 1, + preset: 'legacy', + detectors: { paymentCard: true, phone: true }, + }; + const out = serialize( + '

call 5551234567 4111 1111 1111 1111 now

', + withDetectors, + ); + expect(out).not.toContain('4111 1111 1111 1111'); + }); + + it('legacy without detectors leaves text untouched', () => { + expect(serialize('

bob@example.com

', undefined)).toContain( + 'bob@example.com', + ); + }); +}); diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index f62862244f..10c70119bf 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -5,6 +5,8 @@ import { createMirror, compilePrivacyPolicy, mergeBlockSelectors, + mergeMaskTextSelectors, + mergeUnmaskTextSelectors, sanitizeUrl, } from 'rrweb-snapshot'; import { initObservers, mutationBuffers } from './observer'; @@ -78,7 +80,7 @@ function record( ignoreClass = 'rr-ignore', ignoreSelector = null, maskTextClass = 'rr-mask', - maskTextSelector = null, + maskTextSelector: legacyMaskTextSelector = null, inlineStylesheet = true, maskAllInputs, maskInputOptions: _maskInputOptions, @@ -118,6 +120,11 @@ function record( ); const privacy = compilePrivacyPolicy(portablePrivacyPolicy); const blockSelector = mergeBlockSelectors(legacyBlockSelector, privacy); + const maskTextSelector = mergeMaskTextSelectors( + legacyMaskTextSelector, + privacy, + ); + const unmaskTextSelector = mergeUnmaskTextSelectors(null, privacy); // Strict remains fail-closed for the whole canvas. Region providers are // available to balanced/custom/legacy policies, where the application owns // the completeness of those regions. @@ -344,6 +351,7 @@ function record( blockSelector, maskTextClass, maskTextSelector, + unmaskTextSelector, inlineStylesheet, maskInputOptions, dataURLOptions, @@ -394,6 +402,7 @@ function record( blockSelector, maskTextClass, maskTextSelector, + unmaskTextSelector, inlineStylesheet, maskAllInputs: maskInputOptions, maskTextFn, @@ -549,6 +558,7 @@ function record( ignoreSelector, maskTextClass, maskTextSelector, + unmaskTextSelector, maskInputOptions, maskAllElementAttributes, maskAttributeFn, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 01fb2e63d2..a4af3ae9a9 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -176,6 +176,7 @@ export default class MutationBuffer { private blockSelector: observerParam['blockSelector']; private maskTextClass: observerParam['maskTextClass']; private maskTextSelector: observerParam['maskTextSelector']; + private unmaskTextSelector: observerParam['unmaskTextSelector']; private inlineStylesheet: observerParam['inlineStylesheet']; private maskInputOptions: observerParam['maskInputOptions']; private maskTextFn: observerParam['maskTextFn']; @@ -206,6 +207,7 @@ export default class MutationBuffer { 'blockSelector', 'maskTextClass', 'maskTextSelector', + 'unmaskTextSelector', 'inlineStylesheet', 'maskInputOptions', 'maskTextFn', @@ -327,6 +329,7 @@ export default class MutationBuffer { blockSelector: this.blockSelector, maskTextClass: this.maskTextClass, maskTextSelector: this.maskTextSelector, + unmaskTextSelector: this.unmaskTextSelector, skipChild: true, newlyAddedElement: true, inlineStylesheet: this.inlineStylesheet, @@ -599,6 +602,7 @@ export default class MutationBuffer { m.target, this.maskTextClass, this.maskTextSelector, + this.unmaskTextSelector, true, // checkAncestors ) && value ? this.maskTextFn diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 2bd58ed581..6b4e2afafc 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -117,6 +117,7 @@ export type observerParam = { ignoreSelector: string | null; maskTextClass: maskTextClass; maskTextSelector: string | null; + unmaskTextSelector: string | null; maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; @@ -165,6 +166,7 @@ export type MutationBufferParam = Pick< | 'blockSelector' | 'maskTextClass' | 'maskTextSelector' + | 'unmaskTextSelector' | 'inlineStylesheet' | 'maskInputOptions' | 'maskTextFn' From 72e47638064c6851073b219e52ed8238942e0620 Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 12:37:20 +0200 Subject: [PATCH 09/64] fix(privacy): keep inherited masking across shadow/iframe boundaries, exempt

secret

'; + const styleEl = document.querySelector('style') as HTMLStyleElement; + // with no CSSOM sheet (CSP, cross-origin) the CSS stays on the text node + // instead of moving to `_cssText`, which is what actually exercises + // serializeTextNode's `isStyle` exemption + Object.defineProperty(styleEl, 'sheet', { get: () => null }); + const out = JSON.stringify(snapshot(document, { privacyPolicy: strict })); + expect(out).toContain('body{color:red}'); + expect(out).not.toContain('secret'); + }); + it('unmask selector wins for its subtree, nearest ancestor decides', () => { const out = serialize( '

visible

hidden

', @@ -50,6 +70,42 @@ describe('text masking v2', () => { expect(out).not.toContain('4111 1111 1111 1111'); }); + it('keeps masking inherited from an ancestor outside the shadow root', () => { + withShadowRoot( + '
', + '

secret

', + ); + const out = JSON.stringify( + snapshot(document, { + privacyPolicy: { version: 1, preset: 'balanced' }, + }), + ); + expect(out).not.toContain('secret'); + }); + + it('lets an unmask selector inside the shadow root escape a masked host', () => { + withShadowRoot( + '
', + '

visible

', + ); + const out = JSON.stringify( + snapshot(document, { + privacyPolicy: { version: 1, preset: 'balanced' }, + }), + ); + expect(out).toContain('visible'); + }); + + it('masks a text node parented directly by a shadow root under strict', () => { + document.body.innerHTML = '
'; + const host = document.querySelector('#host') as HTMLElement; + host + .attachShadow({ mode: 'open' }) + .appendChild(document.createTextNode('secret')); + const out = JSON.stringify(snapshot(document, { privacyPolicy: strict })); + expect(out).not.toContain('secret'); + }); + it('legacy without detectors leaves text untouched', () => { expect(serialize('

bob@example.com

', undefined)).toContain( 'bob@example.com', diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index a4af3ae9a9..89af135c1c 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -596,15 +596,24 @@ export default class MutationBuffer { !isBlocked(m.target, this.blockClass, this.blockSelector, false) && value !== m.oldValue ) { + // CSS is never masked, on any path: a starred stylesheet corrupts + // the replay. Mirrors serializeTextNode's `isStyle` exemption. + const parent = dom.parentNode(m.target); + const isStyle = + parent && typeof (parent as HTMLElement).tagName === 'string' + ? (parent as HTMLElement).tagName.toUpperCase() === 'STYLE' + : false; this.texts.push({ value: + !isStyle && needMaskingText( m.target, this.maskTextClass, this.maskTextSelector, this.unmaskTextSelector, true, // checkAncestors - ) && value + ) && + value ? this.maskTextFn ? this.maskTextFn(value, closestElementOfNode(m.target)) : value.replace(/[\S]/g, '*') diff --git a/packages/rrweb/test/record/style-mutation.test.ts b/packages/rrweb/test/record/style-mutation.test.ts new file mode 100644 index 0000000000..3e34d5780c --- /dev/null +++ b/packages/rrweb/test/record/style-mutation.test.ts @@ -0,0 +1,78 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type * as puppeteer from 'puppeteer'; +import { vi } from 'vitest'; +import type { eventWithTime, listenerHandler } from '@rrweb/types'; +import type { recordOptions } from '../../src/types'; +import { launchPuppeteer, waitForRAF } from '../utils'; + +interface IWindow extends Window { + rrweb: { + record: ( + options: recordOptions, + ) => listenerHandler | undefined; + }; + emit: (e: eventWithTime) => undefined; +} + +describe('style text mutations', () => { + vi.setConfig({ testTimeout: 10_000 }); + + let browser: puppeteer.Browser; + let page: puppeteer.Page; + let code: string; + let events: eventWithTime[] = []; + + beforeAll(async () => { + browser = await launchPuppeteer(); + code = fs.readFileSync( + path.resolve(__dirname, '../../dist/rrweb.umd.cjs'), + 'utf8', + ); + }); + + beforeEach(async () => { + page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + '

secret

', + ); + await page.evaluate(code); + events = []; + await page.exposeFunction('emit', (e: eventWithTime) => { + events.push(e); + }); + }); + + afterEach(async () => { + await page.close(); + }); + + afterAll(async () => { + await browser.close(); + }); + + it('records ', 'style'), + name: '_cssText', + value: 'body{color:red}', + privacy: strict, + maskAllElementAttributes: true, + }), + ).toBe('body{color:red}'); + }); + + it('masks listed attributes under strict/balanced', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: strict, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'placeholder', + value: 'Bob', + privacy: balanced, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'aria-label', + value: 'Bob', + privacy: legacy, + }), + ).toBe('Bob'); + }); + + it('strict nulls media sources; URLs sanitized elsewhere', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'src', + value: 'https://a.com/i.png', + privacy: strict, + }), + ).toBeNull(); + expect( + finalizeAttribute({ + element: el('', 'a'), + name: 'href', + value: 'https://u:p@a.com/x?token=t', + privacy: balanced, + }), + ).toBe('https://a.com/x?token=*'); + // non-media element keeps a sanitized src under strict + expect( + finalizeAttribute({ + element: el('
', 'div'), + name: 'src', + value: 'https://a.com/x?page=1', + privacy: strict, + }), + ).toBe('https://a.com/x?page=*'); + }); + + it('masks value on form tags under strict only', () => { + expect( + finalizeAttribute({ + element: el('', 'input'), + name: 'value', + value: 'abc', + privacy: strict, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el('
  • ', 'li'), + name: 'value', + value: '3', + privacy: strict, + }), + ).toBe('3'); + expect( + finalizeAttribute({ + element: el('', 'input'), + name: 'value', + value: 'abc', + privacy: balanced, + }), + ).toBe('abc'); + }); + + it('maskAllElementAttributes stars everything except generated', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: undefined, + maskAllElementAttributes: true, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'rr_open_mode', + value: 'modal', + privacy: undefined, + maskAllElementAttributes: true, + isGenerated: true, + }), + ).toBe('modal'); + }); + + it('generated attributes are exempt from maskAttributeFn and the policy', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'rr_width', + value: '100px', + privacy: strict, + maskAttributeFn: () => 'nope', + isGenerated: true, + }), + ).toBe('100px'); + }); + + // NOTE: must be the first test in this file that combines maskAll + fn -- + // the warning is one-time per module instance. + it('warns once when maskAttributeFn is ignored under maskAllElementAttributes', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const call = () => + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: undefined, + maskAllElementAttributes: true, + maskAttributeFn: () => 'from-fn', + }); + expect(call()).toBe('***'); + expect(call()).toBe('***'); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('maskAttributeFn throw fails closed to stars; fn ignored under maskAll', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: undefined, + maskAttributeFn: () => { + throw new Error('boom'); + }, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: strict, + maskAllElementAttributes: true, + maskAttributeFn: () => 'from-fn', + }), + ).toBe('***'); + }); + + it('maskAttributeFn output wins over the policy', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: strict, + maskAttributeFn: (name, value) => `[${name}:${value.length}]`, + }), + ).toBe('[title:3]'); + }); + + it('passes through null/empty values and untouched attributes', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: null, + privacy: strict, + }), + ).toBeNull(); + expect( + finalizeAttribute({ + element: el(), + name: 'data-x', + value: 'plain', + privacy: strict, + }), + ).toBe('plain'); + }); +}); diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index f8309d4f0d..df39f31e39 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -6,6 +6,8 @@ import { isShadowRoot, needMaskingText, maskInput, + finalizeAttribute, + FORM_VALUE_TAGS, Mirror, isNativeShadowDom, getInputType, @@ -34,6 +36,15 @@ import { } from '../utils'; import dom from '@rrweb/utils'; +/** + * `attributeCursor` plus the names this recorder generated itself (currently + * only `rr_open_mode`), so the finalization sweep can exempt them without a + * side-table keyed by node. + */ +type attributeCursorWithGenerated = attributeCursor & { + generatedAttributes?: Set; +}; + type DoubleLinkedListNode = { previous: DoubleLinkedListNode | null; next: DoubleLinkedListNode | null; @@ -142,8 +153,8 @@ export default class MutationBuffer { private locked = false; private texts: textCursor[] = []; - private attributes: attributeCursor[] = []; - private attributeMap = new WeakMap(); + private attributes: attributeCursorWithGenerated[] = []; + private attributeMap = new WeakMap(); private removes: removedNodeMutation[] = []; private mapRemoves: Node[] = []; @@ -336,6 +347,8 @@ export default class MutationBuffer { maskInputOptions: this.maskInputOptions, maskTextFn: this.maskTextFn, maskInputFn: this.maskInputFn, + maskAllElementAttributes: this.maskAllElementAttributes, + maskAttributeFn: this.maskAttributeFn, privacy: this.privacy, slimDOMOptions: this.slimDOMOptions, dataURLOptions: this.dataURLOptions, @@ -482,11 +495,9 @@ export default class MutationBuffer { attributes: this.attributes .map((attribute) => { const { attributes } = attribute; - if ( - !this.maskAllElementAttributes && - !this.maskAttributeFn && - typeof attributes.style === 'string' - ) { + // `style` is never masked by any privacy path, so the compact style + // mutation can always be used when it is shorter. + if (typeof attributes.style === 'string') { const diffAsStr = JSON.stringify(attribute.styleDiff); const unchangedAsStr = JSON.stringify(attribute._unchangedStyles); // check if the style diff is actually shorter than the regular string based mutation @@ -502,24 +513,20 @@ export default class MutationBuffer { } } } - // Task 6 replaces this with finalizeAttribute. - if (this.maskAllElementAttributes || this.maskAttributeFn) { - for (const [name, value] of Object.entries(attributes)) { - if (typeof value !== 'string') continue; - if (this.maskAllElementAttributes) { - attributes[name] = '*'.repeat(value.length); - } else if (this.maskAttributeFn) { - try { - attributes[name] = this.maskAttributeFn( - name, - value, - attribute.node as Element, - ); - } catch { - attributes[name] = '*'.repeat(value.length); - } - } - } + // The single finalization sweep for the mutation path, mirroring + // serializeElementNode's: every attribute about to be emitted goes + // through `finalizeAttribute` exactly once. + for (const [name, value] of Object.entries(attributes)) { + if (typeof value !== 'string' && value !== null) continue; + attributes[name] = finalizeAttribute({ + element: attribute.node as Element, + name, + value, + privacy: this.privacy, + maskAllElementAttributes: this.maskAllElementAttributes, + maskAttributeFn: this.maskAttributeFn, + isGenerated: attribute.generatedAttributes?.has(name), + }); } return { id: this.mirror.getId(attribute.node), @@ -546,7 +553,7 @@ export default class MutationBuffer { // reset this.texts = []; this.attributes = []; - this.attributeMap = new WeakMap(); + this.attributeMap = new WeakMap(); this.removes = []; this.addedSet = new Set(); this.movedSet = new Set(); @@ -629,7 +636,14 @@ export default class MutationBuffer { let attributeName = m.attributeName as string; let value = (m.target as HTMLElement).getAttribute(attributeName); - if (attributeName === 'value') { + // `value` only means "input value" on form controls; on e.g. `
  • ` or + // `` it is an ordinary attribute and belongs to the normal + // `finalizeAttribute` path instead. + if ( + attributeName === 'value' && + typeof target.tagName === 'string' && + FORM_VALUE_TAGS.has(target.tagName.toUpperCase()) + ) { const type = getInputType(target); value = maskInput({ @@ -736,6 +750,8 @@ export default class MutationBuffer { } else { item.attributes['rr_open_mode'] = 'non-modal'; } + // recorder-generated, never page data: exempt from masking. + (item.generatedAttributes ||= new Set()).add('rr_open_mode'); } } break; diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 9722a919c3..dda00ce984 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -11,6 +11,7 @@ import { IncrementalSource, styleSheetRuleData, selectionData, + attributes, } from '@rrweb/types'; import { assertSnapshot, @@ -126,11 +127,16 @@ describe('record', function (this: ISuite) {

    initial@example.com

    +

    excluded text

    +

    visible text

    `); await ctx.page.evaluate(() => { const { record } = (window as unknown as IWindow).rrweb; record({ emit: (window as unknown as IWindow).emit, + // `data-privacy` selectors are part of the v2 presets; a recording + // with no policy at all stays on legacy semantics by design. + privacyPolicy: { version: 1, preset: 'balanced' }, }); const contact = document.querySelector('#contact')!; @@ -149,7 +155,10 @@ describe('record', function (this: ISuite) { expect(payload).not.toContain('Initial Name'); expect(payload).not.toContain('Changed Name'); expect(payload).not.toContain('secret'); - expect(payload).toContain('xxxxxxx@xxxxxxx.xxx'); + expect(payload).not.toContain('excluded text'); + expect(payload).toContain('visible text'); + // v2 masking is shape-free: stars only, no `xxxx@xxxx.xxx` shape mask. + expect(payload).toContain('*'.repeat('changed@example.com'.length)); }); it('applies detector plugins to the initial full snapshot', async () => { @@ -220,8 +229,60 @@ describe('record', function (this: ISuite) { const payload = JSON.stringify(ctx.events); expect(payload).not.toContain('initial@example.com'); expect(payload).not.toContain('changed@example.com'); - expect(payload).not.toContain('background-color'); expect(payload).toContain('[MASKED]'); + // CSS carried as an attribute is exempt from every masking path, so + // `maskAttributeFn` is never consulted for `style`. + expect(payload).toContain('background-color'); + }); + + it('masks attributes of nodes added after recording starts', async () => { + await ctx.page.setContent(`
    `); + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + maskAllElementAttributes: true, + }); + + const added = document.createElement('div'); + added.setAttribute('data-user', 'bob@x.com'); + document.querySelector('#root')!.appendChild(added); + }); + await waitForRAF(ctx.page); + + const payload = JSON.stringify(ctx.events); + expect(payload).not.toContain('bob@x.com'); + expect(payload).toContain('*'.repeat('bob@x.com'.length)); + }); + + it('leaves the value attribute of non-form elements unmasked', async () => { + await ctx.page.setContent(`
    1. three
    `); + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + privacyPolicy: { version: 1, preset: 'balanced' }, + }); + + document.querySelector('#item')!.setAttribute('value', '7'); + }); + await waitForRAF(ctx.page); + + const attributeMutations = ctx.events + .filter( + (e) => + e.type === EventType.IncrementalSnapshot && + (e.data as { source: IncrementalSource }).source === + IncrementalSource.Mutation, + ) + .flatMap( + (e) => + (e.data as unknown as { attributes: { attributes: attributes }[] }) + .attributes, + ); + expect(attributeMutations.some((m) => m.attributes.value === '7')).toBe( + true, + ); }); it('can checkout full snapshot by count', async () => { From 43afe1ef15c2494b179e071bb6079610777b4b6d Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 13:40:39 +0200 Subject: [PATCH 12/64] fix(privacy): apply policy to renamed rr_src, make maskAttributeFn a pipeline stage rr_src (the name a cross-origin iframe src is renamed to before finalization) was in neither URL_ATTRIBUTES nor MEDIA_SOURCE_ATTRIBUTES, so strict did not null it and balanced did not sanitize it -- userinfo and query tokens survived verbatim. Add it to both sets, keeping the rename-then-finalize order. maskAttributeFn no longer returns early: its output feeds into the policy block, which stays the final authority (design spec 5). Under legacy that block is the identity, so callback output survives verbatim; under balanced/strict the policy applies on top and can only narrow it. Co-Authored-By: Claude Fable 5 --- packages/rrweb-snapshot/src/privacy.ts | 43 +++++--- .../test/privacy-integration.test.ts | 97 ++++++++++++++++++- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/packages/rrweb-snapshot/src/privacy.ts b/packages/rrweb-snapshot/src/privacy.ts index e318966993..27498f4633 100644 --- a/packages/rrweb-snapshot/src/privacy.ts +++ b/packages/rrweb-snapshot/src/privacy.ts @@ -21,7 +21,12 @@ const MASKED_ATTRIBUTE_DEFAULTS = ['title', 'placeholder', 'aria-label']; */ const CSS_ATTRIBUTES = new Set(['style', '_csstext']); -/** Attributes whose value is a URL and therefore goes through `sanitizeUrl`. */ +/** + * Attributes whose value is a URL and therefore goes through `sanitizeUrl`. + * `rr_src` is the name the serializer gives a cross-origin `', 'iframe'); + expect( + finalizeAttribute({ + element: iframe(), + name: 'rr_src', + value: 'https://u:p@x.com/?token=t', + privacy: strict, + }), + ).toBeNull(); + expect( + finalizeAttribute({ + element: iframe(), + name: 'rr_src', + value: 'https://u:p@x.com/?token=t', + privacy: balanced, + }), + ).toBe('https://x.com/?token=*'); }); it('passes through null/empty values and untouched attributes', () => { @@ -414,3 +467,43 @@ describe('finalizeAttribute', () => { ).toBe('plain'); }); }); + +describe('attribute finalization through the serializer', () => { + /** + * A cross-origin ``; + // jsdom hands out a blank contentDocument for every iframe; a real + // cross-origin frame has none, which is what triggers the rr_src rename. + Object.defineProperty(document.querySelector('iframe')!, 'contentDocument', { + value: null, + }); + return JSON.stringify(snapshot(document, { privacyPolicy })); + } + + it('sanitizes the renamed rr_src of a cross-origin iframe under balanced', () => { + const out = serializeOpaqueIframe('https://u:p@x.com/?token=t', { + version: 1, + preset: 'balanced', + }); + expect(out).toContain('"rr_src":"https://x.com/?token=*"'); + expect(out).not.toContain('u:p@x.com'); + expect(out).not.toContain('token=t'); + }); + + it('drops the renamed rr_src of a cross-origin iframe under strict', () => { + const out = serializeOpaqueIframe('https://u:p@x.com/?token=t', { + version: 1, + preset: 'strict', + }); + expect(out).toContain('"rr_src":null'); + expect(out).not.toContain('x.com'); + }); +}); From 5d3270180fee75e072f568ef75ac1b877cfd7c4f Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 13:48:14 +0200 Subject: [PATCH 13/64] fix(privacy): let an emptied attribute reach the policy block The empty-string guard added in the previous commit returned before the whole policy block, so a maskAttributeFn returning '' bypassed the strict media-source null branch. rebuild.ts distinguishes null (attribute removed) from '' (setAttribute(name, '')), so a strict /', 'iframe'), + name: 'rr_src', + value: 'https://x.com/', + privacy: strict, + maskAttributeFn: () => '', + }), + ).toBeNull(); + // On the branches that do not drop it, an emptied value stays empty + // instead of being resolved into a path by sanitizeUrl. + expect( + finalizeAttribute({ + element: el('', 'a'), + name: 'href', + value: 'https://x.com/', + privacy: balanced, + maskAttributeFn: () => '', + }), + ).toBe(''); + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: strict, + maskAttributeFn: () => '', + }), + ).toBe(''); + }); + + it('fails closed when maskAttributeFn returns a non-string', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'data-x', + value: 'Bob', + privacy: legacy, + maskAttributeFn: () => undefined as unknown as string, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'data-x', + value: 'a longer value', + privacy: undefined, + maskAttributeFn: () => ({ nope: true }) as unknown as string, + }), + ).toBe('*'.repeat('a longer value'.length)); + }); + it('applies the strict media-source and URL rules to the renamed rr_src', () => { const iframe = () => el('', 'iframe'); expect( diff --git a/packages/rrweb-snapshot/test/privacy.test.ts b/packages/rrweb-snapshot/test/privacy.test.ts index 64b13afd4e..6f93182f2d 100644 --- a/packages/rrweb-snapshot/test/privacy.test.ts +++ b/packages/rrweb-snapshot/test/privacy.test.ts @@ -156,4 +156,9 @@ describe('sanitizeUrl v2', () => { it('unparseable value under non-legacy fails closed to empty string', () => { expect(sanitizeUrl('http://[broken', balanced)).toBe(''); }); + it('empty in, empty out -- never resolved into a path', () => { + expect(sanitizeUrl('', balanced)).toBe(''); + expect(sanitizeUrl('', strict)).toBe(''); + expect(sanitizeUrl('', legacy)).toBe(''); + }); }); From bdb75973aa788ca9db654c93a97a110b3e935122 Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 13:55:29 +0200 Subject: [PATCH 14/64] feat(privacy): CSS is never masked; delete stylesheet masking call sites Task 1 already removed the CSS masking paths in observer.ts/mutation.ts; the only remnant was StylesheetManager's maskAdoptedRule stub and its now-unused privacy constructor param. Deletes both and flips the stylesheet-manager.test.ts assertion to the v2 invariant: adopted-sheet rules are recorded verbatim regardless of privacy policy. Co-Authored-By: Claude Fable 5 --- packages/rrweb/src/record/index.ts | 1 - packages/rrweb/src/record/stylesheet-manager.ts | 11 ++--------- .../rrweb/test/record/stylesheet-manager.test.ts | 13 ++++++------- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 10c70119bf..f51e100529 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -305,7 +305,6 @@ function record( const stylesheetManager = new StylesheetManager({ mutationCb: wrappedMutationEmit, adoptedStyleSheetCb: wrappedAdoptedStyleSheetEmit, - privacy, }); const iframeManager = new IframeManager({ diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index 4b0697fbf5..796fec8064 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -1,4 +1,4 @@ -import { stringifyRule, type CompiledPrivacyPolicy } from 'rrweb-snapshot'; +import { stringifyRule } from 'rrweb-snapshot'; import type { elementNode, serializedNodeWithId, @@ -18,8 +18,6 @@ export class StylesheetManager { constructor(options: { mutationCb: mutationCallBack; adoptedStyleSheetCb: adoptedStyleSheetCallback; - // Plumbed through for later tasks; not yet consumed here. - privacy?: CompiledPrivacyPolicy; }) { this.mutationCb = options.mutationCb; this.adoptedStyleSheetCb = options.adoptedStyleSheetCb; @@ -72,7 +70,7 @@ export class StylesheetManager { rules: Array.from( sheet.cssRules || sheet.rules || [], (r, index) => ({ - rule: this.maskAdoptedRule(stringifyRule(r, sheet.href)), + rule: stringifyRule(r, sheet.href), index, }), ), @@ -89,11 +87,6 @@ export class StylesheetManager { this.trackedLinkElements = new WeakSet(); } - private maskAdoptedRule(rule: string): string { - // Task 3 reimplements text masking against the compiled policy. - return rule; - } - // TODO: take snapshot on stylesheet reload by applying event listener private trackStylesheetInLinkElement(_linkEl: HTMLLinkElement) { // linkEl.addEventListener('load', () => { diff --git a/packages/rrweb/test/record/stylesheet-manager.test.ts b/packages/rrweb/test/record/stylesheet-manager.test.ts index cb984bed67..ec033ad7ee 100644 --- a/packages/rrweb/test/record/stylesheet-manager.test.ts +++ b/packages/rrweb/test/record/stylesheet-manager.test.ts @@ -2,11 +2,10 @@ * @vitest-environment jsdom */ import { describe, expect, it } from 'vitest'; -import { applyPrivacyDetectors, compilePrivacyPolicy } from 'rrweb-snapshot'; import { StylesheetManager } from '../../src/record/stylesheet-manager'; describe('StylesheetManager privacy', () => { - it('masks PII in newly adopted stylesheet rules', () => { + it('records adopted stylesheet rules unmodified even under a strict privacy policy', () => { document.documentElement.innerHTML = ''; const style = document.createElement('style'); style.textContent = '.x { content: "person@example.com"; }'; @@ -14,19 +13,19 @@ describe('StylesheetManager privacy', () => { expect(style.sheet?.cssRules.length).toBeGreaterThan(0); const emitted: unknown[] = []; + // CSS is never masked, on any path: StylesheetManager has no privacy + // hook at all, so adopted-sheet rules pass through verbatim regardless + // of the caller's privacyPolicy (e.g. { version: 1, preset: 'strict' }). const manager = new StylesheetManager({ mutationCb: () => undefined, adoptedStyleSheetCb: (data) => { emitted.push(data); }, - privacy: compilePrivacyPolicy( - applyPrivacyDetectors({ version: 1, preset: 'balanced' }), - ), }); manager.adoptStyleSheets([style.sheet!], 1); const payload = JSON.stringify(emitted); - expect(payload).not.toContain('person@example.com'); - expect(payload).toContain('xxxxxx@xxxxxxx.xxx'); + expect(payload).toContain('person@example.com'); + expect(payload).not.toContain('xxxxxx@xxxxxxx.xxx'); }); }); From 1b659159467532a99099dc53161f9c37b5d6d407 Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 20:18:30 +0200 Subject: [PATCH 15/64] fix(canvas): masking forces FPS capture path; content-box region scaling; cheap canvas discovery Co-Authored-By: Claude Fable 5 --- packages/rrweb/src/record/canvas-sampling.ts | 23 ++++++++ packages/rrweb/src/record/index.ts | 15 +++++ .../record/observers/canvas/canvas-manager.ts | 53 +++++++++++++++--- .../record/observers/canvas/canvas-mask.ts | 53 ++++++++++++++++++ .../rrweb/src/record/shadow-dom-manager.ts | 10 ++++ .../rrweb/test/record/canvas-mask.test.ts | 55 +++++++++++++++++++ .../rrweb/test/record/canvas-sampling.test.ts | 29 ++++++++++ 7 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 packages/rrweb/src/record/canvas-sampling.ts create mode 100644 packages/rrweb/test/record/canvas-sampling.test.ts diff --git a/packages/rrweb/src/record/canvas-sampling.ts b/packages/rrweb/src/record/canvas-sampling.ts new file mode 100644 index 0000000000..b62b5de036 --- /dev/null +++ b/packages/rrweb/src/record/canvas-sampling.ts @@ -0,0 +1,23 @@ +/** + * Canvas privacy masking only redacts pixels on the FPS/OffscreenCanvas + * capture path (`sampling.canvas` as a number): that path renders full + * frames through `computeFrameMaskRegions` before they ever reach the + * encoding worker. The mutation-mode command stream (`sampling.canvas` as + * `'all'` or `undefined`) replays raw canvas API calls verbatim and has no + * way to redact anything. + * + * If canvas masking is configured but sampling stays in mutation mode, the + * masking is silently bypassed. To make that impossible, canvas masking + * being configured always forces numeric FPS sampling. + */ +export function resolveCanvasSampling( + requestedSampling: number | 'all' | undefined, + canvasMaskingConfigured: boolean, +): number | 'all' | undefined { + if (!canvasMaskingConfigured) return requestedSampling; + if (typeof requestedSampling === 'number') return requestedSampling; + console.warn( + '[rrweb] canvasMasking requires FPS canvas capture; forcing sampling.canvas = 4', + ); + return 4; +} diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index f51e100529..8fbabf084d 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -38,6 +38,9 @@ import { IframeManager } from './iframe-manager'; import { ShadowDomManager } from './shadow-dom-manager'; import { CanvasManager } from './observers/canvas/canvas-manager'; import { isCanvasMaskingConfigured } from './observers/canvas/canvas-mask'; +import { resolveCanvasSampling } from './canvas-sampling'; + +export { resolveCanvasSampling } from './canvas-sampling'; import { StylesheetManager } from './stylesheet-manager'; import ProcessedNodeManager from './processed-node-manager'; import { @@ -133,6 +136,18 @@ function record( const canvasMaskingConfigured = canvasMasking ? () => isCanvasMaskingConfigured(canvasMasking) : undefined; + // A canvas can only be masked on the FPS/OffscreenCanvas capture path, + // which renders full frames through the masking provider before they + // reach the encoding worker. The mutation-mode command stream + // (`sampling.canvas === 'all'`) replays raw canvas API calls verbatim and + // cannot be masked at all. Whenever canvasMasking is configured at all + // (structurally, regardless of any dynamic `isConfigured()` toggle - + // sampling mode can't be switched mid-session), force numeric FPS + // sampling so the unmasked command stream can never run alongside it. + sampling.canvas = resolveCanvasSampling( + sampling.canvas, + Boolean(canvasMasking), + ); registerErrorHandler(errorHandler); diff --git a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts index 94e149aa8c..59ab3b1120 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts @@ -18,7 +18,11 @@ import initCanvasContextObserver from './canvas'; import initCanvasWebGLMutationObserver from './webgl'; import ImageBitmapDataURLWorker from '../../workers/image-bitmap-data-url-worker?worker&inline'; import type { ImageBitmapDataURLRequestWorker } from '../../workers/image-bitmap-data-url-worker'; -import { computeFrameMaskRegions, SKIP_FRAME } from './canvas-mask'; +import { + computeFrameMaskRegions, + getCanvasContentBoxSize, + SKIP_FRAME, +} from './canvas-mask'; export type RafStamps = { latestId: number; invokeId: number | null }; @@ -38,12 +42,31 @@ export class CanvasManager { private locked = false; private resetFrameDedup?: () => void; + /** + * Shadow roots the shadow-DOM manager is actively observing. FPS canvas + * discovery searches these directly instead of recursively re-walking + * `querySelectorAll('*')` for shadow hosts on every animation frame - + * nested shadow roots are tracked in turn as the shadow-DOM manager + * starts observing them, so no recursion is needed here. + */ + private trackedShadowRoots = new Set(); + public reset() { this.pendingCanvasMutations.clear(); this.resetObservers && this.resetObservers(); this.resetFrameDedup = undefined; } + /** Called by the shadow-DOM manager when it starts observing a shadow root. */ + public addShadowRoot(shadowRoot: ShadowRoot) { + this.trackedShadowRoots.add(shadowRoot); + } + + /** Called by the shadow-DOM manager when it stops observing a shadow root. */ + public removeShadowRoot(shadowRoot: ShadowRoot) { + this.trackedShadowRoots.delete(shadowRoot); + } + /** Start a new canvas frame epoch after a DOM full snapshot. */ public onFullSnapshot() { this.resetFrameDedup?.(); @@ -190,21 +213,19 @@ export class CanvasManager { let lastSnapshotTime = 0; const getCanvas = (): HTMLCanvasElement[] => { const matchedCanvas: HTMLCanvasElement[] = []; - const search = (root: ParentNode) => { + const collect = (root: ParentNode) => { try { root.querySelectorAll('canvas').forEach((canvas) => { if (!isBlocked(canvas, blockClass, blockSelector, true)) { matchedCanvas.push(canvas); } }); - root.querySelectorAll('*').forEach((element) => { - if (element.shadowRoot) search(element.shadowRoot); - }); } catch { // A broken custom DOM implementation must not cancel future frames. } }; - search(win.document); + collect(win.document); + this.trackedShadowRoots.forEach((root) => collect(root)); return matchedCanvas; }; @@ -257,8 +278,24 @@ export class CanvasManager { context.clear(context.COLOR_BUFFER_BIT); } } - const displayWidth = canvas.clientWidth || canvas.width; - const displayHeight = canvas.clientHeight || canvas.height; + let displayWidth = canvas.clientWidth || canvas.width; + let displayHeight = canvas.clientHeight || canvas.height; + if (options.canvasMasking) { + // The backing store maps onto the content box, not the + // border box that `clientWidth`/`clientHeight` report - + // measure it precisely whenever masking is configured so + // mask regions never get scaled against the wrong box. A + // canvas whose content box can't be measured (or has zero + // area) fails closed: skip the frame rather than fall back + // to a potentially wrong scale. + const contentBox = getCanvasContentBoxSize(canvas); + if (!contentBox) { + snapshotInProgressMap.set(id, false); + return; + } + displayWidth = contentBox.width; + displayHeight = contentBox.height; + } const maskRegions = computeFrameMaskRegions( options.canvasMasking, canvas, diff --git a/packages/rrweb/src/record/observers/canvas/canvas-mask.ts b/packages/rrweb/src/record/observers/canvas/canvas-mask.ts index 5c000c1c6b..55713781cf 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas-mask.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas-mask.ts @@ -51,6 +51,59 @@ export function computeFrameMaskRegions( }); } +/** + * The canvas backing store maps onto the element's content box (the CSS + * width/height it is drawn at), not its border box. `clientWidth` includes + * padding, so a padded canvas would otherwise skew the scale factor used to + * translate application-provided mask regions into backing-store pixels. + * + * Returns `null` (never a fallback size) when the content box cannot be + * measured or has zero area, so callers fail closed instead of silently + * reinterpreting CSS-pixel regions as backing-store pixels. + */ +export function getCanvasContentBoxSize( + canvas: HTMLCanvasElement, +): { width: number; height: number } | null { + let rect: { width: number; height: number }; + try { + rect = canvas.getBoundingClientRect(); + } catch { + return null; + } + if (!rect || !Number.isFinite(rect.width) || !Number.isFinite(rect.height)) + return null; + + let style: CSSStyleDeclaration; + try { + style = getComputedStyle(canvas); + } catch { + return null; + } + if (!style) return null; + + const px = (value: string): number => { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; + }; + + const width = + rect.width - + px(style.paddingLeft) - + px(style.paddingRight) - + px(style.borderLeftWidth) - + px(style.borderRightWidth); + const height = + rect.height - + px(style.paddingTop) - + px(style.paddingBottom) - + px(style.borderTopWidth) - + px(style.borderBottomWidth); + + if (!(width > 0) || !(height > 0)) return null; + + return { width, height }; +} + export function isCanvasMaskingConfigured( masking: CanvasMasking | undefined, ): boolean { diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 6ffd9941c6..5af18df41c 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -28,6 +28,10 @@ export class ShadowDomManager { private bypassOptions: BypassOptions; private mirror: Mirror; private restoreHandlers: (() => void)[] = []; + // `shadowDoms` is a WeakSet purely for dedup and isn't iterable, so a + // separate iterable set tracks which shadow roots the canvas manager + // currently knows about, to be told when observation of them stops. + private canvasTrackedShadowRoots = new Set(); constructor(options: { mutationCb: mutationCallBack; @@ -53,6 +57,8 @@ export class ShadowDomManager { if (!isNativeShadowDom(shadowRoot)) return; if (this.shadowDoms.has(shadowRoot)) return; this.shadowDoms.add(shadowRoot); + this.canvasTrackedShadowRoots.add(shadowRoot); + this.bypassOptions.canvasManager.addShadowRoot(shadowRoot); const [observer] = initMutationObserver( { ...this.bypassOptions, @@ -153,5 +159,9 @@ export class ShadowDomManager { }); this.restoreHandlers = []; this.shadowDoms = new WeakSet(); + this.canvasTrackedShadowRoots.forEach((shadowRoot) => { + this.bypassOptions.canvasManager.removeShadowRoot(shadowRoot); + }); + this.canvasTrackedShadowRoots = new Set(); } } diff --git a/packages/rrweb/test/record/canvas-mask.test.ts b/packages/rrweb/test/record/canvas-mask.test.ts index 23dbf1cb51..339a437368 100644 --- a/packages/rrweb/test/record/canvas-mask.test.ts +++ b/packages/rrweb/test/record/canvas-mask.test.ts @@ -1,7 +1,11 @@ +/** + * @vitest-environment jsdom + */ import { describe, expect, it } from 'vitest'; import type { CanvasMaskRegion, CanvasMasking } from '@rrweb/types'; import { computeFrameMaskRegions, + getCanvasContentBoxSize, isCanvasMaskingConfigured, SKIP_FRAME, } from '../../src/record/observers/canvas/canvas-mask'; @@ -114,3 +118,54 @@ describe('canvas privacy masking', () => { ).toBe(true); }); }); + +describe('canvas content-box region scaling', () => { + it('uses the content box, not clientWidth, so padding does not skew the scale', () => { + const canvasEl = document.createElement('canvas'); + canvasEl.width = 100; + canvasEl.height = 100; + canvasEl.style.padding = '20px'; + document.body.appendChild(canvasEl); + + // jsdom performs no layout, so getBoundingClientRect/clientWidth are + // stubbed to reflect what a real browser would report: a 100x100 + // backing store padded by 20px on every side renders in a 140x140 + // border box, while clientWidth (border box minus border) is also 140. + canvasEl.getBoundingClientRect = () => + ({ width: 140, height: 140 }) as DOMRect; + Object.defineProperty(canvasEl, 'clientWidth', { value: 140 }); + Object.defineProperty(canvasEl, 'clientHeight', { value: 140 }); + + const contentBox = getCanvasContentBoxSize(canvasEl); + expect(contentBox).toEqual({ width: 100, height: 100 }); + + // Regions expressed in content-box CSS pixels must come back unscaled + // (scale 1) once the content box is used, even though naively using + // clientWidth (140) would have shrunk every coordinate incorrectly. + expect( + computeFrameMaskRegions( + { maskRegions: () => [region] }, + canvasEl, + canvasEl.width, + canvasEl.height, + contentBox!.width, + contentBox!.height, + ), + ).toEqual([region]); + + document.body.removeChild(canvasEl); + }); + + it('reports no content box (fail closed) when the content box has zero area', () => { + const canvasEl = document.createElement('canvas'); + canvasEl.width = 100; + canvasEl.height = 100; + canvasEl.getBoundingClientRect = () => + ({ width: 0, height: 0 }) as DOMRect; + document.body.appendChild(canvasEl); + + expect(getCanvasContentBoxSize(canvasEl)).toBeNull(); + + document.body.removeChild(canvasEl); + }); +}); diff --git a/packages/rrweb/test/record/canvas-sampling.test.ts b/packages/rrweb/test/record/canvas-sampling.test.ts new file mode 100644 index 0000000000..8cf069c527 --- /dev/null +++ b/packages/rrweb/test/record/canvas-sampling.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import { resolveCanvasSampling } from '../../src/record'; + +describe('canvas fail-closed', () => { + it('forces numeric sampling when masking configured', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(resolveCanvasSampling('all', true)).toBe(4); + expect(resolveCanvasSampling(undefined, true)).toBe(4); + expect(resolveCanvasSampling(15, true)).toBe(15); + expect(resolveCanvasSampling('all', false)).toBe('all'); + warn.mockRestore(); + }); + + it('warns once per forced resolution so silent bypass is never possible', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + resolveCanvasSampling('all', true); + expect(warn).toHaveBeenCalledWith( + '[rrweb] canvasMasking requires FPS canvas capture; forcing sampling.canvas = 4', + ); + warn.mockRestore(); + }); + + it('does not warn when a numeric sampling is already provided', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + resolveCanvasSampling(30, true); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); From 0bf6128617d0c567c76b3cb96f4c358bbe710e69 Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 20:46:11 +0200 Subject: [PATCH 16/64] fix(privacy): plugin compile fallback, shared untainted tagName, plugin detects under legacy Co-Authored-By: Claude Fable 5 --- .../rrweb-plugin-privacy-detectors/README.md | 16 ++++++--- .../test/index.test.ts | 14 ++++++++ packages/rrweb-snapshot/src/privacy.ts | 12 ++----- packages/rrweb-snapshot/src/snapshot.ts | 12 +++---- packages/rrweb-snapshot/src/utils.ts | 6 +--- packages/rrweb-snapshot/test/utils.test.ts | 36 +++++++++++++++++++ packages/rrweb/src/record/index.ts | 31 ++++++++++++++-- packages/rrweb/src/record/mutation.ts | 19 +++++----- packages/rrweb/test/record/privacy.test.ts | 31 ++++++++++++++++ packages/utils/src/index.ts | 19 ++++++++++ 10 files changed, 157 insertions(+), 39 deletions(-) create mode 100644 packages/rrweb/test/record/privacy.test.ts diff --git a/packages/plugins/rrweb-plugin-privacy-detectors/README.md b/packages/plugins/rrweb-plugin-privacy-detectors/README.md index 3dd036e5c2..80216217b8 100644 --- a/packages/plugins/rrweb-plugin-privacy-detectors/README.md +++ b/packages/plugins/rrweb-plugin-privacy-detectors/README.md @@ -1,10 +1,13 @@ # @rrweb/rrweb-plugin-privacy-detectors -Opt-in Highlight-style heuristic PII matching for rrweb Privacy at Capture. +Opt-in Highlight-style heuristic PII matching for rrweb Privacy at Capture: +whole-value masking of text nodes and input values when a detector matches +(email, phone, Luhn-valid payment card, SSN-like, IPv4). -`balanced` and `strict` mask form values and honor `data-privacy` / policy -rules. They do **not** scan page text for emails, phones, cards, SSNs, or IP -addresses unless this plugin (or `applyPrivacyDetectors`) is used. +No privacy preset implies detection on its own. `balanced` and `strict` mask +form values and honor `data-privacy` / policy rules, but neither one scans +page text or input values for emails, phones, cards, SSNs, or IP addresses +unless this plugin (or `applyPrivacyDetectors`) is used. ## Installation @@ -35,7 +38,10 @@ record({ ``` If `privacyPolicy` is omitted, the plugin keeps the `legacy` preset (existing -`maskTextFn` / `maskInputOptions` behavior) and only adds heuristic matching. +`maskTextFn` / `maskInputOptions` behavior) -- and still detects: detection is +independent of preset, so a bare `record({ plugins: [getRecordPrivacyDetectorsPlugin()] })` +with no `privacyPolicy` at all masks any text node or input value a detector +matches, on top of whatever `maskTextFn` / `maskInputOptions` already do. For snapshot-only use (no recorder): diff --git a/packages/plugins/rrweb-plugin-privacy-detectors/test/index.test.ts b/packages/plugins/rrweb-plugin-privacy-detectors/test/index.test.ts index cce77d559d..8902bfe8a3 100644 --- a/packages/plugins/rrweb-plugin-privacy-detectors/test/index.test.ts +++ b/packages/plugins/rrweb-plugin-privacy-detectors/test/index.test.ts @@ -4,6 +4,11 @@ import { getRecordPrivacyDetectorsPlugin, PLUGIN_NAME, } from '../src/index'; +import { + compilePrivacyPolicy, + detectSensitiveValue, + type PrivacyPolicy, +} from 'rrweb-snapshot'; describe('privacy detectors plugin', () => { it('opts heuristic detectors onto a balanced policy', () => { @@ -57,4 +62,13 @@ describe('privacy detectors plugin', () => { }, }); }); + + it('plugin with no user policy yields a legacy policy whose compiled detectors are active', () => { + const plugin = getRecordPrivacyDetectorsPlugin(); + const policy = plugin.applyPrivacyPolicy!(undefined) as PrivacyPolicy; + expect(policy.preset).toBe('legacy'); + const compiled = compilePrivacyPolicy(policy); + expect(compiled.detectors.length).toBeGreaterThan(0); + expect(detectSensitiveValue('bob@example.com', compiled)).toBe(true); + }); }); diff --git a/packages/rrweb-snapshot/src/privacy.ts b/packages/rrweb-snapshot/src/privacy.ts index 0fb1ee0a1b..f7db0d00b3 100644 --- a/packages/rrweb-snapshot/src/privacy.ts +++ b/packages/rrweb-snapshot/src/privacy.ts @@ -5,6 +5,7 @@ import type { PrivacyDetectorOptions, PrivacyPolicy, } from './types'; +import { untaintedTagName } from '@rrweb/utils'; const VENDOR_MASK_CLASSES = '.rr-mask,.mp-mask,.fs-mask,.amp-mask,.ph-mask,.sentry-mask,[data-sentry-mask]'; @@ -279,15 +280,6 @@ function stars(value: string): string { return '*'.repeat(value.length); } -/** - * A shadowed or non-string `tagName` (e.g. `` inside a - * form) must not crash the sweep; an unknown tag simply matches no tag set. - */ -function tagNameOf(element: Element): string { - const t: unknown = element.tagName; - return typeof t === 'string' ? t.toUpperCase() : ''; -} - /** * The single decision point for every attribute rrweb records, on both the * snapshot and the mutation path. Called exactly once per attribute, at the @@ -361,7 +353,7 @@ export function finalizeAttribute({ if (!privacy) return current; - const tagName = tagNameOf(element); + const tagName = untaintedTagName(element); if ( privacy.preset === 'strict' && MEDIA_TAGS.has(tagName) && diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 5b096bfff6..1edd8bccdf 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -585,14 +585,12 @@ function serializeTextNode( }, ): serializedNode { const { needsMask, maskTextFn, rootId, cssCaptured, privacy } = options; - // The parent node may not be a html element which has a tagName attribute. - // Named form controls can also shadow `tagName` (e.g. ). - // So just let it be undefined which is ok in this use case. + // The parent node may not be an html element which has a tagName attribute, + // and named form controls can shadow `tagName` (e.g. + // inside a
    ). `untaintedTagName` handles both: '' for a non-element + // parent, the real tag name even when shadowed. const parent = dom.parentNode(n); - const parentTagName = - parent && typeof (parent as HTMLElement).tagName === 'string' - ? (parent as HTMLElement).tagName.toUpperCase() - : undefined; + const parentTagName = dom.untaintedTagName(parent as Element | null) || undefined; let textContent: string | null = ''; const isStyle = parentTagName === 'STYLE' ? true : undefined; const isScript = parentTagName === 'SCRIPT' ? true : undefined; diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index fb128ad165..9d8d9d6be0 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -400,11 +400,7 @@ const PROTECTED_AUTOCOMPLETE = new Set([ * OTP). */ export function isProtectedInput(element: HTMLElement): boolean { - // Task 9 replaces with untaintedTagName. A shadowed/non-string `tagName` - // (e.g. ) fails closed: treat as protected. - const t: unknown = element.tagName; - if (typeof t !== 'string') return true; - if (t !== 'INPUT') return false; + if (dom.untaintedTagName(element) !== 'INPUT') return false; const input = element as HTMLInputElement; const type = getInputType(element); if (type === 'password' || type === 'hidden') { diff --git a/packages/rrweb-snapshot/test/utils.test.ts b/packages/rrweb-snapshot/test/utils.test.ts index f4b68245cc..9894e67dca 100644 --- a/packages/rrweb-snapshot/test/utils.test.ts +++ b/packages/rrweb-snapshot/test/utils.test.ts @@ -11,6 +11,11 @@ import { } from '../src/utils'; import { NodeType } from '@rrweb/types'; import type { serializedNode, serializedNodeWithId } from '@rrweb/types'; +// `@rrweb/utils` has no test infrastructure of its own (no vitest config / +// test dir), so `untaintedTagName` -- shared, package-level shadowing +// hardening added for Task 9 -- is covered here instead, importing it the +// same way rrweb-snapshot's own source does. +import { untaintedTagName } from '@rrweb/utils'; describe('utils', () => { describe('isNodeMetaEqual()', () => { @@ -326,4 +331,35 @@ describe('utils', () => { ); }); }); + + describe('untaintedTagName()', () => { + it('returns the real tag name even when a named form control shadows `tagName`', () => { + // Real browsers make named form controls reachable as own properties + // on their (so `` makes + // `form.tagName` resolve to the , not the string `'FORM'`). + // jsdom doesn't implement that quirk, so we reproduce the same shape + // of shadowing directly: an own `tagName` property that hides the + // inherited `Element.prototype` getter. + document.body.innerHTML = '
    '; + const form = document.querySelector('form')!; + const input = document.querySelector('input')!; + Object.defineProperty(form, 'tagName', { + value: input, + configurable: true, + }); + // sanity check: the shadowing actually took effect + expect(typeof form.tagName).not.toBe('string'); + expect(untaintedTagName(form)).toBe('FORM'); + }); + + it('returns an uppercased tag name for an ordinary element', () => { + const div = document.createElement('div'); + expect(untaintedTagName(div)).toBe('DIV'); + }); + + it('returns an empty string for null/undefined', () => { + expect(untaintedTagName(null)).toBe(''); + expect(untaintedTagName(undefined)).toBe(''); + }); + }); }); diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 8fbabf084d..ce921fdd8e 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -4,6 +4,7 @@ import { type MaskInputOptions, createMirror, compilePrivacyPolicy, + type CompiledPrivacyPolicy, mergeBlockSelectors, mergeMaskTextSelectors, mergeUnmaskTextSelectors, @@ -121,7 +122,33 @@ function record( : policy, privacyPolicy, ); - const privacy = compilePrivacyPolicy(portablePrivacyPolicy); + // What actually gets used from here on -- both to compile `privacy` below + // and to hand to `snapshot()` later (which independently compiles its own + // `privacyPolicy` option). Defaults to the plugin-transformed policy, but + // falls back to the user's own policy if the transform produced something + // uncompilable, so both compile calls stay in agreement. + let effectivePrivacyPolicy = portablePrivacyPolicy; + let privacy: CompiledPrivacyPolicy; + try { + privacy = compilePrivacyPolicy(portablePrivacyPolicy); + } catch (error) { + // A plugin's `applyPrivacyPolicy` transform produced something + // `compilePrivacyPolicy` can't compile. That's the plugin's bug, not the + // user's -- fall back to the user's own (untransformed) policy so a + // broken plugin can't take recording down entirely. If the user's own + // policy is itself invalid, that's a programmer error and should still + // throw. + if (portablePrivacyPolicy !== privacyPolicy) { + console.error( + '[rrweb] plugin-transformed privacy policy failed to compile; using the user policy', + error, + ); + effectivePrivacyPolicy = privacyPolicy; + privacy = compilePrivacyPolicy(privacyPolicy); + } else { + throw error; + } + } const blockSelector = mergeBlockSelectors(legacyBlockSelector, privacy); const maskTextSelector = mergeMaskTextSelectors( legacyMaskTextSelector, @@ -428,7 +455,7 @@ function record( recordCanvas, canvasMaskingConfigured, inlineImages, - privacyPolicy: portablePrivacyPolicy, + privacyPolicy: effectivePrivacyPolicy, onSerialize: (n) => { if (isSerializedIframe(n, mirror)) { iframeManager.addIframe(n as HTMLIFrameElement); diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index df39f31e39..c42357e0ab 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -606,11 +606,11 @@ export default class MutationBuffer { ) { // CSS is never masked, on any path: a starred stylesheet corrupts // the replay. Mirrors serializeTextNode's `isStyle` exemption. + // `untaintedTagName` defeats a shadowed `tagName` (e.g. a form + // control named "tagName") instead of failing closed to "not style". const parent = dom.parentNode(m.target); const isStyle = - parent && typeof (parent as HTMLElement).tagName === 'string' - ? (parent as HTMLElement).tagName.toUpperCase() === 'STYLE' - : false; + dom.untaintedTagName(parent as Element | null) === 'STYLE'; this.texts.push({ value: !isStyle && @@ -638,18 +638,17 @@ export default class MutationBuffer { // `value` only means "input value" on form controls; on e.g. `
  • ` or // `` it is an ordinary attribute and belongs to the normal - // `finalizeAttribute` path instead. - if ( - attributeName === 'value' && - typeof target.tagName === 'string' && - FORM_VALUE_TAGS.has(target.tagName.toUpperCase()) - ) { + // `finalizeAttribute` path instead. `untaintedTagName` reads the real + // tag name even when a named form control (e.g. ) + // shadows the `tagName` property on `target`. + const targetTagName = dom.untaintedTagName(target); + if (attributeName === 'value' && FORM_VALUE_TAGS.has(targetTagName)) { const type = getInputType(target); value = maskInput({ element: target, maskInputOptions: this.maskInputOptions, - tagName: target.tagName, + tagName: targetTagName, type, value: value || '', maskInputFn: this.maskInputFn, diff --git a/packages/rrweb/test/record/privacy.test.ts b/packages/rrweb/test/record/privacy.test.ts new file mode 100644 index 0000000000..91d6eaa6d7 --- /dev/null +++ b/packages/rrweb/test/record/privacy.test.ts @@ -0,0 +1,31 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest'; +import record from '../../src/record'; +import type { RecordPlugin } from '@rrweb/types'; + +describe('record() privacy policy plugin fallback', () => { + it('a plugin returning a malformed policy falls back to the user policy instead of throwing', () => { + const badPlugin: RecordPlugin = { + name: 'bad@1', + applyPrivacyPolicy: () => ({ nonsense: true }) as never, + }; + + let stop: (() => void) | undefined; + expect(() => { + stop = record({ emit: () => {}, plugins: [badPlugin] }); + }).not.toThrow(); + stop?.(); + }); + + it('still throws when the user supplies their own invalid policy directly (no plugin involved)', () => { + expect(() => { + record({ + emit: () => {}, + // @ts-expect-error intentionally invalid for this test + privacyPolicy: { nonsense: true }, + }); + }).toThrow(); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index f181da34f8..0d6dee8000 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -247,6 +247,24 @@ export function querySelectorAll( return getUntaintedAccessor('Element', n, 'querySelectorAll')(selectors); } +/** + * `tagName` can be shadowed by a same-named form control (e.g. + * `
    ` makes `form.tagName` resolve to the + * input element instead of the string `'FORM'`). Reading through the + * untainted `Element.prototype` getter defeats that shadowing so callers get + * the real tag name regardless of what user content declared on the element. + * Returns `''` for a null/undefined element, uppercased otherwise. + */ +export function untaintedTagName(element: Element | null | undefined): string { + if (!element) return ''; + const tagName: unknown = element.tagName; + if (typeof tagName === 'string') return tagName.toUpperCase(); + // Not a string and not actually an Element (e.g. a Document/DocumentFragment + // passed in from a loosely-typed `ParentNode`): there is no tag name. + if (!(element instanceof Element)) return ''; + return getUntaintedAccessor('Element', element, 'tagName').toUpperCase(); +} + export function mutationObserverCtor(): [ (typeof MutationObserver)['prototype']['constructor'], () => void, @@ -325,6 +343,7 @@ export default { shadowRoot, querySelector, querySelectorAll, + untaintedTagName, nowTimestamp, mutationObserverCtor, patch, From 1525d4591de63f6e90de24710fb46e5a75503805 Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 21:03:34 +0200 Subject: [PATCH 17/64] fix(privacy): route remaining mutation.ts tagName reads through untaintedTagName, close form-shadowing crash A
    with a descendant control named "tagName" shadows the tagName getter; toLowerCase(target.tagName) in the attributes-mutation branch crashed on it uncaught inside the MutationObserver callback. Route the remaining reads through untaintedTagName (or the existing targetTagName) and add a regression test. Co-Authored-By: Claude Fable 5 --- packages/rrweb/src/record/mutation.ts | 24 ++++++---- packages/rrweb/test/record/privacy.test.ts | 56 +++++++++++++++++++++- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index c42357e0ab..0c4da3a10b 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -314,7 +314,9 @@ export default class MutationBuffer { } let cssCaptured = false; if (n.nodeType === Node.TEXT_NODE) { - const parentTag = (parent as Element).tagName; + // `parent` can be an arbitrary element (e.g. a ), whose + // `tagName` may be shadowed by a same-named descendant control. + const parentTag = dom.untaintedTagName(parent as Element | null); if (parentTag === 'TEXTAREA') { // genTextAreaValueMutation already called via parent return; @@ -479,7 +481,7 @@ export default class MutationBuffer { .map((text) => { const n = text.node; const parent = dom.parentNode(n); - if (parent && (parent as Element).tagName === 'TEXTAREA') { + if (dom.untaintedTagName(parent as Element | null) === 'TEXTAREA') { // the node is being ignored as it isn't in the mirror, so shift mutation to attributes on parent textarea this.genTextAreaValueMutation(parent as HTMLTextAreaElement); } @@ -581,6 +583,12 @@ export default class MutationBuffer { (cn) => dom.textContent(cn) || '', ).join(''); const type = getInputType(textarea); + // `textarea.tagName` is safe unshadowed here: every caller only reaches + // this method after already comparing that same object's `.tagName` to + // the string literal `'TEXTAREA'` (see the childList/pushAdd/text-mutation + // call sites above, all now routed through `untaintedTagName`). A + // shadowed `tagName` fails that string comparison, so nothing shadowed + // ever reaches this call with a stale/wrong reference. item.attributes.value = maskInput({ element: textarea, maskInputOptions: this.maskInputOptions, @@ -664,7 +672,7 @@ export default class MutationBuffer { let item = this.attributeMap.get(m.target); if ( - target.tagName === 'IFRAME' && + targetTagName === 'IFRAME' && attributeName === 'src' && !this.keepIframeSrcFn(value as string) ) { @@ -691,17 +699,17 @@ export default class MutationBuffer { // This is used to ensure we do not unmask value when using e.g. a "Show password" type button if ( attributeName === 'type' && - target.tagName === 'INPUT' && + targetTagName === 'INPUT' && (m.oldValue || '').toLowerCase() === 'password' ) { target.setAttribute('data-rr-is-password', 'true'); } - if (!ignoreAttribute(target.tagName, attributeName, value)) { + if (!ignoreAttribute(targetTagName, attributeName, value)) { // overwrite attribute if the mutations was triggered in same time item.attributes[attributeName] = transformAttribute( this.doc, - toLowerCase(target.tagName), + toLowerCase(targetTagName), toLowerCase(attributeName), value, ); @@ -743,7 +751,7 @@ export default class MutationBuffer { item.styleDiff[pname] = false; // delete } } - } else if (attributeName === 'open' && target.tagName === 'DIALOG') { + } else if (attributeName === 'open' && targetTagName === 'DIALOG') { if (target.matches('dialog:modal')) { item.attributes['rr_open_mode'] = 'modal'; } else { @@ -762,7 +770,7 @@ export default class MutationBuffer { if (isBlocked(m.target, this.blockClass, this.blockSelector, true)) return; - if ((m.target as Element).tagName === 'TEXTAREA') { + if (dom.untaintedTagName(m.target as Element) === 'TEXTAREA') { // children would be ignored in genAdds as they aren't in the mirror this.genTextAreaValueMutation(m.target as HTMLTextAreaElement); return; // any removedNodes won't have been in mirror either diff --git a/packages/rrweb/test/record/privacy.test.ts b/packages/rrweb/test/record/privacy.test.ts index 91d6eaa6d7..17fb0e4ccc 100644 --- a/packages/rrweb/test/record/privacy.test.ts +++ b/packages/rrweb/test/record/privacy.test.ts @@ -3,7 +3,12 @@ */ import { describe, expect, it } from 'vitest'; import record from '../../src/record'; -import type { RecordPlugin } from '@rrweb/types'; +import { + EventType, + IncrementalSource, + type RecordPlugin, + type eventWithTime, +} from '@rrweb/types'; describe('record() privacy policy plugin fallback', () => { it('a plugin returning a malformed policy falls back to the user policy instead of throwing', () => { @@ -29,3 +34,52 @@ describe('record() privacy policy plugin fallback', () => { }).toThrow(); }); }); + +describe('record() and a whose tagName is shadowed', () => { + it('records an attribute mutation on the form without throwing', async () => { + // Real browsers make a named form control reachable as an own property + // on its (`` makes `form.tagName` + // resolve to the , not the string `'FORM'`). jsdom doesn't + // implement that quirk, so the shadowing is reproduced directly here, + // the same way packages/rrweb-snapshot/test/utils.test.ts does for + // `untaintedTagName` itself. + document.body.innerHTML = ''; + const form = document.querySelector('form')!; + const input = document.querySelector('input')!; + Object.defineProperty(form, 'tagName', { + value: input, + configurable: true, + }); + // sanity check: the shadowing actually took effect + expect(typeof form.tagName).not.toBe('string'); + + const events: eventWithTime[] = []; + let uncaught: unknown; + const onError = (e: ErrorEvent) => { + uncaught = e.error ?? e.message; + }; + window.addEventListener('error', onError); + + const stop = record({ emit: (event) => events.push(event) }); + try { + form.setAttribute('data-x', 'mutated'); + // MutationObserver callbacks run as a microtask; give it a couple of + // ticks (a macrotask is enough to also drain any queued microtasks). + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + stop?.(); + window.removeEventListener('error', onError); + } + + expect(uncaught).toBeUndefined(); + const mutationEmitted = events.some( + (event) => + event.type === EventType.IncrementalSnapshot && + event.data.source === IncrementalSource.Mutation && + event.data.attributes.some( + (a) => 'data-x' in a.attributes && a.attributes['data-x'] === 'mutated', + ), + ); + expect(mutationEmitted).toBe(true); + }); +}); From f00cea86accb99cd9cb931a091d24d8dfc7a4633 Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 21:24:08 +0200 Subject: [PATCH 18/64] docs(privacy): v2 types sync, changeset, guide Mirror rrweb-snapshot's v2 privacy types into @rrweb/types, expose the missing unmaskTextSelector as a record() option (it was previously hardcoded to null when merging), rewrite guide.md's Privacy section to describe v2 exactly, and add the consolidated privacy-v2-simplification changeset. Also corrects the privacy-detectors plugin README, which claimed detection covers input values -- it currently only scans page text nodes at snapshot time. Co-Authored-By: Claude Fable 5 --- .changeset/privacy-v2-simplification.md | 33 ++++ guide.md | 173 +++++++++++------- .../rrweb-plugin-privacy-detectors/README.md | 10 +- .../test/privacy-integration.test.ts | 13 ++ packages/rrweb/src/record/index.ts | 8 +- packages/rrweb/src/types.ts | 8 + packages/types/src/index.ts | 85 +++++++++ 7 files changed, 260 insertions(+), 70 deletions(-) create mode 100644 .changeset/privacy-v2-simplification.md diff --git a/.changeset/privacy-v2-simplification.md b/.changeset/privacy-v2-simplification.md new file mode 100644 index 0000000000..2623057186 --- /dev/null +++ b/.changeset/privacy-v2-simplification.md @@ -0,0 +1,33 @@ +--- +'rrweb-snapshot': minor +'rrweb': minor +'@rrweb/types': major +'@rrweb/rrweb-plugin-privacy-detectors': minor +'@rrweb/utils': minor +--- + +Privacy at Capture v2: policies now compile onto rrweb's existing masking +primitives; heuristic detectors are a fixed whole-value set (custom regex +patterns removed); CSS is never masked; canvas masking forces the FPS capture +path; selector and config errors fail closed. BREAKING (@rrweb/types): +`ImageBitmapDataURLWorkerParams` is a union; privacy rule `style`, +`classification`, custom detectors, and the `'custom'` preset are removed. + +Additional breaking/behavior notes: + +- `needMaskingText` (exported from `rrweb-snapshot`) gained parameters; old + positional callers break. +- `'; + const textNode = document.querySelector('style')!.firstChild as Text; + + const events: eventWithTime[] = []; + const stop = record({ + emit: (event) => events.push(event), + privacyPolicy: withEmailDetector, + }); + try { + // an email-shaped token inside CSS content must never star the sheet + textNode.data = '/* bob@example.com */ body{color:blue}'; + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + stop?.(); + } + + const textMutations = events.flatMap((event) => + event.type === EventType.IncrementalSnapshot && + event.data.source === IncrementalSource.Mutation + ? event.data.texts + : [], + ); + expect(JSON.stringify(textMutations)).toContain('body{color:blue}'); + }); + + it('masks a live input event whose value trips a detector', async () => { + document.body.innerHTML = ''; + const input = document.querySelector('input')!; + + const events: eventWithTime[] = []; + const stop = record({ + emit: (event) => events.push(event), + privacyPolicy: withEmailDetector, + }); + try { + input.value = 'bob@example.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + stop?.(); + } + + const inputEvents = events.filter( + (event) => + event.type === EventType.IncrementalSnapshot && + event.data.source === IncrementalSource.Input, + ); + expect(inputEvents.length).toBeGreaterThan(0); + expect(JSON.stringify(inputEvents)).not.toContain('bob@example.com'); + }); +}); From 9da8a3ad7a706415719a7874c2051cf4c86ad610 Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 21:42:44 +0200 Subject: [PATCH 20/64] docs(privacy): fix detector-coverage claim, changeset hygiene, legacy footnote Round-1 review fixes: - guide.md's heuristic-detection paragraph claimed detectors only scan text nodes at snapshot time. That was true when written, but a concurrent fix (f34e6c77) wired detectSensitiveValue into maskInput() and the characterData mutation path, so detectors now also cover input values and live text-mutation updates (verified via `grep -rn "detectSensitiveValue("` across packages/rrweb-snapshot/src and packages/rrweb/src: three call sites -- snapshot.ts:622, utils.ts's maskInput, mutation.ts's characterData case -- vs. finalizeAttribute, which has none, so attribute values are still not scanned). Updated the paragraph to match. - Reconciled .changeset/calm-ravens-protect.md, which still described the pre-simplification design (a 'custom' preset, "data-privacy works without recorder-specific configuration") and now contradicts the shipped v2 behavior; left kind-pumas-detect.md alone (already accurate). - Deleted .changeset/khaki-hoops-smile.md, an empty/broken stub (`---\n---`, no version bumps, no body) and .changeset/loud-lions-protect.md, which claimed policies "apply to CSS text" -- the opposite of the final "CSS is never masked" behavior. - Added a guide.md footnote documenting the legacy-preset corner case: a selector-based mask/exclude rule also activates the corresponding [data-privacy="mask"/"exclude"] recognition under legacy, but data-privacy="allow" is never recognized under legacy regardless. Co-Authored-By: Claude Fable 5 --- .changeset/calm-ravens-protect.md | 21 +++++++++++++-------- .changeset/khaki-hoops-smile.md | 2 -- .changeset/loud-lions-protect.md | 9 --------- guide.md | 20 ++++++++++++++++---- 4 files changed, 29 insertions(+), 23 deletions(-) delete mode 100644 .changeset/khaki-hoops-smile.md delete mode 100644 .changeset/loud-lions-protect.md diff --git a/.changeset/calm-ravens-protect.md b/.changeset/calm-ravens-protect.md index 7f09e0b03f..79e28be813 100644 --- a/.changeset/calm-ravens-protect.md +++ b/.changeset/calm-ravens-protect.md @@ -3,11 +3,16 @@ "rrweb-snapshot": minor --- -Add an opt-in, versioned privacy policy with strict, balanced, custom, and -legacy presets. Policies consistently protect text, form values, sensitive -attributes, URLs, snapshots, and incremental mutations while preserving the -existing rrweb masking options as the backwards-compatible legacy path. The -vendor-neutral `data-privacy="exclude|mask|allow"` HTML binding works without -recorder-specific configuration. Add fail-closed canvas-region masking for -complex canvas applications, suppress unmasked full-snapshot canvas stills, -and provide coarse and callback-based final attribute masking escape hatches. +Add an opt-in, versioned `privacyPolicy` with `strict`, `balanced`, and +`legacy` presets. Compiled policies consistently protect text, form values, +sensitive attributes (`title`, `placeholder`, `aria-label`), and URLs across +full snapshots and incremental mutations, while the existing rrweb masking +options remain the backwards-compatible `legacy` default. CSS is never +masked, on any preset. Under `balanced`/`strict`, the vendor-neutral +`data-privacy="exclude|mask|allow"` HTML binding and common cross-vendor +masking class names are recognized directly in markup; selector-based policy +`rules` work under every preset, including `legacy`. Add fail-closed +`canvasMasking` region masking for complex canvas applications (configuring +it forces the FPS capture path and suppresses the unmasked `rr_dataURL` +full-snapshot still), plus coarse (`maskAllElementAttributes`) and +callback-based (`maskAttributeFn`) final attribute masking escape hatches. diff --git a/.changeset/khaki-hoops-smile.md b/.changeset/khaki-hoops-smile.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/khaki-hoops-smile.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/loud-lions-protect.md b/.changeset/loud-lions-protect.md deleted file mode 100644 index da76e192f2..0000000000 --- a/.changeset/loud-lions-protect.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"rrweb": patch -"rrweb-snapshot": patch ---- - -Harden privacy detector compilation against nested and high-quantifier ReDoS -patterns, lookaround, and named backreferences. Apply capture policies to CSS -text, `_cssText`, inline style, and stylesheet mutations, and keep custom -detector schema constraints aligned with runtime validation. diff --git a/guide.md b/guide.md index 18f2e194c9..fa739bed9c 100644 --- a/guide.md +++ b/guide.md @@ -290,6 +290,13 @@ markup, no extra configuration required: value makes no decision and the element inherits from its nearest valid ancestor. +> Under `legacy`, `data-privacy="mask"`/`data-privacy="exclude"` are each +> only recognized once you also supply at least one selector-based `mask`/ +> `exclude` rule (of that same action) in `rules`; `data-privacy="allow"` is +> never recognized under `legacy`, with or without rules. This asymmetry is a +> corner of the current implementation, not something to design around -- +> switch to `balanced`/`strict` for unconditional `data-privacy` support. + `unmaskTextSelector` is a `record()`-level escape hatch for text: a plain CSS selector (merged with any policy `unmask`/`allow` rule selectors) that stays unmasked even under `strict`'s mask-everything default or a `mask` rule. It @@ -307,10 +314,15 @@ record({ Heuristic PII detection (email, phone, Luhn-valid payment card, SSN-like, IPv4) is never implied by a preset. Opt in with `@rrweb/rrweb-plugin-privacy-detectors` (or its `applyPrivacyDetectors` -helper), which masks the whole page text node when a detector matches -- -there is no character-range masking and no support for custom detector -patterns. Detection currently only scans page text nodes at snapshot time; it -does not scan input values, attribute values, or later live text mutations. +helper), which masks the whole value when a detector matches -- there is no +character-range masking and no support for custom detector patterns. +Detection scans page text nodes and form input values, both at +snapshot time and on later live updates (text mutations and input events). +It only applies to values that would otherwise be recorded unmasked -- text +or inputs already masked by a preset, selector, or legacy option keep that +masking (including a trusted legacy `maskTextFn`/`maskInputFn` output). +Attribute values are not scanned; use the presets' masked-attribute defaults +or policy rules for those. ```js import { getRecordPrivacyDetectorsPlugin } from '@rrweb/rrweb-plugin-privacy-detectors'; From f7c4f1c08a8893e74ff4c010c3bb6e4dbcc8d6d2 Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 21:54:08 +0200 Subject: [PATCH 21/64] test(privacy): perf smoke + full sweep Co-Authored-By: Claude Fable 5 --- .../rrweb-snapshot/test/privacy-perf.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/rrweb-snapshot/test/privacy-perf.test.ts diff --git a/packages/rrweb-snapshot/test/privacy-perf.test.ts b/packages/rrweb-snapshot/test/privacy-perf.test.ts new file mode 100644 index 0000000000..b060c23603 --- /dev/null +++ b/packages/rrweb-snapshot/test/privacy-perf.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import snapshot from '../src/snapshot'; +import { compilePrivacyPolicy } from '../src/privacy'; + +/** + * Guards the "legacy is sacred" performance contract: a recorder that never + * opted into the v2 privacy policy must pay zero cost for it. If any of + * these selectors show up in an `Element.prototype.matches` call during a + * legacy snapshot, `compilePrivacyPolicy`/`mergeBlockSelectors`/ + * `needMaskingText` have started doing privacy-selector work even when the + * caller never asked for it. + */ +const PRIVACY_SELECTOR_FRAGMENTS = [ + 'data-privacy', + 'rr-mask', + 'rr-unmask', + 'rr-block', + 'mp-mask', + 'mp-block', + 'fs-mask', + 'fs-exclude', + 'amp-mask', + 'amp-unmask', + 'amp-block', + 'ph-mask', + 'ph-no-capture', + 'sentry-mask', + 'sentry-unmask', + 'sentry-block', + 'data-sentry-mask', + 'data-sentry-unmask', +]; + +function buildDeepDom() { + document.body.innerHTML = + '
    '.repeat(200) + 'deep text' + '
    '.repeat(200); +} + +function privacyAttributableCalls(spy: ReturnType) { + return spy.mock.calls.filter( + ([sel]) => + typeof sel === 'string' && + PRIVACY_SELECTOR_FRAGMENTS.some((fragment) => sel.includes(fragment)), + ); +} + +describe('privacy v2 perf smoke', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('legacy snapshot performs no privacy selector matching', () => { + // Sanity-check the compiled shape a caller gets when it never opts into + // v2 privacy: every selector is null/empty, so nothing should ever + // reach `.matches()` with a privacy-attributable selector. + const compiled = compilePrivacyPolicy(undefined); + expect(compiled.preset).toBe('legacy'); + expect(compiled.maskTextSelector).toBeNull(); + expect(compiled.blockSelector).toBeNull(); + + const spy = vi.spyOn(Element.prototype, 'matches'); + buildDeepDom(); + + // No `privacyPolicy` passed in the options object at all -- this is the + // shape every pre-v2 caller uses. + snapshot(document, {}); + + const privacyCalls = privacyAttributableCalls(spy); + expect(privacyCalls).toEqual([]); + spy.mockRestore(); + }); + + it('snapshot with no privacy argument at all behaves identically (legacy sacred)', () => { + const spy = vi.spyOn(Element.prototype, 'matches'); + buildDeepDom(); + + snapshot(document); + + const privacyCalls = privacyAttributableCalls(spy); + expect(privacyCalls).toEqual([]); + spy.mockRestore(); + }); +}); From 5201b4cf6b2b65fa85fe97e0e8902a849f2c741e Mon Sep 17 00:00:00 2001 From: Rogier Trimpe Date: Wed, 26 Aug 2026 22:21:11 +0200 Subject: [PATCH 22/64] =?UTF-8?q?fix(privacy):=20final=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20phone=20recall,=20disclosures,=20preset=20walk=20sh?= =?UTF-8?q?ort-circuit,=20blockMedia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .changeset/privacy-v2-simplification.md | 11 ++++ ...-08-25-privacy-v2-simplification-design.md | 13 ++++- guide.md | 13 ++++- packages/rrweb-snapshot/src/privacy.ts | 53 ++++++++++++++++++- packages/rrweb-snapshot/src/snapshot.ts | 7 +-- .../rrweb-snapshot/test/privacy-perf.test.ts | 52 ++++++++++++++++++ packages/rrweb-snapshot/test/privacy.test.ts | 13 +++++ packages/rrweb/src/record/mutation.ts | 20 ++++++- 8 files changed, 173 insertions(+), 9 deletions(-) diff --git a/.changeset/privacy-v2-simplification.md b/.changeset/privacy-v2-simplification.md index 3f69c6743e..1f6bcc1e9a 100644 --- a/.changeset/privacy-v2-simplification.md +++ b/.changeset/privacy-v2-simplification.md @@ -33,3 +33,14 @@ Additional breaking/behavior notes: - `maskAllElementAttributes` and `maskAttributeFn` are now mutually exclusive: when both are supplied, `maskAllElementAttributes` wins and `maskAttributeFn` is ignored with a one-time console warning. +- Protected inputs -- `password`, `hidden`, `data-rr-is-password`, and + autocomplete `cc-*`/`current-password`/`new-password`/`one-time-code` -- + are now **always** masked, with no `privacyPolicy` required and regardless + of `maskInputOptions`. Previously `hidden` inputs and autocomplete-tagged + credit-card/password/OTP fields could record their raw value under + `legacy`; they cannot anymore. +- An invalid `maskTextSelector`/`unmaskTextSelector` (including the plain + `record()`-level string options, not just policy `rules`) now fails closed + -- the bad selector throws inside the mask decision, which is caught and + masks the text -- instead of being silently ignored as if it had never been + set. diff --git a/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md b/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md index e7b354eed2..00d3b4f353 100644 --- a/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md +++ b/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md @@ -33,7 +33,18 @@ their forks and wrapper layers, maintained by the community. 3. **Legacy is sacred.** With no `privacyPolicy` and no privacy plugin loaded, behavior and performance are byte-identical to rrweb before this feature. (Loading the detectors plugin is an explicit opt-in and does - change behavior — see §6.) + change behavior — see §6.) Two sanctioned exceptions, both required by + principle 1 (fail closed) and neither gated behind `privacyPolicy`: + - **Protected inputs always masked.** `password`/`hidden` inputs and + autocomplete `cc-*`/`current-password`/`new-password`/`one-time-code` + fields are masked unconditionally, with no `privacyPolicy` required and + regardless of `maskInputOptions`. Pre-v2 `legacy` behavior let `hidden` + inputs and autocomplete-tagged card/password/OTP fields record raw -- + that gap is intentionally closed, not preserved. + - **Invalid selectors fail closed.** An invalid `maskTextSelector`/ + `unmaskTextSelector` -- the plain `record()`-level string option or a + policy rule's selector -- throws inside the mask decision and is caught + as a mask, not silently ignored as if unset. ## Decisions (approved) diff --git a/guide.md b/guide.md index fa739bed9c..aba811c1c1 100644 --- a/guide.md +++ b/guide.md @@ -268,7 +268,12 @@ removes a subtree from capture entirely (it replays as a placeholder), which is why an `exclude` decision can't be reopened by a nested `mask` or `unmask`. Protected inputs -- password, hidden, and autocomplete `cc-*` / `current-password` / `new-password` / `one-time-code` fields -- always stay -masked, regardless of any rule or preset. +masked, regardless of any rule or preset. This holds even with no +`privacyPolicy` configured at all, and regardless of `maskInputOptions` -- +these fields cannot be opted back into raw recording. (Previously, under +`legacy`, `hidden` inputs and autocomplete-tagged credit-card/password/OTP +fields could record their raw value; that is a breaking change from pre-v2 +behavior.) Under `balanced` and `strict`, rrweb also recognizes the vendor-neutral `data-privacy` attribute and common cross-vendor class names directly in @@ -304,6 +309,12 @@ only affects text masking -- it cannot unmask input values, the `title`/`placeholder`/`aria-label` attributes, or a sanitized URL, and it cannot override a protected input or an `exclude`. +An invalid `maskTextSelector` or `unmaskTextSelector` -- either this +`record()`-level string option or a policy rule's selector -- fails closed: +rather than being silently ignored (as if it had never been set), it causes +the affected text to be masked. Prefer a selector you've verified with +`document.querySelector` over trusting this as a validation mechanism. + ```js record({ privacyPolicy: { version: 1, preset: 'strict' }, diff --git a/packages/rrweb-snapshot/src/privacy.ts b/packages/rrweb-snapshot/src/privacy.ts index f7db0d00b3..78312e812d 100644 --- a/packages/rrweb-snapshot/src/privacy.ts +++ b/packages/rrweb-snapshot/src/privacy.ts @@ -78,7 +78,7 @@ const CARD_CANDIDATE = /(?:^|[^0-9-])((?:\d[ -]?){12,18}\d)(?:$|[^0-9-])/; const SSN_PATTERN = /\b(?!000|666|9\d{2})\d{3}-?(?!00)\d{2}-?(?!0000)\d{4}\b/; const EMAIL_PATTERN = /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[a-zA-Z0-9-]{1,63}(?:\.[a-zA-Z0-9-]{1,63})+/; -const PHONE_PATTERN = /(?:^|\s)\+?\d[\d ().-]{7,13}\d(?:$|\s)/; +const PHONE_PATTERN = /(?:^|\s)\+?\d{0,3}[\s.-]?\(?\d[\d ().-]{5,13}\d(?:$|\s)/; const IPV4_PATTERN = /\b(?:\d{1,3}\.){3}\d{1,3}\b/; const MAX_SCAN_LENGTH = 10_000; @@ -161,6 +161,55 @@ export function validateSelector(selector: string): boolean { } } +/** + * `querySelector`/`querySelectorAll` never pierce shadow-DOM boundaries, so a + * presence probe that only checked `root` would wrongly conclude an unmask + * selector living inside an open shadow tree doesn't exist anywhere. Walk + * into every open shadow root found under `root` and check there too. + */ +function selectorMatchesAnywhere( + root: Document | ShadowRoot, + selector: string, +): boolean { + if (root.querySelector(selector)) return true; + const all = root.querySelectorAll('*'); + for (let index = 0; index < all.length; index += 1) { + const sr = (all[index] as HTMLElement).shadowRoot; + if (sr && selectorMatchesAnywhere(sr, selector)) return true; + } + return false; +} + +/** + * Every non-legacy preset sets `unmaskTextSelector`, which forces + * `needMaskingText` to re-walk ancestors for every node instead of trusting + * the inherited "already masked" decision (see `serializeNodeWithId`'s + * `checkAncestors` comment). Most pages never put anything under an unmask + * selector, so that walk buys nothing. + * + * Call this once per full snapshot and once per mutation flush -- not per + * node -- to check whether the selector currently matches *anything* in the + * document (including inside open shadow roots). When it matches nothing, + * the caller can pass `null` downward for that pass and the cheap + * short-circuit is restored; when a match exists, the original selector is + * returned unchanged and per-node checking still happens exactly as before. + * A selector that throws (e.g. detached/invalid document) is assumed present + * so behaviour fails closed to masking. + */ +export function resolveUnmaskTextSelector( + doc: Document, + unmaskTextSelector: string | null, +): string | null { + if (!unmaskTextSelector) return null; + try { + return selectorMatchesAnywhere(doc, unmaskTextSelector) + ? unmaskTextSelector + : null; + } catch { + return unmaskTextSelector; + } +} + function joinSelectors(selectors: Array): string | null { const kept: string[] = []; for (const s of selectors) { @@ -355,7 +404,7 @@ export function finalizeAttribute({ const tagName = untaintedTagName(element); if ( - privacy.preset === 'strict' && + privacy.blockMedia && MEDIA_TAGS.has(tagName) && MEDIA_SOURCE_ATTRIBUTES.has(normalizedName) ) { diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 1edd8bccdf..0d692a7d11 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -41,6 +41,7 @@ import { mergeBlockSelectors, mergeMaskTextSelectors, mergeUnmaskTextSelectors, + resolveUnmaskTextSelector, } from './privacy'; import dom from '@rrweb/utils'; @@ -1502,9 +1503,9 @@ function snapshot( legacyMaskTextSelector, privacy, ); - const unmaskTextSelector = mergeUnmaskTextSelectors( - legacyUnmaskTextSelector, - privacy, + const unmaskTextSelector = resolveUnmaskTextSelector( + n, + mergeUnmaskTextSelectors(legacyUnmaskTextSelector, privacy), ); const maskInputOptions: MaskInputOptions = maskAllInputs === true diff --git a/packages/rrweb-snapshot/test/privacy-perf.test.ts b/packages/rrweb-snapshot/test/privacy-perf.test.ts index b060c23603..5e9d90f1fd 100644 --- a/packages/rrweb-snapshot/test/privacy-perf.test.ts +++ b/packages/rrweb-snapshot/test/privacy-perf.test.ts @@ -85,3 +85,55 @@ describe('privacy v2 perf smoke', () => { spy.mockRestore(); }); }); + +/** + * Every non-legacy preset compiles a non-null `unmaskTextSelector` (strict + * and balanced both always include `[data-privacy="allow"]` and the + * cross-vendor unmask classes, rule-configured or not). That forces + * `serializeNodeWithId`'s `checkAncestors` branch permanently on, which + * re-walks ancestors with `Element.prototype.matches` for every single node + * instead of trusting the inherited masking decision -- O(nodes * depth) on + * a page that has no unmask target anywhere. `resolveUnmaskTextSelector` + * probes once per snapshot/flush and passes `null` downward when nothing + * matches, restoring the cheap short-circuit. + */ +describe('privacy v2 unmask-selector short-circuit perf', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + function buildDeepWideDom(depth: number, leaves: number) { + document.body.innerHTML = + '
    '.repeat(depth) + + '

    leaf text

    '.repeat(leaves) + + '
    '.repeat(depth); + } + + it('strict snapshot with no unmask target anywhere avoids per-node ancestor walks', () => { + buildDeepWideDom(40, 40); + const spy = vi.spyOn(Element.prototype, 'matches'); + + snapshot(document, { privacyPolicy: { version: 1, preset: 'strict' } }); + + // Without the short-circuit this scales with leaves * depth (thousands + // of calls for 40x40); with it, the one-time presence probe means the + // per-node walk never engages, so the call count stays close to a + // single linear pass over the tree. A loose bound catches a regression + // to the old O(nodes * depth) behavior without pinning an exact count. + expect(spy.mock.calls.length).toBeLessThan(200); + spy.mockRestore(); + }); + + it('strict snapshot still finds and honors an .rr-unmask target when one exists', () => { + document.body.innerHTML = + '

    visible

    hidden

    '; + + const out = JSON.stringify( + snapshot(document, { privacyPolicy: { version: 1, preset: 'strict' } }), + ); + + expect(out).toContain('visible'); + expect(out).not.toContain('hidden'); + }); +}); diff --git a/packages/rrweb-snapshot/test/privacy.test.ts b/packages/rrweb-snapshot/test/privacy.test.ts index 6f93182f2d..7bd481a22e 100644 --- a/packages/rrweb-snapshot/test/privacy.test.ts +++ b/packages/rrweb-snapshot/test/privacy.test.ts @@ -119,6 +119,11 @@ describe('detectSensitiveValue', () => { expect(detectSensitiveValue('bob@example.com', none)).toBe(false); }); + it('fails closed on absurdly long input instead of scanning it', () => { + const clean = 'a'.repeat(10_001); + expect(detectSensitiveValue(clean, withDetectors)).toBe(true); + }); + it('per-detector toggles work', () => { const emailOff = buildDetectors({ email: false, phone: false, paymentCard: true, ssn: false, ipAddress: false }); expect(emailOff.some((d) => d.name === 'email')).toBe(false); @@ -132,6 +137,14 @@ describe('detectSensitiveValue', () => { it('detects dashed phone format (fix regression)', () => { expect(detectSensitiveValue('555-123-4567', withDetectors)).toBe(true); }); + + it('detects parenthesized area code format (fix regression)', () => { + expect(detectSensitiveValue('(555) 123-4567', withDetectors)).toBe(true); + }); + + it('detects parenthesized area code with country code (fix regression)', () => { + expect(detectSensitiveValue('+1 (555) 123-4567', withDetectors)).toBe(true); + }); }); describe('sanitizeUrl v2', () => { diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index f04a1a79be..99ad535cd0 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -8,6 +8,7 @@ import { maskInput, detectSensitiveValue, finalizeAttribute, + resolveUnmaskTextSelector, FORM_VALUE_TAGS, Mirror, isNativeShadowDom, @@ -189,6 +190,13 @@ export default class MutationBuffer { private maskTextClass: observerParam['maskTextClass']; private maskTextSelector: observerParam['maskTextSelector']; private unmaskTextSelector: observerParam['unmaskTextSelector']; + /** + * `unmaskTextSelector` re-resolved once per mutation flush (see + * `resolveUnmaskTextSelector`): null when nothing in the live document + * currently matches it, so the per-node ancestor walk it otherwise forces + * in `needMaskingText`/`serializeNodeWithId` can short-circuit again. + */ + private effectiveUnmaskTextSelector: string | null = null; private inlineStylesheet: observerParam['inlineStylesheet']; private maskInputOptions: observerParam['maskInputOptions']; private maskTextFn: observerParam['maskTextFn']; @@ -279,6 +287,14 @@ export default class MutationBuffer { } public processMutations = (mutations: mutationRecord[]) => { + // Re-probe once per flush rather than per node -- see + // `resolveUnmaskTextSelector`. Cheap when `unmaskTextSelector` is unset + // (returns null immediately); only walks the live tree when a preset + // actually configured one. + this.effectiveUnmaskTextSelector = resolveUnmaskTextSelector( + this.doc, + this.unmaskTextSelector, + ); mutations.forEach(this.processMutation); // adds mutations to the buffer this.emit(); // clears buffer if not locked/frozen }; @@ -343,7 +359,7 @@ export default class MutationBuffer { blockSelector: this.blockSelector, maskTextClass: this.maskTextClass, maskTextSelector: this.maskTextSelector, - unmaskTextSelector: this.unmaskTextSelector, + unmaskTextSelector: this.effectiveUnmaskTextSelector, skipChild: true, newlyAddedElement: true, inlineStylesheet: this.inlineStylesheet, @@ -631,7 +647,7 @@ export default class MutationBuffer { m.target, this.maskTextClass, this.maskTextSelector, - this.unmaskTextSelector, + this.effectiveUnmaskTextSelector, true, // checkAncestors ) ) { From 0d60d4d0dc38bfd6526a5243c2b487775fb9f067 Mon Sep 17 00:00:00 2001 From: roggernaut Date: Wed, 26 Aug 2026 20:40:56 +0000 Subject: [PATCH 23/64] Apply formatting changes --- .changeset/privacy-v2-simplification.md | 10 +- .../2026-08-25-privacy-v2-simplification.md | 524 ++++++++++++++---- ...-08-25-privacy-v2-simplification-design.md | 70 +-- guide.md | 78 +-- packages/rrweb-snapshot/src/privacy.ts | 95 +++- packages/rrweb-snapshot/src/snapshot.ts | 3 +- packages/rrweb-snapshot/src/types.ts | 5 +- packages/rrweb-snapshot/src/utils.ts | 4 +- .../test/privacy-integration.test.ts | 14 +- packages/rrweb-snapshot/test/privacy.test.ts | 126 ++++- packages/rrweb/src/record/mutation.ts | 4 +- packages/rrweb/src/record/observer.ts | 7 +- .../rrweb/test/record/canvas-mask.test.ts | 5 +- packages/rrweb/test/record/privacy.test.ts | 5 +- 14 files changed, 693 insertions(+), 257 deletions(-) diff --git a/.changeset/privacy-v2-simplification.md b/.changeset/privacy-v2-simplification.md index 1f6bcc1e9a..ea2e343034 100644 --- a/.changeset/privacy-v2-simplification.md +++ b/.changeset/privacy-v2-simplification.md @@ -1,9 +1,9 @@ --- -'rrweb-snapshot': minor -'rrweb': minor -'@rrweb/types': major -'@rrweb/rrweb-plugin-privacy-detectors': minor -'@rrweb/utils': minor +"rrweb-snapshot": minor +"rrweb": minor +"@rrweb/types": major +"@rrweb/rrweb-plugin-privacy-detectors": minor +"@rrweb/utils": minor --- Privacy at Capture v2: policies now compile onto rrweb's existing masking diff --git a/docs/superpowers/plans/2026-08-25-privacy-v2-simplification.md b/docs/superpowers/plans/2026-08-25-privacy-v2-simplification.md index 43fc1078d7..56e56c2000 100644 --- a/docs/superpowers/plans/2026-08-25-privacy-v2-simplification.md +++ b/docs/superpowers/plans/2026-08-25-privacy-v2-simplification.md @@ -22,12 +22,14 @@ ### Task 1: Compiled policy v2 — types and `compilePrivacyPolicy` **Files:** + - Modify: `packages/rrweb-snapshot/src/types.ts` (PrivacyPolicy/CompiledPrivacyPolicy region, ~lines 77-200) - Modify: `packages/rrweb-snapshot/src/privacy.ts` - Delete: `packages/rrweb-snapshot/privacy-policy.schema.json` - Test: `packages/rrweb-snapshot/test/privacy.test.ts` **Interfaces:** + - Produces (later tasks depend on these exact shapes): ```ts @@ -39,34 +41,47 @@ export type PrivacyRule = { action: PrivacyAction; }; // style/classification/attributes removed export type PrivacyDetectorOptions = Partial<{ - email: boolean; phone: boolean; paymentCard: boolean; ssn: boolean; ipAddress: boolean; + email: boolean; + phone: boolean; + paymentCard: boolean; + ssn: boolean; + ipAddress: boolean; }>; // custom removed export type CompiledPrivacyPolicy = { policy: PrivacyPolicy; preset: PrivacyPreset; - maskTextSelector: string | null; // 'mask' rules + [data-privacy="mask"] + vendor classes (+ '*' under strict) + maskTextSelector: string | null; // 'mask' rules + [data-privacy="mask"] + vendor classes (+ '*' under strict) unmaskTextSelector: string | null; // 'allow'/'unmask' rules + [data-privacy="allow"] + vendor unmask classes - blockSelector: string | null; // 'exclude' rules + [data-privacy="exclude"] + vendor block classes - maskAllInputs: boolean; // true under balanced/strict - maskedAttributes: string[]; // ['title','placeholder','aria-label'] under balanced/strict, else [] - blockMedia: boolean; // true under strict - sanitizeUrls: boolean; // true under balanced/strict - blockedQueryParameters: Set; // precomputed, lowercased + blockSelector: string | null; // 'exclude' rules + [data-privacy="exclude"] + vendor block classes + maskAllInputs: boolean; // true under balanced/strict + maskedAttributes: string[]; // ['title','placeholder','aria-label'] under balanced/strict, else [] + blockMedia: boolean; // true under strict + sanitizeUrls: boolean; // true under balanced/strict + blockedQueryParameters: Set; // precomputed, lowercased allowedQueryParameters: Set | null; removeHash: boolean; - detectors: CompiledDetector[]; // populated by Task 2; [] here + detectors: CompiledDetector[]; // populated by Task 2; [] here +}; +export type CompiledDetector = { + name: string; + test: (value: string) => boolean; }; -export type CompiledDetector = { name: string; test: (value: string) => boolean }; ``` ```ts // privacy.ts -export function compilePrivacyPolicy(policy: PrivacyPolicy | undefined): CompiledPrivacyPolicy; -export function mergeBlockSelectors(legacy: string | null, privacy: CompiledPrivacyPolicy | undefined): string | null; // unchanged signature +export function compilePrivacyPolicy( + policy: PrivacyPolicy | undefined, +): CompiledPrivacyPolicy; +export function mergeBlockSelectors( + legacy: string | null, + privacy: CompiledPrivacyPolicy | undefined, +): string | null; // unchanged signature export function validateSelector(selector: string): boolean; // exported for reuse ``` - Vendor-class constants compiled into defaults for every non-legacy preset: + - mask: `.rr-mask, .mp-mask, .fs-mask, .amp-mask, .ph-mask, .sentry-mask, [data-sentry-mask]` - unmask: `.rr-unmask, .amp-unmask, .sentry-unmask, [data-sentry-unmask]` - block: `.rr-block, .mp-block, .fs-exclude, .amp-block, .ph-no-capture, .sentry-block` @@ -75,7 +90,11 @@ export function validateSelector(selector: string): boolean; // exported for reu ```ts import { describe, it, expect, vi } from 'vitest'; -import { compilePrivacyPolicy, validateSelector, mergeBlockSelectors } from '../src/privacy'; +import { + compilePrivacyPolicy, + validateSelector, + mergeBlockSelectors, +} from '../src/privacy'; describe('compilePrivacyPolicy v2', () => { it('legacy preset compiles to inert options', () => { @@ -105,7 +124,8 @@ describe('compilePrivacyPolicy v2', () => { }); it('compiles rules into selector lists, unmask as alias of allow', () => { const c = compilePrivacyPolicy({ - version: 1, preset: 'balanced', + version: 1, + preset: 'balanced', rules: [ { target: { type: 'selector', selector: '.pii' }, action: 'mask' }, { target: { type: 'selector', selector: '.safe' }, action: 'unmask' }, @@ -119,9 +139,13 @@ describe('compilePrivacyPolicy v2', () => { it('drops invalid selectors individually with a warning, keeps the rest', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const c = compilePrivacyPolicy({ - version: 1, preset: 'balanced', + version: 1, + preset: 'balanced', rules: [ - { target: { type: 'selector', selector: ':::garbage' }, action: 'exclude' }, + { + target: { type: 'selector', selector: ':::garbage' }, + action: 'exclude', + }, { target: { type: 'selector', selector: '.valid' }, action: 'exclude' }, ], }); @@ -131,16 +155,26 @@ describe('compilePrivacyPolicy v2', () => { warn.mockRestore(); }); it('throws on bad version/preset/empty selector', () => { - expect(() => compilePrivacyPolicy({ version: 2 as never, preset: 'legacy' })).toThrow(); - expect(() => compilePrivacyPolicy({ version: 1, preset: 'custom' as never })).toThrow(); expect(() => - compilePrivacyPolicy({ version: 1, preset: 'balanced', - rules: [{ target: { type: 'selector', selector: '' }, action: 'mask' }] }), + compilePrivacyPolicy({ version: 2 as never, preset: 'legacy' }), + ).toThrow(); + expect(() => + compilePrivacyPolicy({ version: 1, preset: 'custom' as never }), + ).toThrow(); + expect(() => + compilePrivacyPolicy({ + version: 1, + preset: 'balanced', + rules: [{ target: { type: 'selector', selector: '' }, action: 'mask' }], + }), ).toThrow(); }); it('precomputes lowercased query parameter sets', () => { - const c = compilePrivacyPolicy({ version: 1, preset: 'strict', - url: { blockedQueryParameters: ['SessionID'] } }); + const c = compilePrivacyPolicy({ + version: 1, + preset: 'strict', + url: { blockedQueryParameters: ['SessionID'] }, + }); expect(c.blockedQueryParameters.has('sessionid')).toBe(true); expect(c.blockedQueryParameters.has('token')).toBe(true); // default list }); @@ -155,7 +189,9 @@ describe('mergeBlockSelectors', () => { it('joins legacy selector with compiled blockSelector', () => { const c = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); expect(mergeBlockSelectors('.legacy', c)).toContain('.legacy'); - expect(mergeBlockSelectors('.legacy', c)).toContain('[data-privacy="exclude"]'); + expect(mergeBlockSelectors('.legacy', c)).toContain( + '[data-privacy="exclude"]', + ); }); }); ``` @@ -183,7 +219,9 @@ export function validateSelector(selector: string): boolean { } } -function joinSelectors(selectors: Array): string | null { +function joinSelectors( + selectors: Array, +): string | null { const kept: string[] = []; for (const s of selectors) { if (!s) continue; @@ -196,18 +234,32 @@ function joinSelectors(selectors: Array): string | nu return kept.join(',') || null; } -export function compilePrivacyPolicy(policy?: PrivacyPolicy): CompiledPrivacyPolicy { +export function compilePrivacyPolicy( + policy?: PrivacyPolicy, +): CompiledPrivacyPolicy { const effective: PrivacyPolicy = policy || { version: 1, preset: 'legacy' }; if (effective.version !== 1) - throw new Error(`Unsupported Privacy at Capture policy version: ${String(effective.version)}`); + throw new Error( + `Unsupported Privacy at Capture policy version: ${String( + effective.version, + )}`, + ); if (!PRIVACY_PRESETS.has(effective.preset)) throw new Error(`Unsupported privacy preset: ${String(effective.preset)}`); const preset = effective.preset; const nonLegacy = preset !== 'legacy'; - const bySelector = { mask: [] as string[], unmask: [] as string[], exclude: [] as string[] }; + const bySelector = { + mask: [] as string[], + unmask: [] as string[], + exclude: [] as string[], + }; for (const rule of effective.rules || []) { - if (!rule.target || rule.target.type !== 'selector' || !rule.target.selector) + if ( + !rule.target || + rule.target.type !== 'selector' || + !rule.target.selector + ) throw new Error('Privacy rules require a non-empty selector target'); const action = rule.action === 'allow' ? 'unmask' : rule.action; if (!(action in bySelector)) @@ -221,29 +273,50 @@ export function compilePrivacyPolicy(policy?: PrivacyPolicy): CompiledPrivacyPol maskTextSelector: nonLegacy ? preset === 'strict' ? '*' - : joinSelectors(['[data-privacy="mask"]', VENDOR_MASK_CLASSES, ...bySelector.mask]) - : joinSelectors(bySelector.mask.length ? ['[data-privacy="mask"]', ...bySelector.mask] : []), + : joinSelectors([ + '[data-privacy="mask"]', + VENDOR_MASK_CLASSES, + ...bySelector.mask, + ]) + : joinSelectors( + bySelector.mask.length + ? ['[data-privacy="mask"]', ...bySelector.mask] + : [], + ), unmaskTextSelector: joinSelectors( nonLegacy - ? ['[data-privacy="allow"]', VENDOR_UNMASK_CLASSES, ...bySelector.unmask] + ? [ + '[data-privacy="allow"]', + VENDOR_UNMASK_CLASSES, + ...bySelector.unmask, + ] : bySelector.unmask, ), blockSelector: joinSelectors( nonLegacy - ? ['[data-privacy="exclude"]', VENDOR_BLOCK_CLASSES, ...bySelector.exclude] - : bySelector.exclude.length ? ['[data-privacy="exclude"]', ...bySelector.exclude] : [], + ? [ + '[data-privacy="exclude"]', + VENDOR_BLOCK_CLASSES, + ...bySelector.exclude, + ] + : bySelector.exclude.length + ? ['[data-privacy="exclude"]', ...bySelector.exclude] + : [], ), maskAllInputs: nonLegacy, maskedAttributes: nonLegacy ? [...MASKED_ATTRIBUTE_DEFAULTS] : [], blockMedia: preset === 'strict', sanitizeUrls: nonLegacy, blockedQueryParameters: new Set( - [...DEFAULT_BLOCKED_QUERY_PARAMETERS, ...(effective.url?.blockedQueryParameters || [])].map( - (n) => n.toLowerCase(), - ), + [ + ...DEFAULT_BLOCKED_QUERY_PARAMETERS, + ...(effective.url?.blockedQueryParameters || []), + ].map((n) => n.toLowerCase()), ), allowedQueryParameters: effective.url?.allowedQueryParameters - ? new Set(effective.url.allowedQueryParameters.map((n) => n.toLowerCase())) + ? new Set( + effective.url.allowedQueryParameters.map((n) => n.toLowerCase()), + ) : null, removeHash: effective.url?.removeHash !== false, detectors: [], // populated by applyPrivacyDetectors (Task 2) @@ -262,17 +335,27 @@ Keep `mergeBlockSelectors` as-is (it reads `privacy.blockSelector`, still presen ### Task 2: Fixed detectors with whole-value semantics **Files:** + - Modify: `packages/rrweb-snapshot/src/privacy.ts` - Test: `packages/rrweb-snapshot/test/privacy.test.ts` **Interfaces:** + - Produces: ```ts export const DEFAULT_PRIVACY_DETECTORS: Required; // all true -export function applyPrivacyDetectors(policy: PrivacyPolicy | undefined, options?: PrivacyDetectorOptions): PrivacyPolicy; // keeps legacy base when policy omitted -export function buildDetectors(options: PrivacyDetectorOptions | undefined): CompiledDetector[]; -export function detectSensitiveValue(value: string, privacy: CompiledPrivacyPolicy): boolean; +export function applyPrivacyDetectors( + policy: PrivacyPolicy | undefined, + options?: PrivacyDetectorOptions, +): PrivacyPolicy; // keeps legacy base when policy omitted +export function buildDetectors( + options: PrivacyDetectorOptions | undefined, +): CompiledDetector[]; +export function detectSensitiveValue( + value: string, + privacy: CompiledPrivacyPolicy, +): boolean; export function passesLuhn(candidate: string): boolean; // kept as-is ``` @@ -282,37 +365,72 @@ export function passesLuhn(candidate: string): boolean; // kept as-is - [ ] **Step 1: Write the failing tests:** ```ts -import { compilePrivacyPolicy, detectSensitiveValue, buildDetectors } from '../src/privacy'; +import { + compilePrivacyPolicy, + detectSensitiveValue, + buildDetectors, +} from '../src/privacy'; const withDetectors = compilePrivacyPolicy({ - version: 1, preset: 'legacy', - detectors: { email: true, phone: true, paymentCard: true, ssn: true, ipAddress: true }, + version: 1, + preset: 'legacy', + detectors: { + email: true, + phone: true, + paymentCard: true, + ssn: true, + ipAddress: true, + }, }); describe('detectSensitiveValue', () => { it('detects a Luhn-valid card adjacent to other digits (review regression)', () => { - expect(detectSensitiveValue('call 5551234567 4111 1111 1111 1111 now', withDetectors)).toBe(true); + expect( + detectSensitiveValue( + 'call 5551234567 4111 1111 1111 1111 now', + withDetectors, + ), + ).toBe(true); }); it('detects email, ssn, ip; passes clean prose', () => { - expect(detectSensitiveValue('contact bob@example.com', withDetectors)).toBe(true); + expect(detectSensitiveValue('contact bob@example.com', withDetectors)).toBe( + true, + ); expect(detectSensitiveValue('ssn 123-45-6789', withDetectors)).toBe(true); expect(detectSensitiveValue('host 192.168.0.1', withDetectors)).toBe(true); - expect(detectSensitiveValue('the quick brown fox', withDetectors)).toBe(false); + expect(detectSensitiveValue('the quick brown fox', withDetectors)).toBe( + false, + ); }); it('rejects UUIDs and version strings as cards/ssns (false-positive guard)', () => { - expect(detectSensitiveValue('id 550e8400-e29b-41d4-a716-446655440000', withDetectors)).toBe(false); - expect(detectSensitiveValue('v1.2.3.4000 build', withDetectors)).toBe(false); + expect( + detectSensitiveValue( + 'id 550e8400-e29b-41d4-a716-446655440000', + withDetectors, + ), + ).toBe(false); + expect(detectSensitiveValue('v1.2.3.4000 build', withDetectors)).toBe( + false, + ); }); it('detects regardless of preset (works under legacy)', () => { expect(withDetectors.preset).toBe('legacy'); - expect(detectSensitiveValue('4111 1111 1111 1111', withDetectors)).toBe(true); + expect(detectSensitiveValue('4111 1111 1111 1111', withDetectors)).toBe( + true, + ); }); it('no detectors configured -> never detects', () => { const none = compilePrivacyPolicy({ version: 1, preset: 'strict' }); expect(detectSensitiveValue('bob@example.com', none)).toBe(false); }); it('per-detector toggles work', () => { - const emailOff = buildDetectors({ email: false, phone: false, paymentCard: true, ssn: false, ipAddress: false }); + const emailOff = buildDetectors({ + email: false, + phone: false, + paymentCard: true, + ssn: false, + ipAddress: false, + }); expect(emailOff.some((d) => d.name === 'email')).toBe(false); expect(emailOff.some((d) => d.name === 'payment-card')).toBe(true); }); @@ -332,7 +450,9 @@ const PHONE_PATTERN = /(?:^|\s)\+?\d[\d ().-]{7,18}\d(?:$|\s)/; const IPV4_PATTERN = /\b(?:\d{1,3}\.){3}\d{1,3}\b/; const MAX_SCAN_LENGTH = 10_000; -export function buildDetectors(options: PrivacyDetectorOptions | undefined): CompiledDetector[] { +export function buildDetectors( + options: PrivacyDetectorOptions | undefined, +): CompiledDetector[] { const opts = options || {}; const detectors: CompiledDetector[] = []; if (opts.email) @@ -355,7 +475,8 @@ export function buildDetectors(options: PrivacyDetectorOptions | undefined): Com return !!m && passesLuhn(m[1]); }, }); - if (opts.ssn) detectors.push({ name: 'ssn', test: (v) => SSN_PATTERN.test(v) }); + if (opts.ssn) + detectors.push({ name: 'ssn', test: (v) => SSN_PATTERN.test(v) }); if (opts.ipAddress) detectors.push({ name: 'ip-address', @@ -367,7 +488,10 @@ export function buildDetectors(options: PrivacyDetectorOptions | undefined): Com return detectors; } -export function detectSensitiveValue(value: string, privacy: CompiledPrivacyPolicy): boolean { +export function detectSensitiveValue( + value: string, + privacy: CompiledPrivacyPolicy, +): boolean { if (!privacy.detectors.length || !value) return false; // Fail closed on absurd inputs instead of scanning them. if (value.length > MAX_SCAN_LENGTH) return true; @@ -386,10 +510,12 @@ Card adjacency note (why the review bug disappears): `CARD_CANDIDATE.exec` finds ### Task 3: `sanitizeUrl` v2 — userinfo stripping and precomputed sets **Files:** + - Modify: `packages/rrweb-snapshot/src/privacy.ts` (`sanitizeUrl`) - Test: `packages/rrweb-snapshot/test/privacy.test.ts` **Interfaces:** + - Produces: `sanitizeUrl(value: string, privacy: CompiledPrivacyPolicy | undefined): string` (same signature; behavior changes). - Consumes: `blockedQueryParameters`/`allowedQueryParameters`/`removeHash`/`sanitizeUrls` from Task 1, `detectSensitiveValue` from Task 2. @@ -401,19 +527,35 @@ describe('sanitizeUrl v2', () => { const balanced = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); const legacy = compilePrivacyPolicy(undefined); it('strips userinfo credentials', () => { - expect(sanitizeUrl('https://alice:hunter2@api.example.com/x', balanced)).toBe('https://api.example.com/x'); + expect( + sanitizeUrl('https://alice:hunter2@api.example.com/x', balanced), + ).toBe('https://api.example.com/x'); }); it('masks blocked query parameters, case-insensitively', () => { - expect(sanitizeUrl('https://a.com/?Token=abc&ok=1', balanced)).toBe('https://a.com/?Token=*&ok=1'); + expect(sanitizeUrl('https://a.com/?Token=abc&ok=1', balanced)).toBe( + 'https://a.com/?Token=*&ok=1', + ); }); it('strict masks all params unless allowlisted', () => { - const allow = compilePrivacyPolicy({ version: 1, preset: 'strict', url: { allowedQueryParameters: ['page'] } }); - expect(sanitizeUrl('https://a.com/?page=2&q=x', strict)).toBe('https://a.com/?page=*&q=*'); - expect(sanitizeUrl('https://a.com/?page=2&q=x', allow)).toBe('https://a.com/?page=2&q=*'); + const allow = compilePrivacyPolicy({ + version: 1, + preset: 'strict', + url: { allowedQueryParameters: ['page'] }, + }); + expect(sanitizeUrl('https://a.com/?page=2&q=x', strict)).toBe( + 'https://a.com/?page=*&q=*', + ); + expect(sanitizeUrl('https://a.com/?page=2&q=x', allow)).toBe( + 'https://a.com/?page=2&q=*', + ); }); it('removes hash unless disabled; legacy passes through untouched', () => { - expect(sanitizeUrl('https://a.com/x#frag', balanced)).toBe('https://a.com/x'); - expect(sanitizeUrl('https://alice:pw@a.com/?token=x#f', legacy)).toBe('https://alice:pw@a.com/?token=x#f'); + expect(sanitizeUrl('https://a.com/x#frag', balanced)).toBe( + 'https://a.com/x', + ); + expect(sanitizeUrl('https://alice:pw@a.com/?token=x#f', legacy)).toBe( + 'https://alice:pw@a.com/?token=x#f', + ); }); it('unparseable value under non-legacy fails closed to empty string', () => { expect(sanitizeUrl('http://[broken', balanced)).toBe(''); @@ -426,7 +568,10 @@ describe('sanitizeUrl v2', () => { - [ ] **Step 3: Implement:** ```ts -export function sanitizeUrl(value: string, privacy: CompiledPrivacyPolicy | undefined): string { +export function sanitizeUrl( + value: string, + privacy: CompiledPrivacyPolicy | undefined, +): string { if (!privacy || !privacy.sanitizeUrls) return value; try { const url = new URL(value, 'https://rrweb.invalid'); @@ -436,7 +581,8 @@ export function sanitizeUrl(value: string, privacy: CompiledPrivacyPolicy | unde const lower = name.toLowerCase(); if ( (privacy.preset === 'strict' && !privacy.allowedQueryParameters) || - (privacy.allowedQueryParameters && !privacy.allowedQueryParameters.has(lower)) || + (privacy.allowedQueryParameters && + !privacy.allowedQueryParameters.has(lower)) || privacy.blockedQueryParameters.has(lower) ) { url.searchParams.set(name, '*'); @@ -462,11 +608,13 @@ export function sanitizeUrl(value: string, privacy: CompiledPrivacyPolicy | unde ### Task 4: Core text masking — unmask selector, style exemption, detector hook **Files:** + - Modify: `packages/rrweb-snapshot/src/snapshot.ts` (serializeTextNode ~lines 520-600; needsMask computation ~lines 1080-1160; `snapshot()` options plumbing) - Modify: `packages/rrweb-snapshot/src/utils.ts` (extend the existing mask-check helper) - Test: `packages/rrweb-snapshot/test/privacy-integration.test.ts` (create) **Interfaces:** + - Consumes: `CompiledPrivacyPolicy` (Task 1), `detectSensitiveValue` (Task 2). - Produces: `needsMaskingText(node, maskTextClass, maskTextSelector, unmaskTextSelector, checkAncestors): boolean` in `utils.ts` — nearest-ancestor-wins, fail-closed. All serialization options gain `unmaskTextSelector: string | null` threaded exactly like `maskTextSelector` (serializeNodeWithId opts, serializeTextNode, snapshot()). - Deletes: the `maskTextWithPrivacy`/`shouldMaskInputWithPrivacy`/`maskInputWithPrivacy` privacy branch inside `serializeTextNode`; `maskTextWithPrivacy` itself is removed from `privacy.ts` (its remaining call sites are removed in Tasks 5-6). @@ -478,7 +626,10 @@ import { describe, it, expect } from 'vitest'; import snapshot from '../src/snapshot'; import { compilePrivacyPolicy } from '../src/privacy'; -function serialize(html: string, privacy: ReturnType) { +function serialize( + html: string, + privacy: ReturnType, +) { document.body.innerHTML = html; return JSON.stringify( snapshot(document, { @@ -494,10 +645,15 @@ function serialize(html: string, privacy: ReturnType { const strict = compilePrivacyPolicy({ version: 1, preset: 'strict' }); it('strict masks page text', () => { - expect(serialize('

    hello world

    ', strict)).not.toContain('hello world'); + expect(serialize('

    hello world

    ', strict)).not.toContain( + 'hello world', + ); }); it('never masks

    secret

    ', strict); + const out = serialize( + '

    secret

    ', + strict, + ); expect(out).toContain('body{color:red}'); expect(out).not.toContain('secret'); }); @@ -511,14 +667,21 @@ describe('text masking v2', () => { }); it('detectors mask the whole text node under legacy when configured', () => { const withDet = compilePrivacyPolicy({ - version: 1, preset: 'legacy', detectors: { paymentCard: true, phone: true }, + version: 1, + preset: 'legacy', + detectors: { paymentCard: true, phone: true }, }); - const out = serialize('

    call 5551234567 4111 1111 1111 1111 now

    ', withDet); + const out = serialize( + '

    call 5551234567 4111 1111 1111 1111 now

    ', + withDet, + ); expect(out).not.toContain('4111 1111 1111 1111'); }); it('legacy without detectors leaves text untouched', () => { const legacy = compilePrivacyPolicy(undefined); - expect(serialize('

    bob@example.com

    ', legacy)).toContain('bob@example.com'); + expect(serialize('

    bob@example.com

    ', legacy)).toContain( + 'bob@example.com', + ); }); }); ``` @@ -537,11 +700,14 @@ export function needsMaskingText( ): boolean { try { const el: HTMLElement | null = - node.nodeType === node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement; + node.nodeType === node.ELEMENT_NODE + ? (node as HTMLElement) + : node.parentElement; if (el === null) return false; let current: HTMLElement | null = el; while (current) { - if (unmaskTextSelector && current.matches(unmaskTextSelector)) return false; + if (unmaskTextSelector && current.matches(unmaskTextSelector)) + return false; if (classMatchesMaskTextClass(current, maskTextClass)) return true; // reuse existing class check if (maskTextSelector && current.matches(maskTextSelector)) return true; if (!checkAncestors) break; @@ -557,8 +723,14 @@ export function needsMaskingText( Nearest-ancestor-wins falls out of walking upward and returning on first hit. In `serializeTextNode`: restore the single pre-feature shape — `if (!isStyle && !isScript && textContent && needsMask) { textContent = maskTextFn ? maskTextFn(textContent, parentEl) : textContent.replace(/[\S]/g, '*'); }` — and delete the `if (privacy)` branch entirely. Then add the detector hook after it: ```ts -if (!isStyle && !isScript && textContent && !needsMask && privacy && - detectSensitiveValue(textContent, privacy)) { +if ( + !isStyle && + !isScript && + textContent && + !needsMask && + privacy && + detectSensitiveValue(textContent, privacy) +) { textContent = textContent.replace(/[\S]/g, '*'); } ``` @@ -573,20 +745,32 @@ Thread `unmaskTextSelector` through the same option paths `maskTextSelector` alr ### Task 5: Input masking composition **Files:** + - Modify: `packages/rrweb-snapshot/src/utils.ts` (`maskInputValue`, `getInputType`) - Modify: `packages/rrweb-snapshot/src/privacy.ts` (`isProtectedInput` → exported, reusing `getInputType`) - Modify: `packages/rrweb-snapshot/src/snapshot.ts`, `packages/rrweb/src/record/mutation.ts` (~583-690), `packages/rrweb/src/record/observer.ts` (~425-445) - Test: `packages/rrweb-snapshot/test/privacy-integration.test.ts` **Interfaces:** + - Produces (single entry point; the four legacyMask forks collapse into it): ```ts export function maskInput({ - element, tagName, type, value, maskInputOptions, maskInputFn, privacy, + element, + tagName, + type, + value, + maskInputOptions, + maskInputFn, + privacy, }: { - element: HTMLElement; tagName: string; type: string | null; value: string; - maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; + element: HTMLElement; + tagName: string; + type: string | null; + value: string; + maskInputOptions: MaskInputOptions; + maskInputFn?: MaskInputFn; privacy: CompiledPrivacyPolicy | undefined; }): string; export function isProtectedInput(element: HTMLElement): boolean; // password/hidden/data-rr-is-password/cc-* autocomplete @@ -607,29 +791,63 @@ describe('maskInput v2', () => { return document.querySelector('input') as HTMLInputElement; }; it('balanced masks all inputs shape-free (stars, not digits)', () => { - const out = maskInput({ element: input(), tagName: 'input', type: 'text', - value: '4111 1111 1111 1111', maskInputOptions: {}, privacy: balanced }); + const out = maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: '4111 1111 1111 1111', + maskInputOptions: {}, + privacy: balanced, + }); expect(out).toBe('*'.repeat(19)); }); it('balanced + maskInputFn: fn controls length only, never content', () => { - const out = maskInput({ element: input(), tagName: 'input', type: 'text', - value: 'secret', maskInputOptions: {}, - maskInputFn: () => '[redacted]', privacy: balanced }); + const out = maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: 'secret', + maskInputOptions: {}, + maskInputFn: () => '[redacted]', + privacy: balanced, + }); expect(out).toBe('*'.repeat('[redacted]'.length)); }); it('legacy + maskInputFn trusted verbatim when legacy options mask', () => { - const out = maskInput({ element: input(), tagName: 'input', type: 'text', - value: 'secret', maskInputOptions: { text: true }, - maskInputFn: () => '[redacted]', privacy: legacy }); + const out = maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: 'secret', + maskInputOptions: { text: true }, + maskInputFn: () => '[redacted]', + privacy: legacy, + }); expect(out).toBe('[redacted]'); }); it('legacy without options passes value through', () => { - expect(maskInput({ element: input(), tagName: 'input', type: 'text', - value: 'plain', maskInputOptions: {}, privacy: legacy })).toBe('plain'); + expect( + maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: 'plain', + maskInputOptions: {}, + privacy: legacy, + }), + ).toBe('plain'); }); it('protected inputs always mask, even legacy with no options', () => { - expect(maskInput({ element: input('type="password"'), tagName: 'input', type: 'password', - value: 'pw', maskInputOptions: {}, privacy: legacy })).toBe('**'); + expect( + maskInput({ + element: input('type="password"'), + tagName: 'input', + type: 'password', + value: 'pw', + maskInputOptions: {}, + privacy: legacy, + }), + ).toBe('**'); expect(isProtectedInput(input('autocomplete="cc-number"'))).toBe(true); }); }); @@ -639,16 +857,28 @@ describe('maskInput v2', () => { - [ ] **Step 3: Implement** `maskInput` in `utils.ts` wrapping the existing `maskInputValue` legacy logic: ```ts -export function maskInput(args: {/* as Interfaces */}): string { - const { element, tagName, type, value, maskInputOptions, maskInputFn, privacy } = args; +export function maskInput(args: { + /* as Interfaces */ +}): string { + const { + element, + tagName, + type, + value, + maskInputOptions, + maskInputFn, + privacy, + } = args; if (isProtectedInput(element)) return '*'.repeat(value.length); const legacyWantsMask = Boolean( maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || - (type && maskInputOptions[type.toLowerCase() as keyof MaskInputOptions]), + (type && maskInputOptions[type.toLowerCase() as keyof MaskInputOptions]), ); const presetWantsMask = !!privacy && privacy.maskAllInputs; if (!legacyWantsMask && !presetWantsMask) return value; - let masked = maskInputFn ? maskInputFn(value, element) : '*'.repeat(value.length); + let masked = maskInputFn + ? maskInputFn(value, element) + : '*'.repeat(value.length); if (presetWantsMask && maskInputFn) masked = '*'.repeat(masked.length); // fn controls length only if (presetWantsMask && !maskInputFn) masked = '*'.repeat(value.length); return masked; @@ -665,21 +895,32 @@ Move `isProtectedInput` from `privacy.ts` into `utils.ts` built on `getInputType ### Task 6: Attribute finalization — one pass, one helper **Files:** + - Modify: `packages/rrweb-snapshot/src/privacy.ts` (`protectSerializedAttribute`, `maskAttributeWithPrivacy` deleted, `SENSITIVE_ATTRIBUTES` trimmed) - Modify: `packages/rrweb-snapshot/src/snapshot.ts` (attribute loop ~lines 620-900) - Modify: `packages/rrweb/src/record/mutation.ts` (pushAdd ~329-370; emit attribute loop ~510-530; delete `generatedAttributes` WeakMap ~152/526/559/809) - Test: `packages/rrweb-snapshot/test/privacy-integration.test.ts`, `packages/rrweb/test/record/privacy.test.ts` (adapt existing) **Interfaces:** + - Produces (replaces both `maskAttributeWithPrivacy` and old `protectSerializedAttribute`): ```ts export function finalizeAttribute({ - element, name, value, privacy, maskAllElementAttributes, maskAttributeFn, isGenerated, + element, + name, + value, + privacy, + maskAllElementAttributes, + maskAttributeFn, + isGenerated, }: { - element: Element; name: string; value: string | null; + element: Element; + name: string; + value: string | null; privacy: CompiledPrivacyPolicy | undefined; - maskAllElementAttributes?: boolean; maskAttributeFn?: MaskAttributeFn; + maskAllElementAttributes?: boolean; + maskAttributeFn?: MaskAttributeFn; isGenerated?: boolean; }): string | null; ``` @@ -692,23 +933,74 @@ export function finalizeAttribute({ ```ts describe('finalizeAttribute', () => { const strict = compilePrivacyPolicy({ version: 1, preset: 'strict' }); - const el = () => { document.body.innerHTML = ''; return document.querySelector('img')!; }; + const el = () => { + document.body.innerHTML = + ''; + return document.querySelector('img')!; + }; it('never masks style, even under strict', () => { - expect(finalizeAttribute({ element: el(), name: 'style', value: 'color:red', privacy: strict })).toBe('color:red'); + expect( + finalizeAttribute({ + element: el(), + name: 'style', + value: 'color:red', + privacy: strict, + }), + ).toBe('color:red'); }); it('masks listed attributes under strict/balanced', () => { - expect(finalizeAttribute({ element: el(), name: 'title', value: 'Bob', privacy: strict })).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: strict, + }), + ).toBe('***'); }); it('strict nulls media sources; URLs sanitized elsewhere', () => { - expect(finalizeAttribute({ element: el(), name: 'src', value: 'https://a.com/i.png', privacy: strict })).toBeNull(); + expect( + finalizeAttribute({ + element: el(), + name: 'src', + value: 'https://a.com/i.png', + privacy: strict, + }), + ).toBeNull(); }); it('maskAllElementAttributes stars everything except generated', () => { - expect(finalizeAttribute({ element: el(), name: 'title', value: 'Bob', privacy: undefined, maskAllElementAttributes: true })).toBe('***'); - expect(finalizeAttribute({ element: el(), name: 'rr_open_mode', value: 'modal', privacy: undefined, maskAllElementAttributes: true, isGenerated: true })).toBe('modal'); + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: undefined, + maskAllElementAttributes: true, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'rr_open_mode', + value: 'modal', + privacy: undefined, + maskAllElementAttributes: true, + isGenerated: true, + }), + ).toBe('modal'); }); it('maskAttributeFn throw fails closed to stars; fn ignored under maskAll', () => { - expect(finalizeAttribute({ element: el(), name: 'title', value: 'Bob', privacy: undefined, - maskAttributeFn: () => { throw new Error('boom'); } })).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: undefined, + maskAttributeFn: () => { + throw new Error('boom'); + }, + }), + ).toBe('***'); }); }); ``` @@ -725,6 +1017,7 @@ Plus a recorder-level test in `packages/rrweb/test/record/privacy.test.ts` (adap ### Task 7: Delete CSS masking call sites **Files:** + - Modify: `packages/rrweb/src/record/observer.ts` (delete `maskCssForRecord` + `stylesheetOwnerElement` ~597-616 and the maskTextWithPrivacy calls at ~650, 730, 762, 830, 995) - Modify: `packages/rrweb/src/record/stylesheet-manager.ts` (delete `maskAdoptedRule` ~97-106 and its call at ~80) - Modify: `packages/rrweb/src/record/mutation.ts` (delete styleDiff masking ~763-786) @@ -743,12 +1036,14 @@ Plus a recorder-level test in `packages/rrweb/test/record/privacy.test.ts` (adap ### Task 8: Canvas fail-closed + region scaling **Files:** + - Modify: `packages/rrweb/src/record/index.ts` (canvas wiring ~120-130) - Modify: `packages/rrweb/src/record/observers/canvas/canvas-manager.ts` (constructor ~85-100; `getCanvas`/`search` ~190-215) - Modify: `packages/rrweb/src/record/observers/canvas/canvas-mask.ts` (~40-70) - Test: `packages/rrweb/test/record/canvas-mask.test.ts` (adapt existing canvas tests) **Interfaces:** + - record/index.ts rule (encode as a pure helper so it is unit-testable): ```ts @@ -758,7 +1053,9 @@ export function resolveCanvasSampling( ): number | 'all' | undefined { if (!canvasMaskingConfigured) return requestedSampling; if (typeof requestedSampling === 'number') return requestedSampling; - console.warn('[rrweb] canvasMasking requires FPS canvas capture; forcing sampling.canvas = 4'); + console.warn( + '[rrweb] canvasMasking requires FPS canvas capture; forcing sampling.canvas = 4', + ); return 4; } ``` @@ -792,6 +1089,7 @@ Plus in the existing canvas mask test file: a region-scaling case with a padded ### Task 9: Wiring hardening — plugin fallback, untainted tagName, plugin package **Files:** + - Modify: `packages/rrweb/src/record/index.ts` (~109-130) - Modify: `packages/utils/src/index.ts` (add `untaintedTagName`) - Modify: `packages/rrweb/src/record/mutation.ts` (~663-665 raw tagName reads), `packages/rrweb-snapshot/src/snapshot.ts` (~533-539 inline guard), `packages/rrweb-snapshot/src/privacy.ts` (delete `nativeElementTagName`, `parentElementAcrossShadowRoot` — no remaining callers after Tasks 4-6) @@ -799,6 +1097,7 @@ Plus in the existing canvas mask test file: a region-scaling case with a padded - Test: `packages/plugins/rrweb-plugin-privacy-detectors/test/index.test.ts`, `packages/rrweb/test/record/privacy.test.ts` **Interfaces:** + - `@rrweb/utils` produces: `export function untaintedTagName(element: Element | null | undefined): string` — returns `''` for null; uses the element's own `tagName` when it is a string, else the untainted `Element.prototype` getter via the existing `getUntaintedAccessor` machinery; uppercased. Every privacy-relevant `element.tagName` read in `mutation.ts`/`snapshot.ts` touched by this feature goes through it. - record/index.ts plugin fallback: @@ -808,7 +1107,10 @@ try { privacy = compilePrivacyPolicy(portablePrivacyPolicy); } catch (error) { if (portablePrivacyPolicy !== privacyPolicy) { - console.error('[rrweb] plugin-transformed privacy policy failed to compile; using the user policy', error); + console.error( + '[rrweb] plugin-transformed privacy policy failed to compile; using the user policy', + error, + ); privacy = compilePrivacyPolicy(privacyPolicy); // user's own invalid policy still throws (programmer error) } else { throw error; @@ -832,7 +1134,10 @@ it('plugin with no user policy yields a legacy policy whose compiled detectors a }); // rrweb record suite it('a plugin returning a malformed policy falls back to the user policy instead of throwing', () => { - const badPlugin = { name: 'bad@1', applyPrivacyPolicy: () => ({ nonsense: true }) }; + const badPlugin = { + name: 'bad@1', + applyPrivacyPolicy: () => ({ nonsense: true }), + }; expect(() => record({ emit: () => {}, plugins: [badPlugin as never] }), ).not.toThrow(); @@ -853,6 +1158,7 @@ it('untaintedTagName survives
    ', () => { ### Task 10: Types package, changeset, docs **Files:** + - Modify: `packages/types/src/index.ts` (mirror Task 1 type removals for the public `@rrweb/types` copies; keep the `ImageBitmapDataURLWorkerParams` union but document it) - Modify: `guide.md` (privacy section ~lines 270-300), `packages/plugins/rrweb-plugin-privacy-detectors/README.md` - Create: `.changeset/privacy-v2-simplification.md` @@ -896,10 +1202,12 @@ path; selector and config errors fail closed. BREAKING (@rrweb/types): ```ts it('legacy snapshot performs no privacy selector matching', () => { const spy = vi.spyOn(Element.prototype, 'matches'); - document.body.innerHTML = '
    '.repeat(200) + 'deep text' + '
    '.repeat(200); + document.body.innerHTML = + '
    '.repeat(200) + 'deep text' + '
    '.repeat(200); snapshot(document, { privacy: compilePrivacyPolicy(undefined) }); - const privacyCalls = spy.mock.calls.filter(([sel]) => - typeof sel === 'string' && sel.includes('data-privacy')); + const privacyCalls = spy.mock.calls.filter( + ([sel]) => typeof sel === 'string' && sel.includes('data-privacy'), + ); expect(privacyCalls.length).toBe(0); spy.mockRestore(); }); diff --git a/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md b/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md index 00d3b4f353..03696340bf 100644 --- a/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md +++ b/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md @@ -176,7 +176,7 @@ merged selector lists. No rule engine. ### 8. Hardening - Mask-decision paths are wrapped fail-closed (Mixpanel): decision variable - initialized to *masked*; any throw logs and masks. + initialized to _masked_; any throw logs and masks. - One untainted `tagName` accessor in `@rrweb/utils` (`getUntaintedAccessor('Element', el, 'tagName')`) replaces the two divergent one-off shadowing fixes and is used at every `tagName` read in @@ -218,25 +218,25 @@ are resolved structurally. ## Appendix: prior art per adopted mechanism -| Mechanism adopted | Vendor source (file refs as of 2026-08 clones) | -|---|---| -| Whole-node masking on detector hit | Highlight fork `rrweb-snapshot/src/snapshot.ts:555-578` (`obfuscateText` on `.test()` hit) | -| No user-supplied detector regexes | All five vendors (none ship one) | -| Card/SSN pattern set + Luhn | posthog-js `browser-common/src/utils/autocapture-utils.ts:518-638` | -| Per-selector validation, drop + warn | Amplitude `session-replay-browser/src/config/joined-config.ts:24` (`removeInvalidSelectorsFromPrivacyConfig`) | -| Fail-closed mask decision (init masked, catch masks) | mixpanel-js `src/recorder/session-recording.js:588` (`_getMaskFn`) | -| Fail-closed under mask-all (catch returns masked) | Sentry fork `rrweb-snapshot/src/snapshot.ts:512-516` (`needMaskingText`) | -| `maskInputFn` output star-replaced (fn controls length only) | Sentry fork `rrweb-snapshot/src/utils.ts:274-296` (`maskInputValue`) | -| Forced `password: true` over user config | posthog-js `lazy-loaded-session-recorder.ts:2555` | -| `maskAttributeFn` dropped under `maskAllElementAttributes` | posthog-js `lazy-loaded-session-recorder.ts:2621-2633` | -| Masked attribute defaults (`title`, `placeholder`, `aria-label`) | Sentry `replay-internal/src/integration.ts:142` | -| Inherited mask propagation, checked once per subtree | posthog-js fork `rrweb-snapshot/src/snapshot.ts:1284-1292, 327-340` (tri-state `needsMask`); Highlight fork `snapshot.ts:1142-1150` (`overwrittenPrivacySetting`) | -| Nearest-ancestor mask/unmask tie-break | Sentry fork `rrweb-snapshot/src/snapshot.ts:505-511` | -| CSS/script never masked | Highlight fork `snapshot.ts:565-576` (`IGNORE_TAG_NAMES`); Sentry fork `snapshot.ts:765-805` (`!isStyle` guard) | -| Mask-all-text `strict` posture | Sentry `integration.ts:125-126`; mixpanel-js `session-recording.js:308-310` | -| Cross-vendor mask/block class recognition | mixpanel-js `src/recorder/masking.js` (`.mp-mask, .fs-mask, .amp-mask, .rr-mask, .ph-mask`) | -| Canvas fail-closed when masking configured | posthog-js `lazy-loaded-session-recorder.ts:2596-2610` (regions fn throw → frame dropped); Amplitude hard-off precedent `session-replay.ts:1078` | -| Forced autocomplete `cc-*`/`current-password` masking | Sentry fork `rrweb-snapshot/src/snapshot.ts:452-468` | +| Mechanism adopted | Vendor source (file refs as of 2026-08 clones) | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Whole-node masking on detector hit | Highlight fork `rrweb-snapshot/src/snapshot.ts:555-578` (`obfuscateText` on `.test()` hit) | +| No user-supplied detector regexes | All five vendors (none ship one) | +| Card/SSN pattern set + Luhn | posthog-js `browser-common/src/utils/autocapture-utils.ts:518-638` | +| Per-selector validation, drop + warn | Amplitude `session-replay-browser/src/config/joined-config.ts:24` (`removeInvalidSelectorsFromPrivacyConfig`) | +| Fail-closed mask decision (init masked, catch masks) | mixpanel-js `src/recorder/session-recording.js:588` (`_getMaskFn`) | +| Fail-closed under mask-all (catch returns masked) | Sentry fork `rrweb-snapshot/src/snapshot.ts:512-516` (`needMaskingText`) | +| `maskInputFn` output star-replaced (fn controls length only) | Sentry fork `rrweb-snapshot/src/utils.ts:274-296` (`maskInputValue`) | +| Forced `password: true` over user config | posthog-js `lazy-loaded-session-recorder.ts:2555` | +| `maskAttributeFn` dropped under `maskAllElementAttributes` | posthog-js `lazy-loaded-session-recorder.ts:2621-2633` | +| Masked attribute defaults (`title`, `placeholder`, `aria-label`) | Sentry `replay-internal/src/integration.ts:142` | +| Inherited mask propagation, checked once per subtree | posthog-js fork `rrweb-snapshot/src/snapshot.ts:1284-1292, 327-340` (tri-state `needsMask`); Highlight fork `snapshot.ts:1142-1150` (`overwrittenPrivacySetting`) | +| Nearest-ancestor mask/unmask tie-break | Sentry fork `rrweb-snapshot/src/snapshot.ts:505-511` | +| CSS/script never masked | Highlight fork `snapshot.ts:565-576` (`IGNORE_TAG_NAMES`); Sentry fork `snapshot.ts:765-805` (`!isStyle` guard) | +| Mask-all-text `strict` posture | Sentry `integration.ts:125-126`; mixpanel-js `session-recording.js:308-310` | +| Cross-vendor mask/block class recognition | mixpanel-js `src/recorder/masking.js` (`.mp-mask, .fs-mask, .amp-mask, .rr-mask, .ph-mask`) | +| Canvas fail-closed when masking configured | posthog-js `lazy-loaded-session-recorder.ts:2596-2610` (regions fn throw → frame dropped); Amplitude hard-off precedent `session-replay.ts:1078` | +| Forced autocomplete `cc-*`/`current-password` masking | Sentry fork `rrweb-snapshot/src/snapshot.ts:452-468` | Known vendor defects deliberately **not** adopted: Highlight's unescaped-dot regexes; Sentry's missing style exemption on the characterData path and @@ -247,18 +247,18 @@ selectors. ## Findings resolution map -| Review finding | Resolved by section | -|---|---| -| canvasMasking ignored by mutation-mode capture | §7 | -| ReDoS validator bypass | §6 (no user patterns) | -| Failed detector candidate skips real PII | §6 (whole-node) | -| maskInputFn ignored under presets | §4 | -| Plugin silent no-op | §6 | -| URL userinfo recorded | §5 | -| Invalid selector poisons blockSelector | §2 | -| Added nodes skip attribute masking | §5 | -| strict destroys CSS | §3, §5 | -| `

    secret

    ', - strict, - ); - expect(out).toContain('body{color:red}'); - expect(out).not.toContain('secret'); - }); - it('unmask selector wins for its subtree, nearest ancestor decides', () => { - const out = serialize( - '

    visible

    hidden

    ', - strict, - ); - expect(out).toContain('visible'); - expect(out).not.toContain('hidden'); - }); - it('detectors mask the whole text node under legacy when configured', () => { - const withDet = compilePrivacyPolicy({ - version: 1, - preset: 'legacy', - detectors: { paymentCard: true, phone: true }, - }); - const out = serialize( - '

    call 5551234567 4111 1111 1111 1111 now

    ', - withDet, - ); - expect(out).not.toContain('4111 1111 1111 1111'); - }); - it('legacy without detectors leaves text untouched', () => { - const legacy = compilePrivacyPolicy(undefined); - expect(serialize('

    bob@example.com

    ', legacy)).toContain( - 'bob@example.com', - ); - }); -}); -``` - -- [ ] **Step 2: Run, verify FAIL** — `npx vitest run test/privacy-integration.test.ts`. - -- [ ] **Step 3: Implement.** In `utils.ts`, extend the existing needs-mask helper (keep its inheritance/`checkAncestors` contract intact): - -```ts -export function needsMaskingText( - node: Node, - maskTextClass: string | RegExp, - maskTextSelector: string | null, - unmaskTextSelector: string | null, - checkAncestors: boolean, -): boolean { - try { - const el: HTMLElement | null = - node.nodeType === node.ELEMENT_NODE - ? (node as HTMLElement) - : node.parentElement; - if (el === null) return false; - let current: HTMLElement | null = el; - while (current) { - if (unmaskTextSelector && current.matches(unmaskTextSelector)) - return false; - if (classMatchesMaskTextClass(current, maskTextClass)) return true; // reuse existing class check - if (maskTextSelector && current.matches(maskTextSelector)) return true; - if (!checkAncestors) break; - current = current.parentElement; - } - return false; - } catch { - return true; // fail closed: an error in the mask decision masks - } -} -``` - -Nearest-ancestor-wins falls out of walking upward and returning on first hit. In `serializeTextNode`: restore the single pre-feature shape — `if (!isStyle && !isScript && textContent && needsMask) { textContent = maskTextFn ? maskTextFn(textContent, parentEl) : textContent.replace(/[\S]/g, '*'); }` — and delete the `if (privacy)` branch entirely. Then add the detector hook after it: - -```ts -if ( - !isStyle && - !isScript && - textContent && - !needsMask && - privacy && - detectSensitiveValue(textContent, privacy) -) { - textContent = textContent.replace(/[\S]/g, '*'); -} -``` - -Thread `unmaskTextSelector` through the same option paths `maskTextSelector` already travels (grep for `maskTextSelector` in `snapshot.ts` and mirror each occurrence). Under strict (`maskTextSelector === '*'`), the unmask check must still run per node even when needsMask was inherited: pass `needsMask && !unmaskTextSelector` as the short-circuit condition where the code currently reuses inherited `needsMask`. - -- [ ] **Step 4: Run, verify PASS.** Also run the package's full suite; adapt existing snapshot tests that passed the old `privacy` object expecting engine behavior. -- [ ] **Step 5: Commit** — `git commit -am "feat(privacy): unmask selector + detector hook in core text masking, CSS exempt everywhere"` - ---- - -### Task 5: Input masking composition - -**Files:** - -- Modify: `packages/rrweb-snapshot/src/utils.ts` (`maskInputValue`, `getInputType`) -- Modify: `packages/rrweb-snapshot/src/privacy.ts` (`isProtectedInput` → exported, reusing `getInputType`) -- Modify: `packages/rrweb-snapshot/src/snapshot.ts`, `packages/rrweb/src/record/mutation.ts` (~583-690), `packages/rrweb/src/record/observer.ts` (~425-445) -- Test: `packages/rrweb-snapshot/test/privacy-integration.test.ts` - -**Interfaces:** - -- Produces (single entry point; the four legacyMask forks collapse into it): - -```ts -export function maskInput({ - element, - tagName, - type, - value, - maskInputOptions, - maskInputFn, - privacy, -}: { - element: HTMLElement; - tagName: string; - type: string | null; - value: string; - maskInputOptions: MaskInputOptions; - maskInputFn?: MaskInputFn; - privacy: CompiledPrivacyPolicy | undefined; -}): string; -export function isProtectedInput(element: HTMLElement): boolean; // password/hidden/data-rr-is-password/cc-* autocomplete -``` - -- Behavior table (encode in tests): protected input → always `'*'.repeat(len)` regardless of everything. Legacy preset: mask iff legacy options say so; `maskInputFn` output trusted (today's behavior). Balanced/strict: always mask; if `maskInputFn` present, run it then star-replace its output (`'*'.repeat(fnOutput.length)`) — fn controls length only. -- Deletes: `shouldMaskInputWithPrivacy`, `maskInputWithPrivacy`, `replacePreservingShape` usage for inputs (function itself deleted once Task 6 removes its last use). - -- [ ] **Step 1: Write the failing tests:** - -```ts -import { maskInput, isProtectedInput } from '../src/utils'; -describe('maskInput v2', () => { - const balanced = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); - const legacy = compilePrivacyPolicy(undefined); - const input = (attrs = '') => { - document.body.innerHTML = ``; - return document.querySelector('input') as HTMLInputElement; - }; - it('balanced masks all inputs shape-free (stars, not digits)', () => { - const out = maskInput({ - element: input(), - tagName: 'input', - type: 'text', - value: '4111 1111 1111 1111', - maskInputOptions: {}, - privacy: balanced, - }); - expect(out).toBe('*'.repeat(19)); - }); - it('balanced + maskInputFn: fn controls length only, never content', () => { - const out = maskInput({ - element: input(), - tagName: 'input', - type: 'text', - value: 'secret', - maskInputOptions: {}, - maskInputFn: () => '[redacted]', - privacy: balanced, - }); - expect(out).toBe('*'.repeat('[redacted]'.length)); - }); - it('legacy + maskInputFn trusted verbatim when legacy options mask', () => { - const out = maskInput({ - element: input(), - tagName: 'input', - type: 'text', - value: 'secret', - maskInputOptions: { text: true }, - maskInputFn: () => '[redacted]', - privacy: legacy, - }); - expect(out).toBe('[redacted]'); - }); - it('legacy without options passes value through', () => { - expect( - maskInput({ - element: input(), - tagName: 'input', - type: 'text', - value: 'plain', - maskInputOptions: {}, - privacy: legacy, - }), - ).toBe('plain'); - }); - it('protected inputs always mask, even legacy with no options', () => { - expect( - maskInput({ - element: input('type="password"'), - tagName: 'input', - type: 'password', - value: 'pw', - maskInputOptions: {}, - privacy: legacy, - }), - ).toBe('**'); - expect(isProtectedInput(input('autocomplete="cc-number"'))).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run, verify FAIL.** -- [ ] **Step 3: Implement** `maskInput` in `utils.ts` wrapping the existing `maskInputValue` legacy logic: - -```ts -export function maskInput(args: { - /* as Interfaces */ -}): string { - const { - element, - tagName, - type, - value, - maskInputOptions, - maskInputFn, - privacy, - } = args; - if (isProtectedInput(element)) return '*'.repeat(value.length); - const legacyWantsMask = Boolean( - maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || - (type && maskInputOptions[type.toLowerCase() as keyof MaskInputOptions]), - ); - const presetWantsMask = !!privacy && privacy.maskAllInputs; - if (!legacyWantsMask && !presetWantsMask) return value; - let masked = maskInputFn - ? maskInputFn(value, element) - : '*'.repeat(value.length); - if (presetWantsMask && maskInputFn) masked = '*'.repeat(masked.length); // fn controls length only - if (presetWantsMask && !maskInputFn) masked = '*'.repeat(value.length); - return masked; -} -``` - -Move `isProtectedInput` from `privacy.ts` into `utils.ts` built on `getInputType` (covers the password-revealed-as-text case) plus the `PROTECTED_AUTOCOMPLETE` set. Replace all four call sites (`snapshot.ts` serializeElementNode value handling, `mutation.ts` genTextAreaValueMutation + processMutation value branch, `observer.ts` eventHandler) with single `maskInput` calls — delete each site's local `legacyMask` computation and its `if (privacy) … else …` fork. In `observer.ts`, also delete the outer `shouldMaskInputWithPrivacy` guard (redundant; `maskInput` decides). - -- [ ] **Step 4: Run package suites** (`rrweb-snapshot` fully; `cd packages/rrweb && npx vitest run test/record` for the record paths). Adapt tests asserting `replacePreservingShape` digit-preserving output (`'0000 0000…'`) to expect stars. -- [ ] **Step 5: Commit** — `git commit -am "feat(privacy): single maskInput entry point, Sentry-style fn composition"` - ---- - -### Task 6: Attribute finalization — one pass, one helper - -**Files:** - -- Modify: `packages/rrweb-snapshot/src/privacy.ts` (`protectSerializedAttribute`, `maskAttributeWithPrivacy` deleted, `SENSITIVE_ATTRIBUTES` trimmed) -- Modify: `packages/rrweb-snapshot/src/snapshot.ts` (attribute loop ~lines 620-900) -- Modify: `packages/rrweb/src/record/mutation.ts` (pushAdd ~329-370; emit attribute loop ~510-530; delete `generatedAttributes` WeakMap ~152/526/559/809) -- Test: `packages/rrweb-snapshot/test/privacy-integration.test.ts`, `packages/rrweb/test/record/privacy.test.ts` (adapt existing) - -**Interfaces:** - -- Produces (replaces both `maskAttributeWithPrivacy` and old `protectSerializedAttribute`): - -```ts -export function finalizeAttribute({ - element, - name, - value, - privacy, - maskAllElementAttributes, - maskAttributeFn, - isGenerated, -}: { - element: Element; - name: string; - value: string | null; - privacy: CompiledPrivacyPolicy | undefined; - maskAllElementAttributes?: boolean; - maskAttributeFn?: MaskAttributeFn; - isGenerated?: boolean; -}): string | null; -``` - -- Decision order inside: (1) `isGenerated` → return value untouched (serializer-produced, safe by construction; `rr_dataURL` is intentionally NOT flagged generated). (2) `maskAllElementAttributes` → `'*'.repeat(len)`; when it is set, `maskAttributeFn` is ignored with a one-time `console.warn` (mutually exclusive, PostHog). (3) `maskAttributeFn` → run in try/catch, catch → stars. (4) policy: strict media source attrs → null; URL attrs → `sanitizeUrl`; `privacy.maskedAttributes` list (`title`/`placeholder`/`aria-label`) → stars; `value` attribute on form tags under strict → stars. `style`/`_cssText` are never touched. -- mutation.ts `pushAdd` gains `maskAllElementAttributes: this.maskAllElementAttributes, maskAttributeFn: this.maskAttributeFn` in its serializeNodeWithId options (fixes the added-node bypass). `SAFE_GENERATED_ATTRIBUTES` and the `generatedAttributes` WeakMap are deleted; the single `rr_open_mode` write site passes `isGenerated: true` directly. - -- [ ] **Step 1: Write the failing tests:** - -```ts -describe('finalizeAttribute', () => { - const strict = compilePrivacyPolicy({ version: 1, preset: 'strict' }); - const el = () => { - document.body.innerHTML = - ''; - return document.querySelector('img')!; - }; - it('never masks style, even under strict', () => { - expect( - finalizeAttribute({ - element: el(), - name: 'style', - value: 'color:red', - privacy: strict, - }), - ).toBe('color:red'); - }); - it('masks listed attributes under strict/balanced', () => { - expect( - finalizeAttribute({ - element: el(), - name: 'title', - value: 'Bob', - privacy: strict, - }), - ).toBe('***'); - }); - it('strict nulls media sources; URLs sanitized elsewhere', () => { - expect( - finalizeAttribute({ - element: el(), - name: 'src', - value: 'https://a.com/i.png', - privacy: strict, - }), - ).toBeNull(); - }); - it('maskAllElementAttributes stars everything except generated', () => { - expect( - finalizeAttribute({ - element: el(), - name: 'title', - value: 'Bob', - privacy: undefined, - maskAllElementAttributes: true, - }), - ).toBe('***'); - expect( - finalizeAttribute({ - element: el(), - name: 'rr_open_mode', - value: 'modal', - privacy: undefined, - maskAllElementAttributes: true, - isGenerated: true, - }), - ).toBe('modal'); - }); - it('maskAttributeFn throw fails closed to stars; fn ignored under maskAll', () => { - expect( - finalizeAttribute({ - element: el(), - name: 'title', - value: 'Bob', - privacy: undefined, - maskAttributeFn: () => { - throw new Error('boom'); - }, - }), - ).toBe('***'); - }); -}); -``` - -Plus a recorder-level test in `packages/rrweb/test/record/privacy.test.ts` (adapt existing harness): record with `maskAllElementAttributes: true`, append a new `
    ` after recording starts, flush, assert the emitted add's attributes are starred (the review's added-node bypass regression). - -- [ ] **Step 2: Run, verify FAIL.** -- [ ] **Step 3: Implement** `finalizeAttribute` per the decision order above (single function, ~40 lines; `MEDIA_TAGS`/`MEDIA_SOURCE_ATTRIBUTES`/`URL_ATTRIBUTES`/`FORM_VALUE_TAGS` sets stay; `SENSITIVE_ATTRIBUTES` becomes the compiled `maskedAttributes` list, drop the module-level set). In `snapshot.ts`: delete the per-attribute `maskAttributeWithPrivacy` call in the collection loop; keep exactly ONE finalization sweep at the end of `serializeElementNode` calling `finalizeAttribute` for every entry (including `_cssText`, which it passes through untouched), with `isGenerated` set for serializer-written attributes (`rr_width`, `rr_height`, `rr_scrollLeft`, `rr_scrollTop`, `rr_mediaState`, `rr_open_mode` — not `rr_dataURL`). In `mutation.ts`: emit path uses `finalizeAttribute` (delete its parallel guarded sweep + per-attribute `maskAttributeWithPrivacy` at ~741), `pushAdd` passes the two missing options, WeakMap deleted. -- [ ] **Step 4: Run** `rrweb-snapshot` and `rrweb` record suites; verify PASS. -- [ ] **Step 5: Commit** — `git commit -am "feat(privacy): single attribute finalization pass, added-node coverage, CSS attrs exempt"` - ---- - -### Task 7: Delete CSS masking call sites - -**Files:** - -- Modify: `packages/rrweb/src/record/observer.ts` (delete `maskCssForRecord` + `stylesheetOwnerElement` ~597-616 and the maskTextWithPrivacy calls at ~650, 730, 762, 830, 995) -- Modify: `packages/rrweb/src/record/stylesheet-manager.ts` (delete `maskAdoptedRule` ~97-106 and its call at ~80) -- Modify: `packages/rrweb/src/record/mutation.ts` (delete styleDiff masking ~763-786) -- Test: `packages/rrweb/test/record/stylesheet-manager.test.ts`, `packages/rrweb/test/record/style.test.ts` (adapt) - -**Interfaces:** none new. CSS text (insertRule/replace/replaceSync/setProperty/styleDiff/adopted sheets) is recorded verbatim — the unanimous vendor behavior. Blocked subtrees are already excluded wholesale by `blockSelector`. - -- [ ] **Step 1: Adapt tests** — the PR-added assertions in `stylesheet-manager.test.ts` (~32 lines) and any styleDiff masking tests now assert the INVERSE: adopted-sheet rules and style mutations are recorded unmodified even under `preset: 'strict'`. Write those assertions first. -- [ ] **Step 2: Run, verify FAIL** (masking still active). -- [ ] **Step 3: Delete** the helpers and call sites listed above; remove now-unused `privacy` parameters from the touched signatures (`StylesheetManager` constructor arg, observer param threading) ONLY where nothing else consumes them — `observer.ts` still needs `privacy` for input masking (Task 5). -- [ ] **Step 4: Run** the `rrweb` record suite; verify PASS. -- [ ] **Step 5: Commit** — `git commit -am "feat(privacy): CSS is never masked; delete stylesheet masking call sites"` - ---- - -### Task 8: Canvas fail-closed + region scaling - -**Files:** - -- Modify: `packages/rrweb/src/record/index.ts` (canvas wiring ~120-130) -- Modify: `packages/rrweb/src/record/observers/canvas/canvas-manager.ts` (constructor ~85-100; `getCanvas`/`search` ~190-215) -- Modify: `packages/rrweb/src/record/observers/canvas/canvas-mask.ts` (~40-70) -- Test: `packages/rrweb/test/record/canvas-mask.test.ts` (adapt existing canvas tests) - -**Interfaces:** - -- record/index.ts rule (encode as a pure helper so it is unit-testable): - -```ts -export function resolveCanvasSampling( - requestedSampling: number | 'all' | undefined, - canvasMaskingConfigured: boolean, -): number | 'all' | undefined { - if (!canvasMaskingConfigured) return requestedSampling; - if (typeof requestedSampling === 'number') return requestedSampling; - console.warn( - '[rrweb] canvasMasking requires FPS canvas capture; forcing sampling.canvas = 4', - ); - return 4; -} -``` - -- canvas-mask.ts: scale factors come from `canvas.getBoundingClientRect()` minus computed padding/border (content box), falling back to SKIPPING capture (return no frame) when the content box has zero area — never silently reinterpret regions as backing-store pixels. -- canvas-manager FPS discovery: replace the per-tick `querySelectorAll('*')` recursion with `win.document.querySelectorAll('canvas')` plus canvases from a `trackedShadowRoots: Set` the manager exposes (`addShadowRoot(root)` / `removeShadowRoot(root)`), called by the existing shadow-DOM manager where it already observes attachShadow. - -- [ ] **Step 1: Write the failing tests:** - -```ts -import { resolveCanvasSampling } from '../../src/record'; -describe('canvas fail-closed', () => { - it('forces numeric sampling when masking configured', () => { - expect(resolveCanvasSampling('all', true)).toBe(4); - expect(resolveCanvasSampling(undefined, true)).toBe(4); - expect(resolveCanvasSampling(15, true)).toBe(15); - expect(resolveCanvasSampling('all', false)).toBe('all'); - }); -}); -``` - -Plus in the existing canvas mask test file: a region-scaling case with a padded canvas (`style="padding:20px"`, canvas 100×100 backing store, content box 100×100 → scale 1 even though `clientWidth` is 140), asserting the mask rect coordinates passed to the worker. - -- [ ] **Step 2: Run, verify FAIL.** -- [ ] **Step 3: Implement** the three changes. In `record/index.ts`, apply `resolveCanvasSampling` before constructing `CanvasManager`, so `initCanvasMutationObserver` is unreachable when masking is configured. -- [ ] **Step 4: Run** canvas suites (`npx vitest run test/record` filtered to canvas files); verify PASS. -- [ ] **Step 5: Commit** — `git commit -am "fix(canvas): masking forces FPS capture path; content-box region scaling; cheap canvas discovery"` - ---- - -### Task 9: Wiring hardening — plugin fallback, untainted tagName, plugin package - -**Files:** - -- Modify: `packages/rrweb/src/record/index.ts` (~109-130) -- Modify: `packages/utils/src/index.ts` (add `untaintedTagName`) -- Modify: `packages/rrweb/src/record/mutation.ts` (~663-665 raw tagName reads), `packages/rrweb-snapshot/src/snapshot.ts` (~533-539 inline guard), `packages/rrweb-snapshot/src/privacy.ts` (delete `nativeElementTagName`, `parentElementAcrossShadowRoot` — no remaining callers after Tasks 4-6) -- Modify: `packages/plugins/rrweb-plugin-privacy-detectors/src/index.ts`, its `README.md`, `test/` -- Test: `packages/plugins/rrweb-plugin-privacy-detectors/test/index.test.ts`, `packages/rrweb/test/record/privacy.test.ts` - -**Interfaces:** - -- `@rrweb/utils` produces: `export function untaintedTagName(element: Element | null | undefined): string` — returns `''` for null; uses the element's own `tagName` when it is a string, else the untainted `Element.prototype` getter via the existing `getUntaintedAccessor` machinery; uppercased. Every privacy-relevant `element.tagName` read in `mutation.ts`/`snapshot.ts` touched by this feature goes through it. -- record/index.ts plugin fallback: - -```ts -let privacy: CompiledPrivacyPolicy; -try { - privacy = compilePrivacyPolicy(portablePrivacyPolicy); -} catch (error) { - if (portablePrivacyPolicy !== privacyPolicy) { - console.error( - '[rrweb] plugin-transformed privacy policy failed to compile; using the user policy', - error, - ); - privacy = compilePrivacyPolicy(privacyPolicy); // user's own invalid policy still throws (programmer error) - } else { - throw error; - } -} -``` - -- Plugin: `applyPrivacyDetectors(undefined, opts)` keeps base `{version: 1, preset: 'legacy'}` — and now genuinely detects, because `compilePrivacyPolicy` populates `detectors` regardless of preset and the Task 4 hook runs under legacy. README updated to state exactly that. - -- [ ] **Step 1: Write the failing tests:** - -```ts -// plugin package -it('plugin with no user policy yields a legacy policy whose compiled detectors are active', () => { - const plugin = getRecordPrivacyDetectorsPlugin(); - const policy = plugin.applyPrivacyPolicy!(undefined) as PrivacyPolicy; - expect(policy.preset).toBe('legacy'); - const compiled = compilePrivacyPolicy(policy); - expect(compiled.detectors.length).toBeGreaterThan(0); - expect(detectSensitiveValue('bob@example.com', compiled)).toBe(true); -}); -// rrweb record suite -it('a plugin returning a malformed policy falls back to the user policy instead of throwing', () => { - const badPlugin = { - name: 'bad@1', - applyPrivacyPolicy: () => ({ nonsense: true }), - }; - expect(() => - record({ emit: () => {}, plugins: [badPlugin as never] }), - ).not.toThrow(); -}); -it('untaintedTagName survives ', () => { - document.body.innerHTML = ''; - expect(untaintedTagName(document.querySelector('form'))).toBe('FORM'); -}); -``` - -- [ ] **Step 2: Run, verify FAIL** (the malformed-plugin case throws today). -- [ ] **Step 3: Implement** the three changes; replace the raw `target.tagName.toLowerCase()` at `mutation.ts:665` and the inline typeof guard at `snapshot.ts:533-539` with `untaintedTagName(...)`; delete `nativeElementTagName`/`parentElementAcrossShadowRoot` from `privacy.ts`. -- [ ] **Step 4: Run** plugin + rrweb suites; verify PASS. -- [ ] **Step 5: Commit** — `git commit -am "fix(privacy): plugin compile fallback, shared untainted tagName, plugin detects under legacy"` - ---- - -### Task 10: Types package, changeset, docs - -**Files:** - -- Modify: `packages/types/src/index.ts` (mirror Task 1 type removals for the public `@rrweb/types` copies; keep the `ImageBitmapDataURLWorkerParams` union but document it) -- Modify: `guide.md` (privacy section ~lines 270-300), `packages/plugins/rrweb-plugin-privacy-detectors/README.md` -- Create: `.changeset/privacy-v2-simplification.md` -- Test: `npx tsc -b tsconfig.json` (workspace type-check) as the verification step - -**Interfaces:** none new; this task reconciles public types and docs with Tasks 1-9. - -- [ ] **Step 1: Sync `packages/types`** with the rrweb-snapshot type changes (remove `PrivacyMaskStyle`, `custom` detectors, rule `style`/`classification`/`attributes`; preset union loses `'custom'`). -- [ ] **Step 2: Write the changeset:** - -```md ---- -'rrweb-snapshot': minor -'rrweb': minor -'@rrweb/types': major -'@rrweb/rrweb-plugin-privacy-detectors': minor -'@rrweb/utils': minor ---- - -Privacy at Capture v2: policies now compile onto rrweb's existing masking -primitives; heuristic detectors are a fixed whole-value set (custom regex -patterns removed); CSS is never masked; canvas masking forces the FPS capture -path; selector and config errors fail closed. BREAKING (@rrweb/types): -`ImageBitmapDataURLWorkerParams` is a union; privacy rule `style`, -`classification`, custom detectors, and the `'custom'` preset are removed. -``` - -- [ ] **Step 3: Update `guide.md`:** preset table now states exactly what Task 1 compiles (balanced: inputs + `title`/`placeholder`/`aria-label` + URL sanitization; strict: + all text, media blocked, canvas off; CSS never masked; detectors only via the plugin, active under any preset). Fix the line "Existing masking options are still applied when a policy does not make an explicit decision" to the Task 5 truth: "Under `balanced`/`strict`, `maskInputFn` output is star-replaced — the callback controls length, never content." Update plugin README per Task 9. -- [ ] **Step 4: Verify** — `npx tsc -b tsconfig.json` clean; `git grep -l "maskSensitiveRanges\|getPrivacyAction\|detectSensitiveText\|maskTextWithPrivacy\|maskAttributeWithPrivacy\|maskInputWithPrivacy\|shouldMaskInputWithPrivacy\|SAFE_GENERATED_ATTRIBUTES\|privacy-policy.schema"` returns nothing outside this plan/spec. -- [ ] **Step 5: Commit** — `git commit -am "docs(privacy): v2 types sync, changeset, guide"` - ---- - -### Task 11: Full verification sweep - -**Files:** none created; runs everything. - -- [ ] **Step 1:** `npx yarn@1.22.19 install` if not yet done, then repo-root `npx turbo run test --filter=rrweb-snapshot --filter=rrweb --filter=@rrweb/rrweb-plugin-privacy-detectors --filter=@rrweb/utils` (fall back to per-package `npx vitest run` if turbo is unavailable). Expected: all green. -- [ ] **Step 2: Perf smoke** — add `packages/rrweb-snapshot/test/privacy-perf.test.ts`: - -```ts -it('legacy snapshot performs no privacy selector matching', () => { - const spy = vi.spyOn(Element.prototype, 'matches'); - document.body.innerHTML = - '
    '.repeat(200) + 'deep text' + '
    '.repeat(200); - snapshot(document, { privacy: compilePrivacyPolicy(undefined) }); - const privacyCalls = spy.mock.calls.filter( - ([sel]) => typeof sel === 'string' && sel.includes('data-privacy'), - ); - expect(privacyCalls.length).toBe(0); - spy.mockRestore(); -}); -``` - -- [ ] **Step 3:** Type-check (`npx tsc -b tsconfig.json`) and lint the touched packages (`npx turbo run lint --filter=...` if configured). -- [ ] **Step 4: Commit** — `git commit -am "test(privacy): perf smoke + full sweep"` — then report results (including any deviations) back for review before any push. - ---- - -## Self-review notes - -- Spec §1-§9 → Tasks 1-10 (coverage: §1→T1/T10, §2→T1, §3→T4/T7, §4→T5, §5→T3/T6, §6→T2/T9, §7→T8, §8→T9, §9 deletions distributed, §10→every task + T11). -- Type consistency: `CompiledPrivacyPolicy`, `CompiledDetector`, `finalizeAttribute`, `maskInput`, `needsMaskingText`, `untaintedTagName`, `resolveCanvasSampling` are each defined once in an Interfaces block and consumed by name in later tasks. -- Known judgment calls an implementer may hit: exact current line numbers may have drifted a few lines — anchor on symbol names, not line numbers; existing test harness names (`test/privacy.test.ts` structure) may require merging the new describes into existing files rather than replacing wholesale. diff --git a/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md b/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md deleted file mode 100644 index 03696340bf..0000000000 --- a/docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md +++ /dev/null @@ -1,264 +0,0 @@ -# Privacy at Capture v2 — Simplification Design - -**Date:** 2026-08-25 -**Status:** Approved (design), pending implementation plan -**Branch:** `privacy-v2-simplification` (off `main` @ `41c22825`) - -## Context - -The Privacy at Capture feature (merged via PR #1, `37a946a5..main`) introduced a -versioned privacy policy, selector rules, heuristic PII detectors with -user-supplied regex patterns, canvas masking, and URL sanitization. A -high-effort code review confirmed 24 defects, including silent privacy leaks -(canvas command stream, detector candidate skipping, URL credentials, plugin -no-op), fail-open selector handling, a bypassable ReDoS validator, CSS -destruction under `strict`, and a default-path performance regression from an -uncached per-node ancestor-walk engine. - -A source-level survey of the five major session-replay vendors built on rrweb -(PostHog, Highlight, Sentry, Amplitude, Mixpanel) showed the confirmed bugs -cluster exactly where this feature diverges from field-proven practice: -sub-range regex masking of DOM text and user-supplied detector patterns, which -no vendor ships. - -**Goal:** an upstreamable privacy layer that vendors can adopt in place of -their forks and wrapper layers, maintained by the community. - -## Governing principles - -1. **Fail closed.** Every ambiguity — invalid config, thrown exception, - unreachable mask path — resolves toward masking or not capturing. -2. **Proven mechanisms only.** No mechanism ships that no vendor has run in - production. Where vendors disagree, adopt the safest variant. -3. **Legacy is sacred.** With no `privacyPolicy` and no privacy plugin - loaded, behavior and performance are byte-identical to rrweb before this - feature. (Loading the detectors plugin is an explicit opt-in and does - change behavior — see §6.) Two sanctioned exceptions, both required by - principle 1 (fail closed) and neither gated behind `privacyPolicy`: - - **Protected inputs always masked.** `password`/`hidden` inputs and - autocomplete `cc-*`/`current-password`/`new-password`/`one-time-code` - fields are masked unconditionally, with no `privacyPolicy` required and - regardless of `maskInputOptions`. Pre-v2 `legacy` behavior let `hidden` - inputs and autocomplete-tagged card/password/OTP fields record raw -- - that gap is intentionally closed, not preserved. - - **Invalid selectors fail closed.** An invalid `maskTextSelector`/ - `unmaskTextSelector` -- the plain `record()`-level string option or a - policy rule's selector -- throws inside the mask decision and is caught - as a mask, not silently ignored as if unset. - -## Decisions (approved) - -- **Detectors:** fixed set only (email, phone, Luhn card, SSN, IPv4), each - individually toggleable. **No user-supplied regex patterns.** Any hit masks - the **whole text node / input value**, not character ranges (Highlight - model). Pattern set is derived from PostHog's network-side patterns - (delimited digit runs, Luhn validation, SSN invalid-group exclusions), not - Highlight's (which contain unescaped-dot bugs). -- **Architecture:** `compilePrivacyPolicy` compiles presets and rules down - onto rrweb's **existing masking primitives** (`maskTextSelector`, - `maskAllInputs`, `maskInputOptions`, `blockSelector`, inherited `needsMask` - propagation), extended minimally. The parallel `getPrivacyAction` - ancestor-walk engine is **deleted**. - -## Design - -### 1. Policy surface - -```ts -privacyPolicy: { - version: 1, - preset: 'legacy' | 'balanced' | 'strict', - rules?: { selector: string; action: 'mask' | 'unmask' | 'exclude' | 'allow' }[], - blockedQueryParameters?: string[], - allowedQueryParameters?: string[], -} -``` - -Removed from the schema: custom detector patterns, `minimumLength`, -`maximumMatchLength`, `maskStyle`, `classification` (dead or dangerous per -review). The `privacy-policy.schema.json` file is **deleted**; TypeScript -types plus runtime validation are the single source of truth (fixes the -three-way schema/types/runtime drift that let schema-valid policies crash -`record()`). - -### 2. Compilation - -`compilePrivacyPolicy(policy)` returns a bundle of existing rrweb options plus -merged selector lists. No rule engine. - -- `legacy` → exactly today's defaults. Zero added cost on the default path. -- `balanced` → `maskAllInputs: true`, `maskInputOptions.password: true` - **forced regardless of user config** (PostHog), masked attributes - `['title', 'placeholder', 'aria-label']` (Sentry's default list), URL - sanitization on. -- `strict` → balanced + `maskTextSelector: '*'` (mask-all-text posture, - Sentry/Mixpanel), media blocking (`img, video, audio, source`, Sentry's - `blockAllMedia`), `recordCanvas` forced off, URL sanitization. -- Rules and the three `data-privacy` attribute selectors compile into the - mask / unmask / block selector lists. -- Cross-vendor mask classes recognized in compiled defaults: - `.rr-mask, .mp-mask, .fs-mask, .amp-mask, .ph-mask` and block equivalents - (Mixpanel precedent) — eases vendor adoption of upstream. -- **Per-selector validation at compile:** each selector is probed with - `fragment.querySelector(sel)` in try/catch (Amplitude); invalid selectors - are dropped with a `console.warn` naming the selector. A selector is never - merged unvalidated, so one bad selector cannot poison the merged list. -- Error handling: a user-supplied invalid policy throws at `record()` call - time (programmer error, matches rrweb conventions). A **plugin-transformed** - policy that fails to compile falls back to compiling the user's own policy, - with `console.error`. - -### 3. Text and CSS - -- The single decision channel is the existing inherited `needsMask` - propagation (checked once at subtree root, short-circuits for descendants — - PostHog's tri-state mechanism is the reference). -- `unmaskTextSelector` is added to the core `needsMask` check, - nearest-ancestor-wins (Sentry's `maskDistance <= unmaskDistance` tie-break). -- CSS is **never masked** (unanimous vendor precedent): the `!isStyle` - exemption applies to all paths, including mutation/characterData (fixing - the inconsistency Sentry's own fork still has). -- `maskTextFn` composition unchanged under `legacy`. - -### 4. Inputs - -- All input masking routes through `maskInputValue` + `maskInputOptions`; - presets set the options. -- `legacy`: `maskInputFn` behaves exactly as today. -- `balanced`/`strict`: defense-in-depth (Sentry): the user fn runs, then its - output is star-replaced — the fn controls length, never content. Neither - the preset nor the fn can silently weaken the other. - -### 5. Attributes and URLs - -- **One** attribute finalization pass in one shared helper, used by both - `serializeElementNode` and the mutation emit path (deletes the snapshot - double-masking; mutation-added nodes stop bypassing - `maskAllElementAttributes`/`maskAttributeFn`). -- The four copy-pasted `legacyMask` forks collapse into that helper. -- `style`/`_cssText` are removed from `SENSITIVE_ATTRIBUTES`. -- `maskAllElementAttributes` and `maskAttributeFn` are mutually exclusive; - the fn is dropped with a warning (PostHog fail-closed rationale). -- Generated-attribute safety: trust the serializer's own `isGenerated` flag; - delete the `SAFE_GENERATED_ATTRIBUTES` static list, the per-element Set - bookkeeping, and mutation.ts's `generatedAttributes` WeakMap. -- `sanitizeUrl`: additionally clears `url.username`/`url.password` - (ahead of all five vendors); lowercased blocked/allowed sets precomputed at - compile time. - -### 6. Detectors plugin (`@rrweb/rrweb-plugin-privacy-detectors`) - -- Fixed detectors: email, phone, Luhn payment card, SSN, IPv4. Per-detector - boolean toggles only. -- Scan is `regex.test(value)` per enabled detector with short-circuit; any - hit masks the **entire** text node or input value through the same masking - path as everything else. `mergeMatches`, `maskSensitiveRanges`, - `SensitiveMatch`, `scanCustomPattern`, and `validateCustomDetector` are - deleted. -- Detection runs **independent of preset early-returns**: loading the plugin - with no `privacyPolicy` detects under `legacy` (fixes the silent no-op; - makes the plugin README true). -- Patterns are bounded/linear (audited); Luhn for cards, invalid-group - exclusions for SSN (`(?!000|666)…`), delimiter-aware digit runs to avoid - the UUID/long-number false-positive classes PostHog documents. - -### 7. Canvas - -- Fail closed: when `canvasMasking` is configured, canvas is captured only - via the FPS/worker path where mask regions apply. If `sampling.canvas` is - not numeric, it is forced to a low default (with a `console.warn`) instead - of letting the unmasked mutation-mode command stream run. -- Mask region scaling uses content-box math (`getBoundingClientRect` minus - padding/border), not `clientWidth`; a hidden canvas (0 dimensions) skips - capture rather than assuming backing-store coordinates. -- `strict` keeps `recordCanvas` forced off. - -### 8. Hardening - -- Mask-decision paths are wrapped fail-closed (Mixpanel): decision variable - initialized to _masked_; any throw logs and masks. -- One untainted `tagName` accessor in `@rrweb/utils` - (`getUntaintedAccessor('Element', el, 'tagName')`) replaces the two - divergent one-off shadowing fixes and is used at every `tagName` read in - privacy-relevant paths. Same for the shadow-root walk (`isShadowRoot` + - `dom.host`) and password detection (`getInputType`). -- `ImageBitmapDataURLWorkerParams` union change is declared in the changeset - as a breaking change to `@rrweb/types`. - -### 9. Deletions summary - -`getPrivacyAction` engine and all call sites; range-masking machinery; -custom-pattern validator; `maskStyle`/`classification`/`MASK_STYLES`; -`privacy-policy.schema.json`; `SAFE_GENERATED_ATTRIBUTES` dual mechanism; -`generatedAttributes` WeakMap; duplicated CSS-mask helpers -(`maskAdoptedRule` folds into shared `maskCssForRecord`); the four -`legacyMask` copy-paste forks. Expected: `privacy.ts` shrinks from ~936 to -roughly ~300 lines; all 10 reported review findings and the overflow items -are resolved structurally. - -### 10. Testing - -- Existing privacy/detector/recorder suites adapted to the new shapes. -- New regression tests pinning each confirmed failure mode: - - Detector adjacency: `call 5551234567 4111 1111 1111 1111 now` → node - masked (was: Visa in cleartext). - - Invalid selector in a rule → dropped with warning; other selectors still - enforced; blocked elements stay blocked. - - `