Skip to content

replay: add sensitive content widget to default masking #2989

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
5 changes: 3 additions & 2 deletions flutter/lib/src/screenshot/recorder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'dart:ui';
import 'package:flutter/cupertino.dart' as cupertino;
import 'package:flutter/material.dart' as material;
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' as widgets;
import 'package:meta/meta.dart';

Expand Down Expand Up @@ -34,8 +35,8 @@ class ScreenshotRecorder {
}) {
privacyOptions ??= options.privacy;

final maskingConfig =
privacyOptions.buildMaskingConfig(_log, options.runtimeChecker);
final maskingConfig = privacyOptions.buildMaskingConfig(
_log, options.runtimeChecker, FlutterVersion.version);
_maskingConfig = maskingConfig.length > 0 ? maskingConfig : null;
}

Expand Down
63 changes: 61 additions & 2 deletions flutter/lib/src/sentry_privacy_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ class SentryPrivacyOptions {
Iterable<SentryMaskingRule> get userMaskingRules => _userMaskingRules;

@internal
SentryMaskingConfig buildMaskingConfig(
SdkLogCallback logger, RuntimeChecker runtimeChecker) {
SentryMaskingConfig buildMaskingConfig(SdkLogCallback logger,
RuntimeChecker runtimeChecker, String? flutterVersion) {
// First, we collect rules defined by the user (so they're applied first).
final rules = _userMaskingRules.toList();

Expand Down Expand Up @@ -75,6 +75,8 @@ class SentryPrivacyOptions {
));
}

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()) {
Expand Down Expand Up @@ -159,6 +161,63 @@ 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? flutterVersion) {
if (flutterVersion == null) return false;

final parts = flutterVersion.split('.');
if (parts.length < 2) {
// Malformed version string – be safe and skip.
return false;
}

const requiredMajor = 3;
const requiredMinor = 33;
final major = int.tryParse(parts[0]);
final minor = int.tryParse(parts[1]);
if (major == null || minor == null) {
// Not numeric – treat as unknown.
return false;
}

return major > requiredMajor ||
(major == requiredMajor && minor >= requiredMinor);
}

/// 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).
@visibleForTesting
void maybeAddSensitiveContentRule(
List<SentryMaskingRule> 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<Widget>(
callback: maskSensitiveContent,
name: 'SensitiveContent',
description: 'Mask SensitiveContent widget.',
));
}

SentryMaskingDecision _maskImagesExceptAssets(Element element, Image widget) {
final image = widget.image;
if (image is AssetBundleImageProvider) {
Expand Down
33 changes: 30 additions & 3 deletions flutter/test/screenshot/masking_config_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'package:collection/collection.dart';

Check warning on line 1 in flutter/test/screenshot/masking_config_test.dart

View workflow job for this annotation

GitHub Actions / analyze / analyze

Unused import: 'package:collection/collection.dart'.

Try removing the import directive. See https://dart.dev/diagnostics/unused_import to learn more about this problem.
import 'package:flutter/services.dart';

Check warning on line 2 in flutter/test/screenshot/masking_config_test.dart

View workflow job for this annotation

GitHub Actions / analyze / analyze

Unused import: 'package:flutter/services.dart'.

Try removing the import directive. See https://dart.dev/diagnostics/unused_import to learn more about this problem.
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
Expand Down Expand Up @@ -146,9 +148,10 @@
});

group('$SentryReplayOptions.buildMaskingConfig()', () {
List<String> rulesAsStrings(SentryPrivacyOptions options) {
final config =
options.buildMaskingConfig(MockLogger().call, RuntimeChecker());
List<String> rulesAsStrings(SentryPrivacyOptions options,
{String? flutterVersion}) {
final config = options.buildMaskingConfig(
MockLogger().call, RuntimeChecker(), flutterVersion);
return config.rules
.map((rule) => rule.toString())
// These normalize the string on VM & js & wasm:
Expand Down Expand Up @@ -222,6 +225,30 @@
]);
});

test(
'SensitiveContent rule is automatically added when current Flutter version is equal or newer than 3.33',
() {
final testCases = <String?, bool>{
null: false,
'1.0.0': false,
'3.32.5': false,
'3.33.0': true,
'3.40.0': true,
'4.0.0': true,
'3.a.b': false,
'invalid': false,
};

testCases.forEach((version, shouldAdd) {
final sut = SentryPrivacyOptions();
expect(
rulesAsStrings(sut, flutterVersion: version).contains(
'SentryMaskingCustomRule<SensitiveContent>(Mask SensitiveContent widget.)'),
shouldAdd,
reason: 'Test failed with version: $version');
});
});

group('user rules', () {
final defaultRules = [
...alwaysEnabledRules,
Expand Down
4 changes: 2 additions & 2 deletions flutter/test/screenshot/widget_filter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ void main() async {
..maskAllImages = redactImages
..maskAllText = redactText;
logger.clear();
final maskingConfig = privacyOptions.buildMaskingConfig(
logger.call, runtimeChecker ?? RuntimeChecker());
final maskingConfig = privacyOptions.buildMaskingConfig(logger.call,
runtimeChecker ?? RuntimeChecker(), FlutterVersion.version);
return WidgetFilter(maskingConfig, logger.call);
};

Expand Down
Loading