Files
opencrm/frontend/src/pages/settings/Monitoring.tsx
T
duffyduckandClaude Opus 5 e81a83ae8f Siegelwechsel ist ein Alarm, kein Hinweis (Pentest R185-01/-02)
R185-01 (MEDIUM): Die Flanke, die wir selbst gemeldet hatten, hat der
Tester live bestaetigt. seal-backlog war beim ZWEITEN Aufruf genauso
gegatet wie beim ersten ({"confirm":"SEAL"} -> 200), und das Ereignis
landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
automatische Rueckhalt des Gegenbuchs haengt an `valid` - und `valid`
ueberlebt ein ersetzendes Siegel per Konstruktion. Angriff: Altzeile per
DB-Zugriff loeschen, neu siegeln, Luecke ist beglaubigt, valid wieder
true. Live reproduziert.

Zwei Schichten, in seiner Reihenfolge:

1. Alarmkanal. Neuer SecurityEventType AUDIT_SEAL_CHANGED (Migration
   20260826120000). Erstes Siegeln HIGH, Ersetzen CRITICAL - geht damit
   ueber sendPendingCriticalAlerts sofort per Mail raus. Die Details
   halten Wurzel vorher/nachher und den vollstaendigen Vorbefund fest.
2. Gate. Steht bereits ein Siegel, verlangt der Endpunkt
   {"confirm":"RESEAL"} statt SEAL, mit einem Text, der sagt, was dabei
   verloren geht. Ein Austausch der Beweisgrundlage soll nicht dasselbe
   Wort haben wie das Einrichten.

Und im Gegenbuch selbst: dort stand fuer den Wurzelwechsel ein
console.warn, waehrend der Rueckgabecode auf 0 blieb - also exakt das
Muster, das wir dem CRM zweimal angekreidet haben (R179, R183-02), im
Werkzeug, das dagegen gebaut wurde. Jetzt exit 2, mit alter und neuer
Wurzel samt Blattzahl; "10 Blaetter -> 9 Blaetter" zeigt die Loeschung
sofort. Auch die Erstsiegelung meldet sich, statt stillschweigend
uebernommen zu werden.

Aufloesbar gemacht: der Alarm bricht ab, BEVOR angehaengt wird - ohne
Bestaetigungsweg haette auch ein legitimes Siegeln fuer immer alarmiert
(R183-03-Falle). Neu ist NOTARY_SEAL_ACK, bewusst nicht "true", sondern
die Wurzel selbst (mind. 16 Zeichen): ein stehen gelassener Wert passt
beim naechsten Wechsel nicht mehr und kann keinen weiteren Austausch
durchwinken.

R185-02 (LOW): GET /api/audit-logs?action=<x> gab ungueltige Enum-Werte
roh an die Spalte -> 500. Zweifach schlecht: fehlende Validierung und
Fehler-Orakel (200 vs 500 verraet die Enum-Mitglieder). Jetzt 400 mit
der erlaubten Menge im Klartext. Mitgenommen: sensitivity, Datumsfelder,
Zahlenfelder, Textlaengen und ein Deckel auf limit (200), ueber den sich
sonst die ganze Tabelle an der Seitenlogik vorbei ziehen liess. Beide
Endpunkte.

Getestet ueber HTTP gegen eine Wegwerf-DB, inkl. echtem Gegenbuch-Lauf
mit SSH-signiertem lokalem Repo. Zusaetzlich nachgeholt, was der Tester
nicht herstellen konnte: vollstaendig unsigniertes Protokoll ->
kein_siegel statt der frueheren falschen Entwarnung nicht_noetig, und
seal-backlog nennt den fehlenden Schluessel als naechsten Schritt.
Gegenrichtung geprueft, R183-03 bleibt behoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:41:11 +02:00

