diff --git a/CHANGELOG.md b/CHANGELOG.md index 865943b4bf..c5a2d6fa00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Add [SensitiveContent](https://main-api.flutter.dev/flutter/widgets/SensitiveContent-class.html) widget to default masking ([#2989](https://github.com/getsentry/sentry-dart/pull/2989)) - Tag all spans with thread info ([#3101](https://github.com/getsentry/sentry-dart/pull/3101)) ### Enhancements diff --git a/packages/flutter/lib/src/sentry_privacy_options.dart b/packages/flutter/lib/src/sentry_privacy_options.dart index cbef72d951..70f1c8e10c 100644 --- a/packages/flutter/lib/src/sentry_privacy_options.dart +++ b/packages/flutter/lib/src/sentry_privacy_options.dart @@ -5,6 +5,9 @@ import 'package:meta/meta.dart'; import '../sentry_flutter.dart'; import 'screenshot/masking_config.dart'; import 'screenshot/widget_filter.dart'; +// ignore: implementation_imports +import 'package:sentry/src/event_processor/enricher/flutter_runtime.dart' + as flutter_runtime; /// Configuration of the experimental privacy feature. class SentryPrivacyOptions { @@ -75,6 +78,11 @@ class SentryPrivacyOptions { )); } + const flutterVersion = flutter_runtime.FlutterVersion.version; + if (flutterVersion != null) { + _maybeAddSensitiveContentRule(rules, flutterVersion); + } + // In Debug mode, check if users explicitly mask (or unmask) widgets that // look like they should be masked, e.g. Videos, WebViews, etc. if (runtimeChecker.isDebugMode()) { @@ -159,6 +167,56 @@ class SentryPrivacyOptions { } } +/// Returns `true` if a SensitiveContent masking rule _should_ be added for a +/// given [flutterVersion] string. The SensitiveContent widget was introduced +/// in Flutter 3.33, therefore we only add the masking rule when the detected +/// version is >= 3.33. +bool _shouldAddSensitiveContentRule(String version) { + final dot = version.indexOf('.'); + if (dot == -1) return false; + + final major = int.tryParse(version.substring(0, dot)); + final nextDot = version.indexOf('.', dot + 1); + final minor = int.tryParse( + version.substring(dot + 1, nextDot == -1 ? version.length : nextDot)); + + return major != null && + minor != null && + (major > 3 || (major == 3 && minor >= 33)); +} + +/// Adds a masking rule for the [SensitiveContent] widget. +/// +/// The rule masks any widget that exposes a `sensitivity` property which is an +/// [Enum]. This is how the [SensitiveContent] widget can be detected +/// without depending on its type directly (which would fail to compile on +/// older Flutter versions). +void _maybeAddSensitiveContentRule( + List rules, String flutterVersion) { + if (!_shouldAddSensitiveContentRule(flutterVersion)) { + return; + } + + SentryMaskingDecision maskSensitiveContent(Element element, Widget widget) { + try { + final dynamic dynWidget = widget; + final sensitivity = dynWidget.sensitivity; + // If the property exists, we assume this is the SensitiveContent widget. + assert(sensitivity is Enum); + return SentryMaskingDecision.mask; + } catch (_) { + // Property not found – continue processing other rules. + return SentryMaskingDecision.continueProcessing; + } + } + + rules.add(SentryMaskingCustomRule( + callback: maskSensitiveContent, + name: 'SensitiveContent', + description: 'Mask SensitiveContent widget.', + )); +} + SentryMaskingDecision _maskImagesExceptAssets(Element element, Image widget) { final image = widget.image; if (image is AssetBundleImageProvider) { diff --git a/packages/flutter/test/screenshot/masking_config_test.dart b/packages/flutter/test/screenshot/masking_config_test.dart index 992ef67cc3..b298227cf1 100644 --- a/packages/flutter/test/screenshot/masking_config_test.dart +++ b/packages/flutter/test/screenshot/masking_config_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -125,7 +126,7 @@ void main() async { SentryMaskingDecision.unmask); }); - testWidgets('retuns false if no rule matches', (tester) async { + testWidgets('returns false if no rule matches', (tester) async { final sut = SentryMaskingConfig([ SentryMaskingCustomRule( callback: (e, w) => SentryMaskingDecision.continueProcessing, @@ -169,6 +170,7 @@ void main() async { 'SentryMaskingConstantRule(mask)', 'SentryMaskingConstantRule(mask)', 'SentryMaskingConstantRule(mask)', + ..._maybeWithSensitiveContent(), 'SentryMaskingCustomRule(Debug-mode-only warning for potentially sensitive widgets.)' ]); }); @@ -181,6 +183,7 @@ void main() async { expect(rulesAsStrings(sut), [ ...alwaysEnabledRules, 'SentryMaskingConstantRule(mask)', + ..._maybeWithSensitiveContent(), 'SentryMaskingCustomRule(Debug-mode-only warning for potentially sensitive widgets.)' ]); }); @@ -193,6 +196,7 @@ void main() async { expect(rulesAsStrings(sut), [ ...alwaysEnabledRules, 'SentryMaskingCustomRule(Mask all images except asset images.)', + ..._maybeWithSensitiveContent(), 'SentryMaskingCustomRule(Debug-mode-only warning for potentially sensitive widgets.)' ]); }); @@ -207,6 +211,7 @@ void main() async { 'SentryMaskingConstantRule(mask)', 'SentryMaskingConstantRule(mask)', 'SentryMaskingConstantRule(mask)', + ..._maybeWithSensitiveContent(), 'SentryMaskingCustomRule(Debug-mode-only warning for potentially sensitive widgets.)' ]); }); @@ -218,10 +223,37 @@ void main() async { ..maskAssetImages = false; expect(rulesAsStrings(sut), [ ...alwaysEnabledRules, + ..._maybeWithSensitiveContent(), 'SentryMaskingCustomRule(Debug-mode-only warning for potentially sensitive widgets.)' ]); }); + test( + 'SensitiveContent rule is automatically added when current Flutter version is equal or newer than 3.33', + () { + final sut = SentryPrivacyOptions(); + final version = FlutterVersion.version!; + final dot = version.indexOf('.'); + final major = int.tryParse(version.substring(0, dot)); + final nextDot = version.indexOf('.', dot + 1); + final minor = int.tryParse( + version.substring(dot + 1, nextDot == -1 ? version.length : nextDot)); + + if (major! > 3 || (major == 3 && minor! >= 33)) { + expect( + rulesAsStrings(sut).contains( + 'SentryMaskingCustomRule(Mask SensitiveContent widget.)'), + isTrue, + reason: 'Test failed with version: ${FlutterVersion.version}'); + } else { + expect( + rulesAsStrings(sut).contains( + 'SentryMaskingCustomRule(Mask SensitiveContent widget.)'), + isFalse, + reason: 'Test failed with version: ${FlutterVersion.version}'); + } + }, skip: FlutterVersion.version == null); + group('user rules', () { final defaultRules = [ ...alwaysEnabledRules, @@ -229,20 +261,28 @@ void main() async { 'SentryMaskingConstantRule(mask)', 'SentryMaskingConstantRule(mask)', 'SentryMaskingConstantRule(mask)', + ..._maybeWithSensitiveContent(), 'SentryMaskingCustomRule(Debug-mode-only warning for potentially sensitive widgets.)' ]; + test('mask() takes precedence', () { final sut = SentryPrivacyOptions(); sut.mask(); - expect(rulesAsStrings(sut), - ['SentryMaskingConstantRule(mask)', ...defaultRules]); + expect(rulesAsStrings(sut), [ + 'SentryMaskingConstantRule(mask)', + ...defaultRules, + ]); }); + test('unmask() takes precedence', () { final sut = SentryPrivacyOptions(); sut.unmask(); - expect(rulesAsStrings(sut), - ['SentryMaskingConstantRule(unmask)', ...defaultRules]); + expect(rulesAsStrings(sut), [ + 'SentryMaskingConstantRule(unmask)', + ...defaultRules, + ]); }); + test('are ordered in the call order', () { var sut = SentryPrivacyOptions(); sut.mask(); @@ -272,13 +312,14 @@ void main() async { ...defaultRules ]); }); + test('maskCallback() takes precedence', () { final sut = SentryPrivacyOptions(); sut.maskCallback( (Element element, Image widget) => SentryMaskingDecision.mask); expect(rulesAsStrings(sut), [ 'SentryMaskingCustomRule(Custom callback-based rule (description unspecified))', - ...defaultRules + ...defaultRules, ]); }); test('User cannot add $SentryMask and $SentryUnmask rules', () { @@ -300,6 +341,25 @@ void main() async { }); } +List _maybeWithSensitiveContent() { + final version = FlutterVersion.version; + if (version == null) { + return []; + } + final dot = version.indexOf('.'); + final major = int.tryParse(version.substring(0, dot)); + final nextDot = version.indexOf('.', dot + 1); + final minor = int.tryParse( + version.substring(dot + 1, nextDot == -1 ? version.length : nextDot)); + if (major! > 3 || (major == 3 && minor! >= 33)) { + return [ + 'SentryMaskingCustomRule(Mask SensitiveContent widget.)' + ]; + } else { + return []; + } +} + extension on Element { Element findFirstOfType() { late Element result;