refactor: better log redaction

This commit is contained in:
edde746
2025-11-15 00:30:04 +01:00
parent 0101dbde71
commit 831552d5b8
3 changed files with 161 additions and 57 deletions
+11
View File
@@ -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
+7 -57
View File
@@ -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;
}
+143
View File
@@ -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';
}
}