/** * 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 ` ${ts} ${severityIcon(e.severity)} ${e.severity} ${e.type} ${e.message} ${who} ${ip} ${ep} `; } function buildHtmlEmail(params: AlertEmailParams): string { const rows = params.events.map(eventToHtmlRow).join('\n'); const heading = params.isDigest ? `

OpenCRM Security-Digest

Übersicht der wichtigen Events der letzten Stunde:

` : `

OpenCRM Security-Alert

Folgendes Event wurde als kritisch eingestuft:

`; return ` ${heading} ${rows}
Zeit Severity Typ Nachricht Wer IP Endpoint

Diese Mail wurde vom OpenCRM Monitoring-System gesendet. Konfiguration: Einstellungen → Monitoring.

`; } /** * Versendet einen Alert per E-Mail. Nutzt die System-E-Mail des Providers. */ export async function sendAlertEmail(toAddress: string, params: AlertEmailParams): Promise { 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 { 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)'); }