Skip to content

Commit

Permalink
pass masking rule tags from flutter
Browse files Browse the repository at this point in the history
  • Loading branch information
vaind committed Jan 20, 2025
1 parent 86f8ed3 commit dca474f
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 123 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,18 @@ class SentryFlutterTest {
mapOf(
"sessionSampleRate" to 1,
"onErrorSampleRate" to 1,
"tags" to mapOf(
"maskingRules" to mapOf(
"Image" to "mask",
"SentryMask" to "mask",
"SentryUnmask" to "unmask",
"User" to "custom()"
)
)
"tags" to
mapOf(
"random-key" to "value",
"maskingRules" to
listOf(
mapOf("Image" to "mask"),
mapOf("SentryMask" to "mask"),
mapOf("SentryUnmask" to "unmask"),
mapOf("User" to "custom text"),
mapOf("Image" to "unmask"),
),
),
),
),
)
Expand All @@ -150,18 +154,22 @@ class SentryFlutterTest {
val event = SentryReplayEvent()
val rrwebEvent = RRWebOptionsEvent(fixture.options)
val hint = Hint()
hint.replayRecording = ReplayRecording().also {
it.payload = listOf(rrwebEvent)
}
hint.replayRecording =
ReplayRecording().also {
it.payload = listOf(rrwebEvent)
}
assertEquals(it.execute(event, hint), event)
assertEquals(
mapOf(
"Image" to "mask",
"SentryMask" to "mask",
"SentryUnmask" to "unmask",
"User" to "custom()"
listOf(
mapOf("Image" to "mask"),
mapOf("SentryMask" to "mask"),
mapOf("SentryUnmask" to "unmask"),
mapOf("User" to "custom text"),
mapOf("Image" to "unmask"),
),
rrwebEvent.optionsPayload["maskingRules"])
rrwebEvent.optionsPayload["maskingRules"],
)
assertEquals("value", rrwebEvent.optionsPayload["random-key"])
assertEquals("medium", rrwebEvent.optionsPayload["quality"])
assertEquals(1.0, rrwebEvent.optionsPayload["errorSampleRate"])
assertEquals(1.0, rrwebEvent.optionsPayload["sessionSampleRate"])
Expand Down
15 changes: 11 additions & 4 deletions flutter/lib/src/native/sentry_native_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,17 @@ class SentryNativeChannel
'quality': options.experimental.replay.quality.name,
'sessionSampleRate': options.experimental.replay.sessionSampleRate,
'onErrorSampleRate': options.experimental.replay.onErrorSampleRate,
// TMP: this doesn't actually mask, just ensures we show the correct
// value in tags. https://github.com/getsentry/sentry-cocoa/issues/4666
'maskAllText': options.experimental.privacyForReplay.maskAllText,
'maskAllImages': options.experimental.privacyForReplay.maskAllImages,
'tags': <String, dynamic>{
'maskAllText': options.experimental.privacyForReplay.maskAllText,
'maskAllImages': options.experimental.privacyForReplay.maskAllImages,
'maskAssetImages':
options.experimental.privacyForReplay.maskAssetImages,
if (options.experimental.privacyForReplay.userMaskingRules.isNotEmpty)
'maskingRules': options
.experimental.privacyForReplay.userMaskingRules
.map((rule) => {rule.name: rule.description})
.toList(growable: false),
},
},
'enableSpotlight': options.spotlight.enabled,
'spotlightUrl': options.spotlight.url,
Expand Down
43 changes: 28 additions & 15 deletions flutter/lib/src/screenshot/masking_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,39 +49,52 @@ enum SentryMaskingDecision {
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();
const SentryMaskingRule({required this.name, required this.description});

final String name;

final String description;

String get _ruleType;

@override
String toString() => '$_ruleType<$name>($description)';
}

@internal
class SentryMaskingCustomRule<T extends Widget> extends SentryMaskingRule<T> {
@override
String get _ruleType => 'SentryMaskingCustomRule';

final SentryMaskingDecision Function(Element element, T widget) callback;

const SentryMaskingCustomRule(this.callback);
const SentryMaskingCustomRule({
required this.callback,
required super.name,
required super.description,
});

@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> {
@override
String get _ruleType => 'SentryMaskingConstantRule';

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;
}
const SentryMaskingConstantRule(
{required bool mask, required super.name, String? description})
: _value =
mask ? SentryMaskingDecision.mask : SentryMaskingDecision.unmask,
super(description: description ?? (mask ? 'mask' : 'unmask'));

@override
String toString() =>
'$SentryMaskingConstantRule<$T>(${_value == SentryMaskingDecision.mask ? 'mask' : 'unmask'})';
SentryMaskingDecision shouldMask(Element element, T widget) => _value;
}
100 changes: 65 additions & 35 deletions flutter/lib/src/sentry_privacy_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class SentryPrivacyOptions {

final _userMaskingRules = <SentryMaskingRule>[];

@internal
Iterable<SentryMaskingRule> get userMaskingRules => _userMaskingRules;

@internal
SentryMaskingConfig buildMaskingConfig(
SentryLogger logger, PlatformChecker platform) {
Expand All @@ -34,29 +37,41 @@ class SentryPrivacyOptions {

// Then, we apply rules for [SentryMask] and [SentryUnmask].
rules.add(const SentryMaskingConstantRule<SentryMask>(
SentryMaskingDecision.mask));
mask: true,
name: 'SentryMask',
));
rules.add(const SentryMaskingConstantRule<SentryUnmask>(
SentryMaskingDecision.unmask));
mask: false,
name: 'SentryUnmask',
));

// Then, we apply apply rules based on the configuration.
if (maskAllImages) {
if (maskAssetImages) {
rules.add(
const SentryMaskingConstantRule<Image>(SentryMaskingDecision.mask));
rules.add(const SentryMaskingConstantRule<Image>(
mask: true,
name: 'Image',
));
} else {
rules
.add(const SentryMaskingCustomRule<Image>(_maskImagesExceptAssets));
rules.add(const SentryMaskingCustomRule<Image>(
callback: _maskImagesExceptAssets,
name: 'Image',
description: 'Mask all images except asset images.'));
}
} else {
assert(!maskAssetImages,
"maskAssetImages can't be true if maskAllImages is false");
}

if (maskAllText) {
rules.add(
const SentryMaskingConstantRule<Text>(SentryMaskingDecision.mask));
rules.add(const SentryMaskingConstantRule<Text>(
mask: true,
name: 'Text',
));
rules.add(const SentryMaskingConstantRule<EditableText>(
SentryMaskingDecision.mask));
mask: true,
name: 'EditableText',
));
}

// In Debug mode, check if users explicitly mask (or unmask) widgets that
Expand All @@ -70,24 +85,27 @@ class SentryPrivacyOptions {
SentryFlutterOptions().experimental.privacy;
final optionsName = 'options.experimental.privacy';

rules.add(
SentryMaskingCustomRule<Widget>((Element element, Widget widget) {
final type = widget.runtimeType.toString();
if (regexp.hasMatch(type)) {
logger(
SentryLevel.warning,
'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 '
"doesn't recognize it and can't reliably mask it in release "
'builds (due to obfuscation). '
'Please mask it explicitly using $optionsName.mask<$type>(). '
'If you want to silence this warning and keep the widget '
'visible in captures, you can use $optionsName.unmask<$type>(). '
'Note: the RegExp matched is: $regexp (case insensitive).');
}
return SentryMaskingDecision.continueProcessing;
}));
rules.add(SentryMaskingCustomRule<Widget>(
callback: (Element element, Widget widget) {
final type = widget.runtimeType.toString();
if (regexp.hasMatch(type)) {
logger(
SentryLevel.warning,
'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 '
"doesn't recognize it and can't reliably mask it in release "
'builds (due to obfuscation). '
'Please mask it explicitly using $optionsName.mask<$type>(). '
'If you want to silence this warning and keep the widget '
'visible in captures, you can use $optionsName.unmask<$type>(). '
'Note: the RegExp matched is: $regexp (case insensitive).');
}
return SentryMaskingDecision.continueProcessing;
},
name: 'Widget',
description:
'Debug-mode-only warning for potentially sensitive widgets.'));
}