435 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { monitoringApi, type SecurityEventType, type SecuritySeverity } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import Modal from '../../components/ui/Modal';
import { ArrowLeft, Send, RefreshCw, Mail, ShieldAlert, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Trash2 } from 'lucide-react';
/**
* Liefert die anzuzeigenden Seitenzahlen für die Pagination.
* Bis zu 10 Seitenzahlen, current möglichst mittig.
*/
function paginationWindow(current: number, total: number, size = 10): number[] {
if (total <= size) return Array.from({ length: total }, (_, i) => i + 1);
let start = Math.max(1, current - Math.floor(size / 2));
let end = start + size - 1;
if (end > total) {
end = total;
start = end - size + 1;
}
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
{ value: '', label: 'Alle Typen' },
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
{ value: 'LOGIN_SUCCESS', label: 'Login erfolgreich' },
{ value: 'RATE_LIMIT_HIT', label: 'Rate-Limit greift' },
{ value: 'ACCESS_DENIED', label: 'Zugriff verweigert (IDOR)' },
{ value: 'SSRF_BLOCKED', label: 'SSRF blockiert' },
{ value: 'PASSWORD_RESET_REQUEST', label: 'Passwort-Reset angefordert' },
{ value: 'PASSWORD_RESET_CONFIRM', label: 'Passwort-Reset bestätigt' },
{ value: 'LOGOUT', label: 'Logout' },
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
{ value: 'AUDIT_SEAL_CHANGED', label: 'Bestandssiegel gesetzt/ersetzt' },
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
];
const SEVERITY_OPTIONS: { value: SecuritySeverity | ''; label: string }[] = [
{ value: '', label: 'Alle Stufen' },
{ value: 'INFO', label: 'Info' },
{ value: 'LOW', label: 'Niedrig' },
{ value: 'MEDIUM', label: 'Mittel' },
{ value: 'HIGH', label: 'Hoch' },
{ value: 'CRITICAL', label: 'Kritisch' },
];
function severityClass(s: SecuritySeverity): string {
switch (s) {
case 'CRITICAL': return 'bg-red-100 text-red-800 border border-red-300';
case 'HIGH': return 'bg-orange-100 text-orange-800 border border-orange-300';
case 'MEDIUM': return 'bg-yellow-100 text-yellow-800 border border-yellow-300';
case 'LOW': return 'bg-blue-100 text-blue-800 border border-blue-300';
default: return 'bg-gray-100 text-gray-700 border border-gray-300';
}
}
function severityIcon(s: SecuritySeverity): string {
switch (s) {
case 'CRITICAL': return '🚨';
case 'HIGH': return '⚠️';
case 'MEDIUM': return '🟡';
case 'LOW': return '🟢';
default: return '️';
}
}
export default function Monitoring() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [filters, setFilters] = useState({
type: '' as SecurityEventType | '',
severity: '' as SecuritySeverity | '',
search: '',
ip: '',
});
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearOlderThanDays, setClearOlderThanDays] = useState<number | ''>('');
const [alertEmail, setAlertEmail] = useState('');
const [digestEnabled, setDigestEnabled] = useState(false);
// Settings laden
const { data: settingsData } = useQuery({
queryKey: ['monitoring-settings'],
queryFn: monitoringApi.getSettings,
});
// States nach Laden synchronisieren (nur initial)
if (settingsData?.data && alertEmail === '' && settingsData.data.alertEmail !== '') {
setAlertEmail(settingsData.data.alertEmail);
setDigestEnabled(settingsData.data.digestEnabled);
}
// Events laden
const { data: eventsData, isLoading: eventsLoading } = useQuery({
queryKey: ['monitoring-events', page, pageSize, filters],
queryFn: () => monitoringApi.getEvents({ page, limit: pageSize, ...filters }),
refetchInterval: 30_000, // alle 30s neu laden
});
const clearEvents = useMutation({
mutationFn: (olderThanDays?: number) => monitoringApi.clearEvents(olderThanDays),
onSuccess: (res) => {
toast.success(res.message || 'Events gelöscht');
setShowClearConfirm(false);
setClearOlderThanDays('');
queryClient.invalidateQueries({ queryKey: ['monitoring-events'] });
},
onError: (e: Error) => toast.error(e.message || 'Löschen fehlgeschlagen'),
});
const saveSettings = useMutation({
mutationFn: () => monitoringApi.updateSettings({ alertEmail, digestEnabled }),
onSuccess: () => {
toast.success('Einstellungen gespeichert');
queryClient.invalidateQueries({ queryKey: ['monitoring-settings'] });
},
onError: (e: Error) => toast.error(e.message || 'Speichern fehlgeschlagen'),
});
const testAlert = useMutation({
mutationFn: () => monitoringApi.testAlert(),
onSuccess: (res) => toast.success(res.message || 'Test-Alert versendet'),
onError: (e: Error) => toast.error(e.message || 'Test fehlgeschlagen'),
});
const runDigest = useMutation({
mutationFn: () => monitoringApi.runDigest(),
onSuccess: (res) => {
const r = res.data;
if (r?.sent) toast.success(`Digest mit ${r.eventCount} Events versendet`);
else toast(r?.reason || 'Kein Digest versendet', { icon: '️' });
},
onError: (e: Error) => toast.error(e.message || 'Digest fehlgeschlagen'),
});
const events = eventsData?.data || [];
const stats = eventsData?.stats;
const pagination = eventsData?.pagination;
return (
<div className="container mx-auto px-4 py-6 max-w-7xl">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate('/settings')} className="mb-2">
<ArrowLeft className="w-4 h-4 mr-1" /> Zurück zu Einstellungen
</Button>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ShieldAlert className="w-6 h-6 text-orange-500" /> Sicherheits-Monitoring
</h1>
<p className="text-gray-600 text-sm mt-1">
Sicherheitsrelevante Ereignisse + Alert-Einstellungen.
</p>
</div>
{/* Settings */}
<Card className="mb-6">
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Mail className="w-5 h-5" /> Alert-Empfänger
</h2>
<div className="grid sm:grid-cols-2 gap-4 mb-4">
<div>
<Input
label="E-Mail-Adresse für Alerts"
type="email"
value={alertEmail}
onChange={(e) => setAlertEmail(e.target.value)}
placeholder="security@deine-firma.de"
/>
<p className="text-xs text-gray-500 mt-1">Leer lassen, um Alerts zu deaktivieren.</p>
</div>
<div className="flex items-end gap-3">
<label className="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={digestEnabled}
onChange={(e) => setDigestEnabled(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm">Stündlicher Digest (HIGH+MEDIUM Events)</span>
</label>
</div>
</div>
<div className="flex gap-3 flex-wrap">
<Button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>
Speichern
</Button>
<Button variant="secondary" onClick={() => testAlert.mutate()} disabled={!alertEmail || testAlert.isPending}>
<Send className="w-4 h-4 mr-1" /> Test-Alert senden
</Button>
<Button variant="secondary" onClick={() => runDigest.mutate()} disabled={!alertEmail || runDigest.isPending}>
<RefreshCw className="w-4 h-4 mr-1" /> Digest jetzt ausführen
</Button>
{settingsData?.data?.lastDigestAt && (
<span className="text-xs text-gray-500 self-center">
Letzter Digest: {new Date(settingsData.data.lastDigestAt).toLocaleString('de-DE')}
</span>
)}
</div>
<div className="mt-3 text-xs text-gray-600">
<strong>Sofort-Alert:</strong> CRITICAL-Events (z.B. Brute-Force-Verdacht) werden binnen 1 Minute per
E-Mail versendet.<br />
<strong>Digest:</strong> HIGH+MEDIUM-Events werden zur vollen Stunde gesammelt verschickt (wenn aktiviert).
</div>
</Card>
{/* Stats-Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'] as SecuritySeverity[]).map((sev) => (
<Card key={sev}>
<div className={`text-xs font-semibold ${severityClass(sev).split(' ').filter((c) => c.startsWith('text-'))[0]}`}>
{severityIcon(sev)} {sev}
</div>
<div className="text-2xl font-bold mt-1">{stats.bySeverity[sev] || 0}</div>
</Card>
))}
</div>
)}
{/* Filter */}
<Card className="mb-4">
<div className="grid sm:grid-cols-4 gap-3">
<Select
label="Typ"
value={filters.type}
onChange={(e) => { setFilters((f) => ({ ...f, type: e.target.value as any })); setPage(1); }}
options={TYPE_OPTIONS}
/>
<Select
label="Severity"
value={filters.severity}
onChange={(e) => { setFilters((f) => ({ ...f, severity: e.target.value as any })); setPage(1); }}
options={SEVERITY_OPTIONS}
/>
<Input
label="Suche (Nachricht/User/Endpoint)"
value={filters.search}
onChange={(e) => { setFilters((f) => ({ ...f, search: e.target.value })); setPage(1); }}
placeholder="z.B. admin@admin.com"
/>
<Input
label="IP-Adresse"
value={filters.ip}
onChange={(e) => { setFilters((f) => ({ ...f, ip: e.target.value })); setPage(1); }}
placeholder="z.B. 1.2.3.4"
/>
</div>
</Card>
{/* Tabelle */}
<Card>
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h2 className="text-lg font-semibold">Events</h2>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Pro Seite:</label>
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value)); setPage(1); }}
className="px-2 py-1 border border-gray-300 rounded text-sm"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
<Button
variant="secondary"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['monitoring-events'] })}
disabled={eventsLoading}
title="Events jetzt neu laden. Auto-Refresh läuft sonst alle 30 s im Hintergrund."
>
<RefreshCw className={`w-4 h-4 mr-1 ${eventsLoading ? 'animate-spin' : ''}`} />
Aktualisieren
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowClearConfirm(true)}>
<Trash2 className="w-4 h-4 mr-1" /> Log leeren
</Button>
</div>
</div>
{eventsLoading ? (
<div className="text-gray-500 py-4">Lade</div>
) : events.length === 0 ? (
<div className="text-gray-500 py-8 text-center">Keine Events für diese Filter.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-3 py-2 whitespace-nowrap">Zeit</th>
<th className="px-3 py-2">Severity</th>
<th className="px-3 py-2">Typ</th>
<th className="px-3 py-2">Nachricht</th>
<th className="px-3 py-2">Wer</th>
<th className="px-3 py-2">IP</th>
<th className="px-3 py-2">Endpoint</th>
<th className="px-3 py-2">Alert</th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id} className="border-t hover:bg-gray-50">
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
{new Date(e.createdAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })}
</td>
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${severityClass(e.severity)}`}>
{severityIcon(e.severity)} {e.severity}
</span>
</td>
<td className="px-3 py-2 font-mono text-xs">{e.type}</td>
<td className="px-3 py-2">{e.message}</td>
<td className="px-3 py-2 text-xs">{e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '')}</td>
<td className="px-3 py-2 font-mono text-xs">{e.ipAddress || ''}</td>
<td className="px-3 py-2 font-mono text-xs">{e.endpoint || ''}</td>
<td className="px-3 py-2 text-xs">{e.alerted ? '✉️ ja' : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-wrap items-center justify-between gap-3 mt-4 text-sm">
<span className="text-gray-600">
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(1)}
disabled={page <= 1}
title="Erste Seite"
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
>
<ChevronsLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
title="Vorherige Seite"
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
>
<ChevronLeft className="w-4 h-4" />
</button>
{paginationWindow(page, pagination.totalPages, 10).map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`min-w-[32px] px-2 py-1 rounded border text-sm ${
p === page
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 hover:bg-gray-100'
}`}
>
{p}
</button>
))}
<button
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
disabled={page >= pagination.totalPages}
title="Nächste Seite"
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={() => setPage(pagination.totalPages)}
disabled={page >= pagination.totalPages}
title="Letzte Seite"
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
>
<ChevronsRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</Card>
{/* Clear-Confirm-Modal */}
<Modal
isOpen={showClearConfirm}
onClose={() => setShowClearConfirm(false)}
title="Security-Log leeren"
size="sm"
>
<div className="space-y-4">
<p className="text-sm text-gray-700">
Sicher? Alle Events werden aus der Datenbank entfernt. Ein
Audit-Log-Eintrag mit deinem Namen bleibt erhalten.
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nur Events älter als (Tage)
</label>
<input
type="number"
min="0"
value={clearOlderThanDays}
onChange={(e) => setClearOlderThanDays(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="leer = alle löschen"
className="block w-full max-w-[200px] px-3 py-2 border border-gray-300 rounded text-sm"
/>
<p className="text-xs text-gray-500 mt-1">
Beispiel: 30 = nur Events älter als 30 Tage löschen.
</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowClearConfirm(false)}>
Abbrechen
</Button>
<Button
onClick={() => clearEvents.mutate(clearOlderThanDays === '' ? undefined : Number(clearOlderThanDays))}
disabled={clearEvents.isPending}
className="!bg-red-600 hover:!bg-red-700"
>
<Trash2 className="w-4 h-4 mr-1" />
{clearOlderThanDays === '' ? 'Alle löschen' : `Älter als ${clearOlderThanDays} Tage löschen`}
</Button>
</div>
</div>
</Modal>
</div>
);
}