Skip to content

Commit e239c83

Browse files
vaindbuenaflor
andauthored
feat: custom replay masking rules (#2324)
* refactor: change redaction logic to custom filters * refactor widget filter to handle errors gracefully * cleanup * add an option to disable masking asset images * add new masking config class * update widget filter to use the masking config * cleanup * masking tests * cleanup * test masking editable text * fix tests on web * fix tests on web * fix tests for wasm * add SentryMask and SentryUnmask widgets * linter issue * chore: changelog * rename to SentryMaskingDecision * mark new replay APIs as experimental * Update flutter/lib/src/replay/masking_config.dart Co-authored-by: Giancarlo Buenaflor <[email protected]> * chore: update changelog * test: mask parent if masking the child fails --------- Co-authored-by: Giancarlo Buenaflor <[email protected]>
1 parent f333300 commit e239c83

File tree

11 files changed

+712
-92
lines changed

11 files changed

+712
-92
lines changed

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@
77
- Emit `transaction.data` inside `contexts.trace.data` ([#2284](https://github.com/getsentry/sentry-dart/pull/2284))
88
- Blocking app starts if "appLaunchedInForeground" is false. (Android only) ([#2291](https://github.com/getsentry/sentry-dart/pull/2291))
99
- Windows native error & obfuscation support ([#2286](https://github.com/getsentry/sentry-dart/pull/2286))
10+
- Replay: user-configurable masking (redaction) for widget classes and specific widget instances. ([#2324](https://github.com/getsentry/sentry-dart/pull/2324))
11+
Some examples of the configuration:
12+
13+
```dart
14+
await SentryFlutter.init(
15+
(options) {
16+
...
17+
options.experimental.replay.mask<IconButton>();
18+
options.experimental.replay.unmask<Image>();
19+
options.experimental.replay.maskCallback<Text>(
20+
(Element element, Text widget) =>
21+
(widget.data?.contains('secret') ?? false)
22+
? SentryMaskingDecision.mask
23+
: SentryMaskingDecision.continueProcessing);
24+
},
25+
appRunner: () => runApp(MyApp()),
26+
);
27+
```
28+
29+
Also, you can wrap any of your widgets with `SentryMask()` or `SentryUnmask()` widgets to mask/unmask them, respectively. For example:
30+
31+
```dart
32+
 SentryUnmask(Text('Not secret at all'));
33+
```
34+
1035
- Support `captureFeedback` ([#2230](https://github.com/getsentry/sentry-dart/pull/2230))
1136
- Deprecated `Sentry.captureUserFeedback`, use `captureFeedback` instead.
1237
- Deprecated `Hub.captureUserFeedback`, use `captureFeedback` instead.

flutter/lib/sentry_flutter.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export 'src/sentry_replay_options.dart';
1212
export 'src/flutter_sentry_attachment.dart';
1313
export 'src/sentry_asset_bundle.dart' show SentryAssetBundle;
1414
export 'src/integrations/on_error_integration.dart';
15+
export 'src/replay/masking_config.dart' show SentryMaskingDecision;
16+
export 'src/screenshot/sentry_mask_widget.dart';
17+
export 'src/screenshot/sentry_unmask_widget.dart';
1518
export 'src/screenshot/sentry_screenshot_widget.dart';
1619
export 'src/screenshot/sentry_screenshot_quality.dart';
1720
export 'src/user_interaction/sentry_user_interaction_widget.dart';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import 'package:flutter/widgets.dart';
2+
import 'package:meta/meta.dart';
3+
4+
@internal
5+
class SentryMaskingConfig {
6+
@visibleForTesting
7+
final List<SentryMaskingRule> rules;
8+
9+
final int length;
10+
11+
SentryMaskingConfig(List<SentryMaskingRule> rules)
12+
// Note: fixed-size list has performance benefits over growable list.
13+
: rules = List.of(rules, growable: false),
14+
length = rules.length;
15+
16+
SentryMaskingDecision shouldMask<T extends Widget>(
17+
Element element, T widget) {
18+
for (int i = 0; i < length; i++) {
19+
if (rules[i].appliesTo(widget)) {
20+
// We use a switch here to get lints if more values are added.
21+
switch (rules[i].shouldMask(element, widget)) {
22+
case SentryMaskingDecision.mask:
23+
return SentryMaskingDecision.mask;
24+
case SentryMaskingDecision.unmask:
25+
return SentryMaskingDecision.unmask;
26+
case SentryMaskingDecision.continueProcessing:
27+
// Continue to the next matching rule.
28+
}
29+
}
30+
}
31+
return SentryMaskingDecision.continueProcessing;
32+
}
33+
}
34+
35+
@experimental
36+
enum SentryMaskingDecision {
37+
/// Mask the widget and its children
38+
mask,
39+
40+
/// Leave the widget visible, including its children (no more rules will
41+
/// be checked for children).
42+
unmask,
43+
44+
/// Don't make a decision - continue checking other rules and children.
45+
continueProcessing
46+
}
47+
48+
@internal
49+
abstract class SentryMaskingRule<T extends Widget> {
50+
@pragma('vm:prefer-inline')
51+
bool appliesTo(Widget widget) => widget is T;
52+
SentryMaskingDecision shouldMask(Element element, T widget);
53+
54+
const SentryMaskingRule();
55+
}
56+
57+
@internal
58+
class SentryMaskingCustomRule<T extends Widget> extends SentryMaskingRule<T> {
59+
final SentryMaskingDecision Function(Element element, T widget) callback;
60+
61+
const SentryMaskingCustomRule(this.callback);
62+
63+
@override
64+
SentryMaskingDecision shouldMask(Element element, T widget) =>
65+
callback(element, widget);
66+
67+
@override
68+
String toString() => '$SentryMaskingCustomRule<$T>($callback)';
69+
}
70+
71+
@internal
72+
class SentryMaskingConstantRule<T extends Widget> extends SentryMaskingRule<T> {
73+
final SentryMaskingDecision _value;
74+
const SentryMaskingConstantRule(this._value);
75+
76+
@override
77+
SentryMaskingDecision shouldMask(Element element, T widget) {
78+
// This rule only makes sense with true/false. Continue won't do anything.
79+
assert(_value == SentryMaskingDecision.mask ||
80+
_value == SentryMaskingDecision.unmask);
81+
return _value;
82+
}
83+
84+
@override
85+
String toString() =>
86+
'$SentryMaskingConstantRule<$T>(${_value == SentryMaskingDecision.mask ? 'mask' : 'unmask'})';
87+
}

flutter/lib/src/replay/recorder.dart

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,9 @@ class ScreenshotRecorder {
2121
bool warningLogged = false;
2222

2323
ScreenshotRecorder(this.config, this.options) {
24-
final replayOptions = options.experimental.replay;
25-
if (replayOptions.redactAllText || replayOptions.redactAllImages) {
26-
_widgetFilter = WidgetFilter(
27-
redactText: replayOptions.redactAllText,
28-
redactImages: replayOptions.redactAllImages,
29-
logger: options.logger);
24+
final maskingConfig = options.experimental.replay.buildMaskingConfig();
25+
if (maskingConfig.length > 0) {
26+
_widgetFilter = WidgetFilter(maskingConfig, options.logger);
3027
}
3128
}
3229

Lines changed: 96 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,39 @@
11
import 'package:flutter/rendering.dart';
2-
import 'package:flutter/services.dart';
32
import 'package:flutter/widgets.dart';
43
import 'package:meta/meta.dart';
54

65
import '../../sentry_flutter.dart';
76
import '../sentry_asset_bundle.dart';
7+
import 'masking_config.dart';
88

99
@internal
1010
class WidgetFilter {
1111
final items = <WidgetFilterItem>[];
1212
final SentryLogger logger;
13-
final bool redactText;
14-
final bool redactImages;
13+
final SentryMaskingConfig config;
1514
static const _defaultColor = Color.fromARGB(255, 0, 0, 0);
1615
late double _pixelRatio;
1716
late Rect _bounds;
1817
final _warnedWidgets = <int>{};
19-
final AssetBundle _rootAssetBundle;
2018

21-
WidgetFilter(
22-
{required this.redactText,
23-
required this.redactImages,
24-
required this.logger,
25-
@visibleForTesting AssetBundle? rootAssetBundle})
26-
: _rootAssetBundle = rootAssetBundle ?? rootBundle;
19+
/// Used to test _obscureElementOrParent
20+
@visibleForTesting
21+
bool throwInObscure = false;
22+
23+
WidgetFilter(this.config, this.logger);
2724

2825
void obscure(BuildContext context, double pixelRatio, Rect bounds) {
2926
_pixelRatio = pixelRatio;
3027
_bounds = bounds;
3128
items.clear();
3229
if (context is Element) {
33-
_obscure(context);
30+
_process(context);
3431
} else {
35-
context.visitChildElements(_obscure);
32+
context.visitChildElements(_process);
3633
}
3734
}
3835

39-
void _obscure(Element element) {
36+
void _process(Element element) {
4037
final widget = element.widget;
4138

4239
if (!_isVisible(widget)) {
@@ -47,47 +44,64 @@ class WidgetFilter {
4744
return;
4845
}
4946

50-
final obscured = _obscureIfNeeded(element, widget);
51-
if (!obscured) {
52-
element.visitChildElements(_obscure);
47+
final decision = config.shouldMask(element, widget);
48+
switch (decision) {
49+
case SentryMaskingDecision.mask:
50+
final item = _obscureElementOrParent(element, widget);
51+
if (item != null) {
52+
items.add(item);
53+
}
54+
break;
55+
case SentryMaskingDecision.unmask:
56+
logger(SentryLevel.debug, "WidgetFilter unmasked: $widget");
57+
break;
58+
case SentryMaskingDecision.continueProcessing:
59+
// If this element should not be obscured, visit and check its children.
60+
element.visitChildElements(_process);
61+
break;
5362
}
5463
}
5564

65+
/// Determine the color and bounding box of the widget.
66+
/// If the widget is offscreen, returns null.
67+
/// If the widget cannot be obscured, obscures the parent.
5668
@pragma('vm:prefer-inline')
57-
bool _obscureIfNeeded(Element element, Widget widget) {
58-
Color? color;
59-
60-
if (redactText && widget is Text) {
61-
color = widget.style?.color;
62-
} else if (redactText && widget is EditableText) {
63-
color = widget.style.color;
64-
} else if (redactImages && widget is Image) {
65-
if (widget.image is AssetBundleImageProvider) {
66-
final image = widget.image as AssetBundleImageProvider;
67-
if (isBuiltInAssetImage(image)) {
68-
logger(SentryLevel.debug,
69-
"WidgetFilter skipping asset: $widget ($image).");
70-
return false;
69+
WidgetFilterItem? _obscureElementOrParent(Element element, Widget widget) {
70+
while (true) {
71+
try {
72+
return _obscure(element, widget);
73+
} catch (e, stackTrace) {
74+
final parent = element.parent;
75+
if (!_warnedWidgets.contains(widget.hashCode)) {
76+
_warnedWidgets.add(widget.hashCode);
77+
logger(
78+
SentryLevel.warning,
79+
'WidgetFilter cannot mask widget $widget: $e.'
80+
'Obscuring the parent instead: ${parent?.widget}.',
81+
stackTrace: stackTrace);
7182
}
83+
if (parent == null) {
84+
return WidgetFilterItem(_defaultColor, _bounds);
85+
}
86+
element = parent;
87+
widget = element.widget;
7288
}
73-
color = widget.color;
74-
} else {
75-
// No other type is currently obscured.
76-
return false;
77-
}
78-
79-
final renderObject = element.renderObject;
80-
if (renderObject is! RenderBox) {
81-
_cantObscure(widget, "its renderObject is not a RenderBox");
82-
return false;
8389
}
90+
}
8491

85-
var rect = _boundingBox(renderObject);
92+
/// Determine the color and bounding box of the widget.
93+
/// If the widget is offscreen, returns null.
94+
/// This function may throw in which case the caller is responsible for
95+
/// calling it again on the parent element.
96+
@pragma('vm:prefer-inline')
97+
WidgetFilterItem? _obscure(Element element, Widget widget) {
98+
final RenderBox renderBox = element.renderObject as RenderBox;
99+
var rect = _boundingBox(renderBox);
86100

87101
// If it's a clipped render object, use parent's offset and size.
88102
// This helps with text fields which often have oversized render objects.
89-
if (renderObject.parent is RenderStack) {
90-
final renderStack = (renderObject.parent as RenderStack);
103+
if (renderBox.parent is RenderStack) {
104+
final renderStack = (renderBox.parent as RenderStack);
91105
final clipBehavior = renderStack.clipBehavior;
92106
if (clipBehavior == Clip.hardEdge ||
93107
clipBehavior == Clip.antiAlias ||
@@ -102,19 +116,37 @@ class WidgetFilter {
102116
logger(SentryLevel.debug, "WidgetFilter skipping offscreen: $widget");
103117
return true;
104118
}());
105-
return false;
119+
return null;
106120
}
107121

108-
items.add(WidgetFilterItem(color ?? _defaultColor, rect));
109122
assert(() {
110-
logger(SentryLevel.debug, "WidgetFilter obscuring: $widget");
123+
logger(SentryLevel.debug, "WidgetFilter masking: $widget");
111124
return true;
112125
}());
113126

114-
return true;
127+
Color? color;
128+
if (widget is Text) {
129+
color = (widget).style?.color;
130+
} else if (widget is EditableText) {
131+
color = (widget).style.color;
132+
} else if (widget is Image) {
133+
color = (widget).color;
134+
}
135+
136+
// test-only code
137+
assert(() {
138+
if (throwInObscure) {
139+
throwInObscure = false;
140+
return false;
141+
}
142+
return true;
143+
}());
144+
145+
return WidgetFilterItem(color ?? _defaultColor, rect);
115146
}
116147

117148
// We cut off some widgets early because they're not visible at all.
149+
@pragma('vm:prefer-inline')
118150
bool _isVisible(Widget widget) {
119151
if (widget is Visibility) {
120152
return widget.visible;
@@ -128,9 +160,10 @@ class WidgetFilter {
128160
return true;
129161
}
130162

131-
@visibleForTesting
163+
@internal
132164
@pragma('vm:prefer-inline')
133-
bool isBuiltInAssetImage(AssetBundleImageProvider image) {
165+
static bool isBuiltInAssetImage(
166+
AssetBundleImageProvider image, AssetBundle rootAssetBundle) {
134167
late final AssetBundle? bundle;
135168
if (image is AssetImage) {
136169
bundle = image.bundle;
@@ -140,17 +173,8 @@ class WidgetFilter {
140173
return false;
141174
}
142175
return (bundle == null ||
143-
bundle == _rootAssetBundle ||
144-
(bundle is SentryAssetBundle && bundle.bundle == _rootAssetBundle));
145-
}
146-
147-
@pragma('vm:prefer-inline')
148-
void _cantObscure(Widget widget, String message) {
149-
if (!_warnedWidgets.contains(widget.hashCode)) {
150-
_warnedWidgets.add(widget.hashCode);
151-
logger(SentryLevel.warning,
152-
"WidgetFilter cannot obscure widget $widget: $message");
153-
}
176+
bundle == rootAssetBundle ||
177+
(bundle is SentryAssetBundle && bundle.bundle == rootAssetBundle));
154178
}
155179

156180
@pragma('vm:prefer-inline')
@@ -165,9 +189,21 @@ class WidgetFilter {
165189
}
166190
}
167191

192+
@internal
168193
class WidgetFilterItem {
169194
final Color color;
170195
final Rect bounds;
171196

172197
const WidgetFilterItem(this.color, this.bounds);
173198
}
199+
200+
extension on Element {
201+
Element? get parent {
202+
Element? result;
203+
visitAncestorElements((el) {
204+
result = el;
205+
return false;
206+
});
207+
return result;
208+
}
209+
}

0 commit comments

Comments
 (0)