From e81a83ae8f6175b56a74cb952b0cd929974712ad Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 26 Aug 2026 09:41:11 +0200 Subject: [PATCH] Siegelwechsel ist ein Alarm, kein Hinweis (Pentest R185-01/-02) R185-01 (MEDIUM): Die Flanke, die wir selbst gemeldet hatten, hat der Tester live bestaetigt. seal-backlog war beim ZWEITEN Aufruf genauso gegatet wie beim ersten ({"confirm":"SEAL"} -> 200), und das Ereignis landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der automatische Rueckhalt des Gegenbuchs haengt an `valid` - und `valid` ueberlebt ein ersetzendes Siegel per Konstruktion. Angriff: Altzeile per DB-Zugriff loeschen, neu siegeln, Luecke ist beglaubigt, valid wieder true. Live reproduziert. Zwei Schichten, in seiner Reihenfolge: 1. Alarmkanal. Neuer SecurityEventType AUDIT_SEAL_CHANGED (Migration 20260826120000). Erstes Siegeln HIGH, Ersetzen CRITICAL - geht damit ueber sendPendingCriticalAlerts sofort per Mail raus. Die Details halten Wurzel vorher/nachher und den vollstaendigen Vorbefund fest. 2. Gate. Steht bereits ein Siegel, verlangt der Endpunkt {"confirm":"RESEAL"} statt SEAL, mit einem Text, der sagt, was dabei verloren geht. Ein Austausch der Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten. Und im Gegenbuch selbst: dort stand fuer den Wurzelwechsel ein console.warn, waehrend der Rueckgabecode auf 0 blieb - also exakt das Muster, das wir dem CRM zweimal angekreidet haben (R179, R183-02), im Werkzeug, das dagegen gebaut wurde. Jetzt exit 2, mit alter und neuer Wurzel samt Blattzahl; "10 Blaetter -> 9 Blaetter" zeigt die Loeschung sofort. Auch die Erstsiegelung meldet sich, statt stillschweigend uebernommen zu werden. Aufloesbar gemacht: der Alarm bricht ab, BEVOR angehaengt wird - ohne Bestaetigungsweg haette auch ein legitimes Siegeln fuer immer alarmiert (R183-03-Falle). Neu ist NOTARY_SEAL_ACK, bewusst nicht "true", sondern die Wurzel selbst (mind. 16 Zeichen): ein stehen gelassener Wert passt beim naechsten Wechsel nicht mehr und kann keinen weiteren Austausch durchwinken. R185-02 (LOW): GET /api/audit-logs?action= gab ungueltige Enum-Werte roh an die Spalte -> 500. Zweifach schlecht: fehlende Validierung und Fehler-Orakel (200 vs 500 verraet die Enum-Mitglieder). Jetzt 400 mit der erlaubten Menge im Klartext. Mitgenommen: sensitivity, Datumsfelder, Zahlenfelder, Textlaengen und ein Deckel auf limit (200), ueber den sich sonst die ganze Tabelle an der Seitenlogik vorbei ziehen liess. Beide Endpunkte. Getestet ueber HTTP gegen eine Wegwerf-DB, inkl. echtem Gegenbuch-Lauf mit SSH-signiertem lokalem Repo. Zusaetzlich nachgeholt, was der Tester nicht herstellen konnte: vollstaendig unsigniertes Protokoll -> kein_siegel statt der frueheren falschen Entwarnung nicht_noetig, und seal-backlog nennt den fehlenden Schluessel als naechsten Schritt. Gegenrichtung geprueft, R183-03 bleibt behoben. Co-Authored-By: Claude Opus 5 (1M context) --- .../migration.sql | 17 ++ backend/prisma/schema.prisma | 1 + .../src/controllers/auditLog.controller.ts | 181 +++++++++++++++--- docs/todo.md | 60 ++++++ frontend/src/pages/settings/Monitoring.tsx | 1 + frontend/src/services/api.ts | 2 +- tools/audit-notary/.env.example | 12 ++ tools/audit-notary/README.md | 43 ++++- tools/audit-notary/docker-compose.yml | 3 + tools/audit-notary/notary.mjs | 73 ++++++- 10 files changed, 365 insertions(+), 28 deletions(-) create mode 100644 backend/prisma/migrations/20260826120000_audit_seal_changed_event/migration.sql diff --git a/backend/prisma/migrations/20260826120000_audit_seal_changed_event/migration.sql b/backend/prisma/migrations/20260826120000_audit_seal_changed_event/migration.sql new file mode 100644 index 00000000..75f557e7 --- /dev/null +++ b/backend/prisma/migrations/20260826120000_audit_seal_changed_event/migration.sql @@ -0,0 +1,17 @@ +-- Neuer SecurityEventType AUDIT_SEAL_CHANGED (Pentest R185-01). +-- +-- Das Setzen oder Ersetzen des Bestandssiegels veraendert die Grundlage, gegen +-- die spaeter Manipulation nachgewiesen wird. Bisher landete das ausschliesslich +-- als CRITICAL-Zeile im Audit-Log - also in einem Kanal, den ein Mensch lesen +-- muss. Der Alarmkanal ist ein separater Store; ohne eigenen Ereignistyp gab es +-- dort gar keinen Eintrag, und `valid` bleibt bei einem ersetzenden Siegel +-- konstruktionsbedingt `true`. +-- +-- MODIFY COLUMN ist idempotent (setzt die Enum-Definition, mehrfach ausfuehrbar). +ALTER TABLE `SecurityEvent` + MODIFY COLUMN `type` ENUM( + 'LOGIN_FAILED','LOGIN_SUCCESS','RATE_LIMIT_HIT','ACCESS_DENIED', + 'SSRF_BLOCKED','PASSWORD_RESET_REQUEST','PASSWORD_RESET_CONFIRM', + 'LOGOUT','TOKEN_REJECTED','PERMISSION_CHANGED','AUDIT_SEAL_CHANGED', + 'SUSPICIOUS' + ) NOT NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6f5251d7..00b7b029 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1482,6 +1482,7 @@ enum SecurityEventType { LOGOUT // expliziter Logout TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT PERMISSION_CHANGED // Admin hat Rolle/Permission geändert + AUDIT_SEAL_CHANGED // Bestandssiegel gesetzt oder ersetzt (Beweis-Grundlage) SUSPICIOUS // generischer Catch-All } diff --git a/backend/src/controllers/auditLog.controller.ts b/backend/src/controllers/auditLog.controller.ts index 65bfe5b9..f7322897 100644 --- a/backend/src/controllers/auditLog.controller.ts +++ b/backend/src/controllers/auditLog.controller.ts @@ -3,6 +3,66 @@ import { AuthRequest } from '../types/index.js'; import * as auditService from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js'; import { AuditAction, AuditSensitivity } from '@prisma/client'; +import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js'; + +/** + * Filterwerte aus der Query pruefen (Pentest R185-02). + * + * Vorher gingen `action` und `sensitivity` als roher String an die Enum-Spalte. + * Ein ungueltiger Wert liess Prisma auflaufen und der Handler antwortete 500. + * Das ist zweierlei: eine fehlende Validierung – und ein Fehler-Orakel. Wer + * 200 gegen 500 vergleicht, liest die Enum-Mitglieder aus, ohne sie zu kennen. + * Ein ungueltiger Filter ist eine schlechte ANFRAGE, keine Server-Panne: 400. + * + * Dasselbe gilt fuer Datumsangaben (`new Date('foo')` ergibt Invalid Date und + * sprengt die Query erst in der Datenbank) und fuer Zahlen (`parseInt('x')` + * ergibt NaN). + */ +class FilterFehler extends Error {} + +const AUDIT_ACTIONS = Object.values(AuditAction) as string[]; +const AUDIT_SENSITIVITIES = Object.values(AuditSensitivity) as string[]; + +function pruefeEnum( + wert: unknown, erlaubt: string[], feld: string, +): T | undefined { + if (wert === undefined || wert === '') return undefined; + if (typeof wert !== 'string' || !erlaubt.includes(wert)) { + // Die erlaubten Werte stehen ohnehin in der Oberflaeche und im Schema – + // sie zu nennen verraet nichts und erspart Rateversuche. + throw new FilterFehler( + `Ungültiger Wert für "${feld}". Erlaubt: ${erlaubt.join(', ')}.`, + ); + } + return wert as T; +} + +function pruefeDatum(wert: unknown, feld: string): Date | undefined { + if (wert === undefined || wert === '') return undefined; + const d = new Date(wert as string); + if (Number.isNaN(d.getTime())) { + throw new FilterFehler(`Ungültiges Datum für "${feld}".`); + } + return d; +} + +function pruefeZahl(wert: unknown, feld: string): number | undefined { + if (wert === undefined || wert === '') return undefined; + const n = Number(wert); + if (!Number.isInteger(n) || n < 0) { + throw new FilterFehler(`Ungültige Zahl für "${feld}".`); + } + return n; +} + +/** Freitextfelder begrenzen – unbegrenzte LIKE-Muster sind teuer. */ +function pruefeText(wert: unknown, feld: string, maxLaenge = 200): string | undefined { + if (wert === undefined || wert === '') return undefined; + if (typeof wert !== 'string' || wert.length > maxLaenge) { + throw new FilterFehler(`Ungültiger Wert für "${feld}" (max. ${maxLaenge} Zeichen).`); + } + return wert; +} /** * Audit-Logs mit Filtern abrufen @@ -26,23 +86,29 @@ export async function getAuditLogs(req: AuthRequest, res: Response) { } = req.query; const result = await auditService.searchAuditLogs({ - userId: userId ? parseInt(userId as string) : undefined, - customerId: customerId ? parseInt(customerId as string) : undefined, - dataSubjectId: dataSubjectId ? parseInt(dataSubjectId as string) : undefined, - action: action as AuditAction | undefined, - sensitivity: sensitivity as AuditSensitivity | undefined, - resourceType: resourceType as string | undefined, - resourceId: resourceId as string | undefined, - startDate: startDate ? new Date(startDate as string) : undefined, - endDate: endDate ? new Date(endDate as string) : undefined, + userId: pruefeZahl(userId, 'userId'), + customerId: pruefeZahl(customerId, 'customerId'), + dataSubjectId: pruefeZahl(dataSubjectId, 'dataSubjectId'), + action: pruefeEnum(action, AUDIT_ACTIONS, 'action'), + sensitivity: pruefeEnum(sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'), + resourceType: pruefeText(resourceType, 'resourceType', 100), + resourceId: pruefeText(resourceId, 'resourceId', 100), + startDate: pruefeDatum(startDate, 'startDate'), + endDate: pruefeDatum(endDate, 'endDate'), success: success !== undefined ? success === 'true' : undefined, - search: search as string | undefined, - page: page ? parseInt(page as string) : 1, - limit: limit ? parseInt(limit as string) : 50, + search: pruefeText(search, 'search'), + page: pruefeZahl(page, 'page') || 1, + // Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem + // Zug ziehen, an der Seitenlogik vorbei. + limit: Math.min(pruefeZahl(limit, 'limit') || 50, 200), }); res.json({ success: true, ...result }); } catch (error) { + if (error instanceof FilterFehler) { + res.status(400).json({ success: false, error: error.message }); + return; + } console.error('Fehler beim Abrufen der Audit-Logs:', error); res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' }); } @@ -96,7 +162,7 @@ export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) { */ export async function exportAuditLogs(req: AuthRequest, res: Response) { try { - const format = (req.query.format as 'json' | 'csv') || 'json'; + const format = req.query.format === 'csv' ? 'csv' : 'json'; const { action, sensitivity, @@ -107,11 +173,11 @@ export async function exportAuditLogs(req: AuthRequest, res: Response) { const content = await auditService.exportAuditLogs( { - action: action as AuditAction | undefined, - sensitivity: sensitivity as AuditSensitivity | undefined, - resourceType: resourceType as string | undefined, - startDate: startDate ? new Date(startDate as string) : undefined, - endDate: endDate ? new Date(endDate as string) : undefined, + action: pruefeEnum(action, AUDIT_ACTIONS, 'action'), + sensitivity: pruefeEnum(sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'), + resourceType: pruefeText(resourceType, 'resourceType', 100), + startDate: pruefeDatum(startDate, 'startDate'), + endDate: pruefeDatum(endDate, 'endDate'), }, format ); @@ -126,6 +192,10 @@ export async function exportAuditLogs(req: AuthRequest, res: Response) { res.json({ success: true, data: JSON.parse(content) }) } } catch (error) { + if (error instanceof FilterFehler) { + res.status(400).json({ success: false, error: error.message }); + return; + } console.error('Fehler beim Exportieren der Audit-Logs:', error); res.status(500).json({ success: false, error: 'Fehler beim Exportieren' }); } @@ -338,25 +408,90 @@ export async function getCheckpoint(req: AuthRequest, res: Response) { */ export async function sealBacklog(req: AuthRequest, res: Response) { try { - if (req.body?.confirm !== 'SEAL') { + // Erst nachsehen, ob schon ein Siegel steht (Pentest R185-01). + // + // Ein zweites Siegeln ist etwas grundlegend anderes als das erste: Es + // ERSETZT die Grundlage, gegen die Manipulation nachgewiesen wird. Wer den + // Altbestand per Datenbankzugriff beschneidet und danach neu siegelt, + // bekommt eine passende Wurzel und eine ueber den neuen Vorbefund + // beglaubigte Luecke - und `valid` steht wieder auf `true`. Deshalb + // verlangt das Ersetzen ein eigenes Wort und nicht dasselbe wie das + // Einrichten. + const vorher = await auditService.verifyIntegrity(); + const siegelSteht = + vorher.backlogSealStatus === 'intakt' || + vorher.backlogSealStatus === 'gebrochen' || + vorher.backlogSealStatus === 'entfernt'; + + const erwartet = siegelSteht ? 'RESEAL' : 'SEAL'; + if (req.body?.confirm !== erwartet) { res.status(400).json({ success: false, - error: - 'Versiegelt den aktuellen Stand des Altbestands. Zum Bestätigen ' + - '{"confirm":"SEAL"} mitsenden.', + error: siegelSteht + ? 'Es besteht bereits ein Bestandssiegel. Erneutes Siegeln ERSETZT die ' + + 'bisherige Beweisgrundlage: bestehende Lücken werden neu beglaubigt und ' + + 'ein Befund am Altbestand verschwindet aus der Prüfung. Das ist kein ' + + 'Wartungsschritt. Zum Bestätigen {"confirm":"RESEAL"} mitsenden – und ' + + 'vorher klären, warum das bisherige Siegel nicht mehr passt.' + : 'Versiegelt den aktuellen Stand des Altbestands. Zum Bestätigen ' + + '{"confirm":"SEAL"} mitsenden.', + aktuellerSiegelzustand: vorher.backlogSealStatus, }); return; } + const result = await auditService.sealBacklog({ userEmail: req.user?.email, ipAddress: req.ip || (req.socket as any)?.remoteAddress, }); + + // In den ALARMKANAL, nicht nur ins Audit-Log (Pentest R185-01). + // + // Die CRITICAL-Zeile im Audit-Log gab es schon - aber die muss jemand + // lesen, und genau das ist die R183-02/R184-01-Klasse. Der automatische + // Rueckhalt des Gegenbuchs haengt an `valid`, und `valid` ueberlebt ein + // ersetzendes Siegel per Konstruktion. Der einzige maschinell erkennbare + // Anker ist der Wechsel der Wurzel - der gehoert dorthin, wo etwas von + // selbst passiert. + const ctx = contextFromRequest(req); + emitSecurityEvent({ + type: 'AUDIT_SEAL_CHANGED', + severity: siegelSteht ? 'CRITICAL' : 'HIGH', + message: siegelSteht + ? `Bestandssiegel ERSETZT (${result.sealedCount} Alteinträge, id ${result.fromId}–` + + `${result.toId}). Die bisherige Beweisgrundlage gilt nicht mehr; der Zustand ` + + `davor war "${vorher.backlogSealStatus}". Wenn das keine geplante Maßnahme war, ` + + 'ist es ein Befund.' + : `Bestandssiegel erstmals gesetzt (${result.sealedCount} Alteinträge, id ` + + `${result.fromId}–${result.toId}). Ab jetzt fallen Änderungen am Altbestand auf.`, + ipAddress: ctx.ipAddress, + userId: req.user?.userId, + userEmail: req.user?.email, + endpoint: ctx.endpoint, + details: { + ersetzt: siegelSteht, + zustandVorher: vorher.backlogSealStatus, + wurzelVorher: vorher.backlogSealRoot, + wurzelNachher: result.root, + befundVorher: { + manipuliert: vorher.tamperedEntries, + luecken: vorher.chainGaps, + altbestandVeraendert: vorher.backlogTampered, + altbestandFehlend: vorher.backlogMissing, + }, + }, + }); + res.json({ success: true, data: result, message: `${result.sealedCount} Alteinträge (id ${result.fromId}–${result.toId}) versiegelt. ` + - 'Spätere Änderungen an diesen Einträgen fallen ab sofort auf.', + 'Spätere Änderungen an diesen Einträgen fallen ab sofort auf.' + + (siegelSteht + ? ' ACHTUNG: Dies war ein ERSETZENDES Siegel – die vorherige Beweisgrundlage ' + + 'gilt nicht mehr.' + : ''), }); } catch (error) { console.error('Fehler beim Versiegeln des Altbestands:', error); diff --git a/docs/todo.md b/docs/todo.md index 79f723ab..12a669fa 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,66 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🚨 Siegelwechsel ist ein Alarm, kein Hinweis + Filter-Validierung (Pentest R185)** (2026-08-26) + - **R185-01 (MEDIUM)** – Die Flanke, die wir dem Tester selbst gemeldet + hatten, hat er live bestätigt: `seal-backlog` war beim **zweiten** Aufruf + genauso gegatet wie beim ersten (`{"confirm":"SEAL"}` → 200), und das + Ereignis landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der + automatische Rückhalt des Gegenbuchs hängt an `valid` – und `valid` + überlebt ein ersetzendes Siegel **per Konstruktion**. Angriff: Altzeile + per DB-Zugriff löschen → neu siegeln → Lücke ist beglaubigt, `valid` + wieder `true`. **Live reproduziert.** + - Zwei Schichten, in seiner Reihenfolge: + 1. **Alarmkanal.** Neuer `SecurityEventType` `AUDIT_SEAL_CHANGED` (Migration + `20260826120000`). Erstes Siegeln → HIGH, Ersetzen → **CRITICAL** (geht + damit über `sendPendingCriticalAlerts` sofort per Mail raus). Die + Ereignis-Details halten Wurzel vorher/nachher und den vollständigen + Vorbefund fest. + 2. **Gate.** Steht bereits ein Siegel, verlangt der Endpunkt + `{"confirm":"RESEAL"}` statt `SEAL` – mit einem Text, der sagt, was + dabei verloren geht. Ein Austausch der Beweisgrundlage soll nicht + dasselbe Wort haben wie das Einrichten. + - **Und im Gegenbuch selbst:** Dort stand für den Wurzelwechsel ein + `console.warn`, während der Rückgabecode auf **0** blieb – also exakt das + Muster, das wir dem CRM zweimal angekreidet haben (R179/R183-02), im + Werkzeug, das dagegen gebaut wurde. Jetzt **exit 2**, mit alter und neuer + Wurzel samt Blattzahl (`10 Blätter → 9 Blätter` zeigt die Löschung + sofort). Auch die Erstsiegelung meldet sich, statt stillschweigend + übernommen zu werden. + - **Auflösbar gemacht:** Der Alarm bricht ab, *bevor* angehängt wird – ohne + Bestätigungsweg hätte auch ein legitimes Siegeln für immer alarmiert + (R183-03-Falle). Neu: `NOTARY_SEAL_ACK`. Bewusst **nicht** `true`, sondern + die **Wurzel selbst** (mind. 16 Zeichen): ein stehen gelassener Wert passt + beim nächsten Wechsel nicht mehr und kann keinen weiteren Austausch + durchwinken – der Unterschied zu `NOTARY_GENESIS_ACK`, wo genau diese + Falle dokumentiert werden musste. + - **R185-02 (LOW)** – `GET /api/audit-logs?action=`: ungültige Enum-Werte + gingen roh an die Spalte → **500**. Zweifach schlecht: fehlende + Validierung *und* Fehler-Orakel (200 vs. 500 verrät die Enum-Mitglieder). + Jetzt 400 mit der erlaubten Menge im Klartext – die steht ohnehin in der + Oberfläche. Gleich mitgenommen: `sensitivity`, Datumsfelder + (`new Date('foo')` → Invalid Date → 500), Zahlenfelder (`parseInt` → NaN), + Textlängen, und ein Deckel auf `limit` (200), über den sich sonst die + ganze Tabelle an der Seitenlogik vorbei ziehen ließ. Beide Endpunkte + (`/audit-logs` und `/audit-logs/export`). + - **Getestet über HTTP gegen eine Wegwerf-DB**, inkl. echtem Gegenbuch-Lauf + mit SSH-signiertem lokalem Repo: Erstsiegeln mit `SEAL` → 200; zweites mit + `SEAL` → **400**; mit `RESEAL` → 200; beide Alarmkanal-Ereignisse mit + korrekter Severity vorhanden. Wäsche (Zeile 7 gelöscht → RESEAL) → CRM + meldet `valid:true`, **Gegenbuch exit=2**. Bestätigung: falsche Wurzel → + weiter exit 2, zu kurzer Wert → weiter exit 2, richtige Wurzel → exit 0 + und beglaubigt, Folgelauf ruhig. + - **Nachgeholt, was der Tester nicht herstellen konnte:** vollständig + unsigniertes Protokoll (Platzhalter-`AUDIT_HMAC_KEY`) → `kein_siegel` + statt der früheren falschen Entwarnung `nicht_noetig`, und + `seal-backlog` nennt den fehlenden Schlüssel als nächsten Schritt – die + Warnung ist also auflösbar. Gegenrichtung (Log beginnt signiert) → + weiterhin `nicht_noetig`, R183-03 bleibt behoben. + - Dateien: `backend/src/controllers/auditLog.controller.ts`, + `backend/prisma/schema.prisma` + Migration, `tools/audit-notary/notary.mjs`, + `tools/audit-notary/{docker-compose.yml,.env.example,README.md}`, + `frontend/src/services/api.ts`, `frontend/src/pages/settings/Monitoring.tsx` + - [x] **👁️ Integritätsstatus in der Oberfläche (Einstellungen → Audit-Protokoll)** (2026-08-26) - Bisher war der Zustand der Hash-Kette nur per `POST /api/audit-logs/verify` einsehbar – also praktisch nur für das Gegenbuch und für jemanden mit diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index a622488e..8732c9bc 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -37,6 +37,7 @@ const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [ { value: 'LOGOUT', label: 'Logout' }, { value: 'TOKEN_REJECTED', label: 'Token abgelehnt' }, { value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' }, + { value: 'AUDIT_SEAL_CHANGED', label: 'Bestandssiegel gesetzt/ersetzt' }, { value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' }, ]; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5bfb9fca..5bb50e6b 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1872,7 +1872,7 @@ export interface EmailLog { export type SecurityEventType = | 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED' | 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM' - | 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS'; + | 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'AUDIT_SEAL_CHANGED' | 'SUSPICIOUS'; export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; diff --git a/tools/audit-notary/.env.example b/tools/audit-notary/.env.example index c75c7a4e..d72fc03c 100644 --- a/tools/audit-notary/.env.example +++ b/tools/audit-notary/.env.example @@ -56,6 +56,17 @@ PROD_GENESIS_ACK= # geklärt hast, warum. Siehe README, Abschnitt "Wenn das Gedächtnis fehlt". PROD_ADOPT_ACK= +# Normalerweise leer lassen. +# Gültige Werte: die neue Siegelwurzel (mind. 16 Zeichen) | (leer) +# Das Gegenbuch schlägt Alarm, wenn sich die Wurzel des Bestandssiegels +# ändert – denn ein erneutes Siegeln ersetzt die Grundlage, gegen die +# Manipulation nachgewiesen wird. War der Wechsel gewollt, hier die Wurzel +# eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren. +# Bewusst KEIN "true": ein stehen gelassener Wert passt beim nächsten +# Wechsel nicht mehr und kann darum keinen weiteren stillschweigend +# durchwinken. +PROD_SEAL_ACK= + # Wo das Buch liegt – relativ zu diesem Verzeichnis. # DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel). PROD_DIR=./data/prod @@ -71,4 +82,5 @@ STAGING_CRM_PASSWORD= STAGING_INTERVAL=3600 STAGING_GENESIS_ACK= STAGING_ADOPT_ACK= +STAGING_SEAL_ACK= STAGING_DIR=./data/staging diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index 7cc67aac..4bbd5b4f 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -138,6 +138,45 @@ Sie verschwinden also nicht aus dem Bericht – sie zählen nur nicht mehr als offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte Altzeile und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus. +### Wenn sich die Siegelwurzel ändert: Alarm, und warum + +Erneutes Siegeln **ersetzt** die Grundlage, gegen die Manipulation nachgewiesen +wird. Wer eine Altzeile per Datenbankzugriff entfernt und danach neu siegelt, +bekommt eine passende Wurzel und eine beglaubigte Lücke — und die Prüfung im +CRM meldet wieder `valid: true`. Der Wechsel der Wurzel ist die einzige Spur +davon, die eine Maschine sehen kann. + +Deshalb ist er ein **Alarm** (exit 2), kein Hinweis. Vorher stand hier eine +Zeile Prosa, während der Rückgabecode auf 0 blieb — also genau das Muster, das +wir dem CRM selbst zweimal angekreidet haben. + +Der Alarm nennt die alte und die neue Wurzel samt Blattzahl. Ein Sprung von +`10 Blätter` auf `9 Blätter` sagt sofort, was passiert ist. + +**War der Wechsel gewollt** (typisch: dein einmaliges Erstsiegeln), bestätigst +du ihn mit der Wurzel, die der Alarm ausgibt: + +```bash +# in der .env +PROD_SEAL_ACK=72062c88a8b6e2b53b30496b483885cd + +docker compose up -d # ein Lauf – die neue Wurzel wird beglaubigt +PROD_SEAL_ACK= # danach wieder leeren +``` + +Bestätigt wird bewusst **nicht** mit `true`, sondern mit der Wurzel selbst. +Ein versehentlich stehen gelassener Wert passt beim nächsten Wechsel nicht mehr +und kann deshalb keinen weiteren Austausch stillschweigend durchwinken. + +**War er nicht gewollt**, sieh im CRM nach: das Sicherheits-Ereignis +`AUDIT_SEAL_CHANGED` (Einstellungen → Monitoring) und die CRITICAL-Zeile zu +`/api/audit-logs/seal-backlog` im Audit-Protokoll nennen Konto, Zeitpunkt und +den Befund, der vor dem Siegeln galt. + +Im CRM selbst ist erneutes Siegeln zusätzlich gegatet: es verlangt +`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der +Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten. + ### Zugang einrichten (das brauchst du vorher) Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM – kein Token. Der @@ -442,7 +481,7 @@ Deshalb gilt jetzt: |---|---| | 0 | alles in Ordnung, Checkpoint angehängt (bzw. Prüfung bestanden) | | 1 | Betriebsfehler (Konfiguration, Commit oder Push fehlgeschlagen) | -| 2 | **Befund** – Widerspruch zwischen CRM und Gegenbuch, oder ungültige Signatur | +| 2 | **Befund** – Widerspruch zwischen CRM und Gegenbuch, ungültige Signatur, oder die Wurzel des Bestandssiegels hat sich geändert (`NOTARY_SEAL_ACK`) | | 5 | **Anker unvollständig** – die Kette ist gültig, aber `refs/notary/seq-N` fehlt. Reparierbar durch einen Notar-Schreiblauf | | 4 | **Wächter-Gedächtnis fehlt** – Erstinbetriebnahme unbestätigt, oder Speicher nach der Etablierung verloren | | 3 | beglaubigter Stand nicht abschließend feststellbar – Remote fehlt/unerreichbar, erste Beobachtung, Zurückspulen nicht ausschließbar, **oder** Checkpoint erstellt aber nicht verankert | @@ -491,6 +530,8 @@ an, bis er geklärt ist. | Untergeschobener Commit | Commit ohne gültige Signatur in der Historie | | Nie gepushte lokale Commits | Abgleich gegen den Remote-Kopf | | Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen | +| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt – `valid` allein bleibt dabei `true`** | +| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine | Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt nichts an** – der manipulierte Zustand wird also nicht als neue Wahrheit diff --git a/tools/audit-notary/docker-compose.yml b/tools/audit-notary/docker-compose.yml index fc6880b9..fb8c60bd 100644 --- a/tools/audit-notary/docker-compose.yml +++ b/tools/audit-notary/docker-compose.yml @@ -34,6 +34,8 @@ services: NOTARY_GENESIS_ACK: ${PROD_GENESIS_ACK:-} # Nur nach geklärtem Verlust des Beobachtungsspeichers, siehe README: NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-} + # Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel: + NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-} volumes: # Buch, Schlüssel, Beobachtungsspeicher und Statusdatei. # Dieses Verzeichnis ist das Gegenbuch – es gehört ins Backup. @@ -53,5 +55,6 @@ services: NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost} NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-} NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-} + NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-} volumes: - ${STAGING_DIR:-./data/staging}:/gegenbuch diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index 8413989c..a03acf74 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -135,6 +135,24 @@ if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschu const CRM_EMAIL = process.env.CRM_EMAIL; const CRM_PASSWORD = process.env.CRM_PASSWORD; +// Bestaetigung fuer einen Wechsel der Siegelwurzel (Pentest R185-01). +// +// Ein Wechsel loest Alarm aus – und der Alarm bricht ab, BEVOR der neue Stand +// ins Gegenbuch kommt. Ohne Bestaetigungsweg wuerde deshalb auch ein voellig +// legitimes Siegeln von da an bei jedem Lauf erneut alarmieren, ohne dass der +// Betreiber es je aufloesen koennte. Genau daran stirbt eine Warnung +// (dieselbe Lehre wie R183-03). +// +// Bestaetigt wird deshalb nicht pauschal mit „true“, sondern mit der WURZEL, +// die man akzeptiert – mindestens 16 Zeichen. Ein versehentlich stehen +// gelassener Wert passt beim naechsten Wechsel nicht mehr und kann darum +// keinen weiteren Austausch stillschweigend durchwinken. Das ist der +// Unterschied zu NOTARY_GENESIS_ACK, wo genau diese Falle dokumentiert +// werden musste. +const SEAL_ACK = (process.env.NOTARY_SEAL_ACK || '').trim(); +const siegelBestaetigt = (wurzel) => + SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK); + if (!CRM_URL) { console.error('CRM_URL muss gesetzt sein.'); process.exit(1); @@ -774,10 +792,59 @@ if (letzter) { if (letzter.sealLeafCount > 0 && aktuell.sealLeafCount === 0) { alarm('Die Blattwerte des Bestandssiegels wurden entfernt.'); } + // Wechsel der Siegelwurzel ist ein ALARM, kein Hinweis (Pentest R185-01). + // + // Vorher stand hier ein console.warn und der Rueckgabecode blieb 0 – also + // genau das Muster, das wir dem CRM zweimal angekreidet haben: die Warnung + // steht in der Prosa, die Maschine meldet „in Ordnung“. Das ist hier + // besonders teuer, weil `valid` ein ERSETZENDES Siegel per Konstruktion + // ueberlebt: Wer eine Altzeile per Datenbankzugriff entfernt und danach neu + // siegelt, bekommt eine passende Wurzel und eine beglaubigte Luecke. Die + // Vollpruefung sagt dann `true`. Der Wurzelwechsel ist der EINZIGE + // maschinell erkennbare Anker dagegen – und der gehoert in den Alarm. + // + // Ein legitimes Neu-Siegeln loest hier einmal aus. Das ist gewollt: ein + // Austausch der Beweisgrundlage soll einmal wehtun und bestaetigt werden, + // statt lautlos durchzulaufen. if (letzter.sealRoot && aktuell.sealRoot && letzter.sealRoot !== aktuell.sealRoot) { - console.warn( - `HINWEIS: Das Bestandssiegel wurde erneuert (${letzter.sealRoot.slice(0, 12)}… → ` + - `${aktuell.sealRoot.slice(0, 12)}…). Legitim nach einem Retention-Lauf – sonst prüfen.`, + if (siegelBestaetigt(aktuell.sealRoot)) { + console.log( + `Siegelwechsel bestätigt (NOTARY_SEAL_ACK): ${letzter.sealRoot.slice(0, 16)}… → ` + + `${aktuell.sealRoot.slice(0, 16)}…. Die neue Wurzel wird beglaubigt.\n` + + ' NOTARY_SEAL_ACK danach wieder leeren.', + ); + } else alarm( + 'Das Bestandssiegel wurde ERSETZT – die beglaubigte Grundlage ist eine andere.\n' + + ` beglaubigt: ${letzter.sealRoot.slice(0, 16)}… (${letzter.sealLeafCount} Blätter)\n` + + ` jetzt : ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` + + 'Ein erneutes Siegeln schreibt den AKTUELLEN Stand des Altbestands fest. Wurden\n' + + 'vorher Einträge entfernt, sind deren Lücken danach beglaubigt und die Prüfung im\n' + + 'CRM meldet wieder valid:true – dieser Wurzelwechsel ist die einzige Spur davon.\n' + + 'Wenn das keine geplante Maßnahme war: Im CRM das Ereignis AUDIT_SEAL_CHANGED und\n' + + 'die CRITICAL-Zeile zu /api/audit-logs/seal-backlog ansehen (wer, wann, Vorbefund).\n' + + 'War es geplant, den Wechsel bestätigen und danach wieder leeren:\n' + + ` NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`, + ); + } + // Erstsiegelung: vorher nichts beglaubigt, jetzt eine Wurzel. Das ist der + // eine legitime Einrichtungsschritt – aber auch das Fenster, in dem ein + // beschnittener Altbestand einmalig festgeschrieben werden koennte. Deshalb + // ebenfalls melden, mit eigenem Text statt stillschweigend zu uebernehmen. + if (!letzter.sealRoot && aktuell.sealRoot) { + if (siegelBestaetigt(aktuell.sealRoot)) { + console.log( + `Erstsiegelung bestätigt (NOTARY_SEAL_ACK): ${aktuell.sealRoot.slice(0, 16)}…. ` + + 'Die Wurzel wird beglaubigt.\n NOTARY_SEAL_ACK danach wieder leeren.', + ); + } else alarm( + 'Erstmals ein Bestandssiegel gesetzt – bisher war keines beglaubigt.\n' + + ` Wurzel: ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` + + 'War das dein Einrichtungsschritt, ist alles in Ordnung: Der nächste Lauf läuft\n' + + 'wieder auf 0, sobald diese Wurzel im Gegenbuch steht.\n' + + 'War es das NICHT, dann hat jemand den Stand des Altbestands festgeschrieben –\n' + + 'samt aller Lücken, die zu diesem Zeitpunkt bestanden.\n' + + 'Zum Bestätigen setzen und danach wieder leeren:\n' + + ` NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`, ); } }