return SentryMaskingConfig(rules);
Expand All @@ -97,11 +115,14 @@ class SentryPrivacyOptions {
/// Note: masking rules are called in the order they're added so if a previous
/// rule already makes a decision, this rule won't be called.
@experimental
void mask<T extends Widget>() {
void mask<T extends Widget>({String? name, String? description}) {
assert(T != SentryMask);
assert(T != SentryUnmask);
_userMaskingRules
.add(SentryMaskingConstantRule<T>(SentryMaskingDecision.mask));
_userMaskingRules.add(SentryMaskingConstantRule<T>(
mask: true,
name: name ?? T.toString(),
description: description,
));
}

/// Unmask given widget type [T] (or subclasses of [T]) in the replay. This is
Expand All @@ -112,11 +133,14 @@ class SentryPrivacyOptions {
/// Note: masking rules are called in the order they're added so if a previous
/// rule already makes a decision, this rule won't be called.
@experimental
void unmask<T extends Widget>() {
void unmask<T extends Widget>({String? name, String? description}) {
assert(T != SentryMask);
assert(T != SentryUnmask);
_userMaskingRules
.add(SentryMaskingConstantRule<T>(SentryMaskingDecision.unmask));
_userMaskingRules.add(SentryMaskingConstantRule<T>(
mask: false,
name: name ?? T.toString(),
description: description,
));
}

/// Provide a custom callback to decide whether to mask the widget of class
Expand All @@ -125,10 +149,16 @@ class SentryPrivacyOptions {
/// rule already makes a decision, this rule won't be called.
@experimental
void maskCallback<T extends Widget>(
SentryMaskingDecision Function(Element, T) shouldMask) {
SentryMaskingDecision Function(Element, T) shouldMask,
{String? name,
String? description}) {
assert(T != SentryMask);
assert(T != SentryUnmask);
_userMaskingRules.add(SentryMaskingCustomRule<T>(shouldMask));
_userMaskingRules.add(SentryMaskingCustomRule<T>(
callback: shouldMask,
name: name ?? T.toString(),
description: description ?? '$shouldMask',
));
}
}

Expand Down
19 changes: 15 additions & 4 deletions flutter/test/integrations/init_native_sdk_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
library flutter_test;

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:sentry_flutter/src/native/sentry_native_channel.dart';
Expand Down Expand Up @@ -69,8 +70,11 @@ void main() {
'quality': 'medium',
'sessionSampleRate': null,
'onErrorSampleRate': null,
'maskAllText': true,
'maskAllImages': true,
'tags': {
'maskAllText': true,
'maskAllImages': true,
'maskAssetImages': false,
}
},
'enableSpotlight': false,
'spotlightUrl': null,
Expand Down Expand Up @@ -125,6 +129,7 @@ void main() {
..experimental.replay.quality = SentryReplayQuality.high
..experimental.replay.sessionSampleRate = 0.1
..experimental.replay.onErrorSampleRate = 0.2
..experimental.privacy.mask<Image>()
..spotlight =
Spotlight(enabled: true, url: 'http://localhost:8969/stream');

Expand Down Expand Up @@ -182,8 +187,14 @@ void main() {
'quality': 'high',
'sessionSampleRate': 0.1,
'onErrorSampleRate': 0.2,
'maskAllText': true,
'maskAllImages': true,
'tags': {
'maskAllText': true,
'maskAllImages': true,
'maskAssetImages': false,
'maskingRules': [
{'Image': 'mask'},
]
}
},
'enableSpotlight': true,
'spotlightUrl': 'http://localhost:8969/stream',
Expand Down
Loading

0 comments on commit dca474f

Please sign in to comment.