diff --git a/backend/src/controllers/auditLog.controller.ts b/backend/src/controllers/auditLog.controller.ts index f7322897..78670655 100644 --- a/backend/src/controllers/auditLog.controller.ts +++ b/backend/src/controllers/auditLog.controller.ts @@ -64,43 +64,50 @@ function pruefeText(wert: unknown, feld: string, maxLaenge = 200): string | unde return wert; } +/** + * Filter aus der Query lesen – EINE Stelle fuer Liste und Export (R186-01). + * + * Vorher pflegte jeder Endpunkt seine eigene Liste, und die des Exports war + * kuerzer: `userId`, `customerId`, `dataSubjectId`, `resourceId`, `success` und + * `search` wurden dort stillschweigend verworfen. Ein bewusst eingegrenzter + * Export – „nur die Spur von Benutzer X“ fuer eine DSGVO-Auskunft oder eine + * Innentaeter-Pruefung – lieferte damit das VOLLSTAENDIGE Protokoll aller + * Nutzer zurueck. Mit HTTP 200 und ohne jeden Hinweis: ein beruhigendes + * Signal ueber einem Ergebnis, das genau das Gegenteil dessen ist, was + * angefragt wurde. Auf einem datenminimierungspflichtigen Export ist das + * nicht nur ein fehlender Filter, sondern eine Weitergabe. + * + * Zwei Listen, die dasselbe bedeuten sollen, laufen frueher oder spaeter + * auseinander. Deshalb gibt es jetzt nur noch diese eine. + */ +function leseFilter(req: AuthRequest) { + const q = req.query; + return { + userId: pruefeZahl(q.userId, 'userId'), + customerId: pruefeZahl(q.customerId, 'customerId'), + dataSubjectId: pruefeZahl(q.dataSubjectId, 'dataSubjectId'), + action: pruefeEnum(q.action, AUDIT_ACTIONS, 'action'), + sensitivity: pruefeEnum(q.sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'), + resourceType: pruefeText(q.resourceType, 'resourceType', 100), + resourceId: pruefeText(q.resourceId, 'resourceId', 100), + startDate: pruefeDatum(q.startDate, 'startDate'), + endDate: pruefeDatum(q.endDate, 'endDate'), + success: q.success !== undefined ? q.success === 'true' : undefined, + search: pruefeText(q.search, 'search'), + }; +} + /** * Audit-Logs mit Filtern abrufen */ export async function getAuditLogs(req: AuthRequest, res: Response) { try { - const { - userId, - customerId, - dataSubjectId, - action, - sensitivity, - resourceType, - resourceId, - startDate, - endDate, - success, - search, - page, - limit, - } = req.query; - const result = await auditService.searchAuditLogs({ - userId: pruefeZahl(userId, 'userId'), - customerId: pruefeZahl(customerId, 'customerId'), - dataSubjectId: pruefeZahl(dataSubjectId, 'dataSubjectId'), - action: pruefeEnum(action, AUDIT_ACTIONS, 'action'), - sensitivity: pruefeEnum(sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'), - resourceType: pruefeText(resourceType, 'resourceType', 100), - resourceId: pruefeText(resourceId, 'resourceId', 100), - startDate: pruefeDatum(startDate, 'startDate'), - endDate: pruefeDatum(endDate, 'endDate'), - success: success !== undefined ? success === 'true' : undefined, - search: pruefeText(search, 'search'), - page: pruefeZahl(page, 'page') || 1, + ...leseFilter(req), + page: pruefeZahl(req.query.page, 'page') || 1, // Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem // Zug ziehen, an der Seitenlogik vorbei. - limit: Math.min(pruefeZahl(limit, 'limit') || 50, 200), + limit: Math.min(pruefeZahl(req.query.limit, 'limit') || 50, 200), }); res.json({ success: true, ...result }); @@ -163,24 +170,7 @@ export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) { export async function exportAuditLogs(req: AuthRequest, res: Response) { try { const format = req.query.format === 'csv' ? 'csv' : 'json'; - const { - action, - sensitivity, - resourceType, - startDate, - endDate, - } = req.query; - - const content = await auditService.exportAuditLogs( - { - action: pruefeEnum(action, AUDIT_ACTIONS, 'action'), - sensitivity: pruefeEnum(sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'), - resourceType: pruefeText(resourceType, 'resourceType', 100), - startDate: pruefeDatum(startDate, 'startDate'), - endDate: pruefeDatum(endDate, 'endDate'), - }, - format - ); + const content = await auditService.exportAuditLogs(leseFilter(req), format); if (format === 'csv') { const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`; diff --git a/docs/todo.md b/docs/todo.md index 12a669fa..6914c759 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,35 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **📤 Eingegrenzter Export lieferte alles (Pentest R186-01, MEDIUM)** (2026-08-26) + - Der Tester fand: `GET /audit-logs/export?userId=…` filterte **nicht** – + `userId=999999` gab alle 2761 Datensätze zurück, byte-identisch zum + ungefilterten Export. HTTP 200, sah korrekt aus. + - **Ursache war schlimmer als der Befund.** Nicht `userId` allein fehlte: + der Export-Controller pflegte eine **eigene, kürzere** Filterliste und + verwarf still `userId`, `customerId`, `dataSubjectId`, `resourceId`, + `success` **und** `search`. Der Service konnte alle sechs – sie kamen + nie bei ihm an. + - **Dieselbe Lücke ein drittes Mal in der Oberfläche:** Der CSV-Knopf baute + seine Parameter nochmal von Hand, mit wieder anderen fünf Feldern. Wer im + Suchfeld eingrenzte und dann CSV klickte, bekam das gesamte Protokoll + statt seiner Auswahl. + - **Warum das mehr ist als ein fehlender Filter:** Ein bewusst eingegrenzter + Export – „nur die Spur von Benutzer X" für eine DSGVO-Auskunft oder eine + Innentäter-Prüfung – gab das vollständige Protokoll **aller** Nutzer + heraus, mit einem beruhigenden 200. Auf einem datenminimierungspflichtigen + Pfad ist das eine Weitergabe, kein Schönheitsfehler. + - **Fix ist strukturell, nicht punktuell:** ein gemeinsamer `leseFilter(req)` + für Liste und Export; die Oberfläche schickt alle aktiven Filter statt + einer handgepflegten Auswahl. Drei Listen, die dasselbe bedeuten sollen, + laufen früher oder später auseinander – jetzt gibt es nur noch eine. + - Geprüft über HTTP: Export und Liste liefern für `userId`, `action`, + `search`, `success`, `resourceType` **identische** Treffermengen; auf dem + Export-Pfad gilt jetzt dieselbe Validierung (`userId=abc` → 400 statt + 200). CSV-Pfad gegengeprüft. + - Dateien: `backend/src/controllers/auditLog.controller.ts`, + `frontend/src/pages/settings/AuditLogs.tsx` + - [x] **🚨 Siegelwechsel ist ein Alarm, kein Hinweis + Filter-Validierung (Pentest R185)** (2026-08-26) - **R185-01 (MEDIUM)** – Die Flanke, die wir dem Tester selbst gemeldet hatten, hat er live bestätigt: `seal-backlog` war beim **zweiten** Aufruf diff --git a/frontend/src/pages/settings/AuditLogs.tsx b/frontend/src/pages/settings/AuditLogs.tsx index 0b9748f5..1a1073e7 100644 --- a/frontend/src/pages/settings/AuditLogs.tsx +++ b/frontend/src/pages/settings/AuditLogs.tsx @@ -308,11 +308,16 @@ export default function AuditLogs() { const downloadToken = await authApi.getDownloadToken(); const params = new URLSearchParams(); params.set('format', 'csv'); - if (filters.action) params.set('action', filters.action); - if (filters.sensitivity) params.set('sensitivity', filters.sensitivity); - if (filters.resourceType) params.set('resourceType', filters.resourceType); - if (filters.startDate) params.set('startDate', filters.startDate); - if (filters.endDate) params.set('endDate', filters.endDate); + // ALLE aktiven Filter mitgeben (Pentest R186-01). Vorher standen hier + // nur fuenf – wer im Suchfeld eingrenzte und dann CSV klickte, bekam + // stillschweigend das gesamte Protokoll statt seiner Auswahl. + // `page`/`limit` gehoeren nicht dazu: der Export ist bewusst + // vollstaendig ueber die gefilterte Menge. + for (const [schluessel, wert] of Object.entries(filters)) { + if (schluessel === 'page' || schluessel === 'limit') continue; + if (wert === undefined || wert === null || wert === '') continue; + params.set(schluessel, String(wert)); + } window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank'); } else { const result = await auditLogApi.export({ ...filters, format });