diff --git a/flutter/lib/src/sentry_replay_options.dart b/flutter/lib/src/sentry_replay_options.dart index a6e83fec4..442dd4967 100644 --- a/flutter/lib/src/sentry_replay_options.dart +++ b/flutter/lib/src/sentry_replay_options.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; @@ -85,6 +86,30 @@ class SentryReplayOptions { rules.add(const SentryMaskingConstantRule( SentryMaskingDecision.mask)); } + + // In Debug mode, check if users explicitly masks (or unmasks) widgets that + // look like they should be masked, e.g. Videos, WebViews, etc. + if (kDebugMode) { + rules.add( + SentryMaskingCustomRule((Element element, Widget widget) { + final type = widget.runtimeType.toString(); + final regexp = 'video|webview|password|pinput|camera|chart'; + if (RegExp(regexp, caseSensitive: false).hasMatch(type)) { + final optionsName = 'options.experimental.replay'; + throw Exception( + 'Widget "$widget" name matches widgets that should usually be ' + 'masked because they may contain sensitive data. Because this ' + 'widget comes from a third-party plugin or your code, Sentry ' + 'cannot reliably mask it in release builds (due to obfuscation).' + 'Please mask it explicitly using $optionsName.mask<$type>(). ' + 'If you want to silence this exception and keep the widget ' + 'visible in captures, you can use $optionsName.unmask<$type>(). ' + 'Note: the RegExp matched is: $regexp (case insensitive).'); + } + return SentryMaskingDecision.continueProcessing; + })); + } + return SentryMaskingConfig(rules); } diff --git a/flutter/test/replay/masking_config_test.dart b/flutter/test/replay/masking_config_test.dart index 46e6a9926..fe79a74a7 100644 --- a/flutter/test/replay/masking_config_test.dart +++ b/flutter/test/replay/masking_config_test.dart @@ -120,10 +120,8 @@ void main() async { .map((rule) => rule.toString()) // These normalize the string on VM & js & wasm: .map((str) => str.replaceAll( - RegExp( - r"SentryMaskingDecision from:? [fF]unction '?_maskImagesExceptAssets[@(].*", - dotAll: true), - 'SentryMaskingDecision)')) + RegExp(r"=> SentryMaskingDecision from:? .*", dotAll: true), + '=> SentryMaskingDecision)')) .map((str) => str.replaceAll( ' from: (element, widget) => masking_config.SentryMaskingDecision.mask', '')) @@ -136,7 +134,8 @@ void main() async { ...alwaysEnabledRules, '$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)', '$SentryMaskingConstantRule<$Text>(mask)', - '$SentryMaskingConstantRule<$EditableText>(mask)' + '$SentryMaskingConstantRule<$EditableText>(mask)', + '$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)' ]); }); @@ -148,6 +147,7 @@ void main() async { expect(rulesAsStrings(sut), [ ...alwaysEnabledRules, '$SentryMaskingConstantRule<$Image>(mask)', + '$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)' ]); }); @@ -159,6 +159,7 @@ void main() async { expect(rulesAsStrings(sut), [ ...alwaysEnabledRules, '$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)', + '$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)' ]); }); @@ -171,6 +172,7 @@ void main() async { ...alwaysEnabledRules, '$SentryMaskingConstantRule<$Text>(mask)', '$SentryMaskingConstantRule<$EditableText>(mask)', + '$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)' ]); }); @@ -179,7 +181,10 @@ void main() async { ..maskAllText = false ..maskAllImages = false ..maskAssetImages = false; - expect(rulesAsStrings(sut), alwaysEnabledRules); + expect(rulesAsStrings(sut), [ + ...alwaysEnabledRules, + '$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)' + ]); }); group('user rules', () { @@ -187,7 +192,8 @@ void main() async { ...alwaysEnabledRules, '$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)', '$SentryMaskingConstantRule<$Text>(mask)', - '$SentryMaskingConstantRule<$EditableText>(mask)' + '$SentryMaskingConstantRule<$EditableText>(mask)', + '$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)' ]; test('mask() takes precedence', () { final sut = SentryReplayOptions(); diff --git a/flutter/test/replay/test_widget.dart b/flutter/test/replay/test_widget.dart index 2e8d257a2..7011dc610 100644 --- a/flutter/test/replay/test_widget.dart +++ b/flutter/test/replay/test_widget.dart @@ -35,6 +35,7 @@ Future pumpTestElement(WidgetTester tester, Offstage(offstage: true, child: newImage()), Text(dummyText), Material(child: TextFormField()), + Material(child: TextField()), SizedBox( width: 100, height: 20, diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart index ad76e9bfa..12fc20c3a 100644 --- a/flutter/test/replay/widget_filter_test.dart +++ b/flutter/test/replay/widget_filter_test.dart @@ -31,7 +31,7 @@ void main() async { final sut = createSut(redactText: true); final element = await pumpTestElement(tester); sut.obscure(element, 1.0, defaultBounds); - expect(sut.items.length, 5); + expect(sut.items.length, 6); }); testWidgets('does not redact text when disabled', (tester) async { @@ -53,12 +53,13 @@ void main() async { final sut = createSut(redactText: true); final element = await pumpTestElement(tester); sut.obscure(element, 1.0, defaultBounds); - expect(sut.items.length, 5); + expect(sut.items.length, 6); expect(boundsRect(sut.items[0]), '624x48'); expect(boundsRect(sut.items[1]), '169x20'); expect(boundsRect(sut.items[2]), '800x192'); expect(boundsRect(sut.items[3]), '800x24'); - expect(boundsRect(sut.items[4]), '50x20'); + expect(boundsRect(sut.items[4]), '800x24'); + expect(boundsRect(sut.items[5]), '50x20'); }); });