Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"@rrweb/rrweb-plugin-canvas-webrtc-record",
"@rrweb/rrweb-plugin-canvas-webrtc-replay",
"@rrweb/rrweb-plugin-network-record",
"@rrweb/rrweb-plugin-network-replay"
"@rrweb/rrweb-plugin-network-replay",
"@rrweb/rrweb-plugin-privacy-detectors"
]
],
"linked": [],
Expand Down
26 changes: 26 additions & 0 deletions .changeset/privacy-at-capture-detectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@rrweb/rrweb-plugin-privacy-detectors": minor
"rrweb": minor
"rrweb-snapshot": minor
"@rrweb/types": minor
---

Privacy at Capture: opt-in heuristic PII detectors (**experimental** -- no
production mileage in any shipped session-replay recorder).

- Add `@rrweb/rrweb-plugin-privacy-detectors` (email, phone, Luhn-valid card,
SSN-like, IPv4): masks a whole page text node on match, at snapshot time
and on live `characterData` mutations. Never implied by a preset; opt in
via the plugin or `applyPrivacyDetectors`. Attribute values and custom
detector patterns are not supported. The plugin logs a one-time
`console.info` ("privacy-detectors active: input values record as
length-only stars") the first time it applies its policy.
- While any detector is active, every input value is occluded to its length
regardless of preset (no unmask escape reopens one) -- scanning a value as
typed would leak it through keystroke prefixes.
- Add the `RecordPlugin.applyPrivacyPolicy` hook so a plugin can transform
the policy before `record()` compiles it; see the plugin's README for known
limitations.
- Rebased onto the renamed rule actions: a policy handed to
`applyPrivacyDetectors` or the plugin uses `mask`/`block`/`unmask`, and
`vendorCompat` is carried through untouched.
1 change: 1 addition & 0 deletions docs/recipes/plugin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The plugin API is designed to enable extending the functionality of rrweb withou
- [@rrweb/rrweb-plugin-canvas-webrtc-replay](packages/plugins/rrweb-plugin-canvas-webrtc-replay): A plugin for playing streamed `<canvas>` via WebRTC.
- [@rrweb/rrweb-plugin-network-record](packages/plugins/rrweb-plugin-network-record): A plugin for recording network requests (xhr/fetch).
- [@rrweb/rrweb-plugin-network-replay](packages/plugins/rrweb-plugin-network-replay): A plugin for replaying network requests (xhr/fetch).
- [@rrweb/rrweb-plugin-privacy-detectors](packages/plugins/rrweb-plugin-privacy-detectors): Opt-in heuristic PII matching (email, phone, card, SSN, IPv4).

## Interface

Expand Down
75 changes: 66 additions & 9 deletions guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,8 @@ of preset or configuration.
Masking an input value also suppresses the `selected` flag on the
`<option>` elements of a `<select>`: which option is selected discloses the
select's value just as the value itself does, so both follow one decision.
This applies under `balanced`/`strict` and (as before) under
`maskAllInputs` or `maskInputOptions.select`.
This applies under `balanced`/`strict`, whenever a heuristic detector is
active, and (as before) under `maskAllInputs` or `maskInputOptions.select`.

CSS is never masked, on any preset or code path: `style`/`_cssText`
attributes and `<style>` element text are exempt from every masking branch,
Expand Down Expand Up @@ -531,6 +531,62 @@ record({
});
```

##### Heuristic PII detectors

Heuristic PII detection (email, phone, Luhn-valid payment card, SSN-like,
IPv4) is never implied by a preset. Opt in with
`@rrweb/rrweb-plugin-privacy-detectors` (or its `applyPrivacyDetectors`
helper), which masks the whole text node when a detector matches -- there is
no character-range masking and no support for custom detector patterns.
Detection scans **page text only**, at snapshot time and on later live text
mutations. It only applies to text that would otherwise be recorded unmasked
-- text already masked by a preset, selector, or manual option keeps that
masking (including a trusted manual `maskTextFn` output). Attribute values
are not scanned; use the presets' masked-attribute defaults or policy rules
for those.

Input values are never scanned. Instead, **while detectors are active every
input value is occluded to its length** (`'*'.repeat(value.length)`),
whatever the preset -- the plugin's policy compiles to `maskAllInputs: true`
even on a `minimal` base, and no unmask escape reopens it: neither
`unmaskTextSelector`, nor a policy `unmask` rule, nor `.rr-unmask`
reveals an input value. Scanning one would be worse than useless: a value is
re-examined on every input event, so a card number is recorded verbatim in
every prefix shorter than the first Luhn-valid length, and a value that
scans clean still discloses every kind of PII the fixed pattern set does not
model (passport numbers, non-US phone formats, dates of birth, account
numbers, free text). Page text is different in kind -- already rendered, not
accumulated a character at a time -- which is why scanning still applies
there.

```js
import { getRecordPrivacyDetectorsPlugin } from '@rrweb/rrweb-plugin-privacy-detectors';

record({
emit(event) {
// store event
},
plugins: [getRecordPrivacyDetectorsPlugin()],
});
```

Once loaded, text detection runs independently of the active preset --
including `minimal` -- on top of whatever masking that preset already applies,
and input occlusion is unconditional. The first time the plugin applies its
policy, it logs a one-time `console.info` ("privacy-detectors active: input
values record as length-only stars") as a visible confirmation that input
occlusion is in force.

> **Experimental.** Unlike the rest of Privacy at Capture, whole-value
> heuristic detection on live text mutations has no production mileage in a
> shipped session-replay recorder. Two limitations are known and unfixed: a
> plugin whose policy transform fails to compile silently falls back to your
> own, less restrictive policy with only a `console.error` (which also drops
> the forced input occlusion); and any text over 10,000 characters is masked
> wholesale without being scanned. See the plugin README for details. Treat
> detectors as a backstop, not as the control protecting a field you already
> know is sensitive.

##### Attribute and input masking callbacks

For unusual attribute-bearing applications, `maskAllElementAttributes` is the
Expand All @@ -546,8 +602,9 @@ and can only
narrow what the callback chose to keep, never restore something the policy
would otherwise mask. `style`/`_cssText` are exempt from all of this.
Likewise, under `minimal`, `maskInputFn`'s return value is trusted verbatim;
under `balanced`/`strict`, `maskInputFn` output is star-replaced -- the
callback controls length, never content. Every callback fails closed the
under `balanced`/`strict` -- or with the detectors plugin loaded, which forces
the same posture -- `maskInputFn` output is star-replaced: the callback
controls length, never content. Every callback fails closed the
same way: a `maskInputFn` or `maskTextFn` that throws, or returns anything
other than a string, yields stars for the raw value instead of aborting the
snapshot or recording the value. Protected inputs (see above) never reach
Expand Down Expand Up @@ -645,11 +702,11 @@ Breaking changes versus pre-2.0 masking, for anyone upgrading:
`shouldMaskInputWithPrivacy`, `maskAttributeWithPrivacy`,
`protectSerializedAttribute`, `getPrivacyAction`, and
`mergeBlockSelectors`.
- `rrweb-snapshot`'s barrel now also re-exports three internals --
`splitSelectorList`, `stars`, and `validateSelector`. They are `@internal`
and **unstable**: exported for cross-package use and direct unit testing,
not part of the supported API, and free to change or disappear without a
major bump.
- `rrweb-snapshot`'s barrel now also re-exports four internals --
`splitSelectorList`, `stars`, `passesLuhn`, and `validateSelector`. They
are `@internal` and **unstable**: exported for cross-package use and direct
unit testing, not part of the supported API, and free to change or
disappear without a major bump.

##### For event consumers

Expand Down
124 changes: 124 additions & 0 deletions packages/plugins/rrweb-plugin-privacy-detectors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# @rrweb/rrweb-plugin-privacy-detectors

Opt-in heuristic PII matching for rrweb Privacy at Capture — inspired by the
automatic PII redaction found in commercial session-replay tooling:
whole-value masking of a **page text node** when a detector matches (email,
phone, Luhn-valid payment card, SSN-like, IPv4). Detection covers the full
snapshot and later live text mutations.

**Input values are never scanned. While this plugin is loaded, every input
value is occluded to its length instead** (`'*'.repeat(value.length)`),
whatever privacy preset you configured — including none at all. The one
exception follows from how the guarantee works: occlusion is driven by the
compiled detector set, so loading the plugin with every detector flag
disabled compiles to no detectors and does not force input occlusion. See
[Why inputs are occluded rather than scanned](#why-inputs-are-occluded-rather-than-scanned).

Attribute values are not scanned either -- attribute masking is handled by
the privacy presets and policy rules instead.

No privacy preset implies detection on its own. `balanced` and `strict` mask
form values and honor `data-privacy` / policy rules, but neither one scans
page text for emails, phones, cards, SSNs, or IP addresses unless this plugin
(or `applyPrivacyDetectors`) is used.

## Installation

```bash
npm install @rrweb/rrweb-plugin-privacy-detectors
```

## Usage

```js
import { record } from '@rrweb/record';
import { getRecordPrivacyDetectorsPlugin } from '@rrweb/rrweb-plugin-privacy-detectors';

record({
emit(event) {
// store event
},
privacyPolicy: {
version: 1,
preset: 'balanced',
},
plugins: [
getRecordPrivacyDetectorsPlugin(),
// or disable one class of match:
// getRecordPrivacyDetectorsPlugin({ ipAddress: false }),
],
});
```

If `privacyPolicy` is omitted, the plugin keeps the `minimal` preset (existing
`maskTextFn` / `maskInputOptions` behavior) -- and still detects: detection is
independent of preset, so a bare `record({ plugins: [getRecordPrivacyDetectorsPlugin()] })`
with no `privacyPolicy` at all masks any page text node a detector matches,
at snapshot time and on live text mutations, on top of whatever `maskTextFn`
already does. Detectors only inspect text that would otherwise be recorded
unmasked: where a manual option already masks (e.g. a trusted `maskTextFn`
composition), that output is kept as-is.

Input values are the exception to "independent of preset": the plugin's
policy compiles to `maskAllInputs: true` even on a `minimal` base, so form
values are occluded to length regardless of what you configured.

For snapshot-only use (no recorder):

```js
import { snapshot } from 'rrweb-snapshot';
import { applyPrivacyDetectors } from '@rrweb/rrweb-plugin-privacy-detectors';

snapshot(document, {
privacyPolicy: applyPrivacyDetectors({ version: 1, preset: 'balanced' }),
});
```

## Why inputs are occluded rather than scanned

Two reasons, and neither is fixable by a better pattern set.

**Scanning a value as it is typed records raw prefixes.** An input value is
re-examined on every input event. A payment card number does not trip the
`paymentCard` detector until it is long enough for Luhn to pass, so every
keystroke before that is recorded verbatim and the full number is trivially
reconstructable from the prefixes -- even though the final value comes out
masked. The same holds for an email that is only an email once the `@` and
the domain arrive.

**A clean scan is still a disclosure.** Deciding to record a value because
no detector matched publishes everything the detectors do not model:
passport and national ID numbers outside the SSN shape, non-US phone
formats, dates of birth, account and policy numbers, addresses, free-text
answers. A fixed pattern set cannot enumerate PII, so "nothing matched" is
never evidence that a value is safe.

Occluding the value to its length sidesteps both: no prefix is ever recorded,
and the decision does not depend on the pattern set being complete. Text
nodes are different in kind -- page copy is already rendered and static, is
not accumulated a character at a time, and is overwhelmingly not PII -- which
is why scanning still applies there.

If you know a field is sensitive, say so directly with `data-privacy="mask"`,
a policy rule, or a `balanced`/`strict` preset. Detectors are a backstop, not
a control.

## Known limitations (not yet production-proven)

This plugin is **experimental**. Unlike the mechanisms in the core Privacy at
Capture policy, whole-value heuristic detection on live text mutations has no
production mileage in a shipped session-replay recorder. Two limitations are
known and unfixed:

- **A plugin whose policy transform fails to compile silently downgrades
protection.** If `applyPrivacyPolicy` returns something
`compilePrivacyPolicy` cannot compile, `record()` falls back to the user's
own (less restrictive) policy and continues recording, reporting the
problem only through a `console.error`. Recording does not stop and no
callback is invoked, so a broken transform can go unnoticed in production.
Note that this also drops the forced input occlusion, since that rides on
the transformed policy.
- **Text over 10,000 characters is masked wholesale without scanning.**
`MAX_SCAN_LENGTH` bounds the work each detector does; anything longer fails
closed and is starred in full rather than being inspected. This is safe but
lossy — a long, entirely benign text node is destroyed in the replay.
66 changes: 66 additions & 0 deletions packages/plugins/rrweb-plugin-privacy-detectors/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"name": "@rrweb/rrweb-plugin-privacy-detectors",
"version": "2.1.1",
"description": "Opt-in heuristic PII detectors for rrweb Privacy at Capture",
"type": "module",
"main": "./dist/rrweb-plugin-privacy-detectors.umd.cjs",
"module": "./dist/rrweb-plugin-privacy-detectors.js",
"unpkg": "./dist/rrweb-plugin-privacy-detectors.umd.cjs",
"jsdelivr": "./umd/rrweb-plugin-privacy-detectors.js",
"typings": "dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/rrweb-plugin-privacy-detectors.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/rrweb-plugin-privacy-detectors.umd.cjs"
}
}
},
"files": [
"umd",
"dist",
"package.json"
],
"scripts": {
"dev": "vite build --watch",
"test": "vitest run",
"test:watch": "vitest watch",
"build": "yarn turbo run prepublish",
"check-types": "tsc -noEmit",
"prepublish": "tsc -noEmit && vite build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/rrweb-io/rrweb.git"
},
"keywords": [
"devtools",
"product-analytics",
"real-user-monitoring",
"rrweb",
"privacy"
],
"author": "rrweb Core Team <maintainers@rrweb.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/rrweb-io/rrweb/issues"
},
"homepage": "https://github.com/rrweb-io/rrweb#readme",
"devDependencies": {
"@rrweb/types": "^2.1.1",
"rrweb-snapshot": "^2.1.1",
"typescript": "^5.4.5",
"vite": "^6.0.1",
"vite-plugin-dts": "^3.9.1",
"vitest": "^1.4.0"
},
"peerDependencies": {
"@rrweb/types": "^2.1.1",
"rrweb": "^2.1.1",
"rrweb-snapshot": "^2.1.1"
}
}
41 changes: 41 additions & 0 deletions packages/plugins/rrweb-plugin-privacy-detectors/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { RecordPlugin } from '@rrweb/types';
import {
applyPrivacyDetectors,
DEFAULT_PRIVACY_DETECTORS,
type PrivacyPolicy,
} from 'rrweb-snapshot';

export type PrivacyDetectorsPluginOptions = Partial<
typeof DEFAULT_PRIVACY_DETECTORS
>;

export const PLUGIN_NAME = 'rrweb/privacy-detectors@1';

export { applyPrivacyDetectors, DEFAULT_PRIVACY_DETECTORS };

let loggedActiveOnce = false;

/** Heuristic PII matching over page text; opt in explicitly. See the README for input-occlusion rationale and known limitations. */
export const getRecordPrivacyDetectorsPlugin: (
options?: PrivacyDetectorsPluginOptions,
) => RecordPlugin<PrivacyDetectorsPluginOptions> = (options = {}) => {
const _options = { ...DEFAULT_PRIVACY_DETECTORS, ...options };

return {
name: PLUGIN_NAME,
applyPrivacyPolicy(policy) {
if (!loggedActiveOnce) {
loggedActiveOnce = true;
console.info(
'privacy-detectors active: input values record as length-only stars',
);
}
const portable = (policy as PrivacyPolicy | undefined) || {
version: 1 as const,
preset: 'minimal' as const,
};
return applyPrivacyDetectors(portable, _options);
},
options: _options,
};
};
Loading
Loading