diff --git a/backend/.env.example b/backend/.env.example index fe3cde33..759541c0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/src/controllers/auditLog.controller.ts b/backend/src/controllers/auditLog.controller.ts index 59c031e5..27c87e1a 100644 --- a/backend/src/controllers/auditLog.controller.ts +++ b/backend/src/controllers/auditLog.controller.ts @@ -180,9 +180,12 @@ 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 === 'kein_siegel' - ? ' Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären nicht erkennbar.' - : ''; + : 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. ' + + 'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.' + : ''; // Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es // ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03). @@ -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 = { 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 }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 31da5c83..2b0f0a5b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index e567afee..15d4c4c2 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -15,6 +15,12 @@ export async function logChange(opts: { label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen" details?: Record; // 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; }) { 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, diff --git a/backend/src/services/heartbeatMonitor.service.ts b/backend/src/services/heartbeatMonitor.service.ts new file mode 100644 index 00000000..06d4e32a --- /dev/null +++ b/backend/src/services/heartbeatMonitor.service.ts @@ -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 { + 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(); +} diff --git a/docs/todo.md b/docs/todo.md index 9df5dcd5..9be264f7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index 1cfe98ef..8413989c 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -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) {