288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
/**
|
||
* 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)');
|
||
}
|