# 📋 OpenCRM – Todo-Liste --- ## 🔜 Offen ### Manuelle Tests (vor Release durchklicken) Checklisten fĂŒr Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**. Einmal komplett durchlaufen vor v1.0.0-Release. ### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless **Vision:** OpenCRM als SaaS anbieten. Jeder Kunde bekommt seine eigene isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ĂŒber ein zentrales Admin-Portal. **Architektur-Entscheidung:** Weg C (Instance-per-Customer) - Pro Kunde eine eigene Docker-Instanz mit eigener DB - Keine `tenantId` im CRM-Code → keine Security-Risiken durch vergessene Filter - Komplette Datenisolation (DSGVO-freundlich) - Updates können gestaffelt ausgerollt werden (erst 10% testen) - Bei KĂŒndigung: Docker-Image + DB-Export als "Mitnehm-Paket" **Bewusst NICHT dabei:** eigener Mailserver. Stattdessen Plesk-Integration (die wir schon haben) – Kunde bekommt Mail-Zugang ĂŒber unseren Plesk bei Bedarf. --- **Admin-Portal (separate App, neben den CRM-Instanzen):** - Kundenverwaltung: wer hat welchen Plan, Status (Trial/Active/Suspended/Cancelled) - "Neuen Kunden anlegen" → Provisioning-Script - DB anlegen (Master-DB kennt die Mapping) - Docker-Container starten - Subdomain konfigurieren (`kundenname.deincrm.de` via Caddy/Traefik) - Initial-Admin-Account erstellen + Einladungs-Email senden - Optional: Factory-Defaults fĂŒr Stammdaten einspielen - GoCardless-Integration (Webhook + Dashboard) - Instanz-Management: Pause/Resume bei Zahlungsproblemen - Logs & Metriken pro Instanz (optional) - Support-Bereich (Tickets? oder einfach E-Mail) --- **Abrechnung mit GoCardless (gocardless.com):** - Zahlungsmethoden: SEPA-Lastschrift (Hauptfokus) + Kreditkarte (ĂŒber GoCardless Embedded/Success) - 30 Tage kostenlose Testphase ohne Zahlungsmittel - Nach Trial: Mandats-Erfassung → regelmĂ€ĂŸige Abbuchung - Mehrere PlĂ€ne (z.B. Basic / Pro / Enterprise) mit unterschiedlichen Features - Webhook-Endpoint im Admin-Portal: - `payment_confirmed` → Instanz aktiv lassen - `payment_failed` → Banner im CRM, nach X Tagen pausieren - `mandate_cancelled` → KĂŒndigungs-Flow - Rechnungsstellung: GoCardless liefert Zahlungsbelege, aber **echte Rechnungen** (mit USt-ID, Rechnungsnummer etc.) mĂŒssen wir selbst generieren (evtl. ĂŒber das existierende PDF-Template-System aus dem CRM nutzen) --- **Provisioning-Flow (grober Entwurf):** 1. Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain) 2. Admin-Portal: Trial-Instanz starten - DB erstellen, Docker-Container hochfahren, Caddy-Config fĂŒr Subdomain - Einladungs-Email mit Admin-Login + Passwort-Reset-Link 3. Tag 25: Erinnerungs-Email "Deine Trial lĂ€uft bald ab" 4. Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren" 5. Kunde erfasst GoCardless-Mandat im Admin-Portal-Login 6. Bei erfolgreicher Zahlung: Instanz bleibt aktiv 7. Bei fehlender Zahlung nach 7 Tagen: Instanz pausiert (DB bleibt, UI zeigt Hinweis) --- **Technische Bausteine fĂŒr spĂ€ter:** - Master-DB mit Tenant-Tabelle (Name, Subdomain, DB-Name, Plan, Status, GoCardlessIDs) - Caddy oder Traefik als Reverse-Proxy mit Auto-SSL (Let's Encrypt) - Docker-Orchestrierung: einzelne `docker-compose.yml` pro Kunde oder Docker-Swarm/K8s - Backup-Strategie: pro Tenant separate Backups + zentrale Master-DB-Backups - Monitoring: ein Fail macht nicht alle down, aber wir mĂŒssen es mitbekommen - Logs zentral: z.B. Loki + Grafana fĂŒr aggregierte Logs aller Instanzen --- **Grobe ZeitschĂ€tzung:** - Admin-Portal (MVP): ~1 Woche - GoCardless-Integration + Webhooks: ~3-5 Tage - Provisioning-Automatisierung (Docker + Caddy): ~1 Woche - Landing Page + Checkout: ~3-5 Tage - Tests + Polishing: ~1 Woche - **Gesamt: ~3-4 Wochen** **Vorbereitung JETZT (einfach, macht spĂ€ter Arbeit leichter):** - ✅ Factory-Defaults System (schon erledigt, hilft beim Provisioning) - ✅ Domain/Label dynamisch per Provider (schon erledigt) - Docker-Compose aufrĂ€umen, Env-Variablen dokumentieren (klein, ein Tag) - Backup-Script robust + wiederherstellbar (haben wir schon weitgehend) --- ## ✅ Erledigt - [x] **🔍 `verify` meldet jetzt, dass die Kette neu berechnet wurde** (2026-08-26) - **Im Betrieb entdeckt, nicht im Test.** Das Staging-Gegenbuch meldete „Der beglaubigte Eintrag 5352 existiert nicht mehr". Rekonstruktion aus dem Protokoll: am 22.08. wurden die Aufbewahrungsfristen auf **0** gesetzt, zwei Cleanups löschten **3.155** EintrĂ€ge (id 1–5356), danach lief ein **Rehash**. Seither meldet die PrĂŒfung „Alle EintrĂ€ge sind unverĂ€ndert und **lĂŒckenlos** verkettet". - Wahr – und praktisch das Gegenteil dessen, was ein Leser mitnimmt. Der Rehash verknĂŒpft alles neu; die rund **700** KettenlĂŒcken, die davor bestanden, sind seitdem unsichtbar. Nachweisbar im Vorbefund, den der Rehash selbst mitschreibt (R170-01) – nur schaute den nie jemand an. - Das Gegenbuch war der einzige Zeuge. Innerhalb des CRM war die Löschung nicht mehr feststellbar. - `verifyIntegrity` sammelt jetzt die Rehash-Marker; die Antwort enthĂ€lt `rehashes[]` (Zeitpunkt, Anzahl, Signatur, Vorbefund). Die Meldung nennt sie **immer**, auch im grĂŒnen Fall, und der Einstiegssatz lautet dann „
lĂŒckenlos verkettet – allerdings erst seit der letzten Neuberechnung". - **`valid` bleibt unberĂŒhrt.** Ein Rehash ist eine legitime Maßnahme; ihn dauerhaft als Befund zu fĂŒhren wĂ€re der Dauer-Alarm, den wir mit den beglaubigten LĂŒcken gerade beseitigt haben. Melden, nicht alarmieren. - **Umgekehrte Beweislast als bei Manifest und Siegel:** Dort zĂ€hlen nur signierte TrĂ€ger, weil ein gefĂ€lschter Marker LĂŒcken *wegerklĂ€ren* könnte. Hier erzeugt ein Marker eine *Warnung* – wĂŒrden nur signierte zĂ€hlen, könnte man einen Rehash unsichtbar machen, indem man seine Signatur zerstört. Deshalb zĂ€hlt jeder auswertbare Marker; fehlende Signatur wird zusĂ€tzlich gemeldet. - Getestet ĂŒber HTTP gegen eine Wegwerf-DB, mit echtem Rehash ĂŒber den regulĂ€ren Endpunkt: sauberer Vorzustand → „es wurde nichts ĂŒberdeckt"; Löschung + Rehash (der Staging-Ablauf im Kleinen) → `chainGaps` fĂ€llt von 1 auf 0, die Meldung nennt Zeitpunkt und Vorbefund; zerstörte Signatur → Marker wird weiterhin gemeldet, mit Hinweis. - Dateien: `backend/src/services/audit.service.ts`, `backend/src/controllers/auditLog.controller.ts`, `frontend/src/services/api.ts`, `frontend/src/pages/settings/AuditIntegrityCard.tsx` - [x] **📄 `status.txt` nennt jetzt den Grund, nicht nur das Etikett** (2026-08-26) - In der Statusdatei des Gegenbuchs stand fĂŒr **jeden** Exit-2 derselbe Satz: „BEFUND – Widerspruch zwischen CRM und Gegenbuch". Ein echter Widerspruch sah damit genauso aus wie eine quittierpflichtige Erstsiegelung. - Aufgefallen im Betrieb: Der Betreiber las „BEFUND" auf Staging und konnte nicht entscheiden, ob er handeln muss – obwohl die Kette dort `valid:true` meldet und der Alarm nur den erwarteten Siegelwechsel betraf. Wer nur die Statusdatei liest (und dafĂŒr ist sie da – „damit eine Überwachung sie abgreifen kann, ohne Logs zu durchsuchen"), bekam ein Etikett ohne Inhalt. - Der Lauf wird jetzt mitgeschnitten; die erste `ALARM:`-Zeile landet als `Grund:` in `status.txt`. Ohne Alarm die erste Ausgabezeile. - Datei: `tools/audit-notary/entrypoint.sh` - [x] **⚖ Aufsicht und Eingriff getrennt: neuer Haken „Audit-Betrieb"** (2026-08-26) - Die DSGVO-Rolle trug `audit:*` **komplett**, also auch `audit:admin`. Ein DSGVO-Beauftragter konnte damit `seal-backlog`, `rehash` und `cleanup` – seine eigene Beweisgrundlage ersetzen. Wer das Protokoll beaufsichtigt, darf es nicht umschreiben können. Dieselbe Klasse wie R184-02: falsche DomĂ€ne, zu breit gebĂŒndelt. - **Der naheliegende Fix wĂ€re falsch gewesen.** `audit:admin` einfach aus der DSGVO-Rolle zu streichen hĂ€tte es heimatlos gemacht: Die Admin-Rolle ist ausdrĂŒcklich **ohne** `audit`/`gdpr` gebaut, einzige verbleibende Quelle wĂ€re der Entwicklerzugriff – der *alles* gibt. Prod versiegeln hĂ€tte dann Vollzugriff vorausgesetzt. - Deshalb eine eigene versteckte Rolle **`Audit-Betrieb`** (`audit:read` + `audit:admin`), zugewiesen ĂŒber eine Checkbox wie DSGVO/Entwickler. DSGVO behĂ€lt `audit:read` + `audit:export` + `gdpr:*`. **FĂŒr kein bestehendes Konto weitet sich etwas aus** – im Gegenteil, es wird enger, und wer eingreifen können soll, bekommt es ausdrĂŒcklich. - **Keine zusĂ€tzliche Rechte-HĂŒrde davor**, weil das am Henne-Ei-Problem scheitert: Nach der Aufteilung hĂ€lt zunĂ€chst niemand `audit:admin`, könnte ihn also auch niemand vergeben. Stattdessen wird die Vergabe **laut** – CRITICAL im Protokoll **und** `PERMISSION_CHANGED`/CRITICAL im Alarmkanal, inklusive Kennzeichen, ob sich jemand den Haken selbst gesetzt hat. - Nebenbei geschlossen: `setUserGdprAccess()` legte die DSGVO-Rolle im Notfallpfad mit `audit:*` komplett an – eine zweite Liste, die dasselbe bedeuten sollte und die BĂŒndelung stillschweigend zurĂŒckgebracht hĂ€tte. - **⚠ Beim Deploy:** Bestehende DSGVO-Konten verlieren `audit:admin`. Wer Prod versiegeln will, muss sich vorher „Audit-Betrieb" ankreuzen. - **Gemeldet, nicht geĂ€ndert:** Jeder mit `users:update` (also Admin) kann sich DSGVO oder Entwicklerzugriff selbst vergeben – Entwickler heißt *alle* Rechte. Das ist vorbestehend und gehört ins Rollenmodell-Thema des Pentesters. - Dateien: `backend/prisma/sync-roles.ts`, `backend/src/services/user.service.ts`, `backend/src/controllers/user.controller.ts`, `backend/src/utils/sanitize.ts`, `frontend/src/pages/users/UserList.tsx`, `frontend/src/services/api.ts`, `tools/audit-notary/README.md` - [x] **đŸ·ïž Siegel ĂŒber null BlĂ€ttern meldet nicht mehr „intakt"** (2026-08-26) - Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand vorfand, ist rechnerisch tadellos und schĂŒtzt **nichts**. Gemeldet wurde trotzdem `intakt` – formal richtig, aber es liest sich als Schutzzusage. - Der Pentester hat Stagings Leersiegel genau so missverstanden und hielt es fĂŒr zahnhaltig. Das ist der rote Faden im Kleinen: ein Signal, das beruhigt, wo nichts abgesichert ist. - Eigener Zustand `leer` mit eigenem Text („besteht ein Bestandssiegel, das aber NICHTS umschließt 
 kein Fehler, aber auch keine Zusage"). Wertung wie `nicht_noetig`: kippt `valid` nicht. - Dateien: `backend/src/services/audit.service.ts`, `backend/src/controllers/auditLog.controller.ts`, `frontend/src/services/api.ts`, `frontend/src/pages/settings/AuditIntegrityCard.tsx` - [x] **🔒 `audit:export` gatete nichts – Export hing an `audit:read`** (2026-08-26) - **Bei der Gegenprobe zur neuen Rolle `Gegenbuch` gefunden:** Ein Konto mit ausschließlich `audit:read` bekam auf `GET /audit-logs/export` **200**. Die Berechtigung `audit:export` stand im Katalog und in der Rollenverwaltung – und wurde **nirgends** geprĂŒft. - **Der Unterschied ist nicht kosmetisch.** BlĂ€ttern zeigt 50 Zeilen; der Export liefert in einem Zug das gesamte Protokoll inklusive `changesBefore`/`changesAfter` – also der vollstĂ€ndigen Vorher/Nachher- DatensĂ€tze – dazu `resourceLabel` mit Klartextnamen, IP-Adressen und User-Agents. Live nachgewiesen: 43 EintrĂ€ge mit gefĂŒllter `resourceLabel` allein fĂŒr `resourceType=Customer`. - Damit konnte ausgerechnet das Dienstkonto des Gegenbuchs, dessen Passwort im Klartext in der `.env` auf der Notar-Maschine liegt, Personendaten exportieren – wĂ€hrend README und Rollenname „nur PrĂŒfwerte lesen" versprachen. - `/audit-logs/export` verlangt jetzt `audit:export`. Betroffen ist genau eine Rolle: `Gegenbuch` (gewollt). Die DSGVO-Rolle hat `audit:*` vollstĂ€ndig und behĂ€lt den Export. - OberflĂ€che: JSON- und CSV-Knopf werden nur noch mit `audit:export` angezeigt – sonst stĂŒnden dort Knöpfe, die zuverlĂ€ssig 403 liefern. - Dateien: `backend/src/routes/auditLog.routes.ts`, `frontend/src/pages/settings/AuditLogs.tsx`, `tools/audit-notary/README.md` - [x] **🔑 Rolle „Gegenbuch": Leserecht aufs Audit-Protokoll ohne `audit:admin`** (2026-08-26) - **Beim Selbst-NachprĂŒfen eines Deploys aufgefallen:** Das Gegenbuch- Dienstkonto auf Staging meldete beim Login `["audit:read","audit:export","audit:admin","gdpr:export","gdpr:delete","gdpr:admin"]`. Es braucht **genau eines** davon – `audit:read` – denn es ruft nur `/audit-logs/checkpoint` und `/audit-logs/verify` auf. - **Kein Bedienfehler, ein Konstruktionsfehler:** Es gab keine Rolle, die nur Leserecht aufs Protokoll gibt. Wer das wollte, musste den Haken „DSGVO-Zugriff" setzen – und der vergibt `audit:*` komplett, also auch `audit:admin` mit `seal-backlog`, `rehash`, `cleanup`. Das Label („Audit-Logs, Datenschutz") legt Lesen nahe und liefert Vollzugriff. - **Warum das ernst ist:** Das Passwort des Dienstkontos liegt im Klartext in `tools/audit-notary/.env` auf der Gegenbuch-Maschine. Mit `audit:admin` hĂ€tte ein Einbruch dort nicht nur den WĂ€chter gehabt, sondern gleich die Mittel zur WĂ€sche aus R185-01 – und damit genau die Trennung aufgehoben, wegen der das Gegenbuch ĂŒberhaupt auf einer eigenen Maschine lĂ€uft. - Neue Rolle **`Gegenbuch`** in `sync-roles.ts` mit ausschließlich `audit:read`. LĂ€uft beim Containerstart mit, erscheint danach in der Benutzerverwaltung. README des Gegenbuchs umgeschrieben: Rolle statt „selbst anlegen", ausdrĂŒckliche Warnung vor dem DSGVO-Haken, plus eine Gegenprobe (`checkpoint` → 200, `seal-backlog` → 403). - **Offen, bewusst nicht angefasst:** Dass die DSGVO-Rolle selbst `audit:admin` trĂ€gt, ist ein Gewaltenteilungs-Problem – wer das Protokoll beaufsichtigt, kann seine Beweisgrundlage ersetzen. Ändern hieße bestehenden DSGVO-Konten Rechte entziehen; gehört entschieden, nicht nebenbei gemacht. An den Pentester gemeldet, dessen nĂ€chstes Thema das Rechtemodell ist. - Dateien: `backend/prisma/sync-roles.ts`, `tools/audit-notary/README.md` - [x] **đŸ“€ Eingegrenzter Export lieferte alles (Pentest R186-01, MEDIUM)** (2026-08-26) - Der Tester fand: `GET /audit-logs/export?userId=
` filterte **nicht** – `userId=999999` gab alle 2761 DatensĂ€tze zurĂŒck, byte-identisch zum ungefilterten Export. HTTP 200, sah korrekt aus. - **Ursache war schlimmer als der Befund.** Nicht `userId` allein fehlte: der Export-Controller pflegte eine **eigene, kĂŒrzere** Filterliste und verwarf still `userId`, `customerId`, `dataSubjectId`, `resourceId`, `success` **und** `search`. Der Service konnte alle sechs – sie kamen nie bei ihm an. - **Dieselbe LĂŒcke ein drittes Mal in der OberflĂ€che:** Der CSV-Knopf baute seine Parameter nochmal von Hand, mit wieder anderen fĂŒnf Feldern. Wer im Suchfeld eingrenzte und dann CSV klickte, bekam das gesamte Protokoll statt seiner Auswahl. - **Warum das mehr ist als ein fehlender Filter:** Ein bewusst eingegrenzter Export – „nur die Spur von Benutzer X" fĂŒr eine DSGVO-Auskunft oder eine InnentĂ€ter-PrĂŒfung – gab das vollstĂ€ndige Protokoll **aller** Nutzer heraus, mit einem beruhigenden 200. Auf einem datenminimierungspflichtigen Pfad ist das eine Weitergabe, kein Schönheitsfehler. - **Fix ist strukturell, nicht punktuell:** ein gemeinsamer `leseFilter(req)` fĂŒr Liste und Export; die OberflĂ€che schickt alle aktiven Filter statt einer handgepflegten Auswahl. Drei Listen, die dasselbe bedeuten sollen, laufen frĂŒher oder spĂ€ter auseinander – jetzt gibt es nur noch eine. - GeprĂŒft ĂŒber HTTP: Export und Liste liefern fĂŒr `userId`, `action`, `search`, `success`, `resourceType` **identische** Treffermengen; auf dem Export-Pfad gilt jetzt dieselbe Validierung (`userId=abc` → 400 statt 200). CSV-Pfad gegengeprĂŒft. - Dateien: `backend/src/controllers/auditLog.controller.ts`, `frontend/src/pages/settings/AuditLogs.tsx` - [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 `curl`. Jetzt steht er oben auf der Audit-Seite. - Vier ZustĂ€nde statt „grĂŒn/rot": **unversehrt**, **Befund**, **unversehrt aber ungeschĂŒtzter Altbestand** (`kein_siegel`), **nicht vollstĂ€ndig prĂŒfbar** (signierte Zeilen ohne `AUDIT_HMAC_KEY`). - Die beiden mittleren ZustĂ€nde sind bewusst nicht grĂŒn. Ein Protokoll mit unversiegeltem Altbestand ist rechnerisch stimmig, aber am Altbestand unbemerkt Ă€nderbar; ein Protokoll, das mangels SchlĂŒssel nicht prĂŒfbar ist, ist schlicht ungeprĂŒft. Beides als „alles in Ordnung" zu zeigen wĂ€re genau die Klasse Fehler, die diese ganze Runde behandelt hat. - SchlĂ€gt die PrĂŒfung selbst fehl, steht dort ausdrĂŒcklich: *„Das ist keine Entwarnung – der Zustand der Kette ist damit schlicht unbekannt."* - Aufklappbare Einzelheiten trennen die Kategorien, die nicht gleich schwer wiegen: nachtrĂ€glich verĂ€ndert (ernst) / Verkettung unterbrochen / davon ohne dokumentierte Löschung / davon vom Siegel beglaubigt / ohne SchlĂŒssel nicht prĂŒfbar – mit einer ErklĂ€rung im Klartext darunter. - Die PrĂŒfung liest die **gesamte** Kette. Sie lĂ€uft deshalb einmal beim Öffnen der Seite und wird 5 Minuten wiederverwendet; „Neu prĂŒfen" erzwingt einen frischen Lauf. - Bewusst **read-only**: kein Siegel- oder Rehash-Knopf. Diese Eingriffe verlangen `audit:admin` und eine ausdrĂŒckliche BestĂ€tigung; sie gehören nicht neben eine Statusanzeige, die man im Vorbeigehen anklickt. - Dateien: `frontend/src/pages/settings/AuditIntegrityCard.tsx` (neu), `frontend/src/pages/settings/AuditLogs.tsx`, `frontend/src/services/api.ts` (Typ `IntegrityResult` ausgelagert) - [x] **đŸ§Ÿ Beglaubigte Alt-LĂŒcken: Dauer-Alarm im Gegenbuch beendet** (2026-08-26) - **Ausgangslage.** Das Gegenbuch auf Prod meldete stĂŒndlich `exit=2`. Die CRM-PrĂŒfung lieferte `valid: false` wegen **6 struktureller LĂŒcken** (IDs 33, 44, 45, 922, 1434, 2583). - **Diagnose: harmlos.** Jede LĂŒcke liegt innerhalb eines Schwungs von EintrĂ€gen mit **identischer Sekunde**, betrifft nur `/login` und `/refresh`, kein Eintrag fehlt (kein 404), `tamperedEntries` leer. Das ist die Signatur der Race-Condition, die am 19.08. mit `AuditChainLock` geschlossen wurde (R166-01). BestĂ€tigt durch die Zeilen selbst: Eintrag 2583 stammt vom 21.08., ist aber noch `hashVersion=1` – auf Prod lief zu dem Zeitpunkt also der alte Stand. - **Das eigentliche Problem war nicht die LĂŒcke, sondern der Dauer-Alarm.** Diese LĂŒcken sind nicht heilbar: die Verkettung ist gebrochen, die Inhalte sind unversehrt. Ohne Änderung hĂ€tte das Gegenbuch fĂŒr immer Alarm gemeldet – und ein Signal, das immer schreit, warnt nicht mehr. Dieselbe Klasse wie R162, R174, R179, R182, R183-02. - **Lösung: Beglaubigung statt UnterdrĂŒckung.** Eine LĂŒcke zĂ€hlt nicht mehr als offener Befund, wenn beides gilt: sie liegt im **versiegelten Bereich** UND steht im **Vorbefund des Siegel-Markers**, also im Zustand, den der Betreiber beim Siegeln ausdrĂŒcklich festgeschrieben hat. Der Vorbefund liegt in `changesBefore` und ist ab Version 3 mitgehasht – die Liste lĂ€sst sich ohne `AUDIT_HMAC_KEY` nicht nachtrĂ€glich erweitern (gleiche Absicherung wie beim Löschungs-Manifest, R171-01). - Beglaubigt heißt **nicht verschwunden**: die LĂŒcken bleiben in `chainGaps`, stehen zusĂ€tzlich in neuem Feld `attestedGaps` und werden im Bericht ausdrĂŒcklich benannt. - **Nebenbefund derselben Klasse mitbehoben:** `backlogSealStatus` meldete `nicht_noetig` ("Es gibt keine unsignierten AlteintrĂ€ge"), wenn `v3FromId === null` – das bedeutet aber das **Gegenteil**: dass ĂŒberhaupt nichts signiert ist, etwa weil `AUDIT_HMAC_KEY` fehlt. Ein vollstĂ€ndig unsigniertes Log bekam damit Entwarnung fĂŒr genau den Zustand mit der geringsten Beweiskraft. Die Bedingung hĂ€ngt jetzt an der Zahl der unsignierten Zeilen. R183-03 (unauflösbare Warnung bei frisch signiertem Log) bleibt behoben – per Regressionstest geprĂŒft. - **Getestet ĂŒber HTTP gegen eine eigene Wegwerf-Datenbank**, nicht am Service vorbei: Prod-Zustand nachgebaut (10 v1-Zeilen, Bruch bei id 5) → `valid:false`; nach `seal-backlog` → `valid:true`, `attestedGaps:[5]`. Drei Gegenproben: **neue LĂŒcke** nach dem Siegeln → `valid:false`; **gesiegelte Altzeile verĂ€ndert** → Siegel `gebrochen`, nichts mehr beglaubigt; **Beglaubigungsliste im Marker gefĂ€lscht** (DB-Schreibrecht, kein SchlĂŒssel) → Marker ungĂŒltig, Status `entfernt`, `valid:false`. - Doku: Abschnitt „Wenn der erste Lauf `exit=2` meldet" in `tools/audit-notary/README.md` – inklusive der Warnung, **vor** dem Siegeln zu prĂŒfen, was man da festschreibt. - Dateien: `backend/src/services/audit.service.ts`, `backend/src/controllers/auditLog.controller.ts`, `frontend/src/services/api.ts`, `tools/audit-notary/README.md` - [x] **🔓 Dienstkonto-Flag: Gate in beide Richtungen, richtige Rechte-Domaene (Pentest R184-01/-02)** (2026-08-24) - **R184-01** – Setzen war gegatet, **Entfernen nicht**. Und das Entfernen ist der gefaehrlichere Weg: Der Heartbeat-Wachhund fragt `isServiceAccount: true` ab, haengt also am **Live-Kennzeichen**. Seine Hypothese war damit richtig – Un-Flaggen kappt die Wache und macht genau das moeglich, wogegen der Tripwire gebaut wurde: stilllegen und auf Stille setzen. Fix: Gate in beide Richtungen, mit unterschiedlichem Wortlaut (beim Entfernen: „faellt aus der Ueberwachung heraus“). - **Wichtiger noch:** Die Aenderung geht jetzt zusaetzlich in den **Alarmkanal** (`PERMISSION_CHANGED`/CRITICAL), nicht nur ins Audit-Log. Eine CRITICAL-Zeile muss jemand LESEN – das war die R183-02-Klasse. Jetzt reagiert die Ueberwachung automatisch. - **R184-02** – Das Kennzeichen hing an `users:update`, einer Anwendungs-Berechtigung, obwohl es ein Audit-Governance-Eingriff ist – Geschwister von retention-shorten, seal-backlog, rehash und cleanup, die alle `audit:admin` verlangen. Heute deckungsgleich, aber jede kuenftige Rolle mit „Benutzer bearbeiten“ haette still „Login-Alarme herunterstufen“ geerbt. Fix: zusaetzliche Pruefung auf `audit:admin` im Controller. - Verifiziert: Setzen mit `audit:admin` 200 · Entfernen ohne Bestaetigung **400** (vorher 200) · mit Bestaetigung 200 · ohne `audit:admin` **403** · zwei CRITICAL-Meldungen im Alarmkanal mit sprechendem Text. Frontend sendet die Bestaetigung in beide Richtungen. `npm run build` und Backend-`tsc` gruen. - Seine Non-Findings uebernommen: Selbst-Block nicht ueber Methode, Pfad oder ID-Aliasing umgehbar; Create streift das Feld ab; Confirm-Gate strikt. - [x] **🔒 Dienstkonto-Kennzeichen gegatet, laut protokolliert, kein Selbstbedienen (Pentest R184)** (2026-08-24) - Der Pentester hat sofort erkannt, was das Scharfschalten des Feldes bedeutet: Ein Attribut, das die **Alarmstufe senkt**, war ueber den normalen Benutzer-Update-Pfad setzbar – dieselbe Klasse wie R183-01, nur neu gebaut. Wer sein eigenes Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als Routine erscheinen. Das ist die Waesche. - Ist-Zustand vor dem Fix, alle drei seiner Sorgen bestaetigt: kein Gate, Protokollierung nur als MEDIUM (Standardstufe fuer `User`), und jeder mit `users:update` konnte es auf jedes Konto setzen – auch auf das eigene. - Fix, analog zur Retention-Absenkung: * **Bestaetigung** `{"confirm":"SERVICE_ACCOUNT"}` beim Aktivieren, mit Klartext, was das bedeutet. * **CRITICAL** statt MEDIUM, mit eigenem Label („Dienstkonto-Kennzeichen GESETZT fuer 
 – Anmeldungen werden kuenftig als Routine gefuehrt“) sowie Wer/Vorher/Nachher. * **Kein Selbstbedienen**: am eigenen Konto ist das Kennzeichen weder setzbar noch entfernbar (403 mit Begruendung) – muss ein anderer Administrator vornehmen. - Frontend sendet die Bestaetigung mit; der Haken im Formular IST die Bestaetigung, der Betreiber merkt nichts davon. - Verifiziert ueber den echten Controller: ohne Bestaetigung 400, mit 200, am eigenen Konto 403, Protokolleintrag CRITICAL mit sprechendem Label. `npm run build` (inkl. `tsc`) und Backend-`tsc` gruen. - [x] **đŸ–±ïž Dienstkonto-Kennzeichen in der Benutzerverwaltung** (2026-08-24) - Nachgezogen: Das Feld `isServiceAccount` lag zwar in der Datenbank, war aber **nirgends setzbar** – weder im Formular noch ueber die API. Der Betreiber haette es nicht aktivieren koennen, damit waere die R182-Einstufung wirkungslos geblieben. - Jetzt: Ankreuzfeld „Dienstkonto“ im Benutzerformular unter den zusaetzlichen Berechtigungen, mit Erklaerung im Klartext (planmaessige Anmeldungen als Routine, dafuer Meldung bei AUSBLEIBEN). Feld in der Whitelist (`USER_UPDATABLE_FIELDS`, damit auch beim Anlegen), im Service-Typ und in allen drei `select`-Bloecken, damit es beim Bearbeiten vorbelegt wird. - Verifiziert: Whitelist laesst das Feld durch und blockt Fremdfelder weiterhin; Wert ueber Prisma lesbar; `tsc` und `vite build` gruen. - [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 Neustart-Schleife. Ursache: Das Datenverzeichnis kommt als Bind-Mount vom Host; der Betreiber hatte das Projekt als **root** geklont, der Container lief aber direkt als UID 1000 und durfte dort nichts anlegen. Mein `.gitkeep`-Ansatz hatte stillschweigend angenommen, dass als normaler Benutzer geklont wird – auf einem Server ist root der Normalfall. - Fix: Der Container startet als root, setzt `/gegenbuch` per `chown` auf den Arbeitsbenutzer (`PUID`/`PGID`, Standard 1000) und startet sich per `setpriv` als dieser neu. Die eigentliche Arbeit laeuft damit weiterhin unprivilegiert. Schlaegt das `chown` fehl (z. B. rootless Docker), gibt es einen Hinweis mit dem passenden Host-Befehl statt eines stummen Abbruchs. - Verifiziert mit exakt der Ausgangslage: Datenverzeichnis auf `root:root` gesetzt, Container gestartet → laeuft durch, Schluessel und Buch werden angelegt, erzeugte Dateien gehoeren 1000:1000, der private Schluessel liegt mit `0600`. - [x] **🔑 Gegenbuch: Dienstkonto statt Token, .env mit gueltigen Werten** (2026-08-22) - **Blocker gefunden und behoben:** Ich hatte ein dauerhaftes API-Token vorausgesetzt – das gibt es in OpenCRM gar nicht. Zugangstoken leben **15 Minuten**; der Gegenbuch-Container waere nach dem ersten Durchlauf gestorben. Aufgefallen erst, als der Betreiber fragte, woher er den Token nimmt. Loesung: Das Gegenbuch meldet sich bei **jedem Lauf selbst an** – mit einem eigenen Benutzerkonto, dessen Rolle ausschliesslich `audit:read` traegt. Damit kann es nur Pruefwerte lesen: keine Kundendaten, keine Aenderungen. `CRM_TOKEN` bleibt fuer Tests moeglich, ist aber nicht mehr der Normalweg. - Fehlerfaelle sauber gemeldet: falsches Passwort → Klartext statt HTTP-Code, Anmelde-Bremse (429) benannt, CRM nicht erreichbar unterschieden. - **`.env.example` nennt jetzt die gueltigen Werte** – bisher liess sich nur raten, ob es `prod` oder `production` heisst. Fuer jeden Schalter steht dabei, was erlaubt ist, inkl. des Falls „erst nur Staging testen, Prod spaeter dazunehmen“ (`COMPOSE_PROFILES=staging` → `prod,staging`). - Beide READMEs um „Zugang einrichten“ ergaenzt: Rolle mit nur `audit:read`, Benutzer damit, Zugangsdaten in die `.env`. Mit dem Hinweis, dass jede Anmeldung im Audit-Log erscheint – gewollt, denn so sieht man auch, wenn das Gegenbuch aufhoert zu arbeiten. - Verifiziert gegen eine Attrappe, die wie das echte CRM eine Anmeldung verlangt: Anmeldung + Lauf erfolgreich, falsches Passwort → verstaendliche Meldung, exit 1. - [x] **🐳 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine** (2026-08-22) - Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer **eigenen Maschine** fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch liegt lokal auf dieser Maschine. Die Trennung, auf die es ankommt, ist damit gegeben – wer OpenCRM uebernimmt, kommt nicht ans Buch. - Richtung bewusst so herum: Das Gegenbuch **holt** ueber HTTPS mit einem Token, das nur `audit:read` kann. OpenCRM kennt weder Adresse noch Schluessel des Gegenbuchs. Kein SSH-Zugang zum CRM noetig – das Token kann ausschliesslich Pruefwerte lesen. - Unterverzeichnis `tools/audit-notary/`: `Dockerfile`, `entrypoint.sh`, `docker-compose.yml`, `.env.example`. Zwei Dienste (`prod`, `staging`) mit getrennten Verzeichnissen und Schluesseln, gesteuert ueber `COMPOSE_PROFILES` – dasselbe Muster wie beim Caddy-Profil im Hauptstack. Der Signaturschluessel wird beim ersten Start auf der Gegenbuch-Maschine erzeugt und verlaesst sie nie. - **Lokaler Betrieb ist jetzt ein vollwertiger Modus**, kein Testschalter mehr. Die Erfolgsmeldung benennt bei jedem Lauf, was abgedeckt ist („jemand veraendert Daten im CRM“) und was nicht („jemand uebernimmt diesen Rechner hier“) – statt der frueheren pauschalen Formulierung „kein Manipulationsschutz“, die im Einsatz auf eigener Maschine schlicht falsch war. - Jeder Durchlauf schreibt `status.txt` fuer die Ueberwachung; alles ausser `exit=0` gehoert angesehen. - Verifiziert mit echtem Docker-Build gegen eine CRM-Attrappe: Genesis ohne Bestaetigung → Code 4; mit Bestaetigung → Buch angelegt, Normalbetrieb exit 0; **Eintrag nachtraeglich veraendert → Alarm exit 2**; **Eintraege geloescht (maxId geschrumpft) → Alarm exit 2**; Siegel verschwunden → Alarm. Testartefakte und Image geloescht. - **Daten im Projektverzeichnis, keine Docker-Volumes** (Projektkonvention): Bind-Mount auf `tools/audit-notary/data//`. Der Inhalt ist per `.gitignore` ausgenommen – dort liegt der Signaturschluessel –, die Verzeichnisse selbst bestehen ueber `.gitkeep`. Ohne das haette Docker sie beim ersten Start als `root` angelegt und der Container (UID 1000) haette nicht schreiben koennen. - Behoben beim Bauen: `useradd -u 1000 || true` verschluckte, dass UID 1000 im Node-Image bereits vergeben ist – der Container startete gar nicht. Jetzt laeuft er als der vorhandene Benutzer `node`. - [x] **🧼 Gegenbuch: Pruefmodus schreibt nicht mehr, Widerspruch aufgeloest (Pentest R181)** (2026-08-18) - **R181-01 (LOW→MED)** – Der Pruefmodus sagte zu, nichts zu veraendern und kein Schreibrecht zu brauchen – und pushte trotzdem: `ankerNachziehen()` lief in JEDEM Modus. Ein „read-only“ Audit mutierte damit still das geteilte Substrat. Fix: Nachziehen nur im Schreiblauf. - **R181-02 (MEDIUM) – ein Widerspruch zwischen meinen EIGENEN Fixes.** R180-01 erhebt die Serversperre auf `refs/notary/*` zur tragenden Pflicht; R180-02 verlangt genau dort Schreibrecht zur Selbstheilung. Ergebnis: Sobald je ein Anker fehlte, bekam jeder read-only pruefende Auditor dauerhaft einen Fehler auf einer **voellig gueltigen** Kette – und konnte ihn nicht beheben. Fix: Reparieren ist Sache des Notar-Laufs; im Pruefmodus wird der fehlende Anker gemeldet, nicht repariert. - **Eigener Rueckgabecode 5** (seine Frage b): „Anker unvollstaendig“ ist NICHT „nicht feststellbar“. Die Kette ist geprueft und gueltig, nur das Substrat-Gedaechtnis ist unvollstaendig – ein benannter, reparierbarer Defekt mit klarer Handlungsanweisung. Ihn in Code 3 zu werfen hiesse, eine Anweisung als Ungewissheit zu melden; dieselbe Trennung wie bei Genesis/Adoption. - **Seine Frage (a) empirisch beantwortet:** Die Notar-Identitaet laesst sich eng auf das ANLEGEN von `refs/notary/*` beschraenken, ohne ihr Loeschen oder Ueberschreiben zu geben – serverseitig unterscheidbar an der Null-OID beim Anlegen. Mit `pre-receive`-Hook verifiziert: Backfill greift (exit 0), Loeschen und Force-Overwrite werden weiterhin abgewiesen. Der Hook steht als Beispiel in der README. - Verifiziert: read-only `--check` mit fehlendem Anker → **Code 5, nichts gepusht** (Anker-Anzahl vorher/nachher identisch); Notar-Schreiblauf unter derselben ACL → „Fehlende Anker nachgetragen: 1“, exit 0. - Seine Non-Findings uebernommen: Die Backfill-Zuordnung ist gegen das PIN-Modell robust – jeder Commit mit echter Ledger-Aenderung ist non-merge und muss keyA-signiert sein, sig-exempte Merges tragen keinen Inhalt in die Zuordnungsliste. - [x] **🛑 Gegenbuch: Anker belegen nichts mehr, Anker-Verlust wird laut (Pentest R180-01/-02)** (2026-08-18) - **R180-01 (MEDIUM)** – Genau die Unsicherheit, die ich selbst benannt hatte, bestaetigt: Der Code nahm den **hoechsten noch vorhandenen** `refs/notary/seq-*` als „hoechsten je“. Loescht ein Angreifer nur die OBEREN Anker und laesst einen niedrigeren stehen, senkt er den Vergleichswert selbst – ein frischer Auditoren-Klon meldete `OK`, exit 0 auf gewaschenem Stand. Perverser Gradient: Wer ALLE Refs loeschte, flog auf (Code 4); wer weniger loeschte, kam durch – weil `ankerBelegt` sowohl die Gewissheit begruendete als auch den Code-4-Diskriminator kurzschloss. Fix: **Anker begruenden keine Gewissheit mehr.** Sie koennen ein Zurueckspulen *widerlegen* (hoher Anker nicht mehr im Kopf → Alarm), aber nie Unversehrtheit *belegen*. Der Code-4-Diskriminator haengt nicht mehr an ihnen und greift nur noch im Schreiblauf (ein Auditor hatte nie ein Gedaechtnis, ihm Code 4 vorzuwerfen waere Laerm). - **R180-02 (LOW→MED)** – Der Anker-Push-Fehlschlag war nur eine Warnung mit exit 0, ausgerechnet bei der tragenden Eigenschaft: `main` stand auf dem neuen Checkpoint, der Anker fehlte, niemand holte es nach. Der **Normalbetrieb** senkte damit den Hoechststand still um eins und riss das R180-01-Fenster ohne Angriff auf. Fix: Anker-Push-Fehlschlag → **exit 3**; und jeder Lauf **zieht fehlende Anker nach**, bevor er irgendetwas als OK meldet (Zuordnung ueber die n-te Aenderung am Gegenbuch). - Verifiziert: Teil-Loeschung (main auf seq-2, Anker 3–5 weg, DB gekuerzt), frischer Auditoren-Klon → **exit 3 mit Vorbehalt statt exit 0** (das falsche Gruen ist weg; erkannt wird es in-repo weiterhin nicht, das geht ohne Ref-Schutz nicht); Anker-Push per Hook abgelehnt → **exit 3** statt Hinweis; Folgelauf → „Fehlende Anker nachgetragen: 1“, Anker wieder vollstaendig. - **README:** Der Schutz von `refs/notary/*` gegen Loeschen und Ueberschreiben ist von der Fussnote zur **tragenden Voraussetzung** erhoben – mit der Begruendung, warum Anker nichts belegen koennen, und dem ausdruecklichen Hinweis, dass ein `--check` aus frischem Klon eine Anker-Loeschung nicht erkennt. - [x] **🧠 Gegenbuch: Gedaechtnis ins Substrat verlegt, Verlust laut behandelt (Pentest R180)** (2026-08-18) - Sein Durchrechnen war zwingend: „erste Beobachtung → exit 3“ galt **genau einen Lauf**. Danach baselinete der Waechter auf den AKTUELLEN Kopf – nach einem Rewind also auf den gewaschenen Stand – und meldete dauerhaft gruen. Der Angreifer musste nur **ein einziges exit 3** ueberstehen, ausgerechnet den Code, den der Betreiber bei Remote-Ausfaellen ohnehin staendig sieht. Damit war der Waechter, der Rewind OHNE Notar-Host-Integritaet fangen sollte, wieder an genau diese Integritaet gekoppelt. - **Eigentlicher Fix (sein Vorschlag): Gedaechtnis ins Append-only-Substrat.** Jeder verankerte Checkpoint bekommt einen eigenen Ref `refs/notary/seq-N`. Der ueberlebt einen Force-Push auf den Zweig, weil er ein anderer Ref ist – die hoechste je existierende Nummer ist damit aus dem Server rekonstruierbar. Geprueft wird: ist der hoechste verankerte Checkpoint noch im aktuellen Kopf enthalten, und ist die Reihe mindestens so lang wie verankert. - **Laute Verlustbehandlung mit seinem Diskriminator** („traegt der Remote schon Checkpoints?“): keine Historie → echte Genesis, einmalig `NOTARY_GENESIS_ACK=true`; Historie vorhanden, aber kein Gedaechtnis → **Anomalie**, eigener **Code 4**, **keine stille Adoption**, erst nach `NOTARY_ADOPT_ACK=true`. Eine Baseline zu setzen heisst „ich verbuerge mich, DAS ist der wahre Kopf“ – dieser Akt wird nicht automatisch wiederholt. - Verifiziert: Genesis ohne Bestaetigung → **Code 4**; mit Bestaetigung → Kette aufgebaut, `refs/notary/seq-1..5` auf dem Server; Rewind auf Checkpoint 2 → Alarm **exit 2**, und zwar **auch nach Loeschen des lokalen Speichers** und bei jedem Folgelauf (vorher: ein exit 3, danach dauerhaft gruen); zusaetzlich die Anker-Refs geloescht → **Code 4** statt stiller Uebernahme, erst nach ausdruecklicher Bestaetigung weiter (dann exit 3 mit Vorbehalt, nicht gruen). Labor und Port geraeumt. - **Ehrlich dokumentiert:** Die Anker-Refs muessen serverseitig ebenfalls vor Loeschen/Ueberschreiben geschuetzt sein – sonst verschiebt sich das Problem nur eine Ebene weiter. Server-API-Abfrage der Protection bleibt bewusst weg (er hat sie selbst nur als Plausibilisierung eingeordnet). - [x] **👀 Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (Pentest R179 a/b)** (2026-08-18) - **(a) Probe-Push verworfen – seine Analyse war zwingend.** Ein Testpush haette (1) nur den geprobten Ref beurteilt, nicht `main`; (2) die EIGENE Push-Identitaet gemessen statt die des Angreifers – Bypass-Rechte fuer Admins gehen genau dann auseinander, wenn es zaehlt; (3) nur einen Zeitpunkt abgedeckt und damit die Praesenz-erzeugt-Zuversicht-Falle aus R179-01 neu gebaut. Und: einen zerstoerungsfreien Force-Push gibt es nicht – die bestaetigende Beobachtung waere derselbe Vorgang wie der Schaden. - Stattdessen **Fast-Forward-Waechter**: Bei jedem Lauf wird der beobachtete Remote-Kopf **ausserhalb des Klons** festgehalten (`~/.opencrm-notary/beobachtungen.jsonl`, per `NOTARY_STATE_FILE` aenderbar). Beim naechsten Lauf muss der neue Kopf ein Nachfahre des alten sein – sonst Alarm. Das erkennt das **Ereignis** statt die Regel abzufragen und wirkt unabhaengig davon, ob serverseitiger Schutz existiert. Ein belegter Fast-Forward gilt als Nachweis und blendet den Rewind-Vorbehalt aus (im Gegensatz zur blossen Praesenz eines MIN_SEQ-Werts). - **(b) Code 3 als Tor VOR dem Anhaengen, nicht als Status danach.** Sein Argument: Der Schreiblauf signiert mit dem neuen Checkpoint zugleich ueber den Bestand darunter – ist die Basis ungeklaert, waere das Anhaengen selbst das Waschmittel. Jetzt: Grundlage nicht feststellbar → **nichts anhaengen, exit 3**. Erster Lauf ueberhaupt → Basislinie setzen, aber ehrlich als solche melden (exit 3, kein gruenes Licht). Anhaengen geklappt, Push gescheitert → **exit 3 mit „erstellt, aber NICHT verankert“** statt 0 oder 1. Nur Anhaengen + Push + belegte Verankerung → exit 0. - Verifiziert: Basislinie → exit 3; Folgelauf → exit 0 ohne Vorbehalt; **Rewind aus einem FRISCHEN Auditoren-Klon ohne MIN_SEQ und ohne Zusicherung → Alarm exit 2** (das war bisher stilles Gruen); Push-Ziel kaputt → „erstellt, aber NICHT verankert“ exit 3, Folgelauf haelt den ungepushten Commit fail-closed an (exit 2). Labor und Port geraeumt. - [x] **🚩 Gegenbuch: Unsicherheit erreicht jetzt den Rueckgabecode (Pentest R179)** (2026-08-18) - **R179-b – der wichtigere Punkt:** Mein Rewind-Vorbehalt stand in der Ausgabe, der Exit blieb **0**. Meine eigene README sagt „fuer Cron gilt: jeder Code ausser 0 gehoert gemeldet“ – der Zustand „ich bin an dieser Stelle blind“ erreichte die Ueberwachung also **nie**. Dieselbe Klasse wie R174 (vergrabener Vorbehalt), eine Ebene hoeher. Fix: Code **0 nur bei belegter Gewissheit** – entweder ist serverseitiger Rewind-Schutz zugesichert oder eine mitgegebene Mindesthoehe wurde erfuellt. Sonst **Code 3**, dieselbe Bedeutung wie beim unerreichbaren Remote und bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen. - **R179-01 (MEDIUM)** – `NOTARY_MIN_SEQ` ist eine **Untergrenze**, kein Ist-Stand. Ein veralteter Wert liess einen Teil-Rewind darueber lautlos durch; schlimmer: die blosse **Praesenz** einer Zahl blendete den Vorbehalt aus, ein `MIN_SEQ=0` war damit ein Freibrief und ein Tippfehler (`NaN`) wurde still verschluckt. Ein veralteter Anker erzeugte so ein **selbstbewussteres Ergebnis als gar keiner**. Fix: Der Vorbehalt haengt jetzt allein an der ausdruecklichen Zusicherung `NOTARY_REWIND_PROTECTED=true`; bei gesetztem MIN_SEQ benennt er zusaetzlich die Grenze („deckt nur ab, was UNTERHALB dieser Nummer fehlt“). `MIN_SEQ` ≀ 0 oder unparsbar → **exit 1** mit Erklaerung statt stiller Annahme. - Verifiziert gegen seinen Aufbau (10 Checkpoints, Rewind auf 7, DB passend gekuerzt, frischer Auditoren-Klon): nicht gesetzt → **exit 3** (vorher 0) · 10 → Alarm 2 · 8 → Alarm 2 · 7 (veraltet) → 0, aber **mit Vorbehalt** (vorher ohne) · 0 / `xyz` → **exit 1** (vorher stilles 0) · Rewind-Schutz zugesichert → 0 ohne Vorbehalt. Labor und Port geraeumt. - Seine Positivkontrolle bestaetigt und uebernommen: „in der Mitte gewaschen“ ist dicht, weil der Kopf-Hash bei `atId=letzter.maxId` alles darunter abdeckt – die Reconciliation nur des letzten Checkpoints genuegt. - [x] **🔄 Gegenbuch: Rewind auf signierten Praefix benennbar gemacht (Pentest R178-01)** (2026-08-18) - Fund: Mein Signatur-Gate faengt Force-Push mit **fremder oder unsignierter** Historie – aber ein **Rewind auf einen aelteren, echt signierten Stand** ist signaturseitig einwandfrei. Angreifer spult `origin/main` auf einen frueheren Checkpoint zurueck und kuerzt die Datenbank passend: alle Signaturen `G`, Pin korrekt, Reihe lueckenlos. Ein Notar-Klon mit lokalem Vorlauf merkt es; ein **frischer Auditoren-Klon** meldete `OK`, exit 0 – also ausgerechnet im dokumentierten Pruef-Fall. - **Ehrliche Einordnung:** Das laesst sich im Skript nicht kryptographisch erkennen, die Historie ist ja echt. Also zwei Dinge statt eines Scheinfixes: 1. `NOTARY_MIN_SEQ` – die zuletzt bekannte Nummer als Bezugspunkt. Ist die Reihe kuerzer, ist das das Bild eines Rewinds → Alarm. Jeder Lauf nennt die Nummer am Ende, damit sie ueberhaupt bekannt sein kann. 2. Ohne diesen Bezugspunkt sagt die Erfolgsmeldung jetzt ausdruecklich, dass ein Zurueckspulen **nicht** erkennbar war – „OK“ soll nicht mehr Gewissheit behaupten als vorhanden ist. - README: serverseitiger **Rewind-Schutz (non-fast-forward verbieten)** ist jetzt als Pflicht formuliert, mit der Begruendung warum Signaturpruefung allein dagegen nichts ausrichtet – und mit dem ausdruecklichen Hinweis, dass ein frischer Klon den Rewind nicht sieht. - Kleinkram aus seinem Bericht: `NOTARY_SIGNER_FINGERPRINT` wird beim **Einlesen** getrimmt (ein Zeilenumbruch loeste sonst 4/4 Fehlalarme aus, die auf den *korrekten* Fingerabdruck zeigten); die „nie gepusht“-Meldung priorisiert jetzt **Untersuchen** statt Pushen und nennt den passenden `git log`-Befehl – „pushen“ haette einen falsch signierten Commit dauerhaft in die Kette gebracht. - Beim Testen selbst gefunden: der allererste git-Aufruf war ungeschuetzt und warf bei kaputtem Klon einen Node-Stacktrace – jetzt erklaerende Meldung. - Verifiziert: Rewind 3→1 bei passend gekuerzter DB → frischer Klon ohne Bezugspunkt `OK` **mit Vorbehalt**, mit `NOTARY_MIN_SEQ=3` → **Alarm exit 2**; Pin mit Zeilenumbruch → kein Fehlalarm mehr; regulaerer Lauf unveraendert. Testlabor und Port geraeumt. - Seine Bestaetigungen: Pin-Ableitung faellt bei Key-Literal/GPG-Key-ID korrekt fail-closed aus; der Rollback bei Pin-Mismatch ist vollstaendig, und selbst ein Absturz zwischen Commit und Reset wird vom „nie gepusht“-Check aufgefangen. - [x] **📌 Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (Pentest R177)** (2026-08-18) - **R177-01 (MEDIUM)** – Der Pin war optional. Ohne ihn war der Vertrauensanker die gesamte `allowed_signers`-**Menge**, nicht der eine Notar-Schluessel: Ein zweiter dort gelisteter Schluessel (Kollege, Alt-Key, geteilte Datei) konnte das Gegenbuch waschen, force-pushen – und `%G?` war `G`. Live gezeigt, exit 0. R176-01 hatte „jeder selbst erzeugte Schluessel“ geschlossen, „jeder **erlaubte** Schluessel“ blieb offen. Fix: Pin wird aus `user.signingkey` automatisch abgeleitet (`ssh-keygen -lf` → `SHA256:
`); laesst er sich nicht bestimmen, wird **abgebrochen** statt die ganze Liste zu akzeptieren. - **R177-02 (MEDIUM)** – Der Schreib-Lauf prueft den frischen Commit nur auf `%G?`, nicht auf den Pin. Ein Notar-Host mit falsch konfiguriertem Schluessel meldete „beglaubigt“ und **pushte** – und ab da war die Kette bei jeder Folgepruefung dauerhaft rot, behebbar nur per Force-Push, den die Branch-Protection gerade verhindern soll. Selbst-DoS ohne Signal. Fix: Pin-Abgleich am frischen Commit **vor** dem Push, bei Abweichung Ruecknahme und Abbruch. - Kleinkram aus seinem Bericht: `NOTARY_ALLOW_LOCAL=true` faerbt jede Erfolgsmeldung jetzt ein („nur lokal, kein externer Anker“) und pusht nicht mehr ins Leere; CRM-Fehler (nicht erreichbar, 401/403, kein JSON) liefern eine erklaerende Zeile statt eines Node-Stacktrace. - Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff (in `allowed_signers`, aber nicht der Notar-Key) → **Alarm exit 2 auch ohne gesetzten Pin**; Schreiblauf mit falschem Schluessel → zurueckgerollt, **nichts gepusht**, keine Geisterzeile; CRM nicht erreichbar / 401 → saubere Meldung, exit 1; Lokalmodus → Erfolgsmeldung eingefaerbt; **saubere Historie: vier Laeufe plus Pruefmodus ohne Fehlalarm**. Alle Testartefakte geloescht, Ports frei. - Seine Antworten uebernommen: (a) `%GF` ist kryptographisch an die Signatur gebunden und nicht faelschbar – Non-Finding; (b) Code 3 bleibt getrennt von Code 2, „ich weiss es nicht“ darf nicht als „Manipulation“ gemeldet werden; (c) „Notar-Host **und** Key kompromittiert“ bleibt bewusst offen – dagegen hilft nur Branch-Protection am Remote, im Skript nicht erzwingbar. - [x] **🔑 Gegenbuch: nur wirklich vertrauenswuerdige Signaturen, fail-closed ohne Remote (Pentest R176)** (2026-08-18) - **R176-01 (HIGH)** – Mein Signatur-Gate akzeptierte `%G?` = `G` **oder** `U`. Bei SSH-Signaturen bedeutet `U` woertlich „gute Signatur, aber kein passender Principal“ – also: der Schluessel steht **nicht** in `allowed_signers`. Damit passierte **jeder selbst erzeugte Schluessel** das Gate, und der einzige In-System-Vertrauensanker war wirkungslos. Der Pentester hat es end-to-end gefahren: Gegenbuch mit fremdem Schluessel re-signiert und force-gepusht → „OK, Signaturen gĂŒltig“, exit 0. Vorbedingung war nur Remote-Schreibrecht, kein Host-Zugriff. Fix: **nur `G`** an beiden Stellen. Zusaetzlich optionales Pinnen des erwarteten Signierschluessels ueber `NOTARY_SIGNER_FINGERPRINT` (`%GF`). - **R176-02 (MEDIUM)** – War der Remote unerreichbar, fiel der Ablauf still auf `HEAD` zurueck und die „nie gepusht“-Pruefung wurde uebersprungen – ausgerechnet unter der Bedingung, die einen Push-Fehlschlag verursacht. `--check` meldete waehrend eines Remote-Ausfalls gruenes Licht auf nicht notarisiertem Zustand. Fix: **fail-closed**, Abbruch mit Code 3 statt dem lokalen Stand zu glauben; ebenso, wenn gar kein Remote konfiguriert ist (bewusster Testlauf nur mit `NOTARY_ALLOW_LOCAL=true`). - Nebenbei: git-eigene Fehlermeldungen standen VOR der eigenen Erklaerung – stderr wird jetzt abgefangen und gezielt weitergereicht. - Verifiziert mit zwei SSH-Schluesseln (keyA vertrauenswuerdig, keyB nicht) gegen echten Remote: Angriff mit keyB + Force-Push → **Alarm, exit 2** (vorher „OK“); Remote unerreichbar → **exit 3**; kein Remote → exit 3; falscher Fingerabdruck-Pin → Alarm; **saubere Historie: drei Laeufe und Pruefmodus ohne Fehlalarm**. Alle Testartefakte geloescht, Port frei. - Seine Antwort auf Frage (a) uebernommen: der Vergleich Arbeitsdatei ↔ signierter Baum ist nicht umgehbar; `.trim()` auf beiden Seiten schluckt nur Kosmetik. Notiert als Restrisiko: internes CRLF koennte bei `core.autocrlf` auf dem Notar-Host einen Fehlalarm ausloesen – Verfuegbarkeit, kein Integritaetsloch. - [x] **🔏 Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)** (2026-08-18) - **R175-01 (HIGH)** – Der Kernsatz des Pentesters: „Append-only allein reicht nicht, es braucht einen verifizierenden Leser.“ Meine erste Fassung signierte zwar, **prĂŒfte aber nie**: Sie las ihre Wahrheit per `readFileSync` aus der lokalen **Arbeitsdatei**, nirgends im Repo gab es ein `git verify-commit`. Das `-S` war write-only ohne Konsument. Live reproduziert: DB-Tail abgeschnitten UND die lokale Ledger-Zeile angepasst → „OK, Checkpoint beglaubigt“, exit 0, kein Alarm. Der gekuerzte Zustand wurde zur neuen Wahrheit. Fix: Wahrheitsquelle ist der **signierte Commit-Baum** (bevorzugt der Remote-Kopf); jeder Commit mit Gegenbuch-Aenderung muss eine gueltige Signatur tragen; weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen; lokale, nie gepushte Commits gelten nicht als beglaubigt; die Signatur des frisch erzeugten Commits wird gegengeprueft. Dazu ein **Pruefmodus** `--check` fuer Auditoren (alle Kontrollen, kein Schreibrecht noetig). - **R175-02 (MEDIUM)** – `/checkpoint` fuhr je Aufruf ein volles `verifyIntegrity()` (O(n), gemessen 0,85 s bei 16k Zeilen) – authentifizierte DoS-Verstaerkung. Gebraucht wurde davon nur die Siegel-Wurzel. Jetzt: Kopf-Hash direkt aus der Kopfzeile, Wurzel aus dem juengsten gueltigen Marker (eine Abfrage + Signaturpruefung), plus `sealLeafCount` statt des teuren Status. - **R175-03 (LOW→MED)** – `writeFileSync` lief vor dem Commit: schlug der signierte Commit fehl, blieb eine verwaiste Zeile liegen, die der Folgelauf absorbierte und zementierte. Jetzt Ruecknahme bei Fehlschlag (`git checkout`), und `NOTARY_SIGN=false` verlangt zusaetzlich `NOTARY_INSECURE_ACK=
` – der Reflex „Cron hakt, Signatur abschalten“ soll nicht ausreichen. - Verifiziert mit **echtem SSH-Signaturschluessel** gegen eine CRM-Attrappe: stilles Waschen (DB gekuerzt + lokale Datei gebogen) → Alarm statt „OK“; frei erfundene, nie committete Zeile → Alarm; unsignierter Commit in der Historie → Alarm; Commit-Fehlschlag → Datei zurueckgerollt, keine Geisterzeile; `NOTARY_SIGN=false` ohne Bestaetigung → verweigert; Pruefmodus meldet sauber ohne anzuhaengen. Alle Testartefakte geloescht. - Seine Bestaetigungen: Key-Trennung im Deploy haelt (`printenv` im Container zeigt keinen Notar-Schluessel); `audit:read` kann am Checkpoint keine falschen Werte injizieren. - [x] **⚖ Gegenbuch: externe Notarisierung der Audit-Kette** (2026-08-18) - Abschluss der Anker-Kette. Alle bisherigen Schutzebenen liegen in derselben Datenbank, die sie absichern sollen – der Pentest hat das ueber mehrere Runden Schicht fuer Schicht gezeigt, zuletzt in R174-01 am Siegel- Marker selbst. Ein Gegenbuch an einem fremden Ort durchbricht das. - Aufteilung nach der Analyse des Pentesters („der Schutz kommt vom ORT, nicht von der Signatur“): Das CRM liefert nur einen **lesbaren Kontrollwert ohne Geheimnisse** (`GET /api/audit-logs/checkpoint`, `audit:read`). Signiert, zeitstempelt und angehaengt wird auf einem **anderen Rechner** – Schluessel und Push-Recht liegen NICHT in den Deploy-Secrets des CRM. Ohne diese Trennung waere es „D1 nochmal, nur schlimmer“: sieht nach doppeltem Boden aus, tut still nichts. - Der Kontrollwert enthaelt bewusst `maxId`. Ein blosser Kopf-Hash erkennt Umschreiben, aber kein **Abschneiden am Ende** – genau die R174-01-Klasse, eine Ebene hoeher. `atId` erlaubt der Gegenstelle, einen frueher beglaubigten Kopf erneut abzufragen und nachzurechnen. - Gegenstelle: `tools/audit-notary/notary.mjs` (Cron auf zweitem Rechner, privates Git-Repo als Append-only-Ablage, signierte Commits). Prueft VOR dem Anhaengen und bricht bei Widerspruch mit Exit-Code 2 ab, **ohne zu schreiben** – der manipulierte Zustand wird nicht als neue Wahrheit festgeschrieben. - Verifiziert gegen eine CRM-Attrappe mit echter DB: beglaubigte Zeile veraendert → Alarm; am Ende abgeschnitten (maxId 5→4) → Alarm; Gegenbuch selbst gekuerzt (seq-Luecke) → Alarm; in allen Faellen nichts angehaengt. Wegwerf-DB und Test-Gegenbuch danach geloescht. - **Ehrlich dokumentiert** (README): Restfenster zwischen zwei Laeufen bleibt und ist inhaerent; ein stiller Cron-Ausfall erzeugt im CRM KEINE Warnung und muss auf dem Gegenbuch-Rechner ueberwacht werden; Force-Push muss serverseitig gesperrt sein, sonst ist Append-only nur geliehen. - **Betrieb:** Einrichtung liegt beim Betreiber (privates Repo, Cron, Signaturschluessel auf dem zweiten Rechner). - [x] **🚹 Siegel-Entfernung wird erkannt (Pentest R174-01, HIGH)** (2026-08-18) - Fund: Der Siegelzustand hing **ausschliesslich** am Marker im Audit-Log – und den kann ein DB-Schreiber **ohne Schluessel** loeschen. Danach meldete die Pruefung `kein_siegel`, also **Entwarnung**, ununterscheidbar von „nie versiegelt“. Die 11 Blattwerte blieben verwaist liegen und wurden nie konsultiert; die zuvor erkannte V1-Faelschung war wieder unsichtbar. - Besonders bitter im **Tail-Fall**: Steht der Marker am Ketten-Ende – genau der Zustand direkt nach dem einmaligen `seal-backlog` beim Deploy – reisst beim Loeschen nicht einmal eine Luecke. Ergebnis war `valid: true`, „Alle EintrĂ€ge unverĂ€ndert und lĂŒckenlos verkettet“, **null Spur**. Meine Antwort auf seine Frage (b) war damit falsch: die Luecke reisst nur, solange der Marker NICHT am Ende steht. - Fix (sein Vorschlag): Gegen-Check „Blaetter vorhanden, aber kein gueltiger Marker“ → neuer Status **`entfernt`** statt `kein_siegel`, mit ausdruecklicher Meldung („Das ist KEIN Normalzustand“). Zusaetzlich kippt ein gebrochenes ODER entferntes Siegel jetzt `valid` auf `false`, auch wenn keine einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust ohne Wirkung. Und der beruhigende Einstiegssatz entfaellt bei Siegelproblemen, damit er die Warnung nicht entwertet. - Verifiziert ueber den echten HTTP-Pfad, exakt sein Szenario 4: Marker war nachweislich das Ketten-Ende, Marker + Middleware-Decoy geloescht, **0 Ketten-Luecken** – Ergebnis jetzt `valid: false`, Status `entfernt`, Klartext-Warnung. Zusaetzlich der Nicht-Tail-Fall geprueft. Zwei Wegwerf-Datenbanken, beide danach geloescht; Dev unberuehrt. `tsc` + `vite build` gruen. - Seine Gegenchecks bestaetigt: Frage (a) Middleware kann keine Marker-/ Manifest-Signatur mit auswertbarem Inhalt nachbilden; Frage (c) Re-Insert mit derselben id umgeht nichts, weil das Blatt alle Spalten deckt. - [x] **🔧 Bestandssiegel betriebstauglich gemacht (Pentest R173-01 HIGH, R173-02/-03 MEDIUM)** (2026-08-18) - **R173-01 (HIGH) – Siegel war ueber HTTP tot.** Die generische `auditMiddleware` protokolliert den `POST /seal-backlog`-Request SELBST als `AuditLog / CREATE / endpoint=/api/audit-logs/seal-backlog` – exakt die Signatur, mit der ich den Marker suchte, nur mit hoeherer id und ohne `changesAfter`. Der Selektor griff diese Zeile, `root` war `undefined`, Ergebnis: **dauerhaft „gebrochen“ bei null manipulierten Zeilen.** Das komplette R171-02-Deliverable war damit im Betrieb wertlos. **Mein Testfehler:** Ich hatte `sealBacklog()`/`verifyIntegrity()` direkt aufgerufen, nie ueber HTTP – die Middleware lief nie mit. Dieselbe Fehlerklasse wie R165 (Code korrekt, im Betrieb nicht erreichbar). Fix: eigener Ressourcentyp `AuditBacklogSeal`, den die Middleware nie vergibt (sie leitet ihn aus dem Pfad ab → `AuditLog`), zusaetzlich muss der Marker auswertbares `{toId, root}` tragen. - **R173-02 (MEDIUM) – Siegel war blind fuers Loeschen.** `if (!zeile) continue` uebersprang fehlende Zeilen; das Loeschen eines gesiegelten Einbruchsbelegs erschien nur als „unerklaerte Luecke“, waehrend der dedizierte Indikator „intakt“ meldete – Entwarnung, waehrend Beweismaterial verschwindet. Fix: fehlende gesiegelte Zeilen sind ein Siegelbruch mit eigener Liste (`backlogMissing`) und werden namentlich gemeldet. Deckt zugleich seine Frage (a) ab: geprueft wird jetzt die gesiegelte Menge gegen die Live-Population, nicht nur der Inhalt vorhandener Zeilen. - **R173-03 (MEDIUM) – Re-Seal wusch mit weniger Spur als ein Rehash.** Die R170-01-Haertung (Vorbefund im Marker) war auf `seal-backlog` nie angewandt. Fix: Der Siegel-Marker haelt jetzt den Befund VOR dem Siegeln fest (manipulierte/fehlende Zeilen, Ketten-Luecken) samt Status und Wurzel des vorherigen Siegels; `/verify` weist die Anzahl gueltiger Siegel aus und weist bei >1 ausdruecklich darauf hin. - Verifiziert **ueber den echten HTTP-Pfad inkl. Middleware** (separate Wegwerf-DB, Mischbestand 6×V1/4×V2/4×V3): Middleware-Protokollzeile ist vorhanden, Status trotzdem `intakt`; Loeschen einer gesiegelten Zeile → `gebrochen`, `backlogMissing:[2]`, namentlich in der Meldung; erneutes Siegeln → Anzahl 2 gemeldet, Marker enthaelt „vorheriges Siegel: gebrochen, Befund fehlend [2]“. Wegwerf-DB geloescht, Dev unberuehrt. `tsc` + `vite build` gruen. - [x] **🧿 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02, HIGH)** (2026-08-18) - Der Pentester hat den Fund live reproduziert: Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein DB-Schreibzugriff konnte eine `LOGIN_FAILED`-Zeile auf `success=1` setzen, das Label auf „
hat sich angemeldet“ aendern und `errorMessage` leeren – **alles Nicht-Hash-Felder, Hash unveraendert** → `/verify` meldete `valid=true`. Ein Einbruchsversuch war unsichtbar in einen Erfolg umschreibbar. - Rueckwirkend signieren geht nicht; ein Rehash waere die falsche Medizin. Stattdessen **Bestandssiegel**: einmalig, nicht destruktiv, schreibt nichts um. Je Altzeile ein Blattwert, die Wurzel darueber in einem HMAC-signierten Marker. - Umgesetzt exakt nach seinen vier Bedingungen: 1. **Blaetter ueber den vollen Zeileninhalt** (alle Spalten inkl. `id` und `hashVersion`), NICHT ueber den 7-Feld-V1-Hash – sonst lebte die Luecke im Siegel weiter. 2. **Wurzel signiert** – sie steht im Marker, der selbst Version 3 und HMAC-signiert ist. Ohne `AUDIT_HMAC_KEY` wird das Siegeln abgelehnt. 3. **Bereich fix auf `[1 
 v3FromId-1]`** gebunden statt per Live-Abfrage `hashVersion < 3`. Sein Umgehungsweg: ein Up-Flip der Grenzzeile (V1→V2, schluessellos nachrechenbar) haette sie sonst aus der geprueften Menge gedraengt – sie waere nur als harmlose Luecke erschienen. 4. **Pruefung je id**: vorhanden, weiterhin Altbestand, Inhalt == Blatt. Zusaetzlich Wurzelabgleich, damit auch ein veraenderter Blattbestand auffliegt. - Naht geschlossen: Zeilen ab `v3FromId` MUESSEN laut Versionsgrenze V3 sein, es bleibt also keine ungesiegelte Zone dazwischen. - Neuer Endpunkt `POST /audit-logs/seal-backlog` (`audit:admin`, `{"confirm":"SEAL"}`). `/verify` meldet den Siegelzustand im Klartext – auch wenn **kein** Siegel existiert („Aenderungen daran waeren nicht erkennbar“). - Verifiziert in einer separaten Wegwerf-Datenbank mit realistischem Mischbestand (6×V1, 5×V2, 5×V3): ohne Siegel ist der Angriff unsichtbar (`valid=true`) – mit Siegel wird er erkannt und die Zeile benannt (`[3]`); sein Up-Flip der Grenzzeile wird ebenfalls erkannt (`[6]`). Wegwerf-DB danach geloescht, Dev-Daten unberuehrt. `tsc` + `vite build` gruen. - [x] **🔒 Manifest-Kanal abgesichert (Pentest R171-01, HIGH; R171-03, LOW)** (2026-08-18) - **R171-01:** `verifyIntegrity` vertraute Loeschungs-Manifesten bedingungslos – ohne zu pruefen, ob die Traegerzeile ueberhaupt signiert und gueltig ist. Das Manifest steht in `changesAfter`, und dieses Feld ist erst ab Hash-Version 2 mitgehasht, auf **V1-Altzeilen also voellig unauthentifiziert**. Ein Angreifer konnte in eine BELIEBIGE V1-Zeile ein erfundenes Manifest schreiben, ohne deren Hash zu veraendern, und damit eigene Loeschungen als „erklaert“ ausweisen – womit zugleich die Eskalation an signierten Zeilen ausfiel. Damit waren **Anker UND Versionsgrenze** umgangen. Der Pentester hat es live gezeigt; es war genau die Stelle, die ich beim Uebergeben als meine schwaechste benannt hatte. - Fix: Ein Manifest zaehlt nur noch, wenn seine Traegerzeile (1) laut Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert und (2) ihre HMAC-Signatur mit einem konfigurierten Schluessel aufgeht. Ohne Schluessel gibt es keine gueltigen Traeger – dann bleiben Luecken unerklaert, was die sichere Richtung ist. - **R171-03:** Eskalierte Luecken standen in `tamperedEntries` UND `chainGaps`; `invalidEntries` zaehlte sie doppelt. Jetzt entdoppelt. - Verifiziert: boeswillige Loeschung → Befund; erfundenes Manifest in herabgestufter Traegerzeile → **ignoriert**, Luecke bleibt Befund und die Traegerzeile wird selbst beanstandet; **legitimes signiertes Manifest erklaert die Luecke weiterhin** (Funktion nicht kaputt). `tsc` gruen. - Hinweis: Auf der Dev-DB existiert kein V1-Bestand mehr (der Rehash-Test aus R170-01 hat alles auf V3 gehoben), der Angriff wurde daher mit einer gezielt herabgestuften Traegerzeile nachgestellt. Auf Staging/Prod ist der V1-Altbestand real vorhanden. - [x] **đŸ§Ÿ rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)** (2026-08-18) - Fund: `POST /audit-logs/rehash` (audit:admin) rechnet die Kette MIT dem HMAC-Schluessel neu und macht sie damit wieder stimmig – auch wenn sie vorher berechtigte Beanstandungen enthielt. Der Anker schuetzt also gegen einen DB-Schreiber ohne Schluessel, **nicht** gegen einen Admin mit `audit:admin`. Der bisherige Marker hielt nur fest, DASS rehasht wurde, nicht WAS dabei verschwand. - Fix 1 – **Vorzustand im Marker**: Vor dem Rehash wird `verifyIntegrity()` erhoben und samt Kettenkopf (id, hash, hashVersion) im Marker gesichert: Anzahl geprueft, Listen der manipulierten Zeilen, der Ketten-Luecken, der Luecken ohne dokumentierte Loeschung und der nicht pruefbaren. Dazu ausloesender Benutzer und IP (bisher stand dort pauschal „system“). Der Marker entsteht nach dem Rehash, ist Teil der neuen Kette und signiert – entfernen ginge nur unter Hinterlassung einer Luecke. - Fix 2 – **Bestaetigung verlangen**: `rehash` erfordert `{"confirm":"REHASH"}`, `cleanup` erfordert `{"confirm":"CLEANUP"}`. Der Pentester hatte beide bei blinder Methoden-Erkundung per POST unbeabsichtigt ausgeloest; ein tastender Aufruf laeuft jetzt in 400. - Verifiziert: blinder POST auf beide Endpunkte → 400 ohne Wirkung; mit Bestaetigung → Rehash laeuft, Marker enthaelt Ausloeser, Vorbefund (1 manipuliert, 7 Luecken mit exakten IDs) und Kettenkopf. - **Offengelegt:** Dieser Test hat auf der DEV-Datenbank real rehasht – die dortigen historischen Beanstandungen sind rechnerisch geglaettet (`valid = true`). Genau der beschriebene Effekt; der Vorzustand steht jetzt aber im Marker. Staging/Prod unberuehrt. - Offen (Betreiber-Entscheidung): Off-Site-Notarisierung des Kettenkopfes. Erst sie deckt den Fall „Admin mit audit:admin“ vollstaendig ab. - [x] **🔒 Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01, HIGH)** (2026-08-18) - Der Pentester hat genau die Frage beantwortet, die ich beim Uebergeben gestellt hatte („laesst sich das Kulanzfenster ausnutzen?“) – und zwar nicht per Timing, sondern per **Linien-Fork**: Jede Kulanz-Wiedervorlage rotierte auf einen FRISCHEN Token mit eigenem, zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token konnte damit aus dem erkennbaren Replay-Zustand in eine eigene, sauber weiterrotierende Sitzung entkommen, die nie wieder mit der des Opfers kollidiert – **dauerhaft unsichtbar, kein einziges CRITICAL**. Damit war die Kern-Garantie von R164-02 (Diebstahl faellt bei der naechsten Nutzung auf) ausgehebelt. - Fix (sein Vorschlag): **Kulanz idempotent**. Die jti des Nachfolgers wird schon beim Einloesen im selben bedingten UPDATE reserviert (`replacedByJti`). Eine Wiedervorlage im Fenster gibt **denselben** bereits ausgestellten Nachfolger zurueck, statt neu zu rotieren – ohne neuen Datensatz. Parallele Tabs laufen dadurch auf EINE Linie zusammen; wer den Token spaeter (ausserhalb des Fensters) vorlegt, kollidiert zwangslaeufig und loest den Familien-Widerruf aus. Ist kein Nachfolger hinterlegt, wird bewusst NICHT ersatzweise rotiert (das waere wieder der Fork), sondern fail-closed als Replay gewertet. - Verifiziert – sein PoC nachgebaut: T0 legit → TA, T0 replayt → TC; **TA.jti == TC.jti**, also kein Fork mehr. Ueber HTTP: zwei parallele Tabs beide erfolgreich und auf derselben Linie; gestohlener Token nach Ablauf des Fensters → 401, Familie widerrufen, Angreifer-Linie tot, `SUSPICIOUS/CRITICAL` gemeldet. - Regression der in R168 bestaetigten Faelle: 40 parallel → 4 erfolgreich (1 + Kulanz 3) auf **einer** Linie, Rest abgewiesen; serielles Replay 1–4 ok, 5. → Replay; Token ohne jti → fail-closed; Logout widerruft. `tsc` gruen. - [x] **🔁 Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)** (2026-08-18) - Die Rotation war bisher wirkungslos: Der alte Refresh-Token blieb bis `exp` gueltig, ein gestohlener Token also **bis zu 7 Tage parallel** zum legitimen nutzbar (der Pentester trug mit EINEM Token 90 Parallel-Requests). - Umgesetzt nach OAuth-Sicherheits-BCP: Jeder Refresh-Token traegt eine `jti` und gehoert zu einer Sitzungs-`familyId` (neue Tabelle `RefreshTokenRecord`, Migration `20260818160000`). Beim Einloesen wird die `jti` verbraucht; taucht sie erneut auf, wird die **gesamte Familie** widerrufen – Angreifer und legitimer Nutzer fliegen raus, der Nutzer merkt es und der Vorfall wird als `SUSPICIOUS / CRITICAL` gemeldet. - Der Token selbst wird NICHT gespeichert (die Signatur authentifiziert ihn bereits); ein DB-Leck gibt damit keine nutzbaren Sitzungen preis. - **Kulanzfenster** fuer parallele Tabs: 15 s UND hoechstens 3 Wiederverwendungen. Ohne Toleranz wuerde der zweite legitime Tab die Sitzung sprengen; die enge Grenze laesst einen Missbrauchs-Burst trotzdem auflaufen. - **Einloesen ist atomar** (bedingtes UPDATE statt Lesen-dann-Schreiben) – derselbe Fehlertyp wie bei der Audit-Kette: im ersten Testlauf kamen 90 gleichzeitige Requests ausnahmslos durch, weil alle den Token als „noch unbenutzt“ lasen. Jetzt entscheidet die Datenbank, wer zuerst war. - Verifiziert: Rotation vergibt neue `jti` in derselben Familie; 90 parallele Requests → nur 4 erfolgreich (1 + Kulanz 3), 27 als Replay erkannt, Rest widerrufen, alle daraus entstandenen Tokens tot; 2 parallele Tabs weiterhin erfolgreich; gestohlener Token spaeter erneut → abgewiesen; Logout widerruft die Familie; ueber HTTP kommt `SUSPICIOUS/CRITICAL` an. Audit-Regression unveraendert (25/25, 0 Forks, alle V3). `tsc` + `vite build` gruen. - **Deploy-Hinweis:** Refresh-Tokens ohne `jti` (Bestand vor dem Deploy) werden bewusst **fail-closed** abgewiesen (`REFRESH_LEGACY`, als LOW gemeldet, kein Angriffsindiz). Alle angemeldeten Nutzer muessen sich nach dem Deploy **einmalig neu anmelden**. - [x] **⚓ Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)** (2026-08-18) - Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die Kette selbsttragend – wer die DB schreiben kann, konnte jede Zeile aendern und alle Folgehashes konsistent nachziehen, die Pruefung meldete „gueltig“. - Version 3 signiert denselben Inhalt per **HMAC-SHA256** mit `AUDIT_HMAC_KEY` – einem Schluessel, der NICHT in der Datenbank liegt. Ohne ihn laesst sich keine gueltige Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr. - **Fail-safe:** Ohne Schluessel schreibt das Audit-Log weiter Version 2, es faellt nichts aus. Signierte Zeilen gelten dann als **nicht pruefbar** (eigener Topf `unverifiableEntries`) und ausdruecklich NICHT als manipuliert – eine fehlende Konfiguration darf kein Fehlalarm ueber das gesamte Log sein. - **Schluesselwechsel ohne Rehash:** `AUDIT_HMAC_KEY_OLD` wird bei der Pruefung zusaetzlich akzeptiert. Verifiziert: mit KEY_OLD 0 Befunde, ohne KEY_OLD werden die alt signierten Zeilen erwartungsgemaess auffaellig. - **Restluecke der Versionsgrenze geschlossen (selbst gefunden):** Wird die FRUEHESTE Zeile einer Stufe herabgestuft, wandert `MIN(id)` mit – die Grenze allein haette den Downgrade durchgewunken (mein erster Testlauf fiel genau darauf durch). Der Nachfolger ist jedoch HMAC-signiert, sein `previousHash` ist ohne Schluessel nicht faelschbar. Eine unerklaerte Luecke vor einer signierten Zeile gilt deshalb jetzt als Befund, nicht als struktureller Zufall. - Verifiziert: Inhalt geaendert + Hash beliebig → erkannt; Downgrade 3→2 mit gueltigem V2-Hash auf der **fruehesten** V3-Zeile → erkannt; dasselbe auf der **letzten** V3-Zeile (kein Nachfolger) → erkannt; ohne Schluessel 0 manipuliert / 2 nicht pruefbar; 40 parallele Schreiber → 40/40, 0 Forks, alle V3. `tsc` + `vite build` gruen. - **Betrieb:** `AUDIT_HMAC_KEY` in `.env.example` dokumentiert (`openssl rand -hex 32`). Schluessel sichern – geht er verloren, sind die damit signierten Eintraege dauerhaft nicht mehr pruefbar (aber nicht als manipuliert gemeldet). Der Dev-Schluessel liegt nur lokal in `.env` (gitignored) und ist NICHT der Prod-Schluessel. - [x] **🔒 hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18) - Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02 selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen Live-Code + Live-Daten): `verifyIntegrity` waehlte die Pruefstaerke nach der von der Zeile SELBST deklarierten `hashVersion` – und die ist nicht gehasht. Angriff: `hashVersion` 2→1 setzen, die nur von V2 abgedeckten Felder aendern (`success` false→true, `errorMessage` leeren, `resourceLabel` umschreiben) und den schwachen V1-Hash ueber die 7 unveraenderten Felder nachziehen → Fehl-Login als Erfolg getarnt, Pruefung meldet „gueltig“. Besonders kritisch an der **letzten Zeile** der Kette: dort entsteht nicht einmal ein Gap → dauerhaft unsichtbar. - Fix: **Version-Floor.** Die erwartete Pruefstaerke leitet sich aus der Kette ab (`MIN(id) WHERE hashVersion >= 2`), nicht aus der Selbstauskunft. Ab dieser Grenze muss jede Zeile V2 sein; weicht die deklarierte Version von der erwarteten ab, gilt die Zeile selbst als manipuliert. Geprueft wird immer mit dem ERWARTETEN Verfahren. Die Grenze laesst sich durch das Herabstufen einzelner Zeilen nicht verschieben (Minimum bleibt) – ein Angreifer muesste alle V2-Zeilen ab der Grenze herabstufen und die gesamte Kette neu rechnen (= vollstaendiger Rehash, bekannter Grenzfall). - Zusaetzlich: Die Pruefung konsultiert jetzt das **Loeschungs-Manifest**. Neu `unexplainedGaps` – nur Luecken ohne protokollierte Loeschung sind erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich eine boeswillige Loeschung als „harmloser Gap“ tarnen konnte. - Verifiziert (PoC nachgebaut): Downgrade-Angriff auf Zeile mit Nachfolger → **erkannt**; auf die Tail-Zeile (erzeugt keinen Gap) → **erkannt**; Gegenrichtung (V1-Altzeile faelschlich als V2 deklariert) → **erkannt**; Ausgangslage und Zustand nach Wiederherstellung jeweils 0 manipuliert (keine Falschmeldungen auf Bestandsdaten). `tsc` + `vite build` gruen. - Betriebshinweis dokumentiert: Bei einem rollierenden Deploy mit kurzzeitig parallel schreibender Alt-Instanz koennen echte V1-Zeilen nach der Grenze entstehen und wuerden angezeigt. Beim hier ueblichen Deploy (pull + rebuild + restart) tritt das nicht auf. - [x] **đŸ›Ąïž Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18) - **R166-01 (HIGH) – Kette forkte weiter.** Mein GET_LOCK-Ansatz gab die Sperre im `finally` INNERHALB des Transaktions-Callbacks frei, also VOR dem COMMIT. Im Fenster Release↔Commit las der naechste Schreiber ein noch nicht sichtbares Kettenende → zwei Zeilen am selben Vorgaenger. Meine „100 parallel → 0 Brueche“-Messung war zu schwach: sie suchte Luecken zwischen Nachbarn, nicht Forks, und das Fenster ist lokal sehr schmal. Fix: einzeiliger Mutex `AuditChainLock` + `FOR UPDATE`; InnoDB-Zeilensperren fallen erst beim COMMIT. Dazu `isolationLevel: ReadCommitted`, damit der Lesevorgang den frisch festgeschriebenen Stand sieht. **Belegt** im Direktvergleich mit kuenstlich geweitetem Fenster: Release-vor-Commit → Fork, Zeilensperre → kein Fork. - **R166-02 (MEDIUM) – Hash deckte nur 7 Felder.** `changesBefore/After` (die eigentliche Nutzlast), `success`, `ipAddress`, `resourceLabel`, `dataSubjectId`, `userId`/`customerId` waren NICHT gehasht – ein Einzeledit dort blieb unsichtbar. Fix: `hashVersion` (Migration `20260818150000`) + `generateHashV2` ueber alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash gueltig. `rehashAll` schreibt V2. Verifiziert: Manipulation an success/changesAfter/ipAddress/resourceLabel/ dataSubjectId wird jetzt **5/5 erkannt**, Altbestand weiter gueltig. - **R166-03 (LOW)** – „kein Cookie“ (normaler Erstbesuch) wurde als `HIGH / abgelehnt` gefuehrt. Jetzt eigener Ausgang: LOW + Label „ohne vorliegenden Token“. Nur echte Ablehnung bleibt HIGH. - **R166-04 (LOW, pre-existing)** – `GET /retention-policies` wurde von `GET /:id` verschluckt. Konkrete Routen jetzt konsequent vor der Parameter-Route, mit Warnhinweis im Code. - **Design-Empfehlungen umgesetzt:** `runRetentionCleanup` schreibt ein Loeschungs-Manifest (Bereich `fromId`–`toId`, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag – Luecken ausserhalb bleiben damit erklaerungsbeduerftig; `rehashAll` schreibt einen Marker, dass die Beweiskraft der Vergangenheit zurueckgesetzt wurde. - Verifiziert: 50 parallele Schreiber → 50/50, 0 Forks, alle V2; manipuliert 0, Luecken unveraendert 7. `tsc` + `vite build` gruen. - **Offen (bewusst nicht umgesetzt):** externer Anker (HMAC mit Schluessel ausserhalb der DB). Adressiert Full-DB-Compromise, erfordert aber Schluesselverwaltung/Rotation im Deployment → Entscheidung des Betreibers. - [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18) - **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig unterschiedliche Befunde in einen Topf und meldete beides als „N manipulierte Eintraege“. Eine harmlose Verkettungsluecke sah damit aus wie ein Angriff – die Meldung war im Alltag nicht deutbar und wurde dadurch wertlos (dasselbe Muster wie beim Refresh-Rauschen). - Fix: Rueckgabe um `tamperedEntries` (Inhalt einer Zeile nachtraeglich veraendert – **ernst**) und `chainGaps` (Verkettung unterbrochen durch parallele Schreibvorgaenge oder geloeschte Zeilen – **meist harmlos**) erweitert. `invalidEntries` bleibt als Summe erhalten (Abwaertskompatibilitaet). Controller formuliert die Meldung entsprechend eindeutig; Frontend-API-Typ nachgezogen. - **Problem 2 (Aufbewahrung):** Seit der Entrauschung landen Token-Refreshes als `Authentication / LOW`. Diese Kombination traf auf keine spezifische Regel und fiel in die Auffangregel `*` mit 3650 Tagen – das **Rauschen waere 10 Jahre** aufbewahrt worden, echte Logins nur 2 (730 Tage). - Fix: Regel `Authentication / LOW` → 90 Tage. Als Migration (`20260818130000`, idempotent per `ON DUPLICATE KEY`) **und** im Seed, damit sie sowohl bestehende Installationen als auch Neuinstallationen erreicht. - Hinweis zur Sensitivitaet: Sie steuert die Aufbewahrung, ist also **keine** Alarmstufe. Normale Logins/Logouts sowie Zugriffe auf Bankdaten/Ausweise bleiben bewusst CRITICAL. Ein Herabstufen „fuer eine ruhigere Liste“ wuerde still die Aufbewahrungsfrist verlaengern – daher unterlassen. - Verifiziert: Live-Test gegen Dev-DB – echte Manipulation einer Zeile (`UPDATE 
 SET userEmail`) wird als **manipuliert** erkannt und nicht mit Luecken verwechselt; Ketten-Luecken bleiben bei 7; Originalzustand exakt wiederhergestellt (0 manipuliert danach). `tsc` + `vite build` gruen. - [x] **🔗 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)** (2026-08-18) - `createAuditLog` las den Vorgaenger-Hash und schrieb den neuen Eintrag als zwei getrennte Schritte. Zwei parallele Requests lasen denselben letzten Hash und haengten sich beide daran → Kette zerrissen (echte Bruchstellen im Bestand: 05.05./07.05.2026). - Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen benannten MySQL-Lock (`GET_LOCK('opencrm_audit_chain')`). Liegt in der DB, wirkt daher auch ueber mehrere App-Instanzen hinweg. Release im `finally`, da benannte Locks nicht transaktional sind (sonst wandert die Sperre mit der Verbindung zurueck in den Pool und blockiert alle Schreiber). - **Verworfener erster Ansatz – wichtig:** `SELECT 
 FOR UPDATE` auf das Kettenende nimmt Gap-/Next-Key-Locks, die mit den gleichzeitigen INSERTs kollidieren. Gemessen: **38 von 40** parallelen Eintraegen gingen durch Deadlocks verloren (vom `catch` still verschluckt). Ein FEHLENDER Audit-Eintrag ist unsichtbar und damit gefaehrlicher als ein sichtbarer Kettenbruch – deshalb der Umbau auf den benannten Lock. - Verifiziert: 100 parallele Schreiber → **100/100 geschrieben, 0 neue Brueche** (444 ms); Folge-Schreiber danach in 6 ms, `IS_FREE_LOCK` = frei (kein Lock-Leak). Gesamtzahl ungueltiger Zeilen bleibt bei den 7 historischen. Rechenintensives (Serialisieren/Verschluesseln) liegt bewusst VOR der Transaktion, damit die Sperre kurz bleibt. `tsc` gruen. - [x] **đŸ›Ąïž Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben** (2026-08-18) - Beim Nachpruefen aufgefallen: `verifyIntegrity` meldete **3107 von 4630** Zeilen als „manipuliert“. Davon waren **3100 Fehlalarme** – eingegrenzt auf exakt die Zeilen mit `resourceId = NULL` aus dem Zeitraum 08.02.–01.05.2026. - Ursache: Der R121-Fix nahm an, `resourceId` sei beim Schreiben immer `undefined` gewesen (Key faellt bei `JSON.stringify` weg) und daher wuerden **alle** Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 – aeltere Zeilen wurden mit explizitem `null` serialisiert, der Key war DRIN. - Warum das sicherheitsrelevant ist: Ein Alarm, der staendig grundlos ausloest, wird ignoriert – **echte** Manipulation ginge im LĂ€rm unter (gleiches Muster wie beim Refresh-Rauschen, [R165]). - Fix: `generateHashLegacy()` reproduziert das alte Schreibverhalten; `verifyIntegrity` akzeptiert Altbestand ueber diesen Fallback (nur geprueft, wenn die aktuelle Variante nicht passt). **Kein Rehash** – der waere der naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der Vergangenheit aber unwiederbringlich zerstoeren. Gespeicherte Hashes bleiben unangetastet. - Verifiziert: ungueltige Zeilen **3107 → 7** (die 7 sind echte Ketten-Brueche, siehe naechster Punkt). Adversarial gegengetestet: Manipulation an `userEmail`/`action`/`endpoint`/`createdAt`/`resourceId` wird bei ALTEN wie NEUEN Zeilen weiterhin zu 100 % erkannt (10/10), unveraenderte Zeilen akzeptiert. `tsc` gruen. - [x] **🐛 Audit-Log: Pfad-Matching kaputt – Auth-Actions generisch (Pentest R165-01)** (2026-08-18) - Pentester meldete: Entrauschung (`de0d6bd`) live **nicht wirksam** – jeder `/refresh` weiter `CREATE / CRITICAL / „Anmeldung erstellt“`. Zusatzbefund: auch `/login` und `/logout` liefen als generisches `CREATE`. - **Kein Deploy-Miss** (Alerting aus `d599eb3` lief ja live), sondern **toter Code**: `auditMiddleware` liest `req.path` erst im `res.on('finish')`-Handler. Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url` und stellt ihn nur beim `next()`-Durchlauf wieder her – ein terminaler Handler (`res.json()`) ruft nie `next()`, also bleibt `req.path` router-relativ (`/refresh` statt `/api/auth/refresh`). Alle `path.includes('/auth/...')`-Checks liefen ins Leere → Fallback POST→CREATE + Default-SensitivitĂ€t CRITICAL. Empirisch nachgestellt (Mini-Express: ENTRY `/api/auth/refresh` → FINISH `/refresh`). - Betraf **nicht nur** den neuen `TOKEN_REFRESH`: `LOGIN`/`LOGOUT`/`LOGIN_FAILED` waren im Audit-Stream **seit jeher** kaputt (pre-existing), ebenso das `endpoint`-Feld (router-relativ statt voll). Der SecurityEvent-Stream war nie betroffen (eigene `emit()`-Calls) – daher funktionierte das Alerting korrekt. - Fix: vollen Pfad **einmal synchron beim Eintritt** festhalten (`req.originalUrl.split('?')[0]`, wird von Express nie mutiert) und downstream ausschließlich diesen nutzen – in `determineAction`, `generateHumanLabel`, `extractDataSubjectId`, `manuallyLoggedPaths` und `endpoint`. `TOKEN_REFRESH` zusĂ€tzlich in die „immer loggen“-Ausnahme aufgenommen. - Verifiziert (E2E mit echter Middleware gegen Dev-DB, 5 Requests): `TOKEN_REFRESH/LOW` (Erfolg), `TOKEN_REFRESH/HIGH` (Fehlschlag), `LOGIN/CRITICAL`, `LOGIN_FAILED/CRITICAL`, `LOGOUT/CRITICAL`, alle mit vollem `endpoint`-Pfad und korrekten Labels. `tsc` grĂŒn. - [x] **đŸ›Ąïž Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)** (2026-08-18) - Folgefund zum Entrauschen: `determineAction` gab `/auth/refresh` bedingungslos `TOKEN_REFRESH`/LOW → ein **fehlgeschlagener** Refresh (Replay/Brute-Force auf geraubte/geratene Refresh-Tokens) rutschte als LOW durch und entging der Alarmierung (Angreifer weicht von `/login` auf `/refresh` aus, um unter CRITICAL zu bleiben). - **Wichtig:** Audit-Actions speisen die Alert-Engine NICHT (die zĂ€hlt `SecurityEvent`-Zeilen via `emit()`). Der Tester-Minimalvorschlag (Action → `LOGIN_FAILED`) hĂ€tte also keinen Alert ausgelöst. Echter Fix an 2 Ebenen: - **Detection:** `refresh()`-Catch emittiert jetzt `TOKEN_REJECTED` → greift die bestehende Schwelle `≄3 TOKEN_REJECTED/5min/IP → CRITICAL` (securityAlert.service, kein Severity-Filter). Severity wie Access-Token: abgelaufen/revoked → LOW (benigne, kein Sofort-Alert), ungĂŒltige Signatur/Manipulation → HIGH (Sofort-Alert). `auth.service` reicht dafĂŒr `err.code` REFRESH_EXPIRED/REFRESH_INVALID durch. „Kein Cookie" emittiert NICHT (normaler Erstbesuch). - **Audit-Triage:** fehlgeschlagener Refresh → SensitivitĂ€t HIGH statt LOW + Label „Token-Refresh abgelehnt (ungĂŒltig/abgelaufen)". Action bleibt bewusst `TOKEN_REFRESH` (semantisch ein Refresh, kein Login). - Verifiziert: tsx-Test — abgelaufen→LOW, manipuliert/garbage→HIGH; `tsc` grĂŒn. - Nebenbefund R164-02 (pre-existing, kein Commit von uns): Refresh-Rotation bietet keinen Replay-Schutz (alter Token bis exp gĂŒltig), aber fail-closed nach Logout. Ggf. spĂ€ter: Refresh-Token-Jti-Blacklist / One-Time-Use. - [x] **🔇 Audit-Log: stiller Token-Refresh entrauscht (`TOKEN_REFRESH`)** (2026-08-18) - Automatische `POST /auth/refresh`-Aufrufe (Interceptor bei 401 / nach Seiten-Reload, da Access-Token nur im Speicher) wurden als `CREATE` / „Anmeldung erstellt" / **CRITICAL** / User `anonymous` geloggt → sah aus wie anonyme Login-Flut, war aber die eigene Session. - Neuer `AuditAction`-Wert **`TOKEN_REFRESH`** (Enum-Migration `20260818120000_audit_token_refresh_action`, idempotentes `MODIFY COLUMN`). `determineAction()` erkennt `/auth/refresh` → eigene Action; Label „Sitzung verlĂ€ngert (Token erneuert)"; SensitivitĂ€t in der Middleware explizit auf **LOW** (statt Default `Authentication → CRITICAL`). - Frontend `AuditLogs.tsx`: Filter-Option „Sitzung verlĂ€ngert" + dezente Badge-Farbe (slate); Typ-Union ergĂ€nzt. `anonymous` bleibt (Endpoint lĂ€uft ohne `authenticate`-Middleware, authentifiziert per Cookie im Service) – bewusst nicht geĂ€ndert (Option 1). - Verifiziert: Migration auf Dev-DB aktiv, `tsc` + `vite build` grĂŒn. - [x] **🔒 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)** (2026-08-18) - `createContract`/`updateContract` spreadeten `energyDetails`/`tvDetails`/ `carInsuranceDetails`/`mobileDetails` (via `...mobileData`) roh an Prisma → injizierte `id`/`contractId` konnten ein Detail-Objekt **reparenten** (auf Fremdvertrag umhĂ€ngen) oder den **PK frei setzen** (stilles 200 statt 400). MEDIUM (IntegritĂ€t; kein Cross-Tenant, staff-only, Portal 403). - **Fix:** Feld-Whitelists (`pickEnergyScalars`/`pickMobileScalars`/`pickTvScalars`/ `pickCarInsuranceScalars`, analog R158) an allen Spread-Stellen. `internet` war bereits explizit (safe). Whitelists programmatisch gegen die DB-Spalten abgeglichen (minus id/contractId/verschlĂŒsselt) – alle Diffs leer. - Verifiziert: `energyDetails:{basePrice:99.99, id:999999, contractId:fremd}` → basePrice aktualisiert, ecd.id + contractId **unverĂ€ndert** (kein Reparenting). - [x] **📋 Aufgaben ohne Kunde/Vertrag anlegbar** (2026-08-18) - `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint `POST /tasks` (staff-only, `contracts:update`) fĂŒr allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag → **kein Kunde → nie im Portal sichtbar** (`visibleInPortal` serverseitig erzwungen false; Portal-Reply-Endpoint 403 bei contractloser Aufgabe; getAllTasks-Portal-Filter schließt sie automatisch aus). - Task-Modal (Mitarbeiter): Checkbox „Ohne Kunde (allgemeine Aufgabe)" – blendet Kunden-/Vertragsauswahl **und** „Im Kundenportal sichtbar" aus. Task-Liste zeigt solche Aufgaben als „Allgemeine Aufgabe (ohne Vertrag)" (kein Vertrags-Link). - [x] **📧 Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain** (2026-08-18) - Private Kunden-E-Mail (`Customer.email`) darf nicht auf einer bei den E-Mail- Providern konfigurierten Domain (oder Subdomain) liegen → man trĂ€gt so keine verwaltete Weiterleitungs-/Mailbox-Adresse als private Adresse ein. E-Mail ist **nur beim Anlegen** Pflicht (Bestandskunden ohne E-Mail bleiben editierbar); die Domain-PrĂŒfung greift aber bei create UND update, falls eine gesetzt wird. - Helper `getConfiguredEmailDomains`/`emailUsesDomain` im emailProvider-Service. - [x] **⚡ Energievertrag: Ankreuzfeld „Keine Bonis erwĂŒnscht"** (2026-08-18) - `EnergyContractDetails.noBonusDesired` (Boolean, Migration `20260818100000`). Checkbox im Vertragsformular (Strom/Gas), Anzeige im Vertragsdetail. - [x] **🔌 MaLo-ID (Marktlokation) an die Lieferadresse verschoben (Strom/Gas)** (2026-08-14) - MaLo-ID gehört zur **(Liefer-)Adresse**, nicht zum Vertrag. Adresse bekommt **zwei Felder**: `maloIdElectricity` (Strom) + `maloIdGas` (Gas) – im AddressModal (nur Lieferadresse) pflegbar. - **Im Vertrag** ist die MaLo-ID jetzt ein **Lesefeld**, das je nach Sparte (ELECTRICITY→Strom, GAS→Gas) die MaLo der gewĂ€hlten Lieferadresse zeigt (mit Copy + „dort pflegen"-Link). ContractDetail/-Modal zeigen sie ebenso aus der Adresse. - **Schema + Migration** `20260814100000_address_malo_ids`: 2 Spalten (idempotent) **+ Daten-Migration** (bestehende `EnergyContractDetails.maloId` → jeweilige Lieferadresse, Strom→maloIdElectricity / Gas→maloIdGas). Verifiziert. - **Nebenbei einen selbst verursachten Regressions-Bug gefixt:** Beim R156- Mass-Assignment-Umbau waren die **10 `owner*`-Adressfelder** aus der Address- Whitelist gefallen → die EigentĂŒmer-Sektion speicherte seit `cb21a2c` nicht mehr. Address-Whitelist jetzt per Pick-Helper **programmatisch gegen alle DB-Spalten** abgeglichen (owner* + MaLo drin, id/customerId/Timestamps raus). - [x] **🔒 Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene)** (2026-08-13) - Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen `...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt – konsistent zur R156-HĂ€rtung (BankCard/Address/Document). `id`/`contractNumber`/`createdAt`/ `updatedAt`/`portalPasswordEncrypted` und alle `cancellation*Path`-Felder sind damit **nicht** mehr per Form-Update setzbar (Pfade nur noch ĂŒber die Upload-Endpunkte). - **Whitelist autoritativ aus den DB-Spalten** abgeleitet (nicht aus dem unvollstĂ€ndigen `ContractCreateData`-Typ!) – dabei fielen 4 echte, vom Formular gesendete Felder auf, die NICHT im Typ standen und sonst still gebrochen wĂ€ren: `previousProviderId`, `previousContractNumber`, `previousCustomerNumber`, `nextReviewDate`. - Verifiziert: legit Felder (inkl. der 4) persistieren; injizierte `id`/`contractNumber`/`cancellationLetterPath` werden ignoriert. - [x] **đŸ—‚ïž Neuer Vertragsstatus „GekĂŒndigt / bestĂ€tigt" + KĂŒndigungs-Workflow** (2026-08-13) - Bisheriges **„GekĂŒndigt"** umbenannt in **„GekĂŒndigt / BestĂ€tigung abwarten"** (Status `CANCELLED`) – wird jetzt automatisch gesetzt, sobald ein **KĂŒndigungsschreiben** (`cancellationLetterPath`) hochgeladen wird (aus ACTIVE/PENDING/ONGOING/EXPIRED; nie DRAFT/DEACTIVATED/bereits bestĂ€tigt). - **Neuer Status „GekĂŒndigt / bestĂ€tigt"** (`CANCELLED_CONFIRMED`) – automatisch, sobald ein **KĂŒndigungsbestĂ€tigungsdatum** vorliegt (per BestĂ€tigungsdokument, das das Datum fĂŒllt, ODER manuell) + Vertragsende = KĂŒndigungsdatum. Hebt auch aus „BestĂ€tigung abwarten" hoch. - **Schema:** Enum-Wert `CANCELLED_CONFIRMED`, Migration `20260813200000_contract_status_cancelled_confirmed` (idempotentes `MODIFY COLUMN`). **Daten-Migration:** bestehende `CANCELLED` (unter alter Logik nur bei vorhandener BestĂ€tigung gesetzt) → `CANCELLED_CONFIRMED`. - **Cockpit-Semantik mitgewandert:** Fristen-Skip + „beendet" (Schlussrechnung) gelten jetzt fĂŒr `CANCELLED_CONFIRMED` (nicht mehr das reine „abwarten"); „KĂŒndigungsbestĂ€tigung fehlt"-Warnung greift dadurch weiter fĂŒr CANCELLED. `CANCELLED_CONFIRMED` in Ladeliste + KĂŒndigungsbestĂ€tigungs-Filter aufgenommen. - **Frontend:** Labels/Farben/Status-ErklĂ€rungen + Status-Dropdown in ContractList, ContractDetail, ContractForm, ContractDetailModal, CustomerDetail (CANCELLED = orange „abwarten", CANCELLED_CONFIRMED = rot). Verifiziert: Schreiben→CANCELLED, BestĂ€tigung→CANCELLED_CONFIRMED+Enddatum. - [x] **🔒 Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)** (2026-08-13) - Controller reichten rohen `req.body` an Prisma durch → `customerId` (Owner) und `id` (PK) waren per Update mutierbar (staff-only, kein Cross-Tenant, aber IntegritĂ€tsschwĂ€che – und mit `cardNumber` liegt Finanz-PII drauf). - **Fix:** explizite Feld-Whitelist im Service (create+update) fĂŒr **BankCard, Address, IdentityDocument** – nur benannte Felder gehen an Prisma, kein `...data`/`req.body`-Spread mehr. ZusĂ€tzlich Controller-`pickBankCardFields` fĂŒr saubere Audit-Logs (keine Phantom-EintrĂ€ge injizierter Keys). - Verifiziert: Update mit `{customerId:99999, id:88888, bogusField, cardNumber}` → id+customerId **unverĂ€ndert**, nur cardNumber gesetzt, Fremdfelder ignoriert. - [x] **đŸȘȘ Bankkarte-/Ausweis-Details in Vertrag (Ansicht + Bearbeiten) + Kartennummer** (2026-08-13) - **Schema:** neues Feld `BankCard.cardNumber` (String?, optional). Migration `20260813100000_bank_card_number` (`ADD COLUMN IF NOT EXISTS`), auf Dev angewandt + `prisma generate`. Prod zieht via `migrate deploy` im Entrypoint. Eingabefeld „Kartennummer" im Bankkarten-Modal (Kundenakte) ergĂ€nzt. - **Vertragsansicht (ContractDetail)** – Karten „Bankkarte"/„Ausweis" zeigen zusĂ€tzlich (jeweils mit Copy-Button, nur wenn gesetzt): - Bankkarte: BIC, Bank, Kartennummer, Ablaufdatum - Ausweis: Behörde, Ausstellung, Ablaufdatum + **Geburtsort/Geburtsdatum vom Kunden** - **Vertrag bearbeiten (ContractForm)** – unter den Bankkarte-/Ausweis- Dropdowns dieselben Detailfelder der aktuell gewĂ€hlten Karte/Ausweis (Copy-Buttons). Selects dafĂŒr je in eigenem `
` gewrappt (Grid-Alignment). - Datenquelle war bereits vorhanden: `getContractById` (bankCard/identityDocument/ customer) + `getCustomerById` (bankCards/identityDocuments) liefern alle Felder. - [x] **📄 PDF-Viewer-Modal fĂŒr Bankkarte-/Ausweis-Dokument** (2026-08-13) - Neue wiederverwendbare Komponente `PdfViewerModal` (Modal + `