import prisma from '../lib/prisma.js'; import { emit as emitSecurityEvent } from './securityMonitor.service.js'; /** * Wachhund auf den AUSBLEIBENDEN Heartbeat (Pentest R182/R183). * * Dienstkonten – allen voran das Gegenbuch – melden sich in festem Takt an. * Diese Anmeldungen sind bewusst als Routine eingestuft, damit sie die * CRITICAL-Stufe nicht entwerten. Damit fehlt aber die andere Haelfte: Wer das * Gegenbuch stilllegt, setzt genau darauf, dass STILLE nicht auffaellt. Ein * gestopptes Gegenbuch ist sonst nicht von „laeuft ruhig“ zu unterscheiden. * * Das Sicherheitssignal ist deshalb nicht die Anwesenheit der Anmeldung, * sondern ihre Abweichung: bleibt sie aus, wird es gemeldet. * * Bewusste Zurueckhaltung: Ohne jemals gesehene Anmeldung gibt es keine * Grundlinie – dann wird geschwiegen statt geraten. Und pro Ausfall wird nur * einmal gemeldet, nicht bei jedem Durchlauf. */ const PRUEFTAKT_MS = 15 * 60 * 1000; const MAX_ALTER_MINUTEN = Number.parseInt(process.env.SERVICE_ACCOUNT_MAX_SILENCE_MINUTES || '180', 10); export async function pruefeHeartbeats(): Promise { const konten = await prisma.user.findMany({ where: { isServiceAccount: true, isActive: true }, select: { id: true, email: true }, }); if (konten.length === 0) return; const grenze = new Date(Date.now() - MAX_ALTER_MINUTEN * 60 * 1000); for (const konto of konten) { const letzte = await prisma.auditLog.findFirst({ where: { userId: konto.id, action: 'LOGIN' }, orderBy: { createdAt: 'desc' }, select: { createdAt: true }, }); // Nie angemeldet = keine Grundlinie. Schweigen statt raten. if (!letzte) continue; if (letzte.createdAt >= grenze) continue; // Pro Ausfall nur einmal melden. const schonGemeldet = await prisma.securityEvent.findFirst({ where: { type: 'SUSPICIOUS', userEmail: konto.email, createdAt: { gte: letzte.createdAt }, message: { contains: 'Dienstkonto' }, }, }); if (schonGemeldet) continue; const stillSeit = Math.round((Date.now() - letzte.createdAt.getTime()) / 60000); await emitSecurityEvent({ type: 'SUSPICIOUS', severity: 'CRITICAL', message: `Dienstkonto ${konto.email} meldet sich seit ${stillSeit} Minuten nicht mehr ` + `(erwartet mindestens alle ${MAX_ALTER_MINUTEN} Minuten). ` + 'Entweder steht der zugehörige Dienst – etwa das Gegenbuch – oder er wurde stillgelegt. ' + 'Stille ist hier kein guter Zustand: Ohne laufendes Gegenbuch fällt eine nachträgliche ' + 'Änderung am Audit-Log nicht mehr auf.', userEmail: konto.email, userId: konto.id, endpoint: 'heartbeat-monitor', }); } } export function starteHeartbeatMonitor(): void { if (!Number.isFinite(MAX_ALTER_MINUTEN) || MAX_ALTER_MINUTEN <= 0) { console.warn('[Heartbeat] SERVICE_ACCOUNT_MAX_SILENCE_MINUTES ungültig – Wachhund bleibt aus.'); return; } const lauf = () => { pruefeHeartbeats().catch((e) => console.error('[Heartbeat] Fehler:', e)); }; // Nicht sofort beim Start: Nach einem Neustart darf ein kurz zurueckliegender // Ausfall nicht doppelt melden, und die DB soll erst oben sein. setTimeout(lauf, 60_000); setInterval(lauf, PRUEFTAKT_MS).unref(); }