Skip to content

refactor: Privacy at Capture — compile privacy policies onto rrweb's own masking primitives - #3

Open
roggernaut wants to merge 64 commits into
mainfrom
privacy-at-capture-integration
Open

refactor: Privacy at Capture — compile privacy policies onto rrweb's own masking primitives#3
roggernaut wants to merge 64 commits into
mainfrom
privacy-at-capture-integration

Conversation

@roggernaut

@roggernaut roggernaut commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Summary

Reworks Privacy at Capture (#1) so it matches field-proven practice from the five major session-replay vendors (PostHog, Highlight, Sentry, Amplitude, Mixpanel), resolving all 24 findings from the post-merge review. Design spec and prior-art appendix: docs/superpowers/specs/2026-08-25-privacy-v2-simplification-design.md.

Architecture

  • compilePrivacyPolicy now compiles presets/rules onto rrweb's existing masking primitives (selector lists + inherited needsMask propagation). The parallel getPrivacyAction per-node rule engine is deleted (privacy.ts: 936 → ~400 lines).
  • minimal (no policy, no plugin) is inert: the perf smoke test asserts zero privacy selector matching on the default path.
  • New unmaskTextSelector (record option + allow/unmask rules), nearest-ancestor-wins, with a presence probe restoring subtree short-circuiting when nothing on the page can unmask.
  • Cross-vendor mask/unmask/block classes (.rr-mask, .mp-mask, .fs-mask, .amp-mask, .ph-mask, .sentry-mask, …) recognized under non-legacy presets.

Fail-closed hardening

  • Per-selector validation at compile (invalid selectors dropped with a warning — one bad selector can no longer poison the merged block list, which previously failed open).
  • Mask decisions catch-to-mask; sanitizeUrl strips username:password@ and fails closed on unparseable URLs; malformed plugin-transformed policies fall back to the user's policy instead of crashing record().
  • Protected inputs (password/hidden/cc-*/OTP autocomplete) always masked; maskInputFn/maskAttributeFn outputs are constrained under presets (fn controls length, policy is final authority); one attribute-finalization pass covers snapshot, mutation, and added nodes.
  • Canvas masking forces the FPS capture path (the unmasked mutation-mode command stream is unreachable); mask regions use content-box math.
  • Detectors: fixed whole-value set (email, phone, Luhn card, SSN, IPv4) — custom regex patterns removed (bypassable ReDoS validator with them); detection covers text nodes, live text mutations, and input values, and works under any preset when the plugin is loaded.
  • CSS is never masked, on any path (unanimous vendor precedent).

Breaking changes

See .changeset/privacy-v2-simplification.md: @rrweb/types major (union worker params, removed policy fields), needMaskingText signature, whole-value detector semantics, mask-fn composition rules, protected-inputs default, invalid-selector fail-closed.

Review provenance

11 planned tasks, each implemented and reviewed by independent agents with fix rounds (9 Important + 1 Critical finding fixed pre-merge across rounds), plus a whole-branch final review ("merge with fixes" → fix wave verified). Known post-merge backlog (probe caching, srcset sanitization, attribute-sweep early-out, mutation-path unmask e2e coverage, maskInputValue deprecation) is listed in the final review triage.

Test plan

  • rrweb-snapshot: 253 passed / 1 skipped (unit + integration + perf smoke)
  • rrweb: full suite green modulo two known Puppeteer load flakes (pass in isolation)
  • plugin: 4/4; tsc -b clean across the monorepo
  • Empirical regex verification (phone formats, adjacency case, ReDoS shapes)

🤖 Generated with Claude Code

Provenance and production mileage

A source-level audit classified every mechanism on this branch against the five vendor codebases (PostHog, Highlight, Sentry, Amplitude, Mixpanel forks/SDKs). Full classification in the review record; summary:

Vendor-proven (a vendor runs essentially the same mechanism in production): selector-list compilation and union merging (Sentry getPrivacyOptions); per-selector validation with drop+warn (Amplitude, near-verbatim); cross-vendor mask/block class recognition (Mixpanel); the input-masking composition core incl. star-over-fn (Sentry) and always-masked protected inputs (Mixpanel/Amplitude/Sentry); the attribute finalization sweep, late-write guard, generated-attribute exemption, and maskAll-over-fn precedence (PostHog fork, near-verbatim); the title/placeholder/aria-label masked-attribute defaults (Sentry); canvas mask regions, worker fillRect, and snapshot suppression (PostHog fork); whole-node text-detector masking on snapshot AND live characterData mutations (Highlight fork).

Inspired (concept proven, implementation ours): the preset/rules policy translation itself; unmask-in-the-walk with nearest-ancestor semantics; '*'-as-fallback demotion; SSN/card/email/IPv4 patterns (derived with corrections); data-privacy enumerated attribute; unconditional catch-to-mask (stricter than every vendor); untaintedTagName (proven accessor machinery, new target); strict media-source attribute nulling (Highlight does element-level).

Invented here — no production mileage anywhere (review these hardest):

  • DOM-attribute URL sanitization in its entirety (no vendor sanitizes URLs in the recorded DOM): strict-masks-all-params, userinfo stripping, synthetic-base relative round-trip, fail-closed ''
  • The unmask-selector presence probe (resolveUnmaskTextSelector, shadow-recursive, per snapshot/flush) and the mask-all selector cache
  • The phone pattern + digit-count gate; the MAX_SCAN_LENGTH mask-everything cliff
  • Detector scanning of input values (incl. per-keystroke) — Highlight scans text nodes only
  • maskAttributeFn as a pipeline stage (vendors treat it as terminal), fn try/catch-to-stars, non-string-to-stars
  • The versioned policy schema itself; the plugin applyPrivacyPolicy hook and its compile fallback
  • resolveCanvasSampling coercion; content-box mask scaling; the tracked-shadow-roots canvas registry
  • FORM_VALUE_TAGS gating of mutation value masking; rr_src through URL/media policy; strict forcing recordCanvas off

Post-audit hardening (rounds after the provenance audit)

Three additional commits align invented mechanisms with vendor-proven behavior: same-element mask/unmask ties now mask (vendor-unanimous); record()-level selectors are validated with drop+warn (Amplitude model) and the record()-level unmaskTextSelector now reaches masked attributes; fictional vendor unmask classes removed; canvas sampling coercion keys on isConfigured(); sanitizeUrl fails closed to null (attribute dropped); generated-attribute exemption gained PostHog's name-allowlist and clear-on-write gates.

Merge strategy

This PR is the full-stack integration preview. For merge, the work is split into a stacked set so vendor-proven material can land first: privacy-at-capture-core (proven mechanisms, → main), then privacy-at-capture-detectors, privacy-at-capture-url-sanitization, and privacy-at-capture-unmask-probe (experimental layers, → core).

Rogier Trimpe and others added 27 commits August 25, 2026 21:57
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…n 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 <noreply@anthropic.com>
…attern machinery

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ics, 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 54798a2:
   - 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 <noreply@anthropic.com>
…ils closed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…CSS exempt everywhere

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… exempt <style> text mutations

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e, CSS attrs exempt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <img src>/<iframe src> emptied by
a callback replayed as src="" instead of a dropped attribute.

Delete the guard and handle the narrow case it protected against inside
sanitizeUrl, which now returns '' for '' instead of resolving it to '/'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ing; cheap canvas discovery

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in detects under legacy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntedTagName, close form-shadowing crash

A <form> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The approved design (spec Decisions + §6) says a detector hit masks the
whole text node / input value, but only serializeTextNode was wired up:
input values (snapshot and live input events) and characterData mutations
bypassed detection entirely. Route maskInput and the mutation-buffer
characterData path through detectSensitiveValue, mirroring the text-node
hook's rule that detectors only inspect values that would otherwise leave
unmasked (a trusted legacy maskTextFn/maskInputFn composition is kept, and
<style>/<script> text is never scanned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 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
  (f34e6c7) 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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…walk short-circuit, blockMedia

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reword explicit vendor-name references in shipped code and docs to
'inspired by' phrasing; the mechanisms were re-implemented for rrweb, not
ported. Remove the internal design/plan documents from the branch — they
are working artifacts, not upstream deliverables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es with vendor-proven behavior

Provenance audit of the Privacy at Capture v2 work found six places where
rrweb's behavior diverged from what the vendors it claims compatibility with
actually do. Each fix below matches a verified vendor source.

- needMaskingText resolved a same-element mask/unmask tie to unmask. Sentry
  (maskDistance <= unmaskDistance), Amplitude and Mixpanel all resolve it to
  mask. Both sides are now evaluated at each walk level and mask wins;
  nearest-ancestor-wins across levels is unchanged.
- The record()-level selector options were concatenated onto the compiled
  policy unvalidated, so one malformed selector made every later matches()
  throw and the runtime catch-to-mask starred the whole page. All three merge
  helpers now route the legacy half through the same validateSelector
  drop-and-warn path as policy rule selectors. The runtime catch stays as the
  backstop for a selector that validates but throws while matching.
- VENDOR_UNMASK_CLASSES listed .sentry-unmask/[data-sentry-unmask], which no
  vendor defines (Sentry's unmask default is []). Reduced to .amp-unmask
  (Amplitude) plus rrweb's own .rr-unmask.
- Canvas sampling coercion keyed on Boolean(canvasMasking), coercing even for
  a provider whose isConfigured() returns false -- masking off, so
  mutation-mode capture bypasses nothing. It now keys on the same
  isCanvasMaskingConfigured semantics the snapshot suppression uses,
  fail-closed-to-true behavior included. record() also stops mutating the
  caller's sampling object and no longer re-exports resolveCanvasSampling.
- sanitizeUrl returned '' on an unparseable URL; an empty src/href re-resolves
  to the document URL at replay and gets requested. It now returns null so the
  attribute is dropped, matching the blockMedia branch. The Meta-event caller
  coalesces to ''.
- finalizeAttribute now honors the unmask escape before starring a
  maskedAttributes entry (Sentry maskAttribute precedent), gates the
  isGenerated exemption on a rendering-metadata name allowlist as PostHog
  does, and mutation.ts clears the per-cursor generated flag when a real page
  mutation writes the same attribute name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e masking

Round-2 review of the provenance fix wave.

The masked-attribute unmask escape read `privacy.unmaskTextSelector` -- the
compiled policy's own selector. The `record()`-level `unmaskTextSelector`
string option was merged into a separate variable threaded to
`needMaskingText` and never written back, so it only ever affected text:
`record({privacyPolicy: balanced, unmaskTextSelector: '.support-widget'})`
left `<img title>` starred inside the subtree while `.rr-unmask` unmasked it,
contradicting the documented behavior.

Both `record()` and `snapshot()` now write the merged selector back onto the
compiled policy before it flows into serialization and mutation, so a policy
rule, a vendor class and the record()-level option are one selector honored
everywhere. The policy keeps the *unresolved* selector: the
`resolveUnmaskTextSelector` presence probe is scoped to one document, and the
same policy object is threaded into nested iframe documents.

`joinSelectors` now deduplicates with a Set, which makes the merge helpers
idempotent -- necessary because record() merges the policy's selectors into
the options it hands snapshot(), which compiles the same policy and merges a
second time. Splitting is depth- and quote-aware so `:is(a,b)` and
`[data-x="a,b"]` are not torn in half by the dedupe.

Also from the same review:
- finalizeAttribute's decision-order docblock was stale: step 1 now documents
  the RENDERING_METADATA_ATTRIBUTES second gate, step 4 the unmask escape and
  why it runs after the media-drop and URL branches.
- CanvasMasking.isConfigured is documented as needing to be stable at
  record() time; a false->true flip afterwards keeps mutation-mode capture,
  the disclosed consequence of keying coercion on configured semantics.
- Dropped the removed .sentry-unmask/[data-sentry-unmask] tokens from
  privacy-perf.test.ts's PRIVACY_SELECTOR_FRAGMENTS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ector lists

Round-3 review of the provenance fix wave.

`splitSelectorList` honored a backslash escape only inside a quoted string, so
a bare escaped comma -- valid CSS, `.a\,b` matches class "a,b" -- was torn in
two. `validateSelector` cannot catch this: the selector is perfectly valid, it
just means something other than a separator.

The tear is a fail-open, not a cosmetic bug. `.a\,b` split into `.a\` and `b`,
and the stray `b` then collided in `joinSelectors`' dedupe `Set` with an
independently supplied `b` selector, silently dropping it from the merged
list. A mask selector that quietly stops being applied is exactly the failure
mode the whole wave exists to prevent. Verified before fixing: merging
'.a\,b,b' produced a list that matched the comma-class element but no longer
matched a <b> element.

The scan loop now consumes a backslash escape universally, before the quote
and depth checks, so an escaped character can never be interpreted as syntax.

`splitSelectorList` is exported (marked `@internal`) so the parser can be unit
tested directly -- it is hand-rolled and this is its second review finding, so
it earns its own tests rather than only being exercised through the merge
helpers. `validateSelector` and `resolveUnmaskTextSelector` are already
exported from this module on the same basis.

Tests: direct cases for ordinary lists, `:is(a,b)`, quoted attribute values,
escaped commas quoted and bare, and a trailing backslash (no read past the
end); plus the reviewer's jsdom reproduction asserting the merged list matches
both the comma-class element and the <b> element, in the single-string form
and the form where the two halves arrive from different merge sources. All
three new behavior tests were confirmed to fail against the previous splitter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rogier Trimpe and others added 30 commits August 27, 2026 12:01
…sk carrier

Cluster A of the simplification pass. Behavior-preserving.

- A1: extract `resolvePrivacyContext` into privacy.ts. `record()` and a
  standalone `snapshot()` both run the same compile-merge-write-back
  prologue instead of two near-identical copies. `snapshot()` gains an
  internal `privacy` option carrying an already-compiled policy, which
  `record()` passes so the policy is compiled once per recording rather
  than once per full snapshot; `effectivePrivacyPolicy` is gone.
- A2: the three near-identical merge helpers collapse into one
  `mergeSelectors(legacySelector, compiledSelector)`.
- A3: drop the `unmaskTextSelector` dual channel from observerParam,
  MutationBufferParam and the shadow-DOM bypass bag. It already rides on
  the compiled policy, which is where its single consumer now reads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cluster B. Behavior-preserving.

B1: `resolveTextValue` in privacy.ts owns the whole text ladder -- the
CSS exemption, the maskTextFn-else-stars branch, and the detector
whole-node fallback. `serializeTextNode` and the mutation buffer's
characterData case are each one call into it.

B2: the snapshot path exempts <script> text from the mask branch and the
mutation path does not. That asymmetry is inherited, not intended; it is
now carried by an explicit `exemptScript` argument so both call sites
state which side they are on, rather than being unified silently. Fixing
it is a behavior change and is left for upstream review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… node

Cluster C1/C2. Behavior-preserving.

C1: `splitMaskAllSelector` uses `splitSelectorList` instead of a naive
`split(',')`, so a comma nested in `:is(a,b)`, inside an attribute value,
or escaped as `\,` can no longer tear a selector in half on its way
through the splitter.

C2: the module-global `maskAllSelectorCache` (and its arbitrary 100-entry
cap) is gone. The `{maskAll, selector}` pair is computed once per
serialization pass -- at the top of `snapshot()` and once per mutation
flush, next to the unmask probe -- and threaded down as
`needMaskingText`'s and `serializeNodeWithId`'s `maskTextSelector`
argument, which is now the split pair rather than the raw string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cluster C3. Behavior-preserving.

The mutation buffer already memoises `needMaskingText` per flush; the
snapshot walk did not, so every text and comment child of an element
re-walked the same ancestor chain whenever an unmask selector forced
per-node checking. `snapshot()` now creates one `Map<Node, boolean>`,
keyed by the element the walk starts from, and threads it through the
synchronous recursion only.

The two deferred re-serializations (an iframe or a stylesheet that
finishes loading after `snapshot()` has returned) deliberately do not
get the cache: by then the DOM the decisions were derived from is free
to have changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cluster D. Behavior-preserving.

D1: rrweb-snapshot's nine privacy type declarations become re-exports
from @rrweb/types, which is now the single declared source of truth for
the Privacy at Capture type family. The policy is a portable document
that travels between recorder, plugins and consumers, so no one package
should own two copies of its shape. rrweb-snapshot's public surface is
unchanged.

D2: `CompiledPrivacyPolicy.policy` is dropped. Its only readers were
three `privacy?.policy.preset !== 'strict'` checks, all of which have a
compiled boolean alias to read instead.

D3: those three become `!privacy?.blockMedia`, and the canvas thunk +
preset check fold into `shouldCapturePixels(privacy, canvasMasking?)`,
used by both snapshot pixel sites. The `<img>` inlining site passes no
thunk -- canvas masking is a canvas concern, and widening it to inline
images would be a behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cluster E. Behavior-preserving, with one documented exception (E2).

E1: delete the provably-dead `presetWantsMask && !maskInputFn` branch in
`maskInput` -- it recomputed exactly the stars the line above had already
produced.
E2: `maskInputValue` becomes a thin @deprecated shim over `maskInput`,
deleting the second implementation of the same decision. Going through
`maskInput` means an always-protected input (password/hidden, or a
card/password `autocomplete`) is masked there too regardless of
`maskInputOptions` -- noted in the deprecation comment, and the reason
the name is deprecated rather than merely aliased. The legacy half of
the decision is extracted as `legacyWantsInputMask`. The
snapshot-utils.ts re-export is dropped (no in-repo consumer; the public
export via index.ts is unchanged).
E3: `stars` is exported from privacy.ts and replaces the inline
`'*'.repeat` sites in utils.ts.
E4: one `classMatches(el, matcher)` replaces the three copies of the
string-or-RegExp class check.
E5: one `finalizeAttributes(attributes, ctx)` sweep, called from
serializeElementNode and the mutation buffer's attribute emit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cluster F. Behavior-preserving. F3 and the ResizeObserver half of F5 are
not applied; see the pass report.

F1: `finalizeAttribute` returns its input immediately when there is no
callback, no coarse switch, and the compiled policy has nothing to say
about attributes (`attributePolicyInert`, precomputed). The two
`untaintedTagName` reads move into the two branches that consume them,
instead of running for every attribute of every element.
`maskedAttributes` becomes a Set.
F2: `isProtectedInput` bails on an empty `autocomplete`, tries the whole
value against the set before splitting it, and no longer builds a regex
per call.
F4 (memoised half only): one `closest()` per element per attribute
sweep instead of one per masked attribute.
F5 (gate half only): the per-frame content-box measurement is gated on
`isCanvasMaskingConfigured`, not on the provider merely being present.
F6: the media placeholder is memoised on `WxH` and encoded in one pass.
F7: both finalization sweeps use for-in (via `finalizeAttributes`) and
`detectSensitiveValue` runs a plain loop rather than `.some(closure)`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cluster G1. Behavior-preserving.

`resolveCanvasSampling` was a one-function module sitting next to the
`isCanvasMaskingConfigured` it is always composed with; it now lives in
canvas-mask.ts and canvas-sampling.ts is deleted. Its tests fold into
canvas-mask.test.ts (21 tests there now). record/index.ts's copy of the
rationale comment shrinks to a pointer at the helper that owns it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cluster H2. BEHAVIOR CHANGE, with tests, changeset note and guide line.

The `selected` flag on an `<option>` discloses the parent `<select>`'s
value just as surely as the value attribute does, but the disclosure
decision read `maskInputOptions['select']` and ignored the compiled
policy entirely. Under `balanced`/`strict` -- or with any heuristic
detector active, which forces `maskAllInputs` -- the select's value was
masked while the chosen option was still recorded verbatim,
contradicting the documented form-value guarantee.

Both decisions now go through one exported predicate, `shouldMaskInput`,
which is the same predicate `maskInput` computes. Legacy behavior is
unchanged: `maskInputOptions.select` and `maskAllInputs` suppress the
flag exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rived state

R1: `needMaskingText` is exported from the package root and its third
parameter changed shape this branch. A caller still passing the old raw
selector string destructured to `{maskAll: undefined, selector: undefined}`
and every mask selector silently stopped matching -- a fail-open on the one
path that must never fail quietly. It now coerces a string (and null) through
`splitMaskAllSelector`; `serializeNodeWithId`'s option is widened to match,
since it only threads the value down. Tests cover the legacy string form,
a raw '*', a raw '*' losing to an unmask ancestor, and null/undefined.

R2: `snapshot()`'s compiled-policy branch hand-rolled its own precedence and
discarded the compiled policy's selectors whenever a legacy selector option
was also passed -- the opposite of what `resolvePrivacyContext` means on the
`record()` path. Both entry points now go through it; merging is idempotent,
so `record()`'s pre-merged input is unchanged by the second pass.

R3: `MutationBuffer`'s `splitMaskTextSelector` was assigned only in
`processMutations`, defaulting to "no mask selectors at all" -- and `emit()`
is reachable from `unfreeze()`/`unlock()` without a flush. It is now derived
from the raw selector on first use (fixed for the buffer's lifetime, reset in
`init()`), so it cannot be stale or unset. `effectiveUnmaskTextSelector` gets
the same treatment as a per-flush lazy probe, invalidated rather than
assigned, so an out-of-flush `emit()` probes the current DOM.

R4: the canvas frame gate's behavior change is disclosed. A provider that is
present but switched off no longer costs a per-frame content-box measurement
and no longer drops the frame when that measurement fails -- it masks nothing,
so the box was unused and the frame was discarded for no privacy gain. The
gate, the measurement and the fail-closed skip fold into
`resolveFrameDisplaySize`, which is now covered by four tests, and the change
is written up in the changeset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trim Privacy at Capture docblocks/comments toward rrweb's sparser house
style (multi-paragraph rationale shortened or moved to guide.md, review-
process narration and duplicated second-site rationale removed), and fold
the five overlapping privacy changesets into one integration changeset
mirroring core plus one line per experimental layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delete restated/rationale comments across the stack's source and pin
the invariants they described as named tests instead (splitSelectorList
escaping, RENDERING_METADATA/isGenerated pairing, unmask-not-reopening-
URL/media, maskAttributeFn fail-closed, exemptScript snapshot/mutation
asymmetry, resolveUnmaskTextSelector shadow-root piercing, isUnmasked
fail-closed-on-throw, declaredDimensions never measuring layout,
attributePolicyInert). Rename two private symbols to absorb their
comments (DIMENSION_ATTRIBUTE -> PLAIN_PIXEL_DIMENSION,
selectorMatchesAnywhere -> matchesInDocumentOrOpenShadowRoots). Shrink
public API JSDoc on @rrweb/types to one line per field/tag, keeping the
CanvasMasking.isConfigured stability warning and correcting a stale
claim on unmaskTextSelector (it does reach masked attributes, not only
text, since the record()-level write-back fix). Trim matching
restated-rationale comments in the test files touched along the way.

12 new/extended tests. eslint/tsc/prettier clean; one pre-existing,
unrelated record.test.ts failure (detector-plugin dist) observed and
left alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…closed on an unknown data-privacy value

`data-privacy="exclude"` becomes `data-privacy="block"` and
`data-privacy="allow"` becomes `data-privacy="unmask"`, so the attribute's
vocabulary and the rule actions are one set of three names. `PrivacyAction`
is now exactly `'mask' | 'block' | 'unmask'`: `exclude` and `allow` are
deleted outright rather than aliased, and a rule carrying either throws at
compile time. Pre-release, so no migration path is owed.

An element whose `data-privacy` value is not one of the three now masks.
This is done at the selector level rather than with precedence logic, which
a comma-separated selector list cannot express: the compiled mask token is
`[data-privacy]:not([data-privacy="unmask"]):not([data-privacy="block"])`,
the bare attribute minus the two values that mean something else, so every
other value in the attribute's space -- a typo, an empty value, a value from
a future version -- falls into the mask list by construction. The block and
unmask lists are unchanged, and the same-element mask/unmask tie still
resolves to mask.

`mask-inputs` is reserved in the guide for a future input-only treatment. It
is not implemented, so today it masks like any other unrecognized value.

12 new tests. eslint/tsc/prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…endorCompat

Recognizing another session-replay tool's privacy classes changes what rrweb
records based on markup the embedder may not control -- `.ph-mask` is a
protective declaration to PostHog and an ordinary styling hook to everyone
else -- so honoring a foreign vocabulary is now an explicit choice rather
than something `balanced`/`strict` do on their own.

`PrivacyPolicy` gains `vendorCompat?: boolean`, default false. The constants
split into native and compat lists: a managed preset always merges rrweb's
own `.rr-mask`/`.rr-block`/`.rr-unmask` and the `data-privacy` selectors,
and merges the mp/fs/amp/ph/sentry/dd/nr mask and block tokens only under
the flag. `manual` merges no class conventions either way, so the flag is
inert there.

`.amp-unmask` is in the compat unmask list, which makes it the one direction
in which the flag can reduce masking. The reasoning: turning compat on is a
statement that the page was instrumented for another tool, which makes that
tool's unmask marker an intentional declaration rather than a foreign token
of unknown provenance. No other vendor's unmask convention is honored on
either setting. Called out explicitly in the guide, since an embedder
reaching for the flag is usually reaching for more masking, not less.

The perf-test deny-list keeps every foreign fragment: the manual path must
match none of them regardless of the flag.

9 new tests. eslint/tsc/prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, Meta href scoping, consumer docs

Replays the four changes from the core/detectors/url-sanitization layer
branches onto integration, which is not ancestor-linked to core:

- Restore never-honor-foreign-unmask, absolutely: remove
  COMPAT_UNMASK_CLASSES/.amp-unmask so vendorCompat can only ever increase
  masking or blocking, never reveal; .rr-unmask stays native-only.
- Three one-time warns: strict silently disabling an explicit
  recordCanvas: true; the detectors plugin's first policy application;
  needMaskingText's fail-closed catch (with the caught error message).
- Meta event href scoping: sanitizeMetaUrl masks only blocked-list params
  under strict (the balanced treatment), since the Meta href is the
  recording's own address, not page-author markup; DOM URL attributes keep
  strict's normal mask-everything-unless-allowlisted treatment.
- guide.md "For event consumers" section documenting wire-visible changes
  (nullable attributes, SVG media placeholders, starred text, canvas
  keyframes) and the data-privacy collision with pre-existing attributes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l data-privacy off, per-fragment selectors

Six findings from the fresh-eyes downstream review:

- F1 (blocking): `needMaskingText`'s new 4th parameter silently swallowed a
  pre-2.0 caller's positional `checkAncestors` boolean, leaving the real
  `checkAncestors` `undefined` — a fail-open that stopped the ancestor walk,
  so a `.rr-mask` ancestor no longer masked. A boolean in that slot is now
  shape-detected and shifted back.
- F2: the mutation path gated value masking on `FORM_VALUE_TAGS` alone, so a
  custom element declaring a credential (`<ion-input type="password">`)
  recorded raw where upstream masked. Adds `isProtectedTypeLike`, kept
  password/protected-token specific so `<li type="disc">` and `<ol type="1">`
  are not swept back into the over-masking the tag gate was added to fix.
- F3: `data-privacy` is now fully off under `manual` — for every action, with
  or without rules — instead of switching itself on beside a same-action
  rule for `mask`/`block` only. Code now matches guide.md.
- F4: selector validation falls back to per-fragment when the whole list
  fails, so `.pii, .broken:has-typo(` keeps `.pii` instead of dropping both.
  `validateSelector` assumes valid where there is no `document` to probe.
- F5: the unresolved unmask selector is threaded into the deferred iframe and
  stylesheet re-serializations so each document runs its own presence probe;
  an unmask target living only inside a same-origin iframe was ignored.
- F6: docs/disclosure — probe cost, canvas shadow discovery now requiring
  native shadow roots, the "For event consumers" canvas contradiction
  (`strict` emits no canvas events), root-relative rewriting of relative
  URLs, and the removed/newly-star-exported privacy symbols by name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clean rename, nothing released. Renames the `PrivacyPreset` union member,
`PRIVACY_PRESETS`, every `preset === 'manual'`/`!== 'manual'` comparison,
`applyPrivacyDetectors`'s default base, and every `preset: 'manual'` test
fixture and test name across rrweb-snapshot, rrweb, and the privacy-detectors
plugin, plus guide.md's preset docs and migration notes, the plugin README,
and the changeset. Semantics are unchanged: the permanent tier where masking
is driven by the classic options you configure yourself, with the
non-configurable credential floor (native form controls); `balanced`/`strict`
remain the managed presets.

Prose meaning "manually configured" or "the record()-level selector you
supply manually" is left untouched -- e.g. `manualSelector`/
`manualBlockSelector`/`manualWantsInputMask`, the "manual option"/"trusted
manual `maskTextFn`" phrasing in guide.md and the plugin README, the
"manual baseline" comment in rrweb's types, and the "manual selector"/
"manual half" test names in privacy.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onvention

vendorCompat now recognizes the mask and block markup of twenty tools:
Mixpanel, FullStory, Amplitude, PostHog, Sentry, Datadog, New Relic,
Highlight/LaunchDarkly, LogRocket, Hotjar, Microsoft Clarity, Smartlook,
OpenReplay, Contentsquare, Heap, Mouseflow, Lucky Orange, Inspectlet,
Dynatrace, Userback, and Zipy. Every token was verified against the
vendor's official documentation or open-source SDK; the guide carries the
full table with sources.

Mapping rule: a token whose vendor semantics hide only text joins the mask
list; one that removes or placeholders the element's whole content joins
the block list, the more protective of the two. No vendor's reveal token
or input-ignore token is merged, on either setting. Tokens that could not
be verified against a reachable official source (Quantum Metric, Glassbox,
SessionStack, Session Rewind, Smartlook's legacy attributes) are left out.

Tests pin the extended set, assert every reveal/ignore token stays out of
all three lists, and check that every compat token validates as a selector
so a typo cannot be dropped silently at compile time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Quantum Metric ([data-qm-encrypt] mask; [data-qm-block],
[data-qm-freeze-exclude] block), Glassbox (.cls_mask), SessionStack
(.sessionstack-sensitive), Session Rewind ([data-sr-redact]), and
Smartlook's legacy data-recording-sensitive / data-recording-disable
attributes, which the current web bundle still honors. Where a vendor has
no public documentation the token was read from its shipped SDK, and the
guide says so per row.

Heap's `.heap-ignore` class form is dropped: heap-1.js selects
`[heap-ignore]` only, and no document shows the class. Reveal and ignore
tokens found in this pass ([data-qm-allow], [data-recording-ignore],
smartlook-hide/show) are pinned as never merged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The fourth verb: an ignore subtree's content masks exactly like mask
(through the existing fail-closed mask token), and no input events are
emitted for it at all. Nearest data-privacy annotation decides, so a
descendant unmask re-enables both content and events; the legacy
.rr-ignore control stays per-element and events-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vendorCompat now also accepts an array of vendor ids, compiled from a
per-vendor registry that replaces the two flat token lists; true still
merges every verified vendor's tokens, [] merges none, and an unknown id
is dropped with a console.warn naming it. No vendor's unmask/allow or
input-ignore token is ever merged, under any form of the setting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… layer

Defects:
- splitSelectorList: a stray closing bracket, an unclosed opener, or an
  unterminated quote no longer swallows the separators after it, so a
  malformed fragment is dropped alone instead of taking every valid
  selector behind it down (a silent loss of masking coverage).
- maskInputFn / maskTextFn: a callback that throws or returns a non-string
  now fails closed to stars instead of aborting the snapshot or recording
  the raw value, matching the guard finalizeAttribute already gives
  maskAttributeFn.
- hasProtectedAutocomplete: read the attribute before the IDL property.
  The IDL getter reports '' (not nullish) for an unparseable token order,
  which made the attribute fallback dead code and let a declared cc-number
  through.

Gaps:
- Masked-attribute defaults resolve mask/unmask like text: nearest
  annotated ancestor decides, mask wins a same-element tie, and a
  record()-level maskTextSelector takes part (it is now written back onto
  the policy alongside unmask). strict's '*' fallback stays out of the tie.
- getInputType / isProtectedInput / isProtectedTypeLike: a type or
  autocomplete read that throws (proxied or cross-realm element) is
  treated as protected rather than propagating.
- vendorCompat: add [data-sentry-block] (Sentry ships class and attribute
  spellings for block as for mask) and FullStory's -without-consent
  variants; warn once when the flag is set under minimal, where it has no
  effect. Guide corrects the nr-ignore rationale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g, block, placeholders

- Canvas mask regions scale against the layout content box
  (clientWidth/clientHeight minus padding) instead of the transformed
  bounding rect, so a CSS transform on the canvas can no longer shrink a
  mask to a fraction of its target.
- Supplying canvasMasking forces the FPS capture path on its own; the
  decision no longer consults isConfigured(), which is re-read every frame
  and could flip after record() had already left the mutation-mode command
  stream running with no masking path. The type doc drops the "must be
  stable" constraint accordingly.
- A block selector that throws while matching now blocks the element with a
  one-time console.warn, matching the fail-closed rule the text and
  attribute decisions already follow; the validateSelector comment now
  describes both backstops.
- strict's masked-image placeholder falls back to naturalWidth/naturalHeight
  when the image has no size attributes (intrinsic, still not a layout
  measurement), and the data URL encodes quotes, spaces and commas so it is
  a valid srcset candidate as well as a src.
- Tests: the late-write guard, the maskInputOptions password:false override,
  and each change above.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… verified adopter remaps

Verified from vendor source: Sentry getPrivacyOptions.ts, posthog-js
lazy-loaded-session-recorder.ts, Datadog browser-rum-core privacy.ts,
newrelic-browser-agent init.js, highlight-run client/index.tsx. Vendor
ignore tokens suppress input events from the annotated element only,
mirroring the vendors' own input observers; they never imply masking,
unlike data-privacy="ignore". Datadog mask-user-input keeps its
text-mask mapping: form values are already masked globally wherever
compat applies, and dropping the token would record the form-element
text it protects there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Docs-tier verification (official vendor documentation; archive copies
where the live docs are gone): Heap's redact attributes redact the whole
element in their replay and [heap-ignore] suppresses autocapture events
only; Mouseflow's mf-masked and data-mf-replace remove the subtree there;
Userback's userback-ignore keeps the element rendered and ignores its
input; Smartlook's legacy data-recording-sensitive masked text and ignored
input events; Session Rewind documents only "exclude" with the rendering
unspecified, so the stricter verb. Hotjar keeps block: it masks text in
place there but placeholders images too, which our mask verb would not.
Source-tier (mixpanel-js, Amplitude-TypeScript, highlight/highlight-rrweb,
openreplay clones): mixpanel, amplitude, and openreplay confirmed as
mapped; highlight gains .highlight-ignore as events-only. Glassbox and
Quantum Metric stay observed-only; Zipy, SessionStack, FullStory,
LogRocket, Clarity, Contentsquare, Lucky Orange, Inspectlet, and Dynatrace
confirmed as mapped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant