Skip to content

Commit a321881

Browse files
authored
Merge pull request #155 from mean-weasel/feat/redaction-architecture
refactor: split capture flow into focused modules and harden mask failures
2 parents 184ab6b + 2f60c68 commit a321881

13 files changed

Lines changed: 1293 additions & 485 deletions

docs/website/security.mdx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,20 +78,26 @@ BugDrop is built with a privacy-first approach:
7878

7979
### Screenshot masking
8080

81-
You can mark sensitive elements so BugDrop visually covers them in supported screenshot modes. Add the `data-bugdrop-mask` attribute to any element you want covered:
81+
You can mark sensitive elements so BugDrop visually covers them in supported screenshot modes. Add `data-bugdrop-redact` or `data-bugdrop-mask` to any element you want covered:
8282

8383
```html
84-
<input type="email" data-bugdrop-mask />
84+
<input type="email" data-bugdrop-redact />
8585

8686
<div data-bugdrop-mask>
8787
<span>Customer name</span>
8888
<span>customer@example.com</span>
8989
</div>
9090
```
9191

92-
When a user submits feedback, BugDrop paints an opaque rectangle over each tagged
93-
element's bounding box on the captured PNG before showing the user the annotator
94-
preview. The user sees what is masked and can audit it before submitting.
92+
Supported explicit attributes are `data-bugdrop-redact`, `data-bd-redact`,
93+
`data-bugdrop-redacted`, and `data-bugdrop-mask`.
94+
95+
When a user submits feedback, BugDrop plans redactions from matching DOM
96+
elements, then paints an opaque rectangle over each target's measured bounding
97+
box on supported captured PNGs. In manual screenshot flows, the masked image is
98+
shown in the annotator preview so the user can audit it before submitting. In
99+
automatic screenshot mode, BugDrop applies supported masks but submits without
100+
showing the preview step.
95101

96102
Masking is best-effort visual coverage, not a data-loss-prevention or security
97103
boundary. Users should still review screenshots before submitting when the manual
@@ -108,8 +114,14 @@ prevents gaps from CSS `gap` or non-masked siblings inside a masked container.
108114

109115
**Known limitations:**
110116

111-
- Elements inside Shadow DOM and cross-origin iframes are not traversed in this
112-
iteration.
117+
- Elements inside open Shadow DOM are traversed when the browser exposes the
118+
shadow root. Closed Shadow DOM cannot be traversed; mark the host element if
119+
the whole custom control should be covered.
120+
- Iframe contents are not traversed. Mark the iframe element itself if the whole
121+
embedded frame should be visually covered.
122+
- BugDrop does not inspect pixels or text inside canvas, image, video, plugin,
123+
or iframe content. Mark the containing DOM element if that entire region should
124+
be visually covered.
113125
- Mask rectangles are collected at the start of capture. If the page reflows or reveals
114126
sensitive elements between collection and the moment `html-to-image` finishes
115127
rendering, the mask may not cover the final pixels. Keep masked content stable during

e2e/widget.spec.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2855,6 +2855,39 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
28552855
await expect(errorText).not.toBeVisible({ timeout: 3000 });
28562856
});
28572857

2858+
test('privacy masking failure shows a dedicated modal and submits without a screenshot', async ({
2859+
page,
2860+
}) => {
2861+
const payloads = await trackFeedbackPayloads(page);
2862+
// toPng resolves with a data URL that fails Image.onload, so applyMaskToImage
2863+
// rejects with MaskApplicationError. Use /test/redaction.html so the page has
2864+
// a [data-bugdrop-redact] element — otherwise applyMaskToImage early-returns
2865+
// when rects.length === 0 and the failure path never fires.
2866+
await mockHtmlToImage(
2867+
page,
2868+
"function() { return Promise.resolve('data:image/png;base64,not-a-real-image'); }"
2869+
);
2870+
await page.goto('/test/redaction.html');
2871+
await navigateToFullPageCapture(page);
2872+
2873+
const host = page.locator('#bugdrop-host');
2874+
const modalTitle = host.locator('css=.bd-title');
2875+
await expect(modalTitle).toHaveText('Privacy masking failed', { timeout: 5000 });
2876+
2877+
await expect(host.locator('css=.bd-error-message__text')).toContainText(
2878+
'Automatic redaction of private fields could not be applied'
2879+
);
2880+
// Retry must NOT be offered for masking failures — retrying would fail the
2881+
// same way and a user might be tempted to send unredacted output.
2882+
await expect(host.locator('css=[data-action="retry"]')).not.toBeAttached();
2883+
2884+
await host.locator('css=[data-action="skip"]').click();
2885+
2886+
await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 });
2887+
expect(payloads).toHaveLength(1);
2888+
expect(payloads[0].screenshot).toBeNull();
2889+
});
2890+
28582891
test('retry button on error modal re-attempts capture', async ({ page }) => {
28592892
// First call fails, second call succeeds
28602893
await mockHtmlToImage(
@@ -3252,7 +3285,7 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
32523285
});
32533286

32543287
test('remembers complex-page skip after element capture failure skip', async ({ page }) => {
3255-
await trackFeedbackSubmissions(page);
3288+
const payloads = await trackFeedbackPayloads(page);
32563289
await mockHtmlToImage(page, "function() { return Promise.reject(new Error('mock failure')); }");
32573290
await page.goto('/test/complex-dom.html?nodes=12000');
32583291

@@ -3271,13 +3304,36 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
32713304
await expect(host.locator('css=.bd-error-message__text')).toBeVisible({ timeout: 5000 });
32723305
await host.locator('css=[data-action="skip"]').click();
32733306
await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 });
3307+
expect(payloads).toHaveLength(1);
3308+
expect((payloads[0].metadata as { elementSelector?: string }).elementSelector).toContain('h1');
32743309

32753310
await host.locator('css=.bd-close').click();
32763311
await host.locator('css=.bd-trigger').click();
32773312
await expect(host.locator('css=#title')).toBeVisible({ timeout: 5000 });
32783313
await expect(host.locator('css=#include-screenshot')).not.toBeChecked();
32793314
});
32803315

