diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 1b1c9349..4c16d670 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,6 +1,9 @@ import 'dart:convert'; + import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/log_redaction_manager.dart'; + class StorageService { static const String _keyServerUrl = 'server_url'; static const String _keyToken = 'token'; @@ -32,11 +35,16 @@ class StorageService { Future _init() async { _prefs = await SharedPreferences.getInstance(); + // Seed known values so logs can redact immediately on startup. + LogRedactionManager.registerServerUrl(getServerUrl()); + LogRedactionManager.registerToken(getToken()); + LogRedactionManager.registerToken(getPlexToken()); } // Server URL Future saveServerUrl(String url) async { await _prefs.setString(_keyServerUrl, url); + LogRedactionManager.registerServerUrl(url); } String? getServerUrl() { @@ -46,6 +54,7 @@ class StorageService { // Server Access Token Future saveToken(String token) async { await _prefs.setString(_keyToken, token); + LogRedactionManager.registerToken(token); } String? getToken() { @@ -64,6 +73,7 @@ class StorageService { // Plex.tv Token (for API access) Future savePlexToken(String token) async { await _prefs.setString(_keyPlexToken, token); + LogRedactionManager.registerToken(token); } String? getPlexToken() { @@ -127,6 +137,7 @@ class StorageService { _prefs.remove(_keyHomeUsersCache), _prefs.remove(_keyHomeUsersCacheExpiry), ]); + LogRedactionManager.clearTrackedValues(); } // Get all credentials as a map diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 07c39cb6..9450fed4 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -1,72 +1,22 @@ import 'package:logger/logger.dart'; -/// Redacts sensitive information from log messages +import 'log_redaction_manager.dart'; + +/// Redacts sensitive information from log messages based on known values. String _redactSensitiveData(String message) { - String redacted = message; + var redacted = LogRedactionManager.redact(message); - // Redact Plex tokens (alphanumeric strings typically 20+ characters) - // Pattern: X-Plex-Token=... or token=... or accessToken=... or similar + // Fallbacks for sensitive fields we cannot track ahead of time. redacted = redacted.replaceAllMapped( - RegExp(r'([Tt]oken[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false), + RegExp(r'([Aa]uthorization[=:]\s*)([^\s,]+)'), (match) => '${match.group(1)}[REDACTED]', ); - // Redact authorization headers redacted = redacted.replaceAllMapped( - RegExp( - r'([Aa]uthorization[=:]\s*)([A-Za-z0-9_\-\.]+)', - caseSensitive: false, - ), + RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)'), (match) => '${match.group(1)}[REDACTED]', ); - // Redact API keys - redacted = redacted.replaceAllMapped( - RegExp(r'([Aa]pi[Kk]ey[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false), - (match) => '${match.group(1)}[REDACTED]', - ); - - // Redact passwords - redacted = redacted.replaceAllMapped( - RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)', caseSensitive: false), - (match) => '${match.group(1)}[REDACTED]', - ); - - // Redact full URLs with tokens in query parameters - redacted = redacted.replaceAllMapped( - RegExp( - r'(https?://[^\s]*[?&])([Xx]-[Pp]lex-[Tt]oken|token)=([A-Za-z0-9_-]+)', - ), - (match) => '${match.group(1)}${match.group(2)}=[REDACTED]', - ); - - // Redact IP addresses in dot notation (e.g., 192.168.1.100) - redacted = redacted.replaceAllMapped( - RegExp(r'\b(\d{1,3}\.)(\d{1,3}\.)(\d{1,3}\.)(\d{1,3})\b'), - (match) => '${match.group(1)}***.***.${match.group(4)}', - ); - - // Redact IP addresses in dash notation (e.g., 192-168-1-11) - redacted = redacted.replaceAllMapped( - RegExp(r'\b(\d{1,3}-)(\d{1,3}-)(\d{1,3}-)(\d{1,3})\b'), - (match) => '${match.group(1)}***-***-${match.group(4)}', - ); - - // Redact standalone token-like strings (20+ alphanumeric characters) - // Only if they appear in common token contexts - redacted = redacted.replaceAllMapped(RegExp(r'\b([A-Za-z0-9_-]{20,})\b'), ( - match, - ) { - final token = match.group(1)!; - // Only redact if it looks like a token (mixed case or contains hyphens/underscores) - if (token.contains(RegExp(r'[A-Z]')) && token.contains(RegExp(r'[a-z]')) || - token.contains('_') || - token.contains('-')) { - return '[REDACTED_TOKEN]'; - } - return token; - }); - return redacted; } diff --git a/lib/utils/log_redaction_manager.dart b/lib/utils/log_redaction_manager.dart new file mode 100644 index 00000000..f0aff940 --- /dev/null +++ b/lib/utils/log_redaction_manager.dart @@ -0,0 +1,143 @@ +class LogRedactionManager { + static final Set _tokens = {}; + static final Set _urls = {}; + static final Set _customValues = {}; + static final RegExp _ipv4Pattern = + RegExp(r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b'); + static final RegExp _ipv4HostPattern = + RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); + + /// Register a server access token or Plex.tv token for redaction. + static void registerToken(String? token) { + final normalized = _normalize(token); + if (normalized == null) return; + + _tokens.add(normalized); + + // Tokens often appear URL encoded in query params. + final encoded = Uri.encodeQueryComponent(normalized); + if (encoded != normalized) { + _tokens.add(encoded); + } + } + + /// Register the server/base URL currently in use. + static void registerServerUrl(String? url) { + final normalized = _normalize(url); + if (normalized == null) return; + + final uri = Uri.tryParse(normalized); + final host = uri?.host; + if (host != null && host.isNotEmpty && _isIpv4Like(host)) { + // Do not register full IP-based URLs; regex redaction handles them. + return; + } + + if (host == null && _isIpv4Like(normalized)) { + return; + } + + final strippedSlash = normalized.endsWith('/') + ? normalized.substring(0, normalized.length - 1) + : normalized; + + if (strippedSlash.isNotEmpty) { + _urls.add(strippedSlash); + _urls.add('$strippedSlash/'); // Include trailing slash variant. + } + + // Capture origin and host-level strings as well to cover most cases. + if (uri != null && uri.host.isNotEmpty) { + final origin = + '${uri.scheme.isEmpty ? 'https' : uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; + _urls.add(origin); + if (origin.endsWith('/')) { + _urls.add(origin.substring(0, origin.length - 1)); + } + } + } + + /// Register other sensitive values that need redaction. + static void registerCustomValue(String? value) { + final normalized = _normalize(value); + if (normalized == null) return; + _customValues.add(normalized); + } + + /// Reset any tracked sensitive values (e.g., on logout). + static void clearTrackedValues() { + _tokens.clear(); + _urls.clear(); + _customValues.clear(); + } + + /// Redact known sensitive values from the provided message. + static String redact(String message) { + var redacted = message; + + redacted = redacted.replaceAllMapped( + _ipv4Pattern, + (match) => _maskIpv4(match.group(1)!, match.group(2)!, match.group(5)!), + ); + + for (final url in _urls) { + redacted = redacted.replaceAll(url, _maskUrlPreview(url)); + } + + for (final token in _tokens) { + redacted = redacted.replaceAll(token, '[REDACTED_TOKEN]'); + } + + for (final custom in _customValues) { + redacted = redacted.replaceAll(custom, '[REDACTED]'); + } + + return redacted; + } + + static String? _normalize(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + return trimmed; + } + + static bool _isIpv4Like(String value) { + return _ipv4HostPattern.hasMatch(value); + } + + static String _maskIpv4(String first, String separator, String last) { + return '$first$separator' + 'x$separator' + 'x$separator' + '$last'; + } + + static String _maskUrlPreview(String url) { + const startPreviewLength = 12; + const endPreviewLength = 8; + + if (url.isEmpty) { + return '[REDACTED_URL]'; + } + + if (url.length <= 4) { + return '[REDACTED_URL]'; + } + + final startLength = url.length <= startPreviewLength + ? (url.length / 2).ceil() + : startPreviewLength; + final remainingForEnd = url.length - startLength; + final endLength = + remainingForEnd <= endPreviewLength ? remainingForEnd : endPreviewLength; + + final start = url.substring(0, startLength); + if (endLength <= 0) { + return '$start...[REDACTED_URL]'; + } + + final end = url.substring(url.length - endLength); + return '$start...[REDACTED_URL]...$end'; + } +}