Merge: Datenschutz-Log-Bereinigung (Scrubber + Quelle)
This commit is contained in:
@@ -5,6 +5,26 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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 (`<path>`), nackte URLs (`<url>`), `Bearer`-Token,
|
||||||
|
JWTs (`<jwt>`) und lange Hex-IDs (`<hex>`).
|
||||||
|
- `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)
|
### 🔧 Log-Upload System - Fixes (2026-08-19)
|
||||||
- ✅ **ERROR-Level Logging**: Fehler werden SOFORT zum Server hochgeladen (async/await Fixed)
|
- ✅ **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)
|
- ✅ **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)
|
||||||
|
|||||||
@@ -317,6 +317,21 @@ Phase 2: Offline + Caching (Built on 1)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🔒 Datenschutz-Backlog (Folge-Tasks)
|
||||||
|
|
||||||
|
- [ ] **Diagnose-Logging auf Opt-in umstellen** (Folge zu Branch
|
||||||
|
`feature/log-datenschutz-scrubber`). Aktuell werden Logs standardmäßig an
|
||||||
|
`logs.baka-net.de` gesendet (jetzt zwar bereinigt, aber ohne Einwilligung).
|
||||||
|
Nötig:
|
||||||
|
- Consent-Flag in `SharedPreferences` (Default: **aus**), im Onboarding /
|
||||||
|
in den Einstellungen abfragen.
|
||||||
|
- `LoggerService._uploadLogs()` respektiert das Flag (kein Upload ohne
|
||||||
|
Einwilligung; lokaler `debugPrint`/Puffer bleibt).
|
||||||
|
- Optional: „Logs jetzt senden"-Button + Hinweistext (was wird gesendet).
|
||||||
|
- Priorität: P1 (rechtlich relevant vor öffentlichem Launch).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📚 Referenzen
|
## 📚 Referenzen
|
||||||
|
|
||||||
- Design-Decisions: `/home/dustin/.claude/projects/-home-dustin-mello-dev/memory/project_mello-v3-design.md`
|
- Design-Decisions: `/home/dustin/.claude/projects/-home-dustin-mello-dev/memory/project_mello-v3-design.md`
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -42,21 +41,61 @@ class LoggerService extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
try {
|
// Datenschutz: nur grobe Plattform senden statt Hersteller+Modell
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
// (das war ein Geräte-Fingerprint). Reicht für Bug-Triage.
|
||||||
if (Platform.isAndroid) {
|
_deviceModel = Platform.isAndroid
|
||||||
final info = await deviceInfo.androidInfo;
|
? 'android'
|
||||||
_deviceModel = '${info.manufacturer} ${info.model}';
|
: (Platform.isIOS ? 'ios' : Platform.operatingSystem);
|
||||||
} else {
|
|
||||||
_deviceModel = 'Unknown Device';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_deviceModel = 'Unknown Device';
|
|
||||||
}
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
debugPrint('✅ LoggerService initialized for $_deviceModel');
|
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)}=<redacted>',
|
||||||
|
);
|
||||||
|
s = s
|
||||||
|
.replaceAll(RegExp(r'/(storage|data|home|Users|var)/\S+'), '<path>')
|
||||||
|
.replaceAll(RegExp(r'https?://\S+'), '<url>')
|
||||||
|
.replaceAll(RegExp(r'Bearer\s+[A-Za-z0-9._-]+'), 'Bearer <token>')
|
||||||
|
.replaceAll(RegExp(r'eyJ[A-Za-z0-9._-]{10,}'), '<jwt>')
|
||||||
|
.replaceAll(RegExp(r'\b[A-Fa-f0-9]{16,}\b'), '<hex>');
|
||||||
|
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<String, dynamic> 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)
|
/// Logge einen Fehler (wird sofort zum Server geschickt)
|
||||||
Future<void> error(String message, [Object? error, StackTrace? stackTrace]) async {
|
Future<void> error(String message, [Object? error, StackTrace? stackTrace]) async {
|
||||||
final entry = LogEntry(
|
final entry = LogEntry(
|
||||||
@@ -114,7 +153,7 @@ class LoggerService extends ChangeNotifier {
|
|||||||
Uri.parse(_logServerUrl),
|
Uri.parse(_logServerUrl),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'logs': logsToUpload.map((l) => l.toJson()).toList(),
|
'logs': logsToUpload.map(sanitizedJson).toList(),
|
||||||
'timestamp': DateTime.now().toIso8601String(),
|
'timestamp': DateTime.now().toIso8601String(),
|
||||||
}),
|
}),
|
||||||
).timeout(const Duration(seconds: 10));
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|||||||
@@ -263,23 +263,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
: () async {
|
: () async {
|
||||||
setDialogState(() => verbindet = true);
|
setDialogState(() => verbindet = true);
|
||||||
logger.info(
|
logger.info(
|
||||||
'Login-Versuch: url=${urlCtrl.text} user=$selectedUser '
|
'Navidrome Login-Versuch (pwLeer=${passCtrl.text.isEmpty})');
|
||||||
'pwLeer=${passCtrl.text.isEmpty}');
|
|
||||||
_navidrome.setCredentials(
|
_navidrome.setCredentials(
|
||||||
urlCtrl.text, selectedUser, passCtrl.text);
|
urlCtrl.text, selectedUser, passCtrl.text);
|
||||||
final ok = await _navidrome.ping();
|
final ok = await _navidrome.ping();
|
||||||
setDialogState(() => verbindet = false);
|
setDialogState(() => verbindet = false);
|
||||||
if (ok && ctx.mounted) {
|
if (ok && ctx.mounted) {
|
||||||
logger.info(
|
logger.info('Navidrome Login erfolgreich');
|
||||||
'Login erfolgreich: url=${urlCtrl.text} user=$selectedUser');
|
|
||||||
await _navidrome.speichereZugangsdaten(
|
await _navidrome.speichereZugangsdaten(
|
||||||
urlCtrl.text, selectedUser, passCtrl.text);
|
urlCtrl.text, selectedUser, passCtrl.text);
|
||||||
if (ctx.mounted) Navigator.pop(ctx);
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
} else if (ctx.mounted) {
|
} else if (ctx.mounted) {
|
||||||
await logger.error(
|
await logger.error(
|
||||||
'Login fehlgeschlagen: url=${urlCtrl.text} '
|
'Navidrome Login fehlgeschlagen (pwLeer=${passCtrl.text.isEmpty})');
|
||||||
'user=$selectedUser pwLeer=${passCtrl.text.isEmpty}');
|
|
||||||
setDialogState(() =>
|
setDialogState(() =>
|
||||||
fehler = '❌ Login fehlgeschlagen');
|
fehler = '❌ Login fehlgeschlagen');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=<redacted> ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server-URL (url=) wird redigiert', () {
|
||||||
|
expect(LoggerService.scrubText('verbinde url=https://musik.baka-net.de'),
|
||||||
|
contains('url=<redacted>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Passwort/Token/Secret werden redigiert', () {
|
||||||
|
expect(LoggerService.scrubText('password=geheim123'), 'password=<redacted>');
|
||||||
|
expect(LoggerService.scrubText('token=abc.def'), 'token=<redacted>');
|
||||||
|
expect(LoggerService.scrubText('secret=xyz'), 'secret=<redacted>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 <path> ok');
|
||||||
|
expect(LoggerService.scrubText('crash /home/dustin/secret/x.dart'),
|
||||||
|
'crash <path>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nackte URLs werden maskiert', () {
|
||||||
|
expect(LoggerService.scrubText('GET https://logs.baka-net.de/api/logs'),
|
||||||
|
'GET <url>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Bearer-Token, JWT und lange Hex-IDs werden maskiert', () {
|
||||||
|
expect(LoggerService.scrubText('Authorization: Bearer abc.def-ghi'),
|
||||||
|
'Authorization: Bearer <token>');
|
||||||
|
expect(LoggerService.scrubText('t=eyJhbGciOiJIUzI1NiIsdummydummy'),
|
||||||
|
contains('<jwt>'));
|
||||||
|
final s = LoggerService.scrubText('id deadbeefdeadbeef12');
|
||||||
|
expect(s.contains('deadbeefdeadbeef12'), isFalse);
|
||||||
|
expect(s.contains('<hex>'), 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user