3316+
test('does not remember complex-page skip after element picker cancellation', async ({
3317+
page,
3318+
}) => {
3319+
const payloads = await trackFeedbackPayloads(page);
3320+
await page.goto('/test/complex-dom.html?nodes=12000');
3321+
3322+
const host = await navigateToScreenshotOptions(page);
3323+
await host.locator('css=[data-action="element"]').click();
3324+
await expect(page.locator('#bugdrop-element-picker-tooltip')).toBeVisible({ timeout: 5000 });
3325+
await page.keyboard.press('Escape');
3326+
3327+
await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 });
3328+
expect(payloads).toHaveLength(1);
3329+
expect(payloads[0].screenshot).toBeNull();
3330+
3331+
await host.locator('css=.bd-close').click();
3332+
await host.locator('css=.bd-trigger').click();
3333+
await expect(host.locator('css=#title')).toBeVisible({ timeout: 5000 });
3334+
await expect(host.locator('css=#include-screenshot')).toBeChecked();
3335+
});
3336+
32813337
test('shows all buttons on pages below 10k nodes', async ({ page }) => {
32823338
await mockHtmlToImage(page, spyToPng());
32833339
await page.goto('/test/complex-dom.html?nodes=4000');
@@ -4318,6 +4374,57 @@ test.describe('Screenshot Masking', () => {
43184374
expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
43194375
});
43204376

4377+
test('full-page capture masks transformed redaction targets', async ({ page }) => {
4378+
const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
4379+
page,
4380+
'/test/masking-layout-edge.html'
4381+
);
4382+
4383+
const rect = await docRectOf(page, '#transformed-mask');
4384+
const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio);
4385+
const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio);
4386+
4387+
expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
4388+
});
4389+
4390+
test('full-page capture masks sticky redaction targets after scrolling', async ({ page }) => {
4391+
await page.addInitScript(() => {
4392+
window.addEventListener('DOMContentLoaded', () => {
4393+
requestAnimationFrame(() => window.scrollTo(0, 420));
4394+
});
4395+
});
4396+
4397+
const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
4398+
page,
4399+
'/test/masking-layout-edge.html'
4400+
);
4401+
4402+
const rect = await docRectOf(page, '#sticky-mask');
4403+
const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio);
4404+
const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio);
4405+
4406+
expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
4407+
});
4408+
4409+
test('full-page capture masks fixed redaction targets after scrolling', async ({ page }) => {
4410+
await page.addInitScript(() => {
4411+
window.addEventListener('DOMContentLoaded', () => {
4412+
requestAnimationFrame(() => window.scrollTo(0, 420));
4413+
});
4414+
});
4415+
4416+
const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
4417+
page,
4418+
'/test/masking-layout-edge.html'
4419+
);
4420+
4421+
const rect = await docRectOf(page, '#fixed-mask');
4422+
const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio);
4423+
const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio);
4424+
4425+
expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
4426+
});
4427+
43214428
// Walk the element-picker flow and capture the chosen element.
43224429
//
43234430
// `selector` identifies the element to click in the picker. Because the picker
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>BugDrop - Masking Layout Edge Cases</title>
6+
<style>
7+
body {
8+
margin: 0;
9+
min-height: 1800px;
10+
padding: 24px;
11+
font-family: system-ui, sans-serif;
12+
background: #f8fafc;
13+
}
14+
15+
.spacer {
16+
height: 360px;
17+
}
18+
19+
.panel {
20+
width: 260px;
21+
min-height: 56px;
22+
margin: 24px 0;
23+
padding: 18px;
24+
border: 1px solid #94a3b8;
25+
border-radius: 4px;
26+
background: #fee2e2;
27+
color: #7f1d1d;
28+
font-weight: 700;
29+
}
30+
31+
#transformed-mask {
32+
transform: translate(48px, 24px) rotate(1deg);
33+
}
34+
35+
#sticky-mask {
36+
position: sticky;
37+
top: 16px;
38+
background: #fde68a;
39+
}
40+
41+
#fixed-mask {
42+
position: fixed;
43+
right: 32px;
44+
top: 96px;
45+
width: 220px;
46+
background: #bfdbfe;
47+
}
48+
</style>
49+
</head>
50+
<body>
51+
<h1>Masking Layout Edge Cases</h1>
52+
<p>Fixture used by Playwright for transformed and sticky redaction geometry.</p>
53+
54+
<div id="transformed-mask" class="panel" data-bugdrop-redact>
55+
transformed private account id
56+
</div>
57+
58+
<div class="spacer"></div>
59+
60+
<div id="sticky-mask" class="panel" data-bugdrop-redact>sticky private session id</div>
61+
62+
<div class="spacer"></div>
63+
64+
<div id="fixed-mask" class="panel" data-bugdrop-redact>fixed private token</div>
65+
66+
<script src="/widget.js" data-repo="test-org/test-repo" data-screenshot="optional"></script>
67+
</body>
68+
</html>

src/widget/annotation-flow.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { createAnnotator, type Tool } from './annotator';
2+
import { createModal, redactionNoteHtml } from './ui';
3+
4+
export function showAnnotationStep(
5+
root: HTMLElement,
6+
screenshot: string,
7+
redactionCount = 0,
8+
opts?: { redactionUnavailable?: boolean }
9+
): Promise<string | 'retake' | 'cancel'> {
10+
return new Promise(resolve => {
11+
let redactionNote = '';
12+
if (opts?.redactionUnavailable) {
13+
redactionNote = redactionNoteHtml(
14+
'This browser viewport capture could not apply automatic private-field masks. Review and cover any sensitive areas before sending.'
15+
);
16+
} else if (redactionCount > 0) {
17+
redactionNote = redactionNoteHtml(
18+
`${redactionCount} private ${redactionCount === 1 ? 'item was' : 'items were'} marked for redaction in this screenshot. Review before sending.`
19+
);
20+
}
21+
const modal = createModal(
22+
root,
23+
'Review Screenshot',
24+
`
25+
${redactionNote}
26+
<p style="margin: 0 0 12px; color: var(--bd-text-secondary); font-size: 13px;">
27+
Check that no sensitive information is visible before sending. Cover sensitive areas before submitting. Redactions are baked into the uploaded image.
28+
</p>
29+
<div class="bd-tools">
30+
<button class="bd-tool active" data-tool="draw">✏️ Draw</button>
31+
<button class="bd-tool" data-tool="arrow">➡️ Arrow</button>
32+
<button class="bd-tool" data-tool="rect">▢ Rectangle</button>
33+
<button class="bd-tool" data-tool="redact">Redact</button>
34+
<button class="bd-tool" data-action="undo">↶ Undo</button>
35+
</div>
36+
<div id="annotation-canvas" class="bd-annotation-stage"></div>
37+
<div class="bd-actions">
38+
<button class="bd-btn bd-btn-secondary" data-action="retake">Retake</button>
39+
<button class="bd-btn bd-btn-primary" data-action="done">Submit Feedback</button>
40+
</div>
41+
`,
42+
false,
43+
'bd-modal--annotator'
44+
);
45+
46+
const canvasContainer = modal.querySelector('#annotation-canvas') as HTMLElement;
47+
const annotator = createAnnotator(canvasContainer, screenshot);
48+
49+
const toolButtons = modal.querySelectorAll('[data-tool]');
50+
toolButtons.forEach(btn => {
51+
btn.addEventListener('click', e => {
52+
const target = e.currentTarget as HTMLElement;
53+
const tool = target.dataset.tool;
54+
55+
if (tool) {
56+
toolButtons.forEach(b => b.classList.remove('active'));
57+
target.classList.add('active');
58+
annotator.setTool(tool as Tool);
59+
}
60+
});
61+
});
62+
63+
const undoBtn = modal.querySelector('[data-action="undo"]') as HTMLElement | null;
64+
undoBtn?.addEventListener('click', () => annotator.undo());
65+
66+
const closeBtn = modal.querySelector('.bd-close') as HTMLElement;
67+
const retakeBtn = modal.querySelector('[data-action="retake"]') as HTMLElement;
68+
const doneBtn = modal.querySelector('[data-action="done"]') as HTMLElement;
69+
70+
closeBtn?.addEventListener('click', () => {
71+
annotator.destroy();
72+
modal.remove();
73+
resolve('cancel');
74+
});
75+
76+
retakeBtn?.addEventListener('click', () => {
77+
annotator.destroy();
78+
modal.remove();
79+
resolve('retake');
80+
});
81+
82+
doneBtn?.addEventListener('click', () => {
83+
const annotated = annotator.getImageData();
84+
annotator.destroy();
85+
modal.remove();
86+
resolve(annotated);
87+
});
88+
});
89+
}

0 commit comments

Comments
 (0)