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:
2026-08-24 10:01:32 +02:00
co-authored by Claude Opus 5
parent 41671cbb96
commit 910c94daa1
7 changed files with 246 additions and 10 deletions
+20 -3
View File
@@ -15,6 +15,12 @@ export async function logChange(opts: {
label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen"
details?: Record<string, unknown>; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' }
customerId?: number;
/// Ueberschreibt die aus dem Ressourcentyp abgeleitete Stufe. Noetig, wenn
/// eine Aktion gefaehrlicher ist als ihr Typ vermuten laesst - etwa das
/// Verkuerzen einer Aufbewahrungsfrist, dessen Folge CRITICAL ist
/// (Pentest R183-01).
sensitivity?: AuditSensitivity;
before?: Record<string, unknown>;
}) {
try {
const user = opts.req?.user;
@@ -32,6 +38,8 @@ export async function logChange(opts: {
httpMethod: opts.req?.method || '',
ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown',
dataSubjectId: opts.customerId,
sensitivity: opts.sensitivity,
changesBefore: opts.before,
changesAfter: opts.details,
});
} catch (error) {
@@ -895,7 +903,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
* gueltiger Marker mehr der Anker wurde herausgeloest (Pentest R174-01).
*/
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt';
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
backlogTampered: number[];
/** Gesiegelte Zeilen, die nicht mehr existieren Beweismaterial entfernt. */
@@ -1040,7 +1048,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
// ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01).
const backlogTampered: number[] = [];
const backlogMissing: number[] = [];
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' = 'kein_siegel';
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
let backlogSealRoot: string | null = null;
const siegelKandidaten = await prisma.auditLog.findMany({
@@ -1086,6 +1094,13 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
const blattAnzahl = await prisma.auditBacklogSeal.count();
if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) {
backlogSealStatus = 'entfernt';
} else if (!siegel && blattAnzahl === 0 && (v3FromId === null || v3FromId <= 1)) {
// Es gibt gar keinen Altbestand: Entweder ist alles signiert, oder das Log
// beginnt erst mit der Signierung. Dann ist „nicht versiegelt“ kein Mangel
// (Pentest R183-03) die bisherige Warnung liess sich nicht aufloesen,
// weil seal-backlog zu Recht ablehnte. Eine Warnung, die der Betreiber
// nicht beheben kann, lernt er zu ignorieren.
backlogSealStatus = 'nicht_noetig';
}
if (siegel) {
@@ -1243,7 +1258,9 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
// einzelne Zeile beanstandet ist sonst bliebe der stille Anker-Verlust
// unsichtbar (R174-01).
const siegelInOrdnung =
backlogSealStatus === 'intakt' || backlogSealStatus === 'kein_siegel';
backlogSealStatus === 'intakt' ||
backlogSealStatus === 'kein_siegel' ||
backlogSealStatus === 'nicht_noetig';
return {
valid: invalidEntries.length === 0 && siegelInOrdnung,
@@ -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();
}