Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)
R183-01: PUT /retention-policies/{id} nahm retentionDays 0 ohne Bestaetigung an
und protokollierte es als MEDIUM - waehrend cleanup, seal und rehash alle ein
Confirm-Gate haben und die Folge CRITICAL ist. Die geladene Waffe war ungegatet,
der Abzug gegatet. Fix: Absenken verlangt confirm SHORTEN, wird als CRITICAL mit
Vorher/Nachher protokolliert, Untergrenze 30 Tage fuer Authentication/AuditLog.
R183-02: Nach dem Cleanup meldete verify "Keine Manipulation" bei valid:false
und 3010 endgueltig geloeschten Anmeldeprotokollen - der Befund stand nur im
Feld, die Prosa beruhigte. Fix: ehrliche Formulierung bei Luecken, und das
Gegenbuch ruft /verify mit und wertet valid:false hart, egal wie der Text
klingt.
R183-03: verify warnte dauerhaft "Altbestand nicht versiegelt", waehrend
seal-backlog zu Recht ablehnte. Eine unaufloesbare Warnung lernt man zu
ignorieren. Fix: eigener Zustand nicht_noetig, echte Warnung nennt den Befehl.
Heartbeat-Wachhund: Bleibt ein Dienstkonto laenger still als
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES (Standard 180), gibt es SUSPICIOUS/CRITICAL.
Ohne je gesehene Anmeldung wird geschwiegen statt geraten, pro Ausfall genau
eine Meldung. Verifiziert in allen drei Faellen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user