Skip to content

Commit

Permalink
feat: custom replay masking rules (#2324)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
vaind and buenaflor authored Oct 10, 2024
1 parent f333300 commit e239c83
Show file tree
Hide file tree
Showing 11 changed files with 712 additions and 92 deletions.
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@
- Emit `transaction.data` inside `contexts.trace.data` ([#2284](https://github.com/getsentry/sentry-dart/pull/2284))
- Blocking app starts if "appLaunchedInForeground" is false. (Android only) ([#2291](https://github.com/getsentry/sentry-dart/pull/2291))
- Windows native error & obfuscation support ([#2286](https://github.com/getsentry/sentry-dart/pull/2286))
- Replay: user-configurable masking (redaction) for widget classes and specific widget instances. ([#2324](https://github.com/getsentry/sentry-dart/pull/2324))
Some examples of the configuration:

```dart
await SentryFlutter.init(
(options) {
...
options.experimental.replay.mask<IconButton>();
options.experimental.replay.unmask<Image>();
options.experimental.replay.maskCallback<Text>(
(Element element, Text widget) =>
(widget.data?.contains('secret') ?? false)
? SentryMaskingDecision.mask
: SentryMaskingDecision.continueProcessing);
},
appRunner: () => runApp(MyApp()),
);
```

Also, you can wrap any of your widgets with `SentryMask()` or `SentryUnmask()` widgets to mask/unmask them, respectively. For example:

```dart
 SentryUnmask(Text('Not secret at all'));
```

- Support `captureFeedback` ([#2230](https://github.com/getsentry/sentry-dart/pull/2230))
- Deprecated `Sentry.captureUserFeedback`, use `captureFeedback` instead.
- Deprecated `Hub.captureUserFeedback`, use `captureFeedback` instead.
Expand Down
3 changes: 3 additions & 0 deletions flutter/lib/sentry_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export 'src/sentry_replay_options.dart';
export 'src/flutter_sentry_attachment.dart';
export 'src/sentry_asset_bundle.dart' show SentryAssetBundle;
export 'src/integrations/on_error_integration.dart';
export 'src/replay/masking_config.dart' show SentryMaskingDecision;
export 'src/screenshot/sentry_mask_widget.dart';
export 'src/screenshot/sentry_unmask_widget.dart';
export 'src/screenshot/sentry_screenshot_widget.dart';
export 'src/screenshot/sentry_screenshot_quality.dart';
export 'src/user_interaction/sentry_user_interaction_widget.dart';
Expand Down
87 changes: 87 additions & 0 deletions flutter/lib/src/replay/masking_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';

@internal
class SentryMaskingConfig {
@visibleForTesting
final List<SentryMaskingRule> rules;

final int length;

SentryMaskingConfig(List<SentryMaskingRule> rules)
// Note: fixed-size list has performance benefits over growable list.
: rules = List.of(rules, growable: false),
length = rules.length;

SentryMaskingDecision shouldMask<T extends Widget>(
Element element, T widget) {
for (int i = 0; i < length; i++) {
if (rules[i].appliesTo(widget)) {
// We use a switch here to get lints if more values are added.
switch (rules[i].shouldMask(element, widget)) {
case SentryMaskingDecision.mask:
return SentryMaskingDecision.mask;
case SentryMaskingDecision.unmask:
return SentryMaskingDecision.unmask;
case SentryMaskingDecision.continueProcessing:
// Continue to the next matching rule.
}
}
}
return SentryMaskingDecision.continueProcessing;
}
}

@experimental
enum SentryMaskingDecision {
/// Mask the widget and its children
mask,

/// Leave the widget visible, including its children (no more rules will
/// be checked for children).
unmask,

/// Don't make a decision - continue checking other rules and children.
continueProcessing
}

@internal
abstract class SentryMaskingRule<T extends Widget> {
@pragma('vm:prefer-inline')
bool appliesTo(Widget widget) => widget is T;
SentryMaskingDecision shouldMask(Element element, T widget);

const SentryMaskingRule();
}

@internal
class SentryMaskingCustomRule<T extends Widget> extends SentryMaskingRule<T> {
final SentryMaskingDecision Function(Element element, T widget) callback;

const SentryMaskingCustomRule(this.callback);

@override
SentryMaskingDecision shouldMask(Element element, T widget) =>
callback(element, widget);

@override
String toString() => '$SentryMaskingCustomRule<$T>($callback)';
}

@internal
class SentryMaskingConstantRule<T extends Widget> extends SentryMaskingRule<T> {
final SentryMaskingDecision _value;
const SentryMaskingConstantRule(this._value);

@override
SentryMaskingDecision shouldMask(Element element, T widget) {
// This rule only makes sense with true/false. Continue won't do anything.
assert(_value == SentryMaskingDecision.mask ||
_value == SentryMaskingDecision.unmask);
return _value;
}

@override
String toString() =>
'$SentryMaskingConstantRule<$T>(${_value == SentryMaskingDecision.mask ? 'mask' : 'unmask'})';
}
9 changes: 3 additions & 6 deletions flutter/lib/src/replay/recorder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,9 @@ class ScreenshotRecorder {
bool warningLogged = false;

ScreenshotRecorder(this.config, this.options) {
final replayOptions = options.experimental.replay;
if (replayOptions.redactAllText || replayOptions.redactAllImages) {
_widgetFilter = WidgetFilter(
redactText: replayOptions.redactAllText,
redactImages: replayOptions.redactAllImages,
logger: options.logger);
final maskingConfig = options.experimental.replay.buildMaskingConfig();
if (maskingConfig.length > 0) {
_widgetFilter = WidgetFilter(maskingConfig, options.logger);
}
}

Expand Down
156 changes: 96 additions & 60 deletions flutter/lib/src/replay/widget_filter.dart
Original file line number Diff line number Diff line change
@@ -1,42 +1,39 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';

import '../../sentry_flutter.dart';
import '../sentry_asset_bundle.dart';
import 'masking_config.dart';

@internal
class WidgetFilter {
final items = <WidgetFilterItem>[];
final SentryLogger logger;
final bool redactText;
final bool redactImages;
final SentryMaskingConfig config;
static const _defaultColor = Color.fromARGB(255, 0, 0, 0);
late double _pixelRatio;
late Rect _bounds;
final _warnedWidgets = <int>{};
final AssetBundle _rootAssetBundle;

WidgetFilter(
{required this.redactText,
required this.redactImages,
required this.logger,
@visibleForTesting AssetBundle? rootAssetBundle})
: _rootAssetBundle = rootAssetBundle ?? rootBundle;
/// Used to test _obscureElementOrParent
@visibleForTesting
bool throwInObscure = false;

WidgetFilter(this.config, this.logger);

void obscure(BuildContext context, double pixelRatio, Rect bounds) {
_pixelRatio = pixelRatio;
_bounds = bounds;
items.clear();
if (context is Element) {
_obscure(context);
_process(context);
} else {
context.visitChildElements(_obscure);
context.visitChildElements(_process);
}
}

void _obscure(Element element) {
void _process(Element element) {
final widget = element.widget;

if (!_isVisible(widget)) {
Expand All @@ -47,47 +44,64 @@ class WidgetFilter {
return;
}

final obscured = _obscureIfNeeded(element, widget);
if (!obscured) {
element.visitChildElements(_obscure);
final decision = config.shouldMask(element, widget);
switch (decision) {
case SentryMaskingDecision.mask:
final item = _obscureElementOrParent(element, widget);
if (item != null) {
items.add(item);
}
break;
case SentryMaskingDecision.unmask:
logger(SentryLevel.debug, "WidgetFilter unmasked: $widget");
break;
case SentryMaskingDecision.continueProcessing:
// If this element should not be obscured, visit and check its children.
element.visitChildElements(_process);
break;
}
}

/// Determine the color and bounding box of the widget.
/// If the widget is offscreen, returns null.
/// If the widget cannot be obscured, obscures the parent.
@pragma('vm:prefer-inline')
bool _obscureIfNeeded(Element element, Widget widget) {
Color? color;

if (redactText && widget is Text) {
color = widget.style?.color;
} else if (redactText && widget is EditableText) {
color = widget.style.color;
} else if (redactImages && widget is Image) {
if (widget.image is AssetBundleImageProvider) {
final image = widget.image as AssetBundleImageProvider;
if (isBuiltInAssetImage(image)) {
logger(SentryLevel.debug,
"WidgetFilter skipping asset: $widget ($image).");
return false;
WidgetFilterItem? _obscureElementOrParent(Element element, Widget widget) {
while (true) {
try {
return _obscure(element, widget);
} catch (e, stackTrace) {
final parent = element.parent;
if (!_warnedWidgets.contains(widget.hashCode)) {
_warnedWidgets.add(widget.hashCode);
logger(
SentryLevel.warning,
'WidgetFilter cannot mask widget $widget: $e.'
'Obscuring the parent instead: ${parent?.widget}.',
stackTrace: stackTrace);
}
if (parent == null) {
return WidgetFilterItem(_defaultColor, _bounds);
}
element = parent;
widget = element.widget;
}
color = widget.color;
} else {
// No other type is currently obscured.
return false;
}

final renderObject = element.renderObject;
if (renderObject is! RenderBox) {
_cantObscure(widget, "its renderObject is not a RenderBox");
return false;
}
}

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

// If it's a clipped render object, use parent's offset and size.
// This helps with text fields which often have oversized render objects.
if (renderObject.parent is RenderStack) {
final renderStack = (renderObject.parent as RenderStack);
if (renderBox.parent is RenderStack) {
final renderStack = (renderBox.parent as RenderStack);
final clipBehavior = renderStack.clipBehavior;
if (clipBehavior == Clip.hardEdge ||
clipBehavior == Clip.antiAlias ||
Expand All @@ -102,19 +116,37 @@ class WidgetFilter {
logger(SentryLevel.debug, "WidgetFilter skipping offscreen: $widget");
return true;
}());
return false;
return null;
}

items.add(WidgetFilterItem(color ?? _defaultColor, rect));
assert(() {
logger(SentryLevel.debug, "WidgetFilter obscuring: $widget");
logger(SentryLevel.debug, "WidgetFilter masking: $widget");
return true;
}());

return true;
Color? color;
if (widget is Text) {
color = (widget).style?.color;
} else if (widget is EditableText) {
color = (widget).style.color;
} else if (widget is Image) {
color = (widget).color;
}

// test-only code
assert(() {
if (throwInObscure) {
throwInObscure = false;
return false;
}
return true;
}());

return WidgetFilterItem(color ?? _defaultColor, rect);
}

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

@visibleForTesting
@internal
@pragma('vm:prefer-inline')
bool isBuiltInAssetImage(AssetBundleImageProvider image) {
static bool isBuiltInAssetImage(
AssetBundleImageProvider image, AssetBundle rootAssetBundle) {
late final AssetBundle? bundle;
if (image is AssetImage) {
bundle = image.bundle;
Expand All @@ -140,17 +173,8 @@ class WidgetFilter {
return false;
}
return (bundle == null ||
bundle == _rootAssetBundle ||
(bundle is SentryAssetBundle && bundle.bundle == _rootAssetBundle));
}

@pragma('vm:prefer-inline')
void _cantObscure(Widget widget, String message) {
if (!_warnedWidgets.contains(widget.hashCode)) {
_warnedWidgets.add(widget.hashCode);
logger(SentryLevel.warning,
"WidgetFilter cannot obscure widget $widget: $message");
}
bundle == rootAssetBundle ||
(bundle is SentryAssetBundle && bundle.bundle == rootAssetBundle));
}

@pragma('vm:prefer-inline')
Expand All @@ -165,9 +189,21 @@ class WidgetFilter {
}
}

@internal
class WidgetFilterItem {
final Color color;
final Rect bounds;

const WidgetFilterItem(this.color, this.bounds);
}

extension on Element {
Element? get parent {
Element? result;
visitAncestorElements((el) {
result = el;
return false;
});
return result;
}
}
Loading

0 comments on commit e239c83

Please sign in to comment.