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
+13
View File
@@ -113,3 +113,16 @@ AUDIT_HMAC_KEY_OLD=
# Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel
# UND Datenbank hat, kann die Kette konsistent neu rechnen.
# Erzeugung : openssl rand -hex 32 (256 Bit)
# ==================== DIENSTKONTO-WACHHUND ====================
# Nach wie vielen Minuten Stille eines Dienstkontos (z. B. des Gegenbuchs)
# ein Sicherheitsereignis erzeugt wird.
#
# Hintergrund: Die planmaessigen Anmeldungen selbst sind bewusst als Routine
# eingestuft, damit sie die CRITICAL-Stufe nicht entwerten. Das Signal ist
# deshalb die ABWESENHEIT: Wer das Gegenbuch stilllegt, setzt darauf, dass
# Stille nicht auffaellt.
#
# Faustregel: etwa das Dreifache des Gegenbuch-Takts. Bei stuendlichem Takt
# also 180. Ohne Dienstkonten im System passiert nichts.
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES=180
+64 -3
View File
@@ -180,8 +180,11 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht Änderungen am ' +
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
: result.backlogSealStatus === 'nicht_noetig'
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
: result.backlogSealStatus === 'kein_siegel'
? ' Hinweis: Der Altbestand ist nicht versiegelt Änderungen daran wären nicht erkennbar.'
? ' Hinweis: Der Altbestand ist nicht versiegelt Änderungen daran wären nicht erkennbar. ' +
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
: '';
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
@@ -197,12 +200,20 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
const siegelProblem =
result.backlogSealStatus === 'gebrochen' || result.backlogSealStatus === 'entfernt';
// „Keine Manipulation“ darf NICHT dastehen, solange `valid` falsch ist
// (Pentest R183-02). Nach einem Cleanup mit abgesenkter Aufbewahrung waren
// tausende Anmeldeprotokolle endgueltig geloescht die Luecken durch
// Tombstones „erklaert“, die Meldung las sich beruhigend, und der Befund
// stand nur noch im Feld `valid`. Wer die Prosa liest statt des Felds,
// klickt genau das weg, was ihn haette warnen sollen.
const message = tampered > 0
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
: siegelProblem
? 'Die Kette selbst ist rechnerisch stimmig, ABER:'
: gaps > 0
? `Keine Manipulation. ${luecken} Inhalte unverändert.`
? `Die Kette ist nicht mehr lückenlos: ${luecken}. Die verbliebenen Inhalte sind ` +
'unverändert aber gelöschte Einträge lassen sich naturgemäß nicht mehr prüfen. ' +
'Dokumentierte Löschungen sind erwartbar; unerwartete gehören nachgegangen.'
: 'Alle Einträge sind unverändert und lückenlos verkettet';
res.json({
@@ -353,6 +364,48 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
}
const { retentionDays, description, legalBasis, isActive } = req.body;
// Die Richtlinie bestimmt, WAS der Cleanup vernichtet sie ist die
// geladene Waffe, der Cleanup nur der Abzug (Pentest R183-01). Bisher war
// ausgerechnet der Abzug gegatet und die Waffe frei zugaenglich: Ein
// `retentionDays: 0` ging ohne Bestaetigung durch und wurde nur als MEDIUM
// protokolliert, waehrend seine Wirkung CRITICAL ist. Die ausloesende Tat
// war damit leiser als die Folge und lag unter der Schwelle, bei der
// jemand hinsieht.
const bisher = (await auditService.getRetentionPolicies()).find((p) => p.id === id);
if (!bisher) {
return res.status(404).json({ success: false, error: 'Aufbewahrungsrichtlinie nicht gefunden' });
}
const neueTage = typeof retentionDays === 'number' ? retentionDays : bisher.retentionDays;
// Untergrenze: Auth-Eintraege sind der Einbruchsbeleg schlechthin. Eine
// Aufbewahrung von faktisch null macht sie mit dem naechsten Cleanup
// spurlos entfernbar ueber einen sanktionierten Pfad.
const UNTERGRENZE: Record<string, number> = { Authentication: 30, AuditLog: 30 };
const grenze = UNTERGRENZE[bisher.resourceType];
if (grenze !== undefined && neueTage < grenze) {
return res.status(400).json({
success: false,
error:
`Für ${bisher.resourceType} sind mindestens ${grenze} Tage Aufbewahrung vorgeschrieben ` +
`(angefragt: ${neueTage}). Diese Einträge belegen Anmeldungen und Zugriffe eine ` +
`Aufbewahrung nahe null macht sie beim nächsten Cleanup spurlos entfernbar.`,
});
}
// Absenken ist die gefaehrliche Richtung und braucht dieselbe ausdrueckliche
// Bestaetigung wie der Cleanup selbst.
const senktAb = neueTage < bisher.retentionDays;
if (senktAb && req.body?.confirm !== 'SHORTEN') {
return res.status(400).json({
success: false,
error:
`Das verkürzt die Aufbewahrung von ${bisher.retentionDays} auf ${neueTage} Tage. ` +
'Beim nächsten Cleanup werden dadurch Einträge endgültig gelöscht, die heute noch ' +
'da sind. Zum Bestätigen {"confirm":"SHORTEN"} mitsenden.',
});
}
const policy = await auditService.updateRetentionPolicy(id, {
retentionDays,
description,
@@ -363,7 +416,15 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
await logChange({
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
resourceId: id.toString(),
label: `Aufbewahrungsrichtlinie aktualisiert`,
// Absenkung wird wie ihre Folge eingestuft: CRITICAL, nicht MEDIUM.
sensitivity: senktAb ? 'CRITICAL' : undefined,
label: senktAb
? `Aufbewahrung VERKÜRZT für ${bisher.resourceType}` +
(bisher.sensitivity ? `/${bisher.sensitivity}` : '') +
`: ${bisher.retentionDays}${neueTage} Tage`
: `Aufbewahrungsrichtlinie aktualisiert`,
before: { retentionDays: bisher.retentionDays },
details: { retentionDays: neueTage },
});
res.json({ success: true, data: policy });
+5
View File
@@ -71,6 +71,7 @@ import { startSecurityMonitorScheduler } from './services/securityAlert.service.
import monitoringRoutes from './routes/monitoring.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
import { authenticate } from './middleware/auth.js';
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
@@ -487,6 +488,10 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N
const LISTEN_ADDR = process.env.LISTEN_ADDR
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
starteHeartbeatMonitor();
app.listen(PORT as number, LISTEN_ADDR, () => {
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
+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();
}
+34
View File
@@ -97,6 +97,40 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🚨 Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)** (2026-08-24)
- **R183-01 (HIGH)** Die geladene Waffe war ungegatet, der Abzug gegatet:
`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 ausloesende Tat war leiser als ihre Wirkung.
Fix: Absenken verlangt `{"confirm":"SHORTEN"}`, wird als **CRITICAL** mit
Vorher/Nachher protokolliert („Aufbewahrung VERKUERZT … 730 → 0 Tage“), und
fuer `Authentication`/`AuditLog` gilt eine **Untergrenze von 30 Tagen**.
`logChange` nimmt dafuer jetzt Sensitivitaet und Vorzustand entgegen.
- **R183-02 (HIGH)** Nach dem Cleanup meldete verify
„**Keine Manipulation.** 656 strukturelle Luecken (alle durch protokollierte
Loeschungen erklaert)“ bei `valid: false` und 3010 endgueltig geloeschten
Anmeldeprotokollen. Der Befund stand nur im Feld, die Prosa beruhigte.
Dieselbe Alarm-Muedigkeit wie R162/R182, diesmal im Verifizierer selbst.
Fix: Bei Luecken heisst es jetzt „Die Kette ist nicht mehr lueckenlos …
geloeschte Eintraege lassen sich naturgemaess nicht mehr pruefen“.
Zusaetzlich wertet das **Gegenbuch `valid:false` hart** es ruft
`/verify` mit und schlaegt Alarm, egal wie der Text klingt.
- **R183-03 (MEDIUM)** verify warnte dauerhaft „Altbestand nicht
versiegelt“, waehrend seal-backlog zu Recht ablehnte („kein Altbestand“).
Eine Warnung, die niemand aufloesen kann, lernt man zu ignorieren.
Fix: eigener Zustand `nicht_noetig` mit Klartext; die echte Warnung nennt
jetzt den Befehl zum Beheben.
- **Heartbeat-Wachhund gebaut** (seine wichtigere Haelfte): 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 wird genau einmal gemeldet.
Verifiziert: keine Grundlinie → still; 500 Minuten Stille → Meldung;
Wiederholung → keine Dublette.
- Seine Non-Findings bestaetigt uebernommen: Confirm-Gates gegen neun
Umgehungsvarianten dicht, rehash-Protokoll ehrlich, Tombstone-Mechanik und
Anker-Backstop intakt.
- [x] **🔧 Gegenbuch-Container: Rechte am Bind-Mount selbst geraderuecken** (2026-08-22)
- Fehlerbild aus dem echten Betrieb: `mkdir: cannot create directory
'/gegenbuch/schluessel': Permission denied`, Container in der
+25 -2
View File
@@ -235,13 +235,20 @@ async function anmelden() {
zugangsToken = j.data.token;
}
async function hole(pfad) {
async function hole(pfad, methode = 'GET') {
// Fehler werden hier zu einer erklaerenden Zeile frueher flog ein
// Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer
// git-Meldungen schon abgestellt war.
let r;
try {
r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${zugangsToken}` } });
r = await fetch(`${CRM_URL}${pfad}`, {
method: methode,
headers: {
Authorization: `Bearer ${zugangsToken}`,
...(methode === 'POST' ? { 'content-type': 'application/json' } : {}),
},
...(methode === 'POST' ? { body: '{}' } : {}),
});
} catch (e) {
console.error(
`Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
@@ -737,6 +744,22 @@ if (letzter) {
`jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`,
);
}
// `valid: false` wird HART gewertet, unabhaengig von der erklaerenden Prosa
// (Pentest R183-02). Ein Cleanup mit abgesenkter Aufbewahrung kann tausende
// Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
// ausweisen die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
// das Feld, nicht der Satz.
const pruefung = await hole('/api/audit-logs/verify', 'POST');
if (pruefung && pruefung.valid === false) {
alarm(
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
` CRM-Text: ${pruefung.message}\n` +
'Auch wenn dieser Text harmlos klingt: Es wird nichts beglaubigt, solange die\n' +
'Prüfung nicht sauber ist. Häufigste Ursache ist ein Cleanup mit verkürzter\n' +
'Aufbewahrung dann wurden Einträge endgültig gelöscht.',
);
}
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
if (rueck.atHash !== letzter.chainHead) {