diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e4870171..6061a04ea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### 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)) + ## 9.9.2 ### Fixes diff --git a/packages/dart/lib/src/event_processor/enricher/flutter_runtime.dart b/packages/dart/lib/src/event_processor/enricher/flutter_runtime.dart index 9c7a905194..809ac221c5 100644 --- a/packages/dart/lib/src/event_processor/enricher/flutter_runtime.dart +++ b/packages/dart/lib/src/event_processor/enricher/flutter_runtime.dart @@ -73,4 +73,60 @@ abstract class FlutterVersion { static const String? dartVersion = bool.hasEnvironment('FLUTTER_DART_VERSION') ? String.fromEnvironment('FLUTTER_DART_VERSION') : null; + + /// Parses [version] into components for comparison. + /// + /// Returns `null` if the version string is malformed. + /// + /// Examples: + /// - "3.33.0" -> FlutterVersionComponents(3, 33) + /// - "4.0.0-pre.1" -> FlutterVersionComponents(4, 0) + /// - "3.24" -> FlutterVersionComponents(3, 24) + /// - "invalid" -> null + static FlutterVersionComponents? parseComponents(String version) { + final dot = version.indexOf('.'); + if (dot == -1) return null; + + final major = int.tryParse(version.substring(0, dot)); + if (major == null) return null; + + final nextDot = version.indexOf('.', dot + 1); + final minorEnd = nextDot == -1 ? version.length : nextDot; + final minor = int.tryParse(version.substring(dot + 1, minorEnd)); + if (minor == null) return null; + + return FlutterVersionComponents(major, minor); + } +} + +/// Parsed Flutter version components for comparison. +class FlutterVersionComponents { + final int major; + final int minor; + + const FlutterVersionComponents(this.major, this.minor); + + /// Returns `true` if this version meets or exceeds the minimum requirement. + /// + /// Example: + /// ```dart + /// final version = FlutterVersion.parseComponents('3.33.0'); + /// if (version?.meetsMinimum(3, 33) ?? false) { + /// // Flutter 3.33+ feature available + /// } + /// ``` + bool meetsMinimum(int minMajor, int minMinor) => + major > minMajor || (major == minMajor && minor >= minMinor); + + @override + bool operator ==(Object other) => + other is FlutterVersionComponents && + other.major == major && + other.minor == minor; + + @override + int get hashCode => Object.hash(major, minor); + + @override + String toString() => 'FlutterVersionComponents($major, $minor)'; } diff --git a/packages/dart/test/event_processor/enricher/flutter_runtime_test.dart b/packages/dart/test/event_processor/enricher/flutter_runtime_test.dart new file mode 100644 index 0000000000..44cc0cd6b6 --- /dev/null +++ b/packages/dart/test/event_processor/enricher/flutter_runtime_test.dart @@ -0,0 +1,167 @@ +import 'package:sentry/src/event_processor/enricher/flutter_runtime.dart'; +import 'package:test/test.dart'; + +void main() { + group('FlutterVersionComponents', () { + test('stores major and minor correctly', () { + const components = FlutterVersionComponents(3, 33); + expect(components.major, 3); + expect(components.minor, 33); + }); + + test('equality works correctly', () { + const a = FlutterVersionComponents(3, 33); + const b = FlutterVersionComponents(3, 33); + const c = FlutterVersionComponents(3, 32); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('hashCode is consistent with equality', () { + const a = FlutterVersionComponents(3, 33); + const b = FlutterVersionComponents(3, 33); + + expect(a.hashCode, equals(b.hashCode)); + }); + + test('toString returns readable format', () { + const components = FlutterVersionComponents(3, 33); + expect(components.toString(), 'FlutterVersionComponents(3, 33)'); + }); + + group('meetsMinimum', () { + test('returns false when major is less than threshold', () { + expect( + const FlutterVersionComponents(2, 99).meetsMinimum(3, 0), isFalse); + expect( + const FlutterVersionComponents(2, 0).meetsMinimum(3, 33), isFalse); + }); + + test('returns false when major equals but minor is less than threshold', + () { + expect( + const FlutterVersionComponents(3, 32).meetsMinimum(3, 33), isFalse); + expect( + const FlutterVersionComponents(3, 0).meetsMinimum(3, 33), isFalse); + }); + + test('returns true when major equals and minor equals threshold', () { + expect( + const FlutterVersionComponents(3, 33).meetsMinimum(3, 33), isTrue); + expect(const FlutterVersionComponents(4, 0).meetsMinimum(4, 0), isTrue); + }); + + test('returns true when major equals and minor exceeds threshold', () { + expect( + const FlutterVersionComponents(3, 34).meetsMinimum(3, 33), isTrue); + expect( + const FlutterVersionComponents(3, 99).meetsMinimum(3, 33), isTrue); + }); + + test('returns true when major exceeds threshold', () { + expect( + const FlutterVersionComponents(4, 0).meetsMinimum(3, 33), isTrue); + expect( + const FlutterVersionComponents(5, 0).meetsMinimum(3, 99), isTrue); + expect( + const FlutterVersionComponents(10, 0).meetsMinimum(3, 33), isTrue); + }); + + test('works with various threshold values', () { + // Testing different thresholds + expect(const FlutterVersionComponents(2, 5).meetsMinimum(2, 5), isTrue); + expect( + const FlutterVersionComponents(2, 4).meetsMinimum(2, 5), isFalse); + expect(const FlutterVersionComponents(1, 0).meetsMinimum(1, 0), isTrue); + expect( + const FlutterVersionComponents(0, 0).meetsMinimum(0, 1), isFalse); + }); + }); + }); + + group('FlutterVersion.parseVersion', () { + test('parses standard version format (major.minor.patch)', () { + final result = FlutterVersion.parseComponents('3.33.0'); + expect(result, isNotNull); + expect(result!.major, 3); + expect(result.minor, 33); + }); + + test('parses version with pre-release suffix', () { + final result = FlutterVersion.parseComponents('3.33.0-pre.123'); + expect(result, isNotNull); + expect(result!.major, 3); + expect(result.minor, 33); + }); + + test('parses version with build metadata', () { + final result = FlutterVersion.parseComponents('3.33.0+hotfix.1'); + expect(result, isNotNull); + expect(result!.major, 3); + expect(result.minor, 33); + }); + + test('parses major.minor only (no patch)', () { + final result = FlutterVersion.parseComponents('4.0'); + expect(result, isNotNull); + expect(result!.major, 4); + expect(result.minor, 0); + }); + + test('parses version with large numbers', () { + final result = FlutterVersion.parseComponents('10.100.999'); + expect(result, isNotNull); + expect(result!.major, 10); + expect(result.minor, 100); + }); + + test('returns null for single number (no dot)', () { + expect(FlutterVersion.parseComponents('3'), isNull); + }); + + test('returns null for non-numeric major', () { + expect(FlutterVersion.parseComponents('abc.33.0'), isNull); + }); + + test('returns null for non-numeric minor', () { + expect(FlutterVersion.parseComponents('3.abc.0'), isNull); + }); + + test('returns null for empty string', () { + expect(FlutterVersion.parseComponents(''), isNull); + }); + + test('returns null for dot only', () { + expect(FlutterVersion.parseComponents('.'), isNull); + }); + + test('returns null for leading dot', () { + expect(FlutterVersion.parseComponents('.33.0'), isNull); + }); + + test('returns null for minor with hyphen suffix without patch', () { + // "3.33-beta" -> "33-beta" is not a valid integer + expect(FlutterVersion.parseComponents('3.33-beta'), isNull); + }); + + test('parses version where minor ends at patch separator', () { + // "3.33.0-beta" -> major=3, minor=33 + final result = FlutterVersion.parseComponents('3.33.0-beta'); + expect(result, isNotNull); + expect(result!.major, 3); + expect(result.minor, 33); + }); + + test('handles version 0.0.0', () { + final result = FlutterVersion.parseComponents('0.0.0'); + expect(result, isNotNull); + expect(result!.major, 0); + expect(result.minor, 0); + }); + + test('returns null for malformed version', () { + expect(FlutterVersion.parseComponents('invalid'), isNull); + }); + }); +} diff --git a/packages/flutter/lib/src/sentry_privacy_options.dart b/packages/flutter/lib/src/sentry_privacy_options.dart index 00064b791c..6af5eecaa9 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()) { @@ -172,6 +180,75 @@ class SentryPrivacyOptions { }; } +/// Minimum Flutter version required for SensitiveContent widget support. +const _sensitiveContentMinMajor = 3; +const _sensitiveContentMinMinor = 33; + +/// Determines if the SensitiveContent masking rule should be added +/// based on parsed version components. +/// +/// Returns `false` if [components] is `null` (malformed version string). +@visibleForTesting +bool shouldAddSensitiveContentRule( + flutter_runtime.FlutterVersionComponents? components) { + return components?.meetsMinimum( + _sensitiveContentMinMajor, _sensitiveContentMinMinor) ?? + false; +} + +/// Detects if a widget is a SensitiveContent widget at runtime. +/// +/// Uses dynamic property access to check for the `sensitivity` property, +/// which is unique to the SensitiveContent widget. This approach works in +/// obfuscated builds where type names are mangled. +/// +/// Returns `true` if the widget has a `sensitivity` property that is an +/// [Enum] (as expected from SensitiveContent widget). +@visibleForTesting +bool isSensitiveContentWidget(Widget widget) { + try { + final dynamic dynWidget = widget; + final sensitivity = dynWidget.sensitivity; + // The property must exist AND be an Enum to be considered SensitiveContent. + // This check is done at runtime (not via assert) to ensure consistent + // behavior in both debug and release modes. + if (sensitivity is! Enum) { + return false; + } + return true; + } catch (_) { + // Property not found – not a SensitiveContent widget. + return false; + } +} + +/// 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) { + final components = + flutter_runtime.FlutterVersion.parseComponents(flutterVersion); + if (!shouldAddSensitiveContentRule(components)) { + return; + } + + SentryMaskingDecision maskSensitiveContent(Element element, Widget widget) { + return isSensitiveContentWidget(widget) + ? SentryMaskingDecision.mask + : 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 4c49210726..022e68b07b 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', () { @@ -320,6 +361,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; diff --git a/packages/flutter/test/sentry_privacy_options_test.dart b/packages/flutter/test/sentry_privacy_options_test.dart new file mode 100644 index 0000000000..4cce2c7022 --- /dev/null +++ b/packages/flutter/test/sentry_privacy_options_test.dart @@ -0,0 +1,106 @@ +@TestOn('vm') +library; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +// ignore: implementation_imports +import 'package:sentry/src/event_processor/enricher/flutter_runtime.dart'; +import 'package:sentry_flutter/src/sentry_privacy_options.dart'; + +void main() { + group('shouldAddSensitiveContentRule', () { + test('returns true for supported versions (>= 3.33)', () { + expect( + shouldAddSensitiveContentRule(const FlutterVersionComponents(3, 33)), + isTrue); + expect( + shouldAddSensitiveContentRule(const FlutterVersionComponents(3, 34)), + isTrue); + expect( + shouldAddSensitiveContentRule(const FlutterVersionComponents(4, 0)), + isTrue); + expect( + shouldAddSensitiveContentRule(const FlutterVersionComponents(5, 0)), + isTrue); + }); + + test('returns false for unsupported versions (< 3.33)', () { + expect( + shouldAddSensitiveContentRule(const FlutterVersionComponents(3, 32)), + isFalse); + expect( + shouldAddSensitiveContentRule(const FlutterVersionComponents(3, 0)), + isFalse); + expect( + shouldAddSensitiveContentRule(const FlutterVersionComponents(2, 99)), + isFalse); + expect( + shouldAddSensitiveContentRule(const FlutterVersionComponents(1, 0)), + isFalse); + }); + + test('returns false for null components (malformed version)', () { + expect(shouldAddSensitiveContentRule(null), isFalse); + }); + }); + + group('isSensitiveContentWidget', () { + test('returns false for standard widgets without sensitivity property', () { + expect(isSensitiveContentWidget(Container()), isFalse); + expect(isSensitiveContentWidget(const Text('test')), isFalse); + expect(isSensitiveContentWidget(const SizedBox()), isFalse); + expect(isSensitiveContentWidget(const Placeholder()), isFalse); + }); + + test('returns true for widget with sensitivity Enum property', () { + expect(isSensitiveContentWidget(const _MockSensitiveWidget()), isTrue); + }); + + test('returns false for widget with null sensitivity property', () { + // Widget with a sensitivity property that returns null. + // The property exists but is not an Enum, so it should return false. + // This behavior is consistent in both debug and release modes. + expect(isSensitiveContentWidget(const _WidgetWithNullSensitivity()), + isFalse); + }); + + test('returns false for widget with non-Enum sensitivity property', () { + expect(isSensitiveContentWidget(const _WidgetWithStringSensitivity()), + isFalse); + }); + }); +} + +/// Mock widget that has a `sensitivity` property like SensitiveContent. +class _MockSensitiveWidget extends StatelessWidget { + const _MockSensitiveWidget(); + + // Mimics the SensitiveContent widget's sensitivity property + _MockSensitivity get sensitivity => _MockSensitivity.high; + + @override + Widget build(BuildContext context) => const SizedBox(); +} + +/// Mock enum to mimic the Sensitivity enum from Flutter's SensitiveContent. +enum _MockSensitivity { high, medium, low } + +/// Widget with null sensitivity (edge case). +class _WidgetWithNullSensitivity extends StatelessWidget { + const _WidgetWithNullSensitivity(); + + Object? get sensitivity => null; + + @override + Widget build(BuildContext context) => const SizedBox(); +} + +/// Widget with non-Enum sensitivity (edge case). +class _WidgetWithStringSensitivity extends StatelessWidget { + const _WidgetWithStringSensitivity(); + + String get sensitivity => 'high'; + + @override + Widget build(BuildContext context) => const SizedBox(); +}