Skip to content

feat: screenshot privacy masking via data-bugdrop-mask#148

Merged
neonwatty merged 22 commits into
mainfrom
feat/screenshot-privacy-masking
May 10, 2026
Merged

feat: screenshot privacy masking via data-bugdrop-mask#148
neonwatty merged 22 commits into
mainfrom
feat/screenshot-privacy-masking

Conversation

@neonwatty
Copy link
Copy Markdown
Collaborator

Summary

Adds a developer-configured privacy primitive that paints opaque rectangles over data-bugdrop-mask elements (and password / credit-card autocomplete inputs by default) on the captured screenshot before the annotator opens. Masks are baked into the PNG client-side, work with all three capture modes (full page / element-scoped / area-cropped), and survive page scroll because rectangles are collected in document coordinates.

<input type="email" data-bugdrop-mask />

<div data-bugdrop-mask>
  <span>Customer name</span>
  <span>customer@example.com</span>
</div>

Approach

  • New src/widget/mask.ts exports collectMaskRects(root) (DOM walk → document-coordinate rects) and applyMaskToImage(dataUrl, rects, pixelRatio, originOffset) (canvas blit). Pure helper translateMaskRect extracted for unit-testable math.
  • captureScreenshot invokes them between html-to-image and the existing return path. No DOM mutation, no annotator changes, no new public API.
  • Top-most-ancestor rule: when a parent is masked, the entire parent box is one rectangle — descendants don't get their own rectangles, which prevents leakage from CSS gap / margin gaps.
  • Fail-closed: any mask error propagates and surfaces "Capture Failed" to the user. _error now logged via console.warn('[BugDrop] ...').

Spec & plan

Test plan

  • Unit + integration: 147 passed (was 116 baseline). New: 29 mask-module tests + 2 captureScreenshot integration tests.
  • E2E: 12 new "Screenshot Masking" tests covering default password mask, explicit attribute, inheritance, scrolled full-page, three element-scoped variants, area-cropped, scrolled area-cropped, and a clean-baseline regression guard.
  • npm run build:widget — bundles mask.ts into widget.js cleanly.
  • Lint + typecheck pass.
  • PR review toolkit: 5 specialized agents (code-reviewer, pr-test-analyzer, code-simplifier, silent-failure-hunter, type-design-analyzer) ran in parallel; all findings rated Critical/Important addressed before this PR was opened.

Known limitations (documented in security.mdx)

  • Shadow DOM and cross-origin iframes not traversed.
  • Mid-capture DOM reflow may leave a stale mask rectangle.
  • The viewport-capture fallback (used on >10k-node pages) does not apply masks — that path uses getDisplayMedia with no DOM access.

neonwatty added a commit that referenced this pull request May 10, 2026
Knip flagged MaskRect as exported but unused outside mask.ts. The
interface is referenced only by the module's own internal helpers and
function signatures; consumers infer the type via TypeScript.

Fixes the failing 'Lint, Typecheck, Knip, Audit' CI check on PR #148.
neonwatty added 22 commits May 10, 2026 09:55
Design spec for a developer-configured masking primitive: site owners tag
sensitive elements with `data-bugdrop-mask` (plus built-in defaults for
password/credit-card inputs), and the widget paints opaque rectangles over
those regions on the captured PNG before the annotator opens. Coordinates
are document-space to align with the existing area picker and survive
scroll. No DOM mutation, no annotator changes, no new API surface.
13 TDD-style tasks covering: mask.ts module (collectMaskRects, applyMaskToImage,
translateMaskRect), captureScreenshot wiring, E2E fixtures, 11 Playwright tests
across full-page/element/area-crop captures + scrolled coordinates, and the
security/install/README docs.
Replace flat querySelectorAll traversal with a hand-written tree walk
that respects the top-most-ancestor rule (masked subtrees are not
descended into), scoped collection (only descendants of root), and
root inclusion (root itself is checked before walking).
Wire collectMaskRects and applyMaskToImage into captureScreenshot so
privacy masks are baked into every captured image. Rects and origin
offset are computed before awaiting toPng to snapshot DOM state at the
same instant capture begins.
Adds 3 Screenshot Masking tests that run against real html-to-image
output (no mock) and verify pixel values: password fields are masked
by default, data-bugdrop-mask elements are masked, and unrelated
elements are not. Infers the actual capture pixelRatio from the image
dimensions because the widget enforces a minimum scale of 2.
- Fix NaN-tolerant skip guard in applyMaskToImage: change `t.w <= 0 || t.h <= 0`
  to `!(t.w > 0 && t.h > 0)` so NaN coordinates are caught (canvas silently
  ignores fillRect(NaN,...) — a silent privacy failure)
- Widen pushRect zero-size guard from AND to OR so zero-width or zero-height
  rects are also skipped (both conditions indicate no visible area)
- Replace 14-line inline Promise.race/setTimeout/clearTimeout block in
  captureScreenshot with existing withCaptureTimeout helper (eliminates duplication)
- Replace IIFE-in-ternary for originOffset with plain let + if/else for clarity
Previously the catch block in capturePromiseWithLoading discarded the
error silently (_error). Now it logs the error with the [BugDrop] prefix
so developers can identify why screenshot capture failed in production.
…crolled area E2E

- Tighten integration test assertion to .toBe('data:image/png;base64,masked')
  so it proves applyMaskToImage actually ran (not just that captureScreenshot
  returned something truthy)
- Add applyMaskToImage describe block in mask.test.ts covering: coord translation
  with pixelRatio scaling, empty-rects short-circuit, originOffset subtraction,
  off-canvas skip (non-positive dimensions), null context rejection, and image
  load error rejection (6 new unit tests; 147 total)
- Add E2E test: scrolled area-cropped capture preserves masks at translated
  crop-local coordinates — proves the document-coordinate masking math is correct
  when the page is scrolled at area-selection time
Knip flagged MaskRect as exported but unused outside mask.ts. The
interface is referenced only by the module's own internal helpers and
function signatures; consumers infer the type via TypeScript.

Fixes the failing 'Lint, Typecheck, Knip, Audit' CI check on PR #148.
Two negative-pixel-sample assertions failed on CI but passed locally
because headless Chromium produces fully-black anti-aliased text pixels
that exactly match [0, 0, 0, 255]. With GPU acceleration locally, the
glyphs render slightly off-black so the assertions held by accident.

- 'does not mask unrelated elements': sample inside the panel padding
  (4px from top-left) where the yellow background is guaranteed without
  text overlap, instead of the geometric center which could land on
  glyph anti-aliasing.

- 'masked child of unmasked parent is masked; siblings are not': sample
  four corners of the <p> rect and assert AT LEAST ONE is non-black. A
  real mask would make every pixel inside the rect solid black, so even
  one non-black sample is sufficient to disprove masking. This is robust
  to font rendering differences across environments.
@neonwatty neonwatty force-pushed the feat/screenshot-privacy-masking branch from b393788 to a035663 Compare May 10, 2026 16:55
@neonwatty neonwatty added this pull request to the merge queue May 10, 2026
Merged via the queue into main with commit aaa9bfe May 10, 2026
6 checks passed
@neonwatty neonwatty deleted the feat/screenshot-privacy-masking branch May 10, 2026 17:04
@github-actions
Copy link
Copy Markdown

🎉 This PR is included in version 1.31.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant