Skip to content

Commit ab99a31

Browse files
authored
feat: warning in screenshot/replay creation when a potentially sensitive widget is not masked (#2375)
* feat: fail screenshot/replay creation when a potentially sensitive widget is not masked * fix tests for web * improve text * warn instead of throwin an exception * chore: changelog * test: add debug/profile/release mode tests * test assertion
1 parent 3cc7034 commit ab99a31

File tree

9 files changed

+173
-41
lines changed

9 files changed

+173
-41
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Enhancements
6+
7+
- Warning (in a debug build) if a potentially sensitive widget is not masked or unmasked explicitly ([#2375](https://github.com/getsentry/sentry-dart/pull/2375))
8+
59
### Dependencies
610

711
- Bump Native SDK from v0.7.15 to v0.7.16 ([#2465](https://github.com/getsentry/sentry-dart/pull/2465))

flutter/lib/src/screenshot/recorder.dart

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,26 @@ class ScreenshotRecorder {
4646
final privacyOptions = isReplayRecorder
4747
? options.experimental.privacyForReplay
4848
: options.experimental.privacyForScreenshots;
49-
final maskingConfig = privacyOptions?.buildMaskingConfig();
49+
final maskingConfig =
50+
privacyOptions?.buildMaskingConfig(_log, options.platformChecker);
5051
if (maskingConfig != null && maskingConfig.length > 0) {
5152
_widgetFilter = WidgetFilter(maskingConfig, options.logger);
5253
}
5354
}
5455

56+
void _log(SentryLevel level, String message,
57+
{String? logger, Object? exception, StackTrace? stackTrace}) {
58+
options.logger(level, '$logName: $message',
59+
logger: logger, exception: exception, stackTrace: stackTrace);
60+
}
61+
5562
Future<void> capture(ScreenshotRecorderCallback callback) async {
5663
final context = sentryScreenshotWidgetGlobalKey.currentContext;
5764
final renderObject = context?.findRenderObject() as RenderRepaintBoundary?;
5865
if (context == null || renderObject == null) {
5966
if (!_warningLogged) {
60-
options.logger(SentryLevel.warning,
61-
"$logName: SentryScreenshotWidget is not attached, skipping capture.");
67+
_log(SentryLevel.warning,
68+
"SentryScreenshotWidget is not attached, skipping capture.");
6269
_warningLogged = true;
6370
}
6471
return;
@@ -112,9 +119,9 @@ class ScreenshotRecorder {
112119
try {
113120
final finalImage = await picture.toImage(
114121
(srcWidth * pixelRatio).round(), (srcHeight * pixelRatio).round());
115-
options.logger(
122+
_log(
116123
SentryLevel.debug,
117-
"$logName: captured a screenshot in ${watch.elapsedMilliseconds}"
124+
"captured a screenshot in ${watch.elapsedMilliseconds}"
118125
" ms ($blockingTime ms blocking).");
119126
try {
120127
await callback(finalImage);
@@ -125,8 +132,7 @@ class ScreenshotRecorder {
125132
picture.dispose();
126133
}
127134
} catch (e, stackTrace) {
128-
options.logger(
129-
SentryLevel.error, "$logName: failed to capture screenshot.",
135+
_log(SentryLevel.error, "failed to capture screenshot.",
130136
exception: e, stackTrace: stackTrace);
131137
if (options.automatedTestMode) {
132138
rethrow;

flutter/lib/src/sentry_privacy_options.dart

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class SentryPrivacyOptions {
2727
final _userMaskingRules = <SentryMaskingRule>[];
2828

2929
@internal
30-
SentryMaskingConfig buildMaskingConfig() {
30+
SentryMaskingConfig buildMaskingConfig(
31+
SentryLogger logger, PlatformChecker platform) {
3132
// First, we collect rules defined by the user (so they're applied first).
3233
final rules = _userMaskingRules.toList();
3334

@@ -50,12 +51,45 @@ class SentryPrivacyOptions {
5051
assert(!maskAssetImages,
5152
"maskAssetImages can't be true if maskAllImages is false");
5253
}
54+
5355
if (maskAllText) {
5456
rules.add(
5557
const SentryMaskingConstantRule<Text>(SentryMaskingDecision.mask));
5658
rules.add(const SentryMaskingConstantRule<EditableText>(
5759
SentryMaskingDecision.mask));
5860
}
61+
62+
// In Debug mode, check if users explicitly mask (or unmask) widgets that
63+
// look like they should be masked, e.g. Videos, WebViews, etc.
64+
if (platform.isDebugMode()) {
65+
final regexp = RegExp('video|webview|password|pinput|camera|chart',
66+
caseSensitive: false);
67+
68+
// Note: the following line just makes sure if the option is renamed,
69+
// someone will notice that there is a string that needs updating too.
70+
SentryFlutterOptions().experimental.privacy;
71+
final optionsName = 'options.experimental.privacy';
72+
73+
rules.add(
74+
SentryMaskingCustomRule<Widget>((Element element, Widget widget) {
75+
final type = widget.runtimeType.toString();
76+
if (regexp.hasMatch(type)) {
77+
logger(
78+
SentryLevel.warning,
79+
'Widget "$widget" name matches widgets that should usually be '
80+
'masked because they may contain sensitive data. Because this '
81+
'widget comes from a third-party plugin or your code, Sentry '
82+
"doesn't recognize it and can't reliably mask it in release "
83+
'builds (due to obfuscation). '
84+
'Please mask it explicitly using $optionsName.mask<$type>(). '
85+
'If you want to silence this warning and keep the widget '
86+
'visible in captures, you can use $optionsName.unmask<$type>(). '
87+
'Note: the RegExp matched is: $regexp (case insensitive).');
88+
}
89+
return SentryMaskingDecision.continueProcessing;
90+
}));
91+
}
92+
5993
return SentryMaskingConfig(rules);
6094
}
6195

flutter/test/event_processor/flutter_enricher_event_processor_test.dart

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,24 @@ void main() {
222222

223223
testWidgets('adds correct flutter runtime', (WidgetTester tester) async {
224224
final checkerMap = {
225-
MockPlatformChecker(isWebValue: false, isDebug: true): 'Dart VM',
226-
MockPlatformChecker(isWebValue: false, isProfile: true): 'Dart AOT',
227-
MockPlatformChecker(isWebValue: false, isRelease: true): 'Dart AOT',
228-
MockPlatformChecker(isWebValue: true, isDebug: true): 'dartdevc',
229-
MockPlatformChecker(isWebValue: true, isProfile: true): 'dart2js',
230-
MockPlatformChecker(isWebValue: true, isRelease: true): 'dart2js',
225+
MockPlatformChecker(
226+
isWebValue: false,
227+
buildMode: MockPlatformCheckerBuildMode.debug): 'Dart VM',
228+
MockPlatformChecker(
229+
isWebValue: false,
230+
buildMode: MockPlatformCheckerBuildMode.profile): 'Dart AOT',
231+
MockPlatformChecker(
232+
isWebValue: false,
233+
buildMode: MockPlatformCheckerBuildMode.release): 'Dart AOT',
234+
MockPlatformChecker(
235+
isWebValue: true,
236+
buildMode: MockPlatformCheckerBuildMode.debug): 'dartdevc',
237+
MockPlatformChecker(
238+
isWebValue: true,
239+
buildMode: MockPlatformCheckerBuildMode.profile): 'dart2js',
240+
MockPlatformChecker(
241+
isWebValue: true,
242+
buildMode: MockPlatformCheckerBuildMode.release): 'dart2js',
231243
};
232244

233245
for (var pair in checkerMap.entries) {

flutter/test/integrations/debug_print_integration_test.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,10 @@ class Fixture {
9494
bool debug = false,
9595
bool enablePrintBreadcrumbs = true,
9696
}) {
97-
return defaultTestOptions(MockPlatformChecker(isDebug: debug))
97+
return defaultTestOptions(MockPlatformChecker(
98+
buildMode: debug
99+
? MockPlatformCheckerBuildMode.debug
100+
: MockPlatformCheckerBuildMode.release))
98101
..enablePrintBreadcrumbs = enablePrintBreadcrumbs;
99102
}
100103

flutter/test/mocks.dart

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,18 +99,14 @@ class MockPlatform with NoSuchMethodProvider implements Platform {
9999

100100
class MockPlatformChecker with NoSuchMethodProvider implements PlatformChecker {
101101
MockPlatformChecker({
102-
this.isDebug = false,
103-
this.isProfile = false,
104-
this.isRelease = false,
102+
this.buildMode = MockPlatformCheckerBuildMode.debug,
105103
this.isWebValue = false,
106104
this.hasNativeIntegration = false,
107105
this.isRoot = true,
108106
Platform? mockPlatform,
109107
}) : _mockPlatform = mockPlatform ?? MockPlatform('');
110108

111-
final bool isDebug;
112-
final bool isProfile;
113-
final bool isRelease;
109+
final MockPlatformCheckerBuildMode buildMode;
114110
final bool isWebValue;
115111
final bool isRoot;
116112
final Platform _mockPlatform;
@@ -119,13 +115,13 @@ class MockPlatformChecker with NoSuchMethodProvider implements PlatformChecker {
119115
bool hasNativeIntegration = false;
120116

121117
@override
122-
bool isDebugMode() => isDebug;
118+
bool isDebugMode() => buildMode == MockPlatformCheckerBuildMode.debug;
123119

124120
@override
125-
bool isProfileMode() => isProfile;
121+
bool isProfileMode() => buildMode == MockPlatformCheckerBuildMode.profile;
126122

127123
@override
128-
bool isReleaseMode() => isRelease;
124+
bool isReleaseMode() => buildMode == MockPlatformCheckerBuildMode.release;
129125

130126
@override
131127
bool get isRootZone => isRoot;
@@ -137,6 +133,8 @@ class MockPlatformChecker with NoSuchMethodProvider implements PlatformChecker {
137133
Platform get platform => _mockPlatform;
138134
}
139135

136+
enum MockPlatformCheckerBuildMode { debug, profile, release }
137+
140138
// Does nothing or returns default values.
141139
// Useful for when a Hub needs to be passed but is not used.
142140
class NoOpHub with NoSuchMethodProvider implements Hub {
@@ -237,3 +235,26 @@ class FunctionEventProcessor implements EventProcessor {
237235
return applyFunction(event, hint);
238236
}
239237
}
238+
239+
class MockLogger {
240+
final items = <MockLogItem>[];
241+
242+
void call(SentryLevel level, String message,
243+
{String? logger, Object? exception, StackTrace? stackTrace}) {
244+
items.add(MockLogItem(level, message,
245+
logger: logger, exception: exception, stackTrace: stackTrace));
246+
}
247+
248+
void clear() => items.clear();
249+
}
250+
251+
class MockLogItem {
252+
final SentryLevel level;
253+
final String message;
254+
final String? logger;
255+
final Object? exception;
256+
final StackTrace? stackTrace;
257+
258+
const MockLogItem(this.level, this.message,
259+
{this.logger, this.exception, this.stackTrace});
260+
}

flutter/test/screenshot/masking_config_test.dart

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
33
import 'package:sentry_flutter/sentry_flutter.dart';
44
import 'package:sentry_flutter/src/screenshot/masking_config.dart';
55

6+
import '../mocks.dart';
67
import 'test_widget.dart';
78

89
void main() async {
@@ -115,15 +116,14 @@ void main() async {
115116

116117
group('$SentryReplayOptions.buildMaskingConfig()', () {
117118
List<String> rulesAsStrings(SentryPrivacyOptions options) {
118-
final config = options.buildMaskingConfig();
119+
final config =
120+
options.buildMaskingConfig(MockLogger().call, PlatformChecker());
119121
return config.rules
120122
.map((rule) => rule.toString())
121123
// These normalize the string on VM & js & wasm:
122124
.map((str) => str.replaceAll(
123-
RegExp(
124-
r"SentryMaskingDecision from:? [fF]unction '?_maskImagesExceptAssets[@(].*",
125-
dotAll: true),
126-
'SentryMaskingDecision)'))
125+
RegExp(r"=> SentryMaskingDecision from:? .*", dotAll: true),
126+
'=> SentryMaskingDecision)'))
127127
.map((str) => str.replaceAll(
128128
' from: (element, widget) => masking_config.SentryMaskingDecision.mask',
129129
''))
@@ -136,7 +136,8 @@ void main() async {
136136
...alwaysEnabledRules,
137137
'$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)',
138138
'$SentryMaskingConstantRule<$Text>(mask)',
139-
'$SentryMaskingConstantRule<$EditableText>(mask)'
139+
'$SentryMaskingConstantRule<$EditableText>(mask)',
140+
'$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)'
140141
]);
141142
});
142143

@@ -148,6 +149,7 @@ void main() async {
148149
expect(rulesAsStrings(sut), [
149150
...alwaysEnabledRules,
150151
'$SentryMaskingConstantRule<$Image>(mask)',
152+
'$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)'
151153
]);
152154
});
153155

@@ -159,6 +161,7 @@ void main() async {
159161
expect(rulesAsStrings(sut), [
160162
...alwaysEnabledRules,
161163
'$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)',
164+
'$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)'
162165
]);
163166
});
164167

@@ -171,6 +174,7 @@ void main() async {
171174
...alwaysEnabledRules,
172175
'$SentryMaskingConstantRule<$Text>(mask)',
173176
'$SentryMaskingConstantRule<$EditableText>(mask)',
177+
'$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)'
174178
]);
175179
});
176180

@@ -179,15 +183,19 @@ void main() async {
179183
..maskAllText = false
180184
..maskAllImages = false
181185
..maskAssetImages = false;
182-
expect(rulesAsStrings(sut), alwaysEnabledRules);
186+
expect(rulesAsStrings(sut), [
187+
...alwaysEnabledRules,
188+
'$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)'
189+
]);
183190
});
184191

185192
group('user rules', () {
186193
final defaultRules = [
187194
...alwaysEnabledRules,
188195
'$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)',
189196
'$SentryMaskingConstantRule<$Text>(mask)',
190-
'$SentryMaskingConstantRule<$EditableText>(mask)'
197+
'$SentryMaskingConstantRule<$EditableText>(mask)',
198+
'$SentryMaskingCustomRule<$Widget>(Closure: ($Element, $Widget) => $SentryMaskingDecision)'
191199
];
192200
test('mask() takes precedence', () {
193201
final sut = SentryPrivacyOptions();

flutter/test/screenshot/test_widget.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Future<Element> pumpTestElement(WidgetTester tester,
3535
Offstage(offstage: true, child: newImage()),
3636
Text(dummyText),
3737
Material(child: TextFormField()),
38+
Material(child: TextField()),
3839
SizedBox(
3940
width: 100,
4041
height: 20,

0 commit comments

Comments
 (0)