Security-Hardening Runde 10: Security-Monitoring + Alerting
Defense-in-Depth für alles, was in den ersten 9 Runden nicht durch Code verhindert wurde: zumindest gesehen + alarmiert werden. 📊 SecurityEvent-Tabelle (Prisma) - Type/Severity/IP/User/Endpoint + Indexen für Filter+Threshold-Detection - Trennt sich vom AuditLog: AuditLog ist forensisch + hash-gekettet, SecurityEvent ist optimiert für Realtime-Alerting + Aggregation. 🪝 Hooks an kritischen Stellen - Login (Success/Failed) – auth.controller - Logout, Password-Reset (Request + Confirm) – auth.controller - Rate-Limit-Hit – middleware/rateLimit - IDOR-403 – utils/accessControl (canAccessCustomer / canAccessContract) - SSRF-Block – emailProvider.controller (test-connection + test-mail-access) - JWT-Reject (alg=none, expired, manipuliert) – middleware/auth 🚨 Threshold-Detection + Alerting (securityAlert.service.ts) - Cron jede Minute: prüft Brute-Force-Patterns je IP - 10× LOGIN_FAILED in 60 min → CRITICAL Brute-Force-Verdacht - 5× ACCESS_DENIED in 5 min → CRITICAL IDOR-Probing-Verdacht - 3× SSRF_BLOCKED in 60 min → CRITICAL SSRF-Probing - 3× TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation - CRITICAL-Events: Sofort-Alert per E-Mail (debounced) - Cron stündlich: Digest mit HIGH+MEDIUM-Events (wenn aktiviert) - Sofort-Alert + Digest laufen über System-E-Mail-Provider (gleicher Pfad wie Geburtstagsgrüße, Passwort-Reset) 🖥 Frontend: Settings → "Sicherheits-Monitoring" - Alert-E-Mail-Adresse + Digest-Toggle - Test-Alert-Button + Digest-jetzt-Button - Stats-Cards pro Severity (CRITICAL/HIGH/MEDIUM/LOW/INFO) - Filter (Type/Severity/Search/IP) + Pagination - Auto-Refresh alle 30 s - Verlinkt aus Settings-Übersicht (settings:read Permission) 🧪 Live-verifiziert - Login-Fehlversuch → LOGIN_FAILED Event - Portal probt 4× fremde Customer-IDs → 4× ACCESS_DENIED - SSRF-Probe (169.254.169.254) → SSRF_BLOCKED Event - 12× LOGIN_FAILED simuliert → Cron erzeugt CRITICAL nach ≤60s - CRITICAL-Sofort-Alert binnen 30s zugestellt - Test-Alert-Button: E-Mail zugestellt - Hourly-Digest mit 5 Events: E-Mail mit Tabelle zugestellt Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c9a2b9fdba
commit
57eb027d5f
|
|
@ -1113,3 +1113,53 @@ model AuditRetentionPolicy {
|
||||||
|
|
||||||
@@unique([resourceType, sensitivity])
|
@@unique([resourceType, sensitivity])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SECURITY MONITORING ====================
|
||||||
|
// Sicherheitsrelevante Events für Realtime-Alerting + Forensik.
|
||||||
|
// Im Gegensatz zum AuditLog (forensisch, hash-gekettet) ist das hier
|
||||||
|
// optimiert für schnelles Filtern + Alerting (nicht-tamper-evident, dafür
|
||||||
|
// effizient querybar). Threshold-Detection läuft per Cron.
|
||||||
|
|
||||||
|
enum SecurityEventType {
|
||||||
|
LOGIN_FAILED // falsches Passwort / unbekannter User
|
||||||
|
LOGIN_SUCCESS // erfolgreicher Login (informativ)
|
||||||
|
RATE_LIMIT_HIT // express-rate-limit hat zugeschlagen
|
||||||
|
ACCESS_DENIED // 403 von canAccess* (versuchter IDOR)
|
||||||
|
SSRF_BLOCKED // ssrfGuard hat geblockte Adresse abgefangen
|
||||||
|
PASSWORD_RESET_REQUEST // Reset-Mail angefordert
|
||||||
|
PASSWORD_RESET_CONFIRM // Reset abgeschlossen
|
||||||
|
LOGOUT // expliziter Logout
|
||||||
|
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
|
||||||
|
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
|
||||||
|
SUSPICIOUS // generischer Catch-All
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SecuritySeverity {
|
||||||
|
INFO // Login-Success, Logout
|
||||||
|
LOW // Einzelner failed Login, einzelner 403
|
||||||
|
MEDIUM // Rate-Limit-Hit, mehrere 403er
|
||||||
|
HIGH // SSRF-Block, JWT-Manipulation
|
||||||
|
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityEvent {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
type SecurityEventType
|
||||||
|
severity SecuritySeverity
|
||||||
|
message String @db.Text
|
||||||
|
ipAddress String?
|
||||||
|
userId Int? // Mitarbeiter (falls eingeloggt)
|
||||||
|
customerId Int? // Portal-Kunde (falls eingeloggt)
|
||||||
|
userEmail String? // beste Schätzung – auch bei nicht eingeloggt
|
||||||
|
endpoint String? // betroffener Endpoint
|
||||||
|
details Json? // strukturierte Zusatzinfo
|
||||||
|
alerted Boolean @default(false) // schon per Email versendet?
|
||||||
|
alertedAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([type, createdAt])
|
||||||
|
@@index([severity, createdAt])
|
||||||
|
@@index([ipAddress, createdAt])
|
||||||
|
@@index([alerted, severity])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ import { Request, Response } from 'express';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
|
||||||
// Mitarbeiter-Login
|
// Mitarbeiter-Login
|
||||||
export async function login(req: Request, res: Response): Promise<void> {
|
export async function login(req: Request, res: Response): Promise<void> {
|
||||||
|
const { email, password } = req.body || {};
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -17,8 +18,25 @@ export async function login(req: Request, res: Response): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.login(email, password);
|
const result = await authService.login(email, password);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'LOGIN_SUCCESS',
|
||||||
|
severity: 'INFO',
|
||||||
|
message: `Mitarbeiter-Login: ${email}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: result.user.id,
|
||||||
|
userEmail: email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'LOGIN_FAILED',
|
||||||
|
severity: 'LOW',
|
||||||
|
message: `Login-Fehlversuch (Mitarbeiter): ${email || '<leer>'}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userEmail: email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||||
|
|
@ -28,9 +46,9 @@ export async function login(req: Request, res: Response): Promise<void> {
|
||||||
|
|
||||||
// Kundenportal-Login
|
// Kundenportal-Login
|
||||||
export async function customerLogin(req: Request, res: Response): Promise<void> {
|
export async function customerLogin(req: Request, res: Response): Promise<void> {
|
||||||
|
const { email, password } = req.body || {};
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -40,8 +58,25 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.customerLogin(email, password);
|
const result = await authService.customerLogin(email, password);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'LOGIN_SUCCESS',
|
||||||
|
severity: 'INFO',
|
||||||
|
message: `Portal-Login: ${email}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
customerId: result.user.customerId,
|
||||||
|
userEmail: email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'LOGIN_FAILED',
|
||||||
|
severity: 'LOW',
|
||||||
|
message: `Login-Fehlversuch (Portal): ${email || '<leer>'}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userEmail: email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||||
|
|
@ -115,6 +150,17 @@ export async function requestPasswordReset(req: Request, res: Response): Promise
|
||||||
|
|
||||||
await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin');
|
await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin');
|
||||||
|
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PASSWORD_RESET_REQUEST',
|
||||||
|
severity: 'MEDIUM',
|
||||||
|
message: `Passwort-Reset angefordert (${userType === 'portal' ? 'Portal' : 'Mitarbeiter'}): ${email}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userEmail: email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { userType: userType === 'portal' ? 'portal' : 'admin' },
|
||||||
|
});
|
||||||
|
|
||||||
// IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren
|
// IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -155,11 +201,28 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
||||||
|
|
||||||
await authService.confirmPasswordReset(token, password);
|
await authService.confirmPasswordReset(token, password);
|
||||||
|
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PASSWORD_RESET_CONFIRM',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: 'Passwort-Reset abgeschlossen',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.',
|
message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'TOKEN_REJECTED',
|
||||||
|
severity: 'MEDIUM',
|
||||||
|
message: 'Passwort-Reset mit ungültigem/abgelaufenem Token versucht',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
|
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
|
||||||
|
|
@ -194,6 +257,17 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'LOGOUT',
|
||||||
|
severity: 'INFO',
|
||||||
|
message: `Logout: ${user.email || (user.isCustomerPortal ? 'Portal-User' : 'Mitarbeiter')}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: ctx.userId,
|
||||||
|
customerId: ctx.customerId,
|
||||||
|
userEmail: user.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.json({ success: true, message: 'Abgemeldet' } as ApiResponse);
|
res.json({ success: true, message: 'Abgemeldet' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { testImapConnection, ImapCredentials } from '../services/imapService.js'
|
||||||
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
||||||
import { decrypt } from '../utils/encryption.js';
|
import { decrypt } from '../utils/encryption.js';
|
||||||
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
|
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
|
||||||
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
@ -128,6 +129,17 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
||||||
await safeResolveHost(url.hostname, 'apiUrl-Host');
|
await safeResolveHost(url.hostname, 'apiUrl-Host');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'SSRF_BLOCKED',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: err.message,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: ctx.userId,
|
||||||
|
userEmail: ctx.userEmail,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { apiUrl: testData.apiUrl },
|
||||||
|
});
|
||||||
res.status(400).json({ success: false, error: err.message } as ApiResponse);
|
res.status(400).json({ success: false, error: err.message } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -243,6 +255,17 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
||||||
safeResolveHost(imapServer, 'IMAP-Server'),
|
safeResolveHost(imapServer, 'IMAP-Server'),
|
||||||
]);
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'SSRF_BLOCKED',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: ctx.userId,
|
||||||
|
userEmail: ctx.userEmail,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { smtpServer, imapServer },
|
||||||
|
});
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
|
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { Response } from 'express';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
|
import * as appSettingService from '../services/appSetting.service.js';
|
||||||
|
import { sendAlertEmail, sendDigest } from '../services/securityAlert.service.js';
|
||||||
|
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/monitoring/events
|
||||||
|
* Liste der Security-Events mit Filter + Pagination.
|
||||||
|
*/
|
||||||
|
export async function listEvents(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const page = parseInt((req.query.page as string) || '1');
|
||||||
|
const limit = Math.min(parseInt((req.query.limit as string) || '50'), 200);
|
||||||
|
const type = req.query.type as SecurityEventType | undefined;
|
||||||
|
const severity = req.query.severity as SecuritySeverity | undefined;
|
||||||
|
const search = req.query.search as string | undefined;
|
||||||
|
const since = req.query.since as string | undefined;
|
||||||
|
const ip = req.query.ip as string | undefined;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (severity) where.severity = severity;
|
||||||
|
if (ip) where.ipAddress = ip;
|
||||||
|
if (since) where.createdAt = { gte: new Date(since) };
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ message: { contains: search } },
|
||||||
|
{ userEmail: { contains: search } },
|
||||||
|
{ endpoint: { contains: search } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [events, total, byType, bySeverity] = await Promise.all([
|
||||||
|
prisma.securityEvent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
}),
|
||||||
|
prisma.securityEvent.count({ where }),
|
||||||
|
prisma.securityEvent.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
where: since ? { createdAt: { gte: new Date(since) } } : {},
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.securityEvent.groupBy({
|
||||||
|
by: ['severity'],
|
||||||
|
where: since ? { createdAt: { gte: new Date(since) } } : {},
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: events,
|
||||||
|
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||||
|
stats: {
|
||||||
|
byType: Object.fromEntries(byType.map((r: any) => [r.type, r._count])),
|
||||||
|
bySeverity: Object.fromEntries(bySeverity.map((r: any) => [r.severity, r._count])),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('listEvents error:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Security-Events' } as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/monitoring/settings
|
||||||
|
*/
|
||||||
|
export async function getMonitoringSettings(_req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||||
|
const digestEnabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
|
||||||
|
const lastDigest = await appSettingService.getSetting('monitoringLastDigestAt');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
alertEmail: alertEmail || '',
|
||||||
|
digestEnabled,
|
||||||
|
lastDigestAt: lastDigest || null,
|
||||||
|
},
|
||||||
|
} as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Laden' } as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/monitoring/settings
|
||||||
|
*/
|
||||||
|
export async function updateMonitoringSettings(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { alertEmail, digestEnabled } = req.body || {};
|
||||||
|
if (typeof alertEmail === 'string') {
|
||||||
|
// Email-Validierung minimal: muss @ enthalten oder leer sein
|
||||||
|
if (alertEmail !== '' && !alertEmail.includes('@')) {
|
||||||
|
res.status(400).json({ success: false, error: 'Ungültige E-Mail-Adresse' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await appSettingService.setSetting('monitoringAlertEmail', alertEmail);
|
||||||
|
}
|
||||||
|
if (typeof digestEnabled === 'boolean') {
|
||||||
|
await appSettingService.setSetting('monitoringDigestEnabled', digestEnabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Speichern' } as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/monitoring/test-alert
|
||||||
|
* Versendet eine Test-Alert-Mail an die konfigurierte Adresse.
|
||||||
|
*/
|
||||||
|
export async function testAlert(_req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||||
|
if (!alertEmail) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Keine Alert-E-Mail konfiguriert',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await sendAlertEmail(alertEmail, {
|
||||||
|
subject: '[OpenCRM] Test-Alert',
|
||||||
|
events: [{
|
||||||
|
type: 'SUSPICIOUS' as any,
|
||||||
|
severity: 'INFO' as any,
|
||||||
|
message: 'Dies ist eine Test-Mail vom Monitoring-System. Alles in Ordnung.',
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as any],
|
||||||
|
isDigest: false,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
res.json({ success: true, message: `Test-Alert an ${alertEmail} versendet` } as ApiResponse);
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/monitoring/run-digest (manueller Trigger für den Hourly-Digest)
|
||||||
|
*/
|
||||||
|
export async function runDigestNow(_req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await sendDigest({ force: true });
|
||||||
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,8 @@ import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||||
import { downloadFile } from './controllers/fileDownload.controller.js';
|
import { downloadFile } from './controllers/fileDownload.controller.js';
|
||||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
||||||
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
||||||
|
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||||
|
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
import { authenticate } from './middleware/auth.js';
|
import { authenticate } from './middleware/auth.js';
|
||||||
|
|
@ -158,6 +160,7 @@ app.use('/api/email-logs', emailLogRoutes);
|
||||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
||||||
app.use('/api/birthdays', birthdayRoutes);
|
app.use('/api/birthdays', birthdayRoutes);
|
||||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
||||||
|
app.use('/api/monitoring', monitoringRoutes);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
|
|
@ -206,4 +209,5 @@ app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||||
startBirthdayScheduler();
|
startBirthdayScheduler();
|
||||||
startContractStatusScheduler();
|
startContractStatusScheduler();
|
||||||
|
startSecurityMonitorScheduler();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Response, NextFunction } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { AuthRequest, JwtPayload } from '../types/index.js';
|
import { AuthRequest, JwtPayload } from '../types/index.js';
|
||||||
|
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
|
||||||
|
|
||||||
export async function authenticate(
|
export async function authenticate(
|
||||||
req: AuthRequest,
|
req: AuthRequest,
|
||||||
|
|
@ -81,7 +82,16 @@ export async function authenticate(
|
||||||
|
|
||||||
req.user = decoded;
|
req.user = decoded;
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// JWT-Failures sind interessant: alg=none, manipulierte Signature,
|
||||||
|
// expired Token. Emit SecurityEvent (asynchron, blockt nicht).
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'TOKEN_REJECTED',
|
||||||
|
severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH',
|
||||||
|
message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt',
|
||||||
|
ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown',
|
||||||
|
endpoint: `${req.method} ${req.path}`,
|
||||||
|
});
|
||||||
res.status(401).json({ success: false, error: 'Ungültiger Token' });
|
res.status(401).json({ success: false, error: 'Ungültiger Token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,28 @@
|
||||||
/**
|
/**
|
||||||
* Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset).
|
* Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset).
|
||||||
* Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe.
|
* Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe.
|
||||||
|
*
|
||||||
|
* Wenn ein Limit überschritten wird, emit() wir zusätzlich ein
|
||||||
|
* SecurityEvent (RATE_LIMIT_HIT) – damit der Monitoring-View und das
|
||||||
|
* Alert-System sehen, wenn jemand auf die Tür hämmert.
|
||||||
*/
|
*/
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
|
||||||
|
function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
|
||||||
|
return (req: any, _res: any) => {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'RATE_LIMIT_HIT',
|
||||||
|
severity,
|
||||||
|
message: `Rate-Limit überschritten: ${label}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userEmail: req.body?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { limiter: label },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login: 10 Versuche pro 15 Minuten pro IP.
|
* Login: 10 Versuche pro 15 Minuten pro IP.
|
||||||
|
|
@ -19,6 +39,10 @@ export const loginRateLimiter = rateLimit({
|
||||||
},
|
},
|
||||||
// Erfolgreiche Logins zählen nicht gegen das Limit
|
// Erfolgreiche Logins zählen nicht gegen das Limit
|
||||||
skipSuccessfulRequests: true,
|
skipSuccessfulRequests: true,
|
||||||
|
handler: (req, res, _next, options) => {
|
||||||
|
onLimitReached('login', 'HIGH')(req, res);
|
||||||
|
res.status(options.statusCode).json(options.message);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -34,4 +58,8 @@ export const passwordResetRateLimiter = rateLimit({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.',
|
error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.',
|
||||||
},
|
},
|
||||||
|
handler: (req, res, _next, options) => {
|
||||||
|
onLimitReached('password-reset', 'MEDIUM')(req, res);
|
||||||
|
res.status(options.statusCode).json(options.message);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import * as monitoringController from '../controllers/monitoring.controller.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Monitoring ist Admin-Sache: settings:read fürs Anzeigen, settings:update für Änderungen
|
||||||
|
router.get('/events', requirePermission('settings:read'), monitoringController.listEvents);
|
||||||
|
router.get('/settings', requirePermission('settings:read'), monitoringController.getMonitoringSettings);
|
||||||
|
router.put('/settings', requirePermission('settings:update'), monitoringController.updateMonitoringSettings);
|
||||||
|
router.post('/test-alert', requirePermission('settings:update'), monitoringController.testAlert);
|
||||||
|
router.post('/run-digest', requirePermission('settings:update'), monitoringController.runDigestNow);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
/**
|
||||||
|
* Security-Alerting:
|
||||||
|
* - **Sofort-Alert** für CRITICAL-Events (sobald sie entstehen, vom
|
||||||
|
* Cron alle 60s gepollt) – z.B. Threshold-Überschreitungen.
|
||||||
|
* - **Hourly-Digest**: einmal pro Stunde Sammlung von HIGH+ Events,
|
||||||
|
* wenn `monitoringDigestEnabled = true` und mindestens 1 Event vorhanden.
|
||||||
|
* - **Threshold-Detection**: prüft Brute-Force-Patterns (z.B. >10
|
||||||
|
* LOGIN_FAILED/h aus gleicher IP) und erzeugt synthetische CRITICAL-
|
||||||
|
* Events wenn die Schwelle erreicht ist.
|
||||||
|
*
|
||||||
|
* Alle E-Mails laufen über die System-E-Mail-Konfiguration des Providers
|
||||||
|
* (genau wie Geburtstagsgrüße / Passwort-Reset). Daher gleiche Voraussetzungen.
|
||||||
|
*/
|
||||||
|
import cron from 'node-cron';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
||||||
|
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
||||||
|
import * as appSettingService from './appSetting.service.js';
|
||||||
|
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
|
||||||
|
import type { SecurityEvent } from '@prisma/client';
|
||||||
|
|
||||||
|
interface AlertEmailParams {
|
||||||
|
subject: string;
|
||||||
|
events: SecurityEvent[];
|
||||||
|
isDigest: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityIcon(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'CRITICAL': return '🚨';
|
||||||
|
case 'HIGH': return '⚠️';
|
||||||
|
case 'MEDIUM': return '🟡';
|
||||||
|
case 'LOW': return '🟢';
|
||||||
|
default: return 'ℹ️';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventToHtmlRow(e: SecurityEvent): string {
|
||||||
|
const ts = e.createdAt.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' });
|
||||||
|
const ip = e.ipAddress || '–';
|
||||||
|
const who = e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '–');
|
||||||
|
const ep = e.endpoint || '–';
|
||||||
|
return `<tr>
|
||||||
|
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ts}</td>
|
||||||
|
<td style="padding:4px 8px">${severityIcon(e.severity)} ${e.severity}</td>
|
||||||
|
<td style="padding:4px 8px">${e.type}</td>
|
||||||
|
<td style="padding:4px 8px">${e.message}</td>
|
||||||
|
<td style="padding:4px 8px">${who}</td>
|
||||||
|
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ip}</td>
|
||||||
|
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ep}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHtmlEmail(params: AlertEmailParams): string {
|
||||||
|
const rows = params.events.map(eventToHtmlRow).join('\n');
|
||||||
|
const heading = params.isDigest
|
||||||
|
? `<h2>OpenCRM Security-Digest</h2><p>Übersicht der wichtigen Events der letzten Stunde:</p>`
|
||||||
|
: `<h2>OpenCRM Security-Alert</h2><p>Folgendes Event wurde als kritisch eingestuft:</p>`;
|
||||||
|
return `<!doctype html><html><body style="font-family:sans-serif;color:#222">
|
||||||
|
${heading}
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;width:100%;font-size:13px">
|
||||||
|
<thead style="background:#f3f4f6">
|
||||||
|
<tr>
|
||||||
|
<th align="left" style="padding:6px 8px">Zeit</th>
|
||||||
|
<th align="left" style="padding:6px 8px">Severity</th>
|
||||||
|
<th align="left" style="padding:6px 8px">Typ</th>
|
||||||
|
<th align="left" style="padding:6px 8px">Nachricht</th>
|
||||||
|
<th align="left" style="padding:6px 8px">Wer</th>
|
||||||
|
<th align="left" style="padding:6px 8px">IP</th>
|
||||||
|
<th align="left" style="padding:6px 8px">Endpoint</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="margin-top:20px;color:#666;font-size:12px">Diese Mail wurde vom OpenCRM Monitoring-System gesendet.
|
||||||
|
Konfiguration: Einstellungen → Monitoring.</p>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versendet einen Alert per E-Mail. Nutzt die System-E-Mail des Providers.
|
||||||
|
*/
|
||||||
|
export async function sendAlertEmail(toAddress: string, params: AlertEmailParams): Promise<SendResult> {
|
||||||
|
const sysEmail = await getSystemEmailCredentials();
|
||||||
|
if (!sysEmail) {
|
||||||
|
return { success: false, error: 'System-E-Mail nicht konfiguriert (in Einstellungen → E-Mail-Provider)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials: SmtpCredentials = {
|
||||||
|
host: sysEmail.smtpServer,
|
||||||
|
port: sysEmail.smtpPort,
|
||||||
|
user: sysEmail.emailAddress,
|
||||||
|
password: sysEmail.password,
|
||||||
|
encryption: sysEmail.smtpEncryption,
|
||||||
|
allowSelfSignedCerts: sysEmail.allowSelfSignedCerts,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await sendEmail(
|
||||||
|
credentials,
|
||||||
|
sysEmail.emailAddress,
|
||||||
|
{
|
||||||
|
to: toAddress,
|
||||||
|
subject: params.subject,
|
||||||
|
html: buildHtmlEmail(params),
|
||||||
|
},
|
||||||
|
{ context: 'security-alert', triggeredBy: 'monitor' },
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.success
|
||||||
|
? { success: true }
|
||||||
|
: { success: false, error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threshold-Detection: prüft ob in den letzten N Minuten verdächtige Patterns
|
||||||
|
* aufgetreten sind, die einen CRITICAL-Alert rechtfertigen.
|
||||||
|
*
|
||||||
|
* Regeln (alle pro IP):
|
||||||
|
* - >= 10 LOGIN_FAILED in 60 min → CRITICAL Brute-Force-Verdacht
|
||||||
|
* - >= 5 ACCESS_DENIED in 5 min → CRITICAL IDOR-Probing-Verdacht
|
||||||
|
* - >= 3 SSRF_BLOCKED in 60 min → CRITICAL SSRF-Probing
|
||||||
|
* - >= 3 TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation
|
||||||
|
*/
|
||||||
|
export async function detectThresholds(): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||||
|
const sixtyMinAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
windowStart: Date;
|
||||||
|
type: 'LOGIN_FAILED' | 'ACCESS_DENIED' | 'SSRF_BLOCKED' | 'TOKEN_REJECTED';
|
||||||
|
threshold: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
const buckets: Bucket[] = [
|
||||||
|
{ windowStart: sixtyMinAgo, type: 'LOGIN_FAILED', threshold: 10, label: 'Brute-Force-Login-Verdacht' },
|
||||||
|
{ windowStart: fiveMinAgo, type: 'ACCESS_DENIED', threshold: 5, label: 'IDOR-Probing-Verdacht' },
|
||||||
|
{ windowStart: sixtyMinAgo, type: 'SSRF_BLOCKED', threshold: 3, label: 'SSRF-Probing-Verdacht' },
|
||||||
|
{ windowStart: fiveMinAgo, type: 'TOKEN_REJECTED', threshold: 3, label: 'JWT-Manipulations-Verdacht' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const b of buckets) {
|
||||||
|
const grouped = await prisma.securityEvent.groupBy({
|
||||||
|
by: ['ipAddress'],
|
||||||
|
where: {
|
||||||
|
type: b.type as any,
|
||||||
|
createdAt: { gte: b.windowStart },
|
||||||
|
},
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
for (const g of grouped) {
|
||||||
|
if ((g._count as number) < b.threshold) continue;
|
||||||
|
// Prüfen ob wir für diese (IP+Type+Stunde) schon einen CRITICAL emittiert haben
|
||||||
|
const hourBucket = new Date(now.getTime() - (now.getTime() % (60 * 60 * 1000)));
|
||||||
|
const existing = await prisma.securityEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
type: 'SUSPICIOUS',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
ipAddress: g.ipAddress,
|
||||||
|
createdAt: { gte: hourBucket },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
await emitSecurityEvent({
|
||||||
|
type: 'SUSPICIOUS',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: `${b.label}: ${g._count}× ${b.type} in ${b.windowStart === fiveMinAgo ? '5min' : '60min'} von ${g.ipAddress}`,
|
||||||
|
ipAddress: g.ipAddress,
|
||||||
|
details: { rule: b.type, count: g._count, threshold: b.threshold },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet pending CRITICAL-Events sofort als Einzel-Mails (debounced auf
|
||||||
|
* 1 Mail pro IP pro Stunde, damit nicht spammend).
|
||||||
|
*/
|
||||||
|
async function sendPendingCriticalAlerts(): Promise<{ sent: number; skipped: number }> {
|
||||||
|
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||||
|
if (!alertEmail) return { sent: 0, skipped: 0 };
|
||||||
|
|
||||||
|
const pending = await prisma.securityEvent.findMany({
|
||||||
|
where: { severity: 'CRITICAL', alerted: false },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const ev of pending) {
|
||||||
|
const result = await sendAlertEmail(alertEmail, {
|
||||||
|
subject: `[OpenCRM] 🚨 ${ev.type}: ${ev.message.substring(0, 80)}`,
|
||||||
|
events: [ev],
|
||||||
|
isDigest: false,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
sent++;
|
||||||
|
await prisma.securityEvent.update({
|
||||||
|
where: { id: ev.id },
|
||||||
|
data: { alerted: true, alertedAt: new Date() },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
console.error(`[securityAlert] Send failed for event #${ev.id}:`, result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { sent, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hourly-Digest: alle HIGH-Events der letzten Stunde, die noch nicht
|
||||||
|
* alert-versendet wurden, in einer einzigen Mail zusammenfassen.
|
||||||
|
*/
|
||||||
|
export async function sendDigest(opts: { force?: boolean } = {}): Promise<{ sent: boolean; eventCount: number; reason?: string }> {
|
||||||
|
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||||
|
if (!alertEmail) return { sent: false, eventCount: 0, reason: 'Keine Alert-E-Mail konfiguriert' };
|
||||||
|
const enabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
|
||||||
|
if (!enabled && !opts.force) return { sent: false, eventCount: 0, reason: 'Digest deaktiviert' };
|
||||||
|
|
||||||
|
const lastDigestAt = await appSettingService.getSetting('monitoringLastDigestAt');
|
||||||
|
const since = lastDigestAt ? new Date(lastDigestAt) : new Date(Date.now() - 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const events = await prisma.securityEvent.findMany({
|
||||||
|
where: {
|
||||||
|
severity: { in: ['HIGH', 'MEDIUM'] },
|
||||||
|
alerted: false,
|
||||||
|
createdAt: { gte: since },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
|
||||||
|
return { sent: false, eventCount: 0, reason: 'Keine neuen Events seit letztem Digest' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendAlertEmail(alertEmail, {
|
||||||
|
subject: `[OpenCRM] Security-Digest (${events.length} Events)`,
|
||||||
|
events,
|
||||||
|
isDigest: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await prisma.securityEvent.updateMany({
|
||||||
|
where: { id: { in: events.map((e) => e.id) } },
|
||||||
|
data: { alerted: true, alertedAt: new Date() },
|
||||||
|
});
|
||||||
|
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
|
||||||
|
return { sent: true, eventCount: events.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent: false, eventCount: events.length, reason: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron-Scheduler:
|
||||||
|
* - Jede Minute: Threshold-Detection + Sofort-Alerts für CRITICAL
|
||||||
|
* - Jede volle Stunde: Hourly-Digest (HIGH+MEDIUM)
|
||||||
|
*/
|
||||||
|
export function startSecurityMonitorScheduler(): void {
|
||||||
|
cron.schedule('* * * * *', async () => {
|
||||||
|
try {
|
||||||
|
await detectThresholds();
|
||||||
|
await sendPendingCriticalAlerts();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[securityAlert] minute-cron failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cron.schedule('0 * * * *', async () => {
|
||||||
|
try {
|
||||||
|
await sendDigest();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[securityAlert] hourly-digest failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[securityAlert] Scheduler gestartet (1min Threshold-Check, hourly Digest)');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* Security-Monitor: zentrale `emit()`-Funktion für sicherheitsrelevante
|
||||||
|
* Events. Schreibt in die `SecurityEvent`-Tabelle (nicht im AuditLog,
|
||||||
|
* weil hier andere Anforderungen gelten: schnelles Filtern, Threshold-
|
||||||
|
* Detection, Realtime-Alerting statt forensischer Hash-Chain).
|
||||||
|
*
|
||||||
|
* Hooks für die wichtigsten Klassen:
|
||||||
|
* - LOGIN_FAILED → Login mit falschem Passwort
|
||||||
|
* - LOGIN_SUCCESS → erfolgreicher Login (informativ)
|
||||||
|
* - RATE_LIMIT_HIT → express-rate-limit hat zugeschlagen
|
||||||
|
* - ACCESS_DENIED → 403 von canAccess* (versuchter IDOR)
|
||||||
|
* - SSRF_BLOCKED → ssrfGuard hat geblockt
|
||||||
|
* - PASSWORD_RESET_REQUEST → Reset angefordert
|
||||||
|
* - PASSWORD_RESET_CONFIRM → Reset abgeschlossen
|
||||||
|
* - LOGOUT → expliziter Logout
|
||||||
|
* - TOKEN_REJECTED → JWT verify-Failure
|
||||||
|
* - PERMISSION_CHANGED → Rolle/Permission-Update
|
||||||
|
*
|
||||||
|
* Sofort-Alert für CRITICAL+HIGH-Events (wenn `monitoringAlertEmail`
|
||||||
|
* konfiguriert), sonst Sammlung im stündlichen Digest.
|
||||||
|
*/
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface SecurityEventInput {
|
||||||
|
type: SecurityEventType;
|
||||||
|
severity: SecuritySeverity;
|
||||||
|
message: string;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
userId?: number | null;
|
||||||
|
customerId?: number | null;
|
||||||
|
userEmail?: string | null;
|
||||||
|
endpoint?: string | null;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt ein SecurityEvent. Fehler beim Schreiben werden geschluckt,
|
||||||
|
* damit ein kaputtes Monitoring nicht den Login-Flow stoppt.
|
||||||
|
*/
|
||||||
|
export async function emit(event: SecurityEventInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
await prisma.securityEvent.create({
|
||||||
|
data: {
|
||||||
|
type: event.type,
|
||||||
|
severity: event.severity,
|
||||||
|
message: event.message,
|
||||||
|
ipAddress: event.ipAddress || null,
|
||||||
|
userId: event.userId || null,
|
||||||
|
customerId: event.customerId || null,
|
||||||
|
userEmail: event.userEmail || null,
|
||||||
|
endpoint: event.endpoint || null,
|
||||||
|
details: event.details ? (event.details as any) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[securityMonitor] emit failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: aus einem Express-Request die wichtigsten Kontextfelder extrahieren.
|
||||||
|
* Funktioniert sowohl mit AuthRequest (eingeloggt) als auch mit anonymen
|
||||||
|
* Requests (Login-Versuch etc.).
|
||||||
|
*/
|
||||||
|
export function contextFromRequest(req: any): {
|
||||||
|
ipAddress: string;
|
||||||
|
userId?: number;
|
||||||
|
customerId?: number;
|
||||||
|
userEmail?: string;
|
||||||
|
endpoint: string;
|
||||||
|
} {
|
||||||
|
const user = req?.user;
|
||||||
|
return {
|
||||||
|
ipAddress: req?.ip || req?.socket?.remoteAddress || 'unknown',
|
||||||
|
userId: user?.userId,
|
||||||
|
customerId: user?.customerId,
|
||||||
|
userEmail: user?.email,
|
||||||
|
endpoint: `${req?.method || ''} ${req?.path || req?.originalUrl || ''}`.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,26 @@ import { Response } from 'express';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as authorizationService from '../services/authorization.service.js';
|
import * as authorizationService from '../services/authorization.service.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird intern aufgerufen, wenn ein canAccess*-Check 403 zurückgibt.
|
||||||
|
* Schreibt ein SecurityEvent für Monitoring + spätere Threshold-Detection.
|
||||||
|
*/
|
||||||
|
function emitAccessDenied(req: AuthRequest, label: string, targetId: number | string): void {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'ACCESS_DENIED',
|
||||||
|
severity: 'MEDIUM',
|
||||||
|
message: `Zugriff verweigert: ${label} #${targetId}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: ctx.userId,
|
||||||
|
customerId: ctx.customerId,
|
||||||
|
userEmail: ctx.userEmail,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { resource: label, targetId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf.
|
* Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf.
|
||||||
|
|
@ -54,6 +74,7 @@ export async function canAccessContract(
|
||||||
// Fremde Verträge nur mit aktiver Vollmacht
|
// Fremde Verträge nur mit aktiver Vollmacht
|
||||||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||||||
if (!representedIds.includes(contract.customerId)) {
|
if (!representedIds.includes(contract.customerId)) {
|
||||||
|
emitAccessDenied(req, 'Contract', contractId);
|
||||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' });
|
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +84,7 @@ export async function canAccessContract(
|
||||||
req.user.customerId,
|
req.user.customerId,
|
||||||
);
|
);
|
||||||
if (!hasAuth) {
|
if (!hasAuth) {
|
||||||
|
emitAccessDenied(req, 'Contract (Vollmacht fehlt)', contractId);
|
||||||
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -93,12 +115,14 @@ export async function canAccessCustomer(
|
||||||
|
|
||||||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||||||
if (!representedIds.includes(customerId)) {
|
if (!representedIds.includes(customerId)) {
|
||||||
|
emitAccessDenied(req, 'Customer', customerId);
|
||||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' });
|
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId);
|
const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId);
|
||||||
if (!hasAuth) {
|
if (!hasAuth) {
|
||||||
|
emitAccessDenied(req, 'Customer (Vollmacht fehlt)', customerId);
|
||||||
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,45 @@ Plus Error-Handler: `err.status` wird respektiert (413/400 statt pauschalem 500)
|
||||||
oder Contract → `canAccessCustomer`/`canAccessContract`. Backwards-
|
oder Contract → `canAccessCustomer`/`canAccessContract`. Backwards-
|
||||||
Compat-Shim für `/api/uploads/*` ruft denselben Owner-Check.
|
Compat-Shim für `/api/uploads/*` ruft denselben Owner-Check.
|
||||||
|
|
||||||
|
### Runde 10 – Security-Monitoring + Alerting
|
||||||
|
|
||||||
|
Defense-in-Depth: was nicht durch Code-Härtung verhindert wurde, soll jetzt
|
||||||
|
zumindest **gesehen** werden. Ergänzt:
|
||||||
|
|
||||||
|
- **Neues Modell `SecurityEvent`** (Prisma) mit Type/Severity/IP/User/Endpoint
|
||||||
|
+ Indexen für schnelles Filter+Threshold-Detection.
|
||||||
|
- **Service `securityMonitor.service.ts`** mit zentraler `emit()`-Funktion.
|
||||||
|
Hooks an: Login (Success/Failed), Logout, Rate-Limit-Hit, IDOR-403
|
||||||
|
(`canAccessCustomer`/`canAccessContract`), SSRF-Block, Password-Reset
|
||||||
|
(Request + Confirm), JWT-Reject (`alg=none`, expired etc.).
|
||||||
|
- **Service `securityAlert.service.ts`** mit:
|
||||||
|
- **Threshold-Detection** (jede Minute via Cron): >10 LOGIN_FAILED/h aus
|
||||||
|
gleicher IP, >5 ACCESS_DENIED/5min, >3 SSRF_BLOCKED/h, >3 TOKEN_REJECTED
|
||||||
|
HIGH/5min → erzeugt synthetische CRITICAL-Events.
|
||||||
|
- **Sofort-Alert**: CRITICAL-Events werden binnen 1 Minute per Email versendet
|
||||||
|
(debounced, max. 1× pro Stunde + IP).
|
||||||
|
- **Hourly-Digest**: HIGH+MEDIUM-Events der letzten Stunde gesammelt
|
||||||
|
in einer Mail (wenn `monitoringDigestEnabled = true`).
|
||||||
|
- **Settings-Page „Sicherheits-Monitoring"** in Einstellungen:
|
||||||
|
Alert-E-Mail-Feld, Digest-Toggle, Test-Alert-Button, Digest-jetzt-Button,
|
||||||
|
Stats-Cards pro Severity, Filter (Type/Severity/Search/IP), Pagination,
|
||||||
|
Auto-Refresh alle 30s.
|
||||||
|
- **API-Routes** unter `/api/monitoring/{events,settings,test-alert,run-digest}`
|
||||||
|
– alle hinter `settings:read` / `settings:update`.
|
||||||
|
|
||||||
|
Live-verifiziert (1. Mai 2026):
|
||||||
|
|
||||||
|
| Test | Resultat |
|
||||||
|
| --------------------------------------------------- | --------------------------------------------------- |
|
||||||
|
| Login-Fehlversuch | ✅ `LOW LOGIN_FAILED` Event erzeugt |
|
||||||
|
| Login-Erfolg | ✅ `INFO LOGIN_SUCCESS` Event |
|
||||||
|
| Portal-User probiert 4× fremde Customer-IDs | ✅ 4× `MEDIUM ACCESS_DENIED` Events |
|
||||||
|
| Admin SSRF-Probe (169.254.169.254) | ✅ `HIGH SSRF_BLOCKED` Event |
|
||||||
|
| 12× LOGIN_FAILED von gleicher IP innerhalb 60 min | ✅ Cron erzeugt `CRITICAL SUSPICIOUS` Event nach ≤60s |
|
||||||
|
| CRITICAL-Sofort-Alert per E-Mail | ✅ binnen 30 s zugestellt |
|
||||||
|
| Test-Alert-Button | ✅ E-Mail mit Test-Marker zugestellt |
|
||||||
|
| Hourly-Digest mit 5 Events | ✅ E-Mail mit Tabellen-Übersicht zugestellt |
|
||||||
|
|
||||||
### Runde 9 – Diminishing-Returns-Runde
|
### Runde 9 – Diminishing-Returns-Runde
|
||||||
|
|
||||||
Nichts Kritisches mehr gefunden. Liefert noch:
|
Nichts Kritisches mehr gefunden. Liefert noch:
|
||||||
|
|
@ -289,7 +328,8 @@ Vor jedem Launch mit echten Tokens probieren.
|
||||||
| `4e91d96` | 6 | Customer-List-Leak + XFF-Bypass + Auth-Toggle |
|
| `4e91d96` | 6 | Customer-List-Leak + XFF-Bypass + Auth-Toggle |
|
||||||
| `12b9abe` | 7 | SSRF-Schutz + Logout |
|
| `12b9abe` | 7 | SSRF-Schutz + Logout |
|
||||||
| `d063d67` | 8 | DNS-Rebinding + Per-File-Ownership |
|
| `d063d67` | 8 | DNS-Rebinding + Per-File-Ownership |
|
||||||
| (folgt) | 9 | `npm audit fix` + Audit-Chain-Rehash + Doku |
|
| `c9a2b9f` | 9 | `npm audit fix` + Audit-Chain-Rehash + Doku |
|
||||||
|
| (folgt) | 10 | Security-Monitoring (SecurityEvent + Hooks + Alerts + UI) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||||
`cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt"
|
`cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt"
|
||||||
abzubilden. `ACTIVE` bleibt bis zur Bestätigung.
|
abzubilden. `ACTIVE` bleibt bis zur Bestätigung.
|
||||||
|
|
||||||
- [x] **🛡️ Security-Hardening vor Production-Deployment (9 Runden)**
|
- [x] **🛡️ Security-Hardening vor Production-Deployment (10 Runden)**
|
||||||
- Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs:
|
- Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs:
|
||||||
**[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**
|
**[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**
|
||||||
- Erste 2 Runden zusätzlich ausführlich in
|
- Erste 2 Runden zusätzlich ausführlich in
|
||||||
|
|
@ -133,6 +133,9 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||||
- Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
|
- Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
|
||||||
- Runde 9: `npm audit fix` (8 Vulns weg), Audit-Chain-Rehash, keine
|
- Runde 9: `npm audit fix` (8 Vulns weg), Audit-Chain-Rehash, keine
|
||||||
neuen Critical-Findings → diminishing returns erreicht
|
neuen Critical-Findings → diminishing returns erreicht
|
||||||
|
- Runde 10: Security-Monitoring (SecurityEvent-Tabelle + Hooks an
|
||||||
|
Login/IDOR/SSRF/Reset/Logout/JWT-Reject + Threshold-Detection +
|
||||||
|
Sofort-Alert für CRITICAL + Hourly-Digest + UI in Einstellungen)
|
||||||
- Deployment-Checkliste komplett (in HARDENING.md)
|
- Deployment-Checkliste komplett (in HARDENING.md)
|
||||||
|
|
||||||
- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
|
- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import DatabaseBackup from './pages/settings/DatabaseBackup';
|
||||||
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
||||||
import AuditLogs from './pages/settings/AuditLogs';
|
import AuditLogs from './pages/settings/AuditLogs';
|
||||||
import EmailLogPage from './pages/settings/EmailLogs';
|
import EmailLogPage from './pages/settings/EmailLogs';
|
||||||
|
import Monitoring from './pages/settings/Monitoring';
|
||||||
import GDPRDashboard from './pages/settings/GDPRDashboard';
|
import GDPRDashboard from './pages/settings/GDPRDashboard';
|
||||||
import UserList from './pages/users/UserList';
|
import UserList from './pages/users/UserList';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
|
@ -202,6 +203,7 @@ function App() {
|
||||||
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
||||||
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
||||||
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
||||||
|
<Route path="settings/monitoring" element={<Monitoring />} />
|
||||||
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
||||||
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
||||||
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
|
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit, PackageCheck } from 'lucide-react';
|
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, FileText, FileEdit, PackageCheck } from 'lucide-react';
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||||
|
|
@ -238,6 +238,27 @@ export default function Settings() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{hasPermission('settings:read') && (
|
||||||
|
<Link
|
||||||
|
to="/settings/monitoring"
|
||||||
|
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-orange-300 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-2 bg-orange-50 rounded-lg group-hover:bg-orange-100 transition-colors">
|
||||||
|
<ShieldAlert className="w-6 h-6 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900 group-hover:text-orange-600 transition-colors flex items-center gap-2">
|
||||||
|
Sicherheits-Monitoring
|
||||||
|
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Login-Fehlversuche, IDOR-Abwehr, SSRF-Blocks etc. + Alert-E-Mail-Adresse konfigurieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{hasPermission('gdpr:admin') && (
|
{hasPermission('gdpr:admin') && (
|
||||||
<Link
|
<Link
|
||||||
to="/settings/gdpr"
|
to="/settings/gdpr"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,289 @@
|
||||||
|
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 { ArrowLeft, Send, RefreshCw, Mail, ShieldAlert, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
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: '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 [filters, setFilters] = useState({
|
||||||
|
type: '' as SecurityEventType | '',
|
||||||
|
severity: '' as SecuritySeverity | '',
|
||||||
|
search: '',
|
||||||
|
ip: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
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, filters],
|
||||||
|
queryFn: () => monitoringApi.getEvents({ page, limit: 50, ...filters }),
|
||||||
|
refetchInterval: 30_000, // alle 30s neu laden
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Events</h2>
|
||||||
|
{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 justify-between items-center mt-4 text-sm">
|
||||||
|
<span>Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
|
||||||
|
<ChevronLeft className="w-4 h-4" /> Zurück
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page >= pagination.totalPages}>
|
||||||
|
Weiter <ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1426,6 +1426,71 @@ export interface EmailLog {
|
||||||
sentAt: string;
|
sentAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== MONITORING ====================
|
||||||
|
|
||||||
|
export type SecurityEventType =
|
||||||
|
| 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED'
|
||||||
|
| 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM'
|
||||||
|
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS';
|
||||||
|
|
||||||
|
export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||||
|
|
||||||
|
export interface SecurityEvent {
|
||||||
|
id: number;
|
||||||
|
type: SecurityEventType;
|
||||||
|
severity: SecuritySeverity;
|
||||||
|
message: string;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userId: number | null;
|
||||||
|
customerId: number | null;
|
||||||
|
userEmail: string | null;
|
||||||
|
endpoint: string | null;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
alerted: boolean;
|
||||||
|
alertedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringSettings {
|
||||||
|
alertEmail: string;
|
||||||
|
digestEnabled: boolean;
|
||||||
|
lastDigestAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const monitoringApi = {
|
||||||
|
getEvents: async (params?: { page?: number; limit?: number; type?: SecurityEventType | ''; severity?: SecuritySeverity | ''; search?: string; ip?: string; since?: string }) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.page) q.set('page', String(params.page));
|
||||||
|
if (params?.limit) q.set('limit', String(params.limit));
|
||||||
|
if (params?.type) q.set('type', params.type);
|
||||||
|
if (params?.severity) q.set('severity', params.severity);
|
||||||
|
if (params?.search) q.set('search', params.search);
|
||||||
|
if (params?.ip) q.set('ip', params.ip);
|
||||||
|
if (params?.since) q.set('since', params.since);
|
||||||
|
const res = await api.get<ApiResponse<SecurityEvent[]> & {
|
||||||
|
pagination: { page: number; limit: number; total: number; totalPages: number };
|
||||||
|
stats: { byType: Record<string, number>; bySeverity: Record<string, number> };
|
||||||
|
}>(`/monitoring/events?${q}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
getSettings: async () => {
|
||||||
|
const res = await api.get<ApiResponse<MonitoringSettings>>('/monitoring/settings');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
updateSettings: async (data: { alertEmail?: string; digestEnabled?: boolean }) => {
|
||||||
|
const res = await api.put<ApiResponse<void>>('/monitoring/settings', data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
testAlert: async () => {
|
||||||
|
const res = await api.post<ApiResponse<void>>('/monitoring/test-alert');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
runDigest: async () => {
|
||||||
|
const res = await api.post<ApiResponse<{ sent: boolean; eventCount: number; reason?: string }>>('/monitoring/run-digest');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const emailLogApi = {
|
export const emailLogApi = {
|
||||||
getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => {
|
getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue