From d83f8c8c54287d2c7744d9db10236ad66f8d61d5 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 20 Aug 2026 01:38:56 +0200 Subject: [PATCH] Datenschutz: Log-Bereinigung vor Server-Upload (Scrubber + Quelle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnose-Logs werden zentral bereinigt, bevor sie an logs.baka-net.de gehen. Lokaler debugPrint bleibt vollständig. - Freitext-Scrubber (LoggerService.scrubText) für message + stackTrace: maskiert user=/pw=/password=/token=/secret=/url= sowie Datei-Pfade, nackte URLs, Bearer-Token, JWTs und lange Hex-IDs. - stackTrace auf 8 Zeilen gekürzt (capFrames). - deviceModel nur noch grobe Plattform (android/ios) statt Hersteller+Modell; device_info_plus im Logger entfernt. - Quelle entschärft: settings_screen loggte Navidrome-Login mit Klartext user=/url= — jetzt nur neutraler Status inkl. pwLeer-Flag. - Neue Tests: test/logger_service_test.dart (9). 79/79 grün, analyze sauber. Opt-in/Einwilligung bleibt separater Folgeschritt. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Tr2UwurtxyJUpWpBSeVmBm --- CHANGELOG.md | 20 +++++++++ lib/services/logger_service.dart | 65 ++++++++++++++++++++++++------ lib/settings/settings_screen.dart | 9 ++--- test/logger_service_test.dart | 67 +++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 19 deletions(-) create mode 100644 test/logger_service_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf865a..af16cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🔒 Datenschutz: Log-Bereinigung vor Server-Upload (2026-08-20) +- ✅ **Was:** Diagnose-Logs werden jetzt zentral bereinigt, bevor sie an + `logs.baka-net.de` gehen. Der lokale `debugPrint` bleibt vollständig. +- 🔧 **Details:** + - Freitext-Scrubber für `message` + `stackTrace`: maskiert sensible + `key=value`-Fragmente (`user=`, `pw=`, `password=`, `token=`, `secret=`, + `url=`), Datei-Pfade (``), nackte URLs (``), `Bearer`-Token, + JWTs (``) und lange Hex-IDs (``). + - `stackTrace` wird auf max. 8 Zeilen gekürzt. + - `deviceModel` sendet nur noch die grobe Plattform (`android`/`ios`) statt + Hersteller+Modell (war ein Geräte-Fingerprint) — `device_info_plus` im + Logger entfernt. +- 🐛 **Fix (Quelle):** Der Navidrome-Login in `settings_screen` loggte + Benutzername + Server-URL im Klartext (`Login: url=… user=…`). Diese + Aufrufe senden jetzt nur noch den neutralen Status inkl. `pwLeer`-Flag. +- 🧪 **Tests:** neue Suite `test/logger_service_test.dart` (9 Tests) — 79/79 grün, + `flutter analyze` ohne Issues. +- ℹ️ **Offen:** Opt-in/Einwilligung für den Diagnose-Versand bleibt ein + separater Folgeschritt (braucht UI). + ### 🔧 Log-Upload System - Fixes (2026-08-19) - ✅ **ERROR-Level Logging**: Fehler werden SOFORT zum Server hochgeladen (async/await Fixed) - ✅ **Navidrome-Service Error Logging**: Alle API-Fehler (ping, getAlben, getSongs, getFavorites, getArtists, getArtistSongs, getLyrics, scrobble, getBookmarks, getPlaylists, getPlaylistSongs) werden jetzt mit `logger.error()` zum Server geloggt (statt nur debugPrint) diff --git a/lib/services/logger_service.dart b/lib/services/logger_service.dart index 856d0d9..33f3e77 100644 --- a/lib/services/logger_service.dart +++ b/lib/services/logger_service.dart @@ -1,6 +1,5 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; -import 'package:device_info_plus/device_info_plus.dart'; import 'dart:convert'; import 'dart:io'; @@ -42,21 +41,61 @@ class LoggerService extends ChangeNotifier { Future init() async { if (_initialized) return; - try { - final deviceInfo = DeviceInfoPlugin(); - if (Platform.isAndroid) { - final info = await deviceInfo.androidInfo; - _deviceModel = '${info.manufacturer} ${info.model}'; - } else { - _deviceModel = 'Unknown Device'; - } - } catch (e) { - _deviceModel = 'Unknown Device'; - } + // Datenschutz: nur grobe Plattform senden statt Hersteller+Modell + // (das war ein Geräte-Fingerprint). Reicht für Bug-Triage. + _deviceModel = Platform.isAndroid + ? 'android' + : (Platform.isIOS ? 'ios' : Platform.operatingSystem); _initialized = true; debugPrint('✅ LoggerService initialized for $_deviceModel'); } + // ─── Datenschutz: Bereinigung vor dem Server-Upload ─── + + static const int _maxFrames = 8; + + /// Maskiert sensible Fragmente in Log-Freitext (message/stackTrace), bevor + /// er an den Server geht: sensible `key=value`-Fragmente, Datei-Pfade, + /// nackte URLs, Bearer-Token, JWTs und lange Hex-IDs. + @visibleForTesting + static String scrubText(String? roh) { + if (roh == null || roh.isEmpty) return roh ?? ''; + var s = roh.replaceAllMapped( + RegExp(r'\b(user|username|benutzer|pw|pass|passwort|password|token|secret|url|serverurl)=([^\s&]+)', + caseSensitive: false), + (m) => '${m.group(1)}=', + ); + s = s + .replaceAll(RegExp(r'/(storage|data|home|Users|var)/\S+'), '') + .replaceAll(RegExp(r'https?://\S+'), '') + .replaceAll(RegExp(r'Bearer\s+[A-Za-z0-9._-]+'), 'Bearer ') + .replaceAll(RegExp(r'eyJ[A-Za-z0-9._-]{10,}'), '') + .replaceAll(RegExp(r'\b[A-Fa-f0-9]{16,}\b'), ''); + return s; + } + + /// Kürzt einen (bereits gescrubbten) Stack-Trace auf wenige Zeilen. + @visibleForTesting + static String capFrames(String s) { + final zeilen = s.split('\n'); + if (zeilen.length <= _maxFrames) return s; + final rest = zeilen.length - _maxFrames; + return '${zeilen.take(_maxFrames).join('\n')}\n… ($rest weitere Zeilen entfernt)'; + } + + /// Bereinigter Upload-Payload eines Eintrags. Der lokale Puffer/debugPrint + /// bleibt unberührt — die Maskierung greift nur auf dem Versand-Pfad. + @visibleForTesting + static Map sanitizedJson(LogEntry l) => { + 'level': l.level, + 'message': scrubText(l.message), + 'stackTrace': + l.stackTrace == null ? null : capFrames(scrubText(l.stackTrace)), + 'timestamp': l.timestamp.toIso8601String(), + 'userAgent': l.userAgent, + 'deviceModel': l.deviceModel, + }; + /// Logge einen Fehler (wird sofort zum Server geschickt) Future error(String message, [Object? error, StackTrace? stackTrace]) async { final entry = LogEntry( @@ -114,7 +153,7 @@ class LoggerService extends ChangeNotifier { Uri.parse(_logServerUrl), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ - 'logs': logsToUpload.map((l) => l.toJson()).toList(), + 'logs': logsToUpload.map(sanitizedJson).toList(), 'timestamp': DateTime.now().toIso8601String(), }), ).timeout(const Duration(seconds: 10)); diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 0e98779..774823e 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -263,23 +263,20 @@ class _SettingsScreenState extends State { : () async { setDialogState(() => verbindet = true); logger.info( - 'Login-Versuch: url=${urlCtrl.text} user=$selectedUser ' - 'pwLeer=${passCtrl.text.isEmpty}'); + 'Navidrome Login-Versuch (pwLeer=${passCtrl.text.isEmpty})'); _navidrome.setCredentials( urlCtrl.text, selectedUser, passCtrl.text); final ok = await _navidrome.ping(); setDialogState(() => verbindet = false); if (ok && ctx.mounted) { - logger.info( - 'Login erfolgreich: url=${urlCtrl.text} user=$selectedUser'); + logger.info('Navidrome Login erfolgreich'); await _navidrome.speichereZugangsdaten( urlCtrl.text, selectedUser, passCtrl.text); if (ctx.mounted) Navigator.pop(ctx); if (mounted) setState(() {}); } else if (ctx.mounted) { await logger.error( - 'Login fehlgeschlagen: url=${urlCtrl.text} ' - 'user=$selectedUser pwLeer=${passCtrl.text.isEmpty}'); + 'Navidrome Login fehlgeschlagen (pwLeer=${passCtrl.text.isEmpty})'); setDialogState(() => fehler = '❌ Login fehlgeschlagen'); } diff --git a/test/logger_service_test.dart b/test/logger_service_test.dart new file mode 100644 index 0000000..358ad19 --- /dev/null +++ b/test/logger_service_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/services/logger_service.dart'; + +/// Sicherheits-Tests für die Log-Bereinigung vor dem Server-Upload +/// (logs.baka-net.de). Der Logger ist freitextbasiert, daher wird der +/// message-/stackTrace-Text zentral maskiert, bevor er das Gerät verlässt. +void main() { + group('LoggerService.scrubText – sensible Fragmente', () { + test('Benutzername (user=) wird redigiert', () { + expect(LoggerService.scrubText('Login user=Dustin ok'), + 'Login user= ok'); + }); + + test('Server-URL (url=) wird redigiert', () { + expect(LoggerService.scrubText('verbinde url=https://musik.baka-net.de'), + contains('url=')); + }); + + test('Passwort/Token/Secret werden redigiert', () { + expect(LoggerService.scrubText('password=geheim123'), 'password='); + expect(LoggerService.scrubText('token=abc.def'), 'token='); + expect(LoggerService.scrubText('secret=xyz'), 'secret='); + }); + + test('pwLeer=false (bool-Flag) bleibt erhalten', () { + expect(LoggerService.scrubText('Login pwLeer=false'), 'Login pwLeer=false'); + }); + }); + + group('LoggerService.scrubText – Pfade/URLs/Token', () { + test('Datei-Pfade werden maskiert', () { + expect(LoggerService.scrubText('lese /storage/emulated/0/m.mp3 ok'), + 'lese ok'); + expect(LoggerService.scrubText('crash /home/dustin/secret/x.dart'), + 'crash '); + }); + + test('Nackte URLs werden maskiert', () { + expect(LoggerService.scrubText('GET https://logs.baka-net.de/api/logs'), + 'GET '); + }); + + test('Bearer-Token, JWT und lange Hex-IDs werden maskiert', () { + expect(LoggerService.scrubText('Authorization: Bearer abc.def-ghi'), + 'Authorization: Bearer '); + expect(LoggerService.scrubText('t=eyJhbGciOiJIUzI1NiIsdummydummy'), + contains('')); + final s = LoggerService.scrubText('id deadbeefdeadbeef12'); + expect(s.contains('deadbeefdeadbeef12'), isFalse); + expect(s.contains(''), isTrue); + }); + }); + + group('LoggerService.capFrames', () { + test('kürzt lange Stack-Traces auf wenige Zeilen', () { + final viele = List.generate(20, (i) => 'frame $i').join('\n'); + final s = LoggerService.capFrames(viele); + expect(s.contains('weitere Zeilen entfernt'), isTrue); + expect(s.contains('frame 19'), isFalse); + }); + + test('kurze Traces bleiben unverändert', () { + const kurz = 'a\nb\nc'; + expect(LoggerService.capFrames(kurz), kurz); + }); + }); +}