refactor: Privacy at Capture — compile privacy policies onto rrweb's own masking primitives - #3
Open
roggernaut wants to merge 64 commits into
Open
refactor: Privacy at Capture — compile privacy policies onto rrweb's own masking primitives#3roggernaut wants to merge 64 commits into
roggernaut wants to merge 64 commits into
Conversation
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>
Closed
4 tasks
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
compilePrivacyPolicynow compiles presets/rules onto rrweb's existing masking primitives (selector lists + inheritedneedsMaskpropagation). The parallelgetPrivacyActionper-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.unmaskTextSelector(record option +allow/unmaskrules), nearest-ancestor-wins, with a presence probe restoring subtree short-circuiting when nothing on the page can unmask..rr-mask,.mp-mask,.fs-mask,.amp-mask,.ph-mask,.sentry-mask, …) recognized under non-legacy presets.Fail-closed hardening
sanitizeUrlstripsusername:password@and fails closed on unparseable URLs; malformed plugin-transformed policies fall back to the user's policy instead of crashingrecord().cc-*/OTP autocomplete) always masked;maskInputFn/maskAttributeFnoutputs are constrained under presets (fn controls length, policy is final authority); one attribute-finalization pass covers snapshot, mutation, and added nodes.Breaking changes
See
.changeset/privacy-v2-simplification.md:@rrweb/typesmajor (union worker params, removed policy fields),needMaskingTextsignature, 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,
maskInputValuedeprecation) is listed in the final review triage.Test plan
tsc -bclean across the monorepo🤖 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); thetitle/placeholder/aria-labelmasked-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-privacyenumerated 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):
''resolveUnmaskTextSelector, shadow-recursive, per snapshot/flush) and the mask-all selector cacheMAX_SCAN_LENGTHmask-everything cliffmaskAttributeFnas a pipeline stage (vendors treat it as terminal), fn try/catch-to-stars, non-string-to-starsapplyPrivacyPolicyhook and its compile fallbackresolveCanvasSamplingcoercion; content-box mask scaling; the tracked-shadow-roots canvas registryFORM_VALUE_TAGSgating of mutation value masking;rr_srcthrough URL/media policy;strictforcingrecordCanvasoffPost-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
unmaskTextSelectornow reaches masked attributes; fictional vendor unmask classes removed; canvas sampling coercion keys onisConfigured();sanitizeUrlfails closed tonull(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), thenprivacy-at-capture-detectors,privacy-at-capture-url-sanitization, andprivacy-at-capture-unmask-probe(experimental layers, → core).