opencrm/backend/src/services/securityAlert.service.ts

288 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)');
}