Skip to content

Mask password fields in the replayed page by default - #530

Open
vishnuv688 wants to merge 5 commits into
mainfrom
feat/503-redaction
Open

vishnuv688 wants to merge 5 commits into
mainfrom
feat/503-redaction

Conversation

@vishnuv688

@vishnuv688 vishnuv688 commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

What & why

Closes #503. Part of #494.

The trace replay records every field as it was typed, so a password typed into a login form showed in the replayed page, keystroke by keystroke. Now that traces can be opened on a hosted player and shared, the replay should not show passwords by default.

The replayed page now masks password fields by default, on all four adapters, in live and trace mode. Their value is shown as •••• (a fixed placeholder that hides the length) in the DOM snapshot, the field state and every value change. Masking happens in the browser, before the collector hands anything to the adapter.

Command rows and the Log section always show the real typed value, passwords included: setValue("#username", "tomsmith") reads as written. Only the replayed page is masked.

Options:

  • maskInputs (default true): mask <input type="password"> values in the replayed page. A field that was a password field when the collector saw it stays masked after a show-password toggle turns it into a text field. maskInputs: false turns this off.
  • maskSelectors: also mask the text and value of matching elements and everything inside them, plus their aria-label, title, alt and placeholder. Their text never reaches an ancestor's accessible name or text locator.
  • Python: enable(mask_inputs=..., mask_selectors=[...]), --devtools-no-mask-inputs, --devtools-mask-selector, ini keys devtools_mask_inputs / devtools_mask_selectors, env DEVTOOLS_MASK_INPUTS / DEVTOOLS_MASK_SELECTORS.

Type of change

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Polish (an improvement to an existing feature)
  • Breaking change (existing behavior changes for users)
  • Documentation
  • Internal (build, CI, dependencies, tooling)

Packages touched

  • shared (types and contracts)
  • core (framework-agnostic capture/reporting)
  • elements (published element/snapshot API — @wdio/elements)
  • service (WebdriverIO adapter)
  • nightwatch-devtools (Nightwatch adapter)
  • selenium-devtools (Selenium adapter)
  • selenium-devtools-py (Selenium Python adapter)
  • backend (server)
  • app (UI)
  • script (page-injected runtime)
  • trace (Trace mode)

Plus conformance (a rule and the fake driver). elements and backend bundle the changed shared/script code (patch changesets) but have no source changes.

Capability matrix

  • packages/conformance/src/features.ts updated, and tables regenerated with pnpm docs:capabilities, if this adds or changes user-facing behaviour

Feature input-masking, backed for all four adapters by the conformance rule password-masked-only-in-the-page; options declared in capability-options.ts. The docs-site tables are refreshed with the docs-site PR.

Notes for reviewers

Why mostly script and shared: the masking runs in the page collector (script/src/masking.ts) and the element scripts (shared/src/element-scripts.ts). The policy reaches the page as a short prelude ahead of the collector source (shared/src/input-mask.ts, core/src/capture-mask.ts), on both the BiDi preload and the <script> fallback. Each adapter only resolves its options into that policy. Python mirrors the prelude, checked against mirror-vectors.json (collectorMaskPrelude).

History of this PR: the first commit masked every typed value everywhere: page, command arguments, and scrubbing from errors, console and bodies. Seeing setValue("#username", "••••") in the Log made rows unreadable, so the second commit narrows masking to password fields in the replayed page and removes the argument masking and scrubbing entirely. Read the PR as one diff against main.

Evidence: the conformance rule checks, for every adapter, that each collector source starts with the mask policy and that the typed text stays on its rows. The page masking itself is covered by packages/script/tests/masking.test.ts (password-only default, show-password toggle, maskSelectors with attributes, maskInputs: false). The element-scripts tests check that masked text reaches no ancestor's name or locator.

Not covered (also in the README "Password masking" section):

  • the password is still in the trace through command rows, the transcript and the test source;
  • screenshots and screencast pixels;
  • console output and request bodies;
  • text the page shows by itself (the-internet prints its demo password: use maskSelectors);
  • attributes other than value, aria-label, title, alt and placeholder under a mask selector;
  • a password field the page turns into a text field before the collector sees it;
  • native apps.

Review: an independent review of the rework found two issues to fix before merge: Python reading an unset pytest 7 ini as "off", and masked text reaching an ancestor's name. Both are fixed, along with the smaller items.

Testing

  • Frozen install, pnpm build, pnpm test (3573 passed, 1 skipped), pnpm lint, pnpm docs:capabilities --check are green; no workspace import leaks in any bundle.
  • Python: 865 passed. Two test_instrumentation.py tests fail locally on Python 3.12 both with and without this PR; CI is the check there.

Needs checking by hand (optionally pnpm smoke:fixtures and DEVTOOLS_EXAMPLE_BASE_URL=http://127.0.0.1:4567)

  1. Live, each of pnpm demo:wdio:mocha, pnpm demo:nightwatch, pnpm demo:selenium, pnpm demo:python:pytest: clicking the username row shows tomsmith in Log and in the replayed field; clicking the password row shows the real password in Log and •••• in the replayed password field.
  2. Trace mode (DEVTOOLS_MODE=trace), opened with pnpm show-trace <zip>: the same as above.
  3. maskSelectors: ['.subheader'] on one adapter: the subheader text shows •••• in the replay.
  4. maskInputs: false on one adapter: the password field shows its value in the replay.

Not in this PR: the docs-site mirror of the README section and capability tables.

Screenshots / recordings

To be added: the username and password rows with Log and the replayed field.

🤖 Generated with Claude Code

A trace recorded everything the run typed, credentials included, several
times over: the DOM mutation stream (keystroke by keystroke), the field
state, input commands' arguments, the transcript, values read back and
assertion errors. With a hosted player inviting traces from other people,
that had to stop being the default.

Typed text is now replaced by a fixed placeholder that hides its length
before it leaves the browser or the adapter process: page-side in the
collector for text-like inputs, textareas and editable regions, and
adapter-side for the typed arguments of input commands in each vocabulary.
Each typed value is remembered and scrubbed wherever it comes back: rows,
errors (test and hook errors stored as scrubbed copies, never mutating the
runner's own), console and log lines, and request and response bodies. Only
the recorded copy is masked; the driver still receives what the test typed.

maskSelectors masks more, maskInputs: false restores full capture, and the
README lists what is not covered (pixels, test source, text the page
displays by itself). All four adapters, with Python mirrored and checked by
shared mirror vectors and a conformance rule.

Closes #503

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/service/src/session.ts Fixed
Masking every typed value hid the username in the replay and showed
setValue("#username", "••••") in the Log, which made rows hard to read.
Command rows and the Log now always carry the real typed value. By
default the page masks the value of password fields only (kept masked
after a show-password toggle); maskSelectors masks anything else, its
text and its aria-label, title, alt and placeholder, and never lets that
text reach an ancestor's name or locator. maskInputs: false turns the
password masking off.

Argument masking, key-action collapsing and the scrubbing of typed
values from errors, console and bodies are removed in every adapter.
The conformance rule now checks that each adapter hands the page its
mask policy and keeps typed text on its rows. The pytest plugin treats
an unset ini answered as [] as unset, and an empty DEVTOOLS_MASK_INPUTS
keeps masking on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vishnuv688 vishnuv688 changed the title Mask typed input in traces and the dashboard by default Mask password fields in the replayed page by default Nov 3, 2026
vishnuv688 and others added 3 commits November 3, 2026 13:38
# Conflicts:
#	packages/selenium-devtools-py/tests/test_mirror_vectors.py
#	packages/selenium-devtools/tests/mirror-vectors.test.ts
The mask policy is written into the collector's script source as JSON,
and a user's maskSelectors value is part of it. A selector holding
</script>, */ or a line separator could end the script or a comment
around it. The prelude now writes <, >, /, U+2028 and U+2029 as \uXXXX
escapes, which JSON reads back as the same text; Python writes the same
bytes, checked by a new mirror case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	README.md
#	packages/conformance/src/features.ts
@vishnuv688

Copy link
Copy Markdown
Member Author

Docs site: webdriverio/webdriverio#16378 (draft, merges after this).

This branch has not been deployed

No deployments
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.

Capture: redact sensitive values before they enter the trace

2 participants