refactor: better log redaction
This commit is contained in:
@@ -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<void> _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<void> saveServerUrl(String url) async {
|
||||
await _prefs.setString(_keyServerUrl, url);
|
||||
LogRedactionManager.registerServerUrl(url);
|
||||
}
|
||||
|
||||
String? getServerUrl() {
|
||||
@@ -46,6 +54,7 @@ class StorageService {
|
||||
// Server Access Token
|
||||
Future<void> 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<void> 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
class LogRedactionManager {
|
||||
static final Set<String> _tokens = <String>{};
|
||||
static final Set<String> _urls = <String>{};
|
||||
static final Set<String> _customValues = <String>{};
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user