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 koennen. Dieselbe Klasse wie R184-02: falsche Domaene,
zu breit gebuendelt.
Der naheliegende Fix waere falsch gewesen. audit:admin einfach aus der
DSGVO-Rolle zu streichen haette es heimatlos gemacht: Die Admin-Rolle ist
ausdruecklich ohne audit/gdpr gebaut, einzige verbleibende Quelle waere
der Entwicklerzugriff - der alles gibt. Prod versiegeln haette dann
Vollzugriff vorausgesetzt.
Deshalb eine eigene versteckte Rolle "Audit-Betrieb" (audit:read +
audit:admin), zugewiesen ueber eine Checkbox wie DSGVO/Entwickler. DSGVO
behaelt audit:read + audit:export + gdpr:*. Fuer kein bestehendes Konto
weitet sich etwas aus; es wird enger, und wer eingreifen koennen soll,
bekommt es ausdruecklich.
Keine zusaetzliche Rechte-Huerde davor, weil das am Henne-Ei-Problem
scheitert: Nach der Aufteilung haelt zunaechst niemand audit:admin,
koennte ihn also auch niemand vergeben. Stattdessen wird die Vergabe
laut - CRITICAL im Protokoll und PERMISSION_CHANGED/CRITICAL im
Alarmkanal, samt 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 Buendelung stillschweigend zurueckgebracht haette.
ACHTUNG beim Deploy: Bestehende DSGVO-Konten verlieren audit:admin. Wer
Prod versiegeln will, muss sich vorher "Audit-Betrieb" ankreuzen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand
vorfand, ist rechnerisch tadellos und schuetzt nichts. Gemeldet wurde
trotzdem "intakt" - formal richtig, aber es liest sich als
Schutzzusage. Der Pentester hat Stagings Leersiegel genau so
missverstanden und hielt es fuer zahnhaltig.
Das ist der rote Faden im Kleinen: ein Signal, das beruhigt, wo nichts
abgesichert ist. Deshalb ein eigener Zustand "leer" mit eigenem Text -
gewertet wie "nicht_noetig", kippt `valid` also nicht, sagt aber auch
nichts zu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bei der Gegenprobe zur neuen Rolle "Gegenbuch" gefunden: Ein Konto mit
ausschliesslich audit:read bekam auf GET /audit-logs/export eine 200. Die
Berechtigung audit:export stand im Katalog und in der Rollenverwaltung -
und wurde nirgends geprueft.
Der Unterschied ist nicht kosmetisch. Blaettern zeigt 50 Zeilen; der
Export liefert in einem Zug das gesamte Protokoll inklusive changesBefore
und changesAfter, also der vollstaendigen Vorher/Nachher-Datensaetze,
dazu resourceLabel mit Klartextnamen, IP-Adressen und User-Agents. Live
nachgewiesen auf Staging: 43 Eintraege mit gefuellter resourceLabel
allein fuer resourceType=Customer.
Damit konnte ausgerechnet das Dienstkonto des Gegenbuchs Personendaten
exportieren - das Konto, dessen Passwort im Klartext in der .env auf der
Notar-Maschine liegt, und dem README und Rollenname "nur Pruefwerte
lesen" zusichern.
/audit-logs/export verlangt jetzt audit:export. Betroffen ist genau eine
Rolle: Gegenbuch, und zwar gewollt. Die DSGVO-Rolle traegt audit:*
vollstaendig und behaelt den Export.
In der Oberflaeche erscheinen JSON- und CSV-Knopf nur noch mit
audit:export - sonst stuenden dort Knoepfe, die zuverlaessig 403 liefern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Beim Selbst-Nachpruefen eines Deploys auf Staging aufgefallen: Das
Gegenbuch-Dienstkonto meldete beim Login audit:read, audit:export,
audit:admin, gdpr:export, gdpr:delete und gdpr:admin. Es braucht genau
eines davon - audit:read -, denn es ruft nur /audit-logs/checkpoint und
/audit-logs/verify auf.
Das war kein Bedienfehler, sondern 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 und 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
haette ein Einbruch dort nicht nur den Waechter gehabt, sondern gleich
die Mittel zur Waesche aus R185-01 - und damit genau die Trennung
aufgehoben, wegen der das Gegenbuch auf einer eigenen Maschine laeuft.
Neue Rolle "Gegenbuch" in sync-roles.ts mit ausschliesslich audit:read.
sync-roles laeuft beim Containerstart mit, die Rolle erscheint danach in
der Benutzerverwaltung. README des Gegenbuchs umgeschrieben: Rolle statt
"selbst anlegen", ausdrueckliche Warnung vor dem DSGVO-Haken, dazu eine
Gegenprobe (checkpoint -> 200, seal-backlog -> 403).
Bewusst NICHT angefasst: dass die DSGVO-Rolle selbst audit:admin traegt,
ist ein Gewaltenteilungs-Problem - wer das Protokoll beaufsichtigt, kann
seine Beweisgrundlage ersetzen. Das zu aendern entzieht bestehenden
DSGVO-Konten Rechte und gehoert entschieden, nicht nebenbei gemacht.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Der Tester fand: GET /audit-logs/export?userId=… filterte nicht.
userId=999999 gab alle 2761 Datensaetze zurueck, byte-identisch zum
ungefilterten Export. HTTP 200, sah korrekt aus.
Die Ursache war breiter als der Befund. Nicht userId allein fehlte: der
Export-Controller pflegte eine eigene, kuerzere Filterliste und verwarf
still userId, customerId, dataSubjectId, resourceId, success UND search.
Der Service konnte alle sechs - sie kamen nie bei ihm an. Dieselbe Luecke
ein drittes Mal in der Oberflaeche: der CSV-Knopf baute seine Parameter
nochmal von Hand, mit wieder anderen fuenf 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" fuer eine DSGVO-Auskunft oder eine
Innentaeter-Pruefung - gab das vollstaendige Protokoll aller Nutzer
heraus, mit einem beruhigenden 200. Auf einem datenminimierungs-
pflichtigen Pfad ist das eine Weitergabe, kein Schoenheitsfehler.
Der Fix ist strukturell: ein gemeinsamer leseFilter(req) fuer Liste und
Export, und die Oberflaeche schickt alle aktiven Filter statt einer
handgepflegten Auswahl. Drei Listen, die dasselbe bedeuten sollen, laufen
frueher oder spaeter auseinander; jetzt gibt es nur noch eine.
Geprueft ueber HTTP gegen eine Wegwerf-DB: Export und Liste liefern fuer
userId, action, search, success und resourceType identische
Treffermengen; auf dem Export-Pfad gilt jetzt dieselbe Validierung
(userId=abc -> 400 statt 200). CSV-Pfad gegengeprueft.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
R185-01 (MEDIUM): Die Flanke, die wir selbst gemeldet hatten, hat der
Tester live bestaetigt. seal-backlog war beim ZWEITEN Aufruf genauso
gegatet wie beim ersten ({"confirm":"SEAL"} -> 200), und das Ereignis
landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
automatische Rueckhalt des Gegenbuchs haengt an `valid` - und `valid`
ueberlebt ein ersetzendes Siegel per Konstruktion. Angriff: Altzeile per
DB-Zugriff loeschen, neu siegeln, Luecke ist beglaubigt, valid wieder
true. Live reproduziert.
Zwei Schichten, in seiner Reihenfolge:
1. Alarmkanal. Neuer SecurityEventType AUDIT_SEAL_CHANGED (Migration
20260826120000). Erstes Siegeln HIGH, Ersetzen CRITICAL - geht damit
ueber sendPendingCriticalAlerts sofort per Mail raus. Die Details
halten Wurzel vorher/nachher und den vollstaendigen Vorbefund fest.
2. Gate. Steht bereits ein Siegel, verlangt der Endpunkt
{"confirm":"RESEAL"} statt SEAL, mit einem Text, der sagt, was dabei
verloren geht. Ein Austausch der Beweisgrundlage soll nicht dasselbe
Wort haben wie das Einrichten.
Und im Gegenbuch selbst: dort stand fuer den Wurzelwechsel ein
console.warn, waehrend der Rueckgabecode auf 0 blieb - also exakt das
Muster, das wir dem CRM zweimal angekreidet haben (R179, R183-02), im
Werkzeug, das dagegen gebaut wurde. Jetzt exit 2, mit alter und neuer
Wurzel samt Blattzahl; "10 Blaetter -> 9 Blaetter" zeigt die Loeschung
sofort. Auch die Erstsiegelung meldet sich, statt stillschweigend
uebernommen zu werden.
Aufloesbar gemacht: der Alarm bricht ab, BEVOR angehaengt wird - ohne
Bestaetigungsweg haette auch ein legitimes Siegeln fuer immer alarmiert
(R183-03-Falle). Neu ist NOTARY_SEAL_ACK, bewusst nicht "true", sondern
die Wurzel selbst (mind. 16 Zeichen): ein stehen gelassener Wert passt
beim naechsten Wechsel nicht mehr und kann keinen weiteren Austausch
durchwinken.
R185-02 (LOW): GET /api/audit-logs?action=<x> gab ungueltige Enum-Werte
roh an die Spalte -> 500. Zweifach schlecht: fehlende Validierung und
Fehler-Orakel (200 vs 500 verraet die Enum-Mitglieder). Jetzt 400 mit
der erlaubten Menge im Klartext. Mitgenommen: sensitivity, Datumsfelder,
Zahlenfelder, Textlaengen und ein Deckel auf limit (200), ueber den sich
sonst die ganze Tabelle an der Seitenlogik vorbei ziehen liess. Beide
Endpunkte.
Getestet ueber HTTP gegen eine Wegwerf-DB, inkl. echtem Gegenbuch-Lauf
mit SSH-signiertem lokalem Repo. Zusaetzlich nachgeholt, was der Tester
nicht herstellen konnte: vollstaendig unsigniertes Protokoll ->
kein_siegel statt der frueheren falschen Entwarnung nicht_noetig, und
seal-backlog nennt den fehlenden Schluessel als naechsten Schritt.
Gegenrichtung geprueft, R183-03 bleibt behoben.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Das Gegenbuch auf Prod meldete stuendlich exit=2, weil die CRM-Pruefung
wegen 6 struktureller Luecken valid:false lieferte (IDs 33, 44, 45, 922,
1434, 2583).
Diagnose: harmlos. Jede Luecke liegt innerhalb eines Schwungs von
Eintraegen mit identischer Sekunde, betrifft nur /login und /refresh,
kein Eintrag fehlt, tamperedEntries ist leer. Das ist die Signatur der
Race-Condition, die am 19.08. mit AuditChainLock geschlossen wurde
(R166-01). Die Zeilen datieren ihren eigenen Code: Eintrag 2583 stammt
vom 21.08., ist aber noch hashVersion=1.
Das eigentliche Problem war nicht die Luecke, sondern der Dauer-Alarm.
Diese Luecken sind nicht heilbar - die Verkettung ist gebrochen, die
Inhalte sind unversehrt. Ohne Aenderung haette das Gegenbuch fuer immer
Alarm gemeldet, und ein Signal, das immer schreit, warnt nicht mehr.
Dieselbe Klasse wie R162, R174, R179, R182, R183-02.
Loesung: Beglaubigung statt Unterdrueckung. Eine Luecke zaehlt nicht mehr
als offener Befund, wenn sie im versiegelten Bereich liegt UND im
Vorbefund des Siegel-Markers steht - also im Zustand, den der Betreiber
beim Siegeln ausdruecklich festgeschrieben hat. Der Vorbefund liegt in
changesBefore und ist ab Version 3 mitgehasht; ohne AUDIT_HMAC_KEY laesst
sich die Liste nicht nachtraeglich erweitern (gleiche Absicherung wie
beim Loeschungs-Manifest, R171-01). Beglaubigt heisst nicht verschwunden:
die Luecken bleiben in chainGaps, stehen zusaetzlich in attestedGaps und
werden im Bericht ausdruecklich benannt.
Nebenbefund derselben Klasse mitbehoben: backlogSealStatus meldete
"nicht_noetig" ("Es gibt keine unsignierten Alteintraege"), wenn
v3FromId === null - das bedeutet aber das Gegenteil, naemlich dass
ueberhaupt nichts signiert ist, etwa weil AUDIT_HMAC_KEY fehlt. Ein
vollstaendig unsigniertes Log bekam damit Entwarnung fuer genau den
Zustand mit der geringsten Beweiskraft. Die Bedingung haengt jetzt an der
Zahl der unsignierten Zeilen; R183-03 bleibt behoben (Regressionstest).
Getestet ueber 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 Luecke nach dem Siegeln -> valid:false; gesiegelte
Altzeile veraendert -> Siegel gebrochen, nichts mehr beglaubigt;
Beglaubigungsliste im Marker gefaelscht (DB-Schreibrecht, kein
Schluessel) -> Marker ungueltig, Status entfernt, valid:false.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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; die Hypothese des Pentesters war richtig,
Un-Flaggen kappt die Wache. Genau das stilllegen-und-auf-Stille-setzen, gegen
das der Tripwire gebaut wurde. Fix: Gate in beide Richtungen mit eigenem
Wortlaut beim Entfernen.
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.
R184-02: Das Kennzeichen hing an users:update, 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.
Verifiziert: Entfernen ohne Bestaetigung jetzt 400 statt 200, ohne audit:admin
403, zwei CRITICAL-Meldungen im Alarmkanal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mit dem Scharfschalten des Feldes wurde ein alarm-senkendes Attribut 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.
Alle drei Sorgen des Pentesters 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; CRITICAL statt MEDIUM mit eigenem Label und Wer/Vorher/Nachher;
kein Selbstbedienen (403 am eigenen Konto, muss ein anderer Administrator
vornehmen). Das Frontend sendet die Bestaetigung mit - der Haken im Formular
ist die Bestaetigung.
Verifiziert ueber den echten Controller: ohne Bestaetigung 400, mit 200, am
eigenen Konto 403, Protokolleintrag CRITICAL mit sprechendem Label.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nachgezogen: Das Feld isServiceAccount lag 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 mit Erklaerung im
Klartext, Feld in der Mass-Assignment-Whitelist (gilt 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.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
R183-01: PUT /retention-policies/{id} nahm retentionDays 0 ohne Bestaetigung an
und protokollierte es als MEDIUM - waehrend cleanup, seal und rehash alle ein
Confirm-Gate haben und die Folge CRITICAL ist. Die geladene Waffe war ungegatet,
der Abzug gegatet. Fix: Absenken verlangt confirm SHORTEN, wird als CRITICAL mit
Vorher/Nachher protokolliert, Untergrenze 30 Tage fuer Authentication/AuditLog.
R183-02: Nach dem Cleanup meldete verify "Keine Manipulation" bei valid:false
und 3010 endgueltig geloeschten Anmeldeprotokollen - der Befund stand nur im
Feld, die Prosa beruhigte. Fix: ehrliche Formulierung bei Luecken, und das
Gegenbuch ruft /verify mit und wertet valid:false hart, egal wie der Text
klingt.
R183-03: verify warnte dauerhaft "Altbestand nicht versiegelt", waehrend
seal-backlog zu Recht ablehnte. Eine unaufloesbare Warnung lernt man zu
ignorieren. Fix: eigener Zustand nicht_noetig, echte Warnung nennt den Befehl.
Heartbeat-Wachhund: Bleibt ein Dienstkonto laenger still als
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES (Standard 180), gibt es SUSPICIOUS/CRITICAL.
Ohne je gesehene Anmeldung wird geschwiegen statt geraten, pro Ausfall genau
eine Meldung. Verifiziert in allen drei Faellen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Das Gegenbuch meldet sich stuendlich an - protokolliert wurde das als
Authentication/CRITICAL. Damit stand das vorhersagbarste Ereignis im System auf
der hoechsten Stufe: Jeder legitime Lauf trainiert den Betreiber darauf,
CRITICAL wegzuklicken, und der erste echte Vorfall erbt diesen Reflex. Dieselbe
Alarm-Muedigkeits-Klasse wie R162, diesmal von uns selbst erzeugt.
Nicht unterdrueckt, sondern eingestuft: Neues Kennzeichen isServiceAccount am
Benutzer (Migration 20260822100000). Anmeldungen solcher Konten erscheinen
weiterhin im Log - man soll sehen, dass das Gegenbuch arbeitet - aber als
Routine (LOW) mit eigenem Label 'Dienstkonto ... angemeldet (planmaessig)'.
Alle anderen Anmeldungen bleiben CRITICAL.
Offen und dem Pentester so benannt: Der eigentliche Tripwire waere der
AUSBLEIBENDE Heartbeat (Dienstkonto meldet sich N Intervalle nicht mehr) sowie
Anmeldungen ausserhalb der Kadenz oder von fremder Quell-IP. Das braucht einen
Zeitgeber und ist noch nicht gebaut.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
R175-01 (HIGH): Die erste Fassung signierte zwar, prueft aber nie. Sie las ihre
Wahrheit per readFileSync aus der lokalen Arbeitsdatei, nirgends 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.
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.
R175-02 (MEDIUM): /checkpoint fuhr je Aufruf ein volles verifyIntegrity (O(n),
0,85 s bei 16k Zeilen) - authentifizierte DoS-Verstaerkung. Gebraucht wurde nur
die Siegel-Wurzel. Jetzt Kopf-Hash aus der Kopfzeile, Wurzel aus dem juengsten
gueltigen Marker, sealLeafCount statt des teuren Status.
R175-03: writeFileSync lief vor dem Commit, eine verwaiste Zeile wurde vom
Folgelauf zementiert. Jetzt Ruecknahme bei Fehlschlag, und NOTARY_SIGN=false
verlangt zusaetzlich NOTARY_INSECURE_ACK.
Verifiziert mit echtem SSH-Signaturschluessel: stilles Waschen -> Alarm;
erfundene Zeile -> Alarm; unsignierter Commit -> Alarm; Commit-Fehlschlag ->
zurueckgerollt; Reflex-Schalter verweigert; Pruefmodus haengt nichts an.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
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, zeitgestempelt 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.
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.
Ehrlich dokumentiert: 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.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
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 und "Alle Eintraege
unveraendert und lueckenlos verkettet", also null Spur. Meine Antwort auf die
Frage des Pentesters war damit falsch: die Luecke reisst nur, solange der
Marker nicht am Ende steht.
Fix: Gegen-Check "Blaetter vorhanden, aber kein gueltiger Marker" -> neuer
Status entfernt statt kein_siegel, mit ausdruecklicher Meldung. Ein gebrochenes
oder entferntes Siegel kippt jetzt valid auf false, auch ohne beanstandete
Einzelzeile. Der beruhigende Einstiegssatz entfaellt bei Siegelproblemen.
Verifiziert ueber den echten HTTP-Pfad, exakt Szenario 4: Marker nachweislich
das Ketten-Ende, Marker + Middleware-Decoy geloescht, 0 Ketten-Luecken ->
valid:false, Status entfernt, Klartext-Warnung. Auch der Nicht-Tail-Fall
geprueft. Wegwerf-Datenbanken danach geloescht, Dev unberuehrt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
R173-01 (HIGH): Das Siegel war ueber HTTP tot. Die generische auditMiddleware
protokolliert den POST /seal-backlog-Request selbst als AuditLog/CREATE mit
demselben endpoint - exakt die Signatur, mit der der Marker gesucht wurde, nur
mit hoeherer id und ohne changesAfter. Der Selektor griff diese Zeile, root war
undefined, Ergebnis: dauerhaft "gebrochen" bei null manipulierten Zeilen.
Mein Testfehler: sealBacklog()/verifyIntegrity() direkt aufgerufen, nie ueber
HTTP - die Middleware lief nie mit. Dieselbe Fehlerklasse wie R165.
Fix: eigener Ressourcentyp AuditBacklogSeal, den die Middleware nie vergibt,
zusaetzlich muss der Marker auswertbares {toId, root} tragen.
R173-02 (MEDIUM): Fehlende Zeilen wurden uebersprungen, das Loeschen eines
gesiegelten Einbruchsbelegs erschien nur als unerklaerte Luecke, waehrend der
Indikator "intakt" meldete. Fix: fehlende gesiegelte Zeilen sind ein
Siegelbruch mit eigener Liste (backlogMissing) und werden namentlich gemeldet.
R173-03 (MEDIUM): Die R170-01-Haertung (Vorbefund im Marker) war auf
seal-backlog nie angewandt, ein Re-Seal absorbierte Manipulationen mit weniger
Spur als ein Rehash. Fix: Der Marker haelt den Befund vor dem Siegeln fest
samt Status und Wurzel des vorherigen Siegels; verify weist die Anzahl
gueltiger Siegel aus und warnt bei mehr als einem.
Verifiziert ueber den echten HTTP-Pfad inkl. Middleware (separate Wegwerf-DB,
Mischbestand 6xV1/4xV2/4xV3): Protokollzeile vorhanden, Status trotzdem
intakt; Loeschen einer gesiegelten Zeile -> gebrochen mit backlogMissing[2];
erneutes Siegeln -> Anzahl 2 gemeldet, Vorbefund im Marker. tsc + vite build
gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 umschreiben und
errorMessage leeren - alles Nicht-Hash-Felder, Hash unveraendert - und /verify
meldete weiterhin valid=true. Ein Einbruchsversuch war unsichtbar in einen
Erfolg umschreibbar.
Rueckwirkend signieren geht nicht, ein Rehash waere die falsche Medizin.
Stattdessen ein einmaliges, nicht destruktives Bestandssiegel: je Altzeile ein
Blattwert, die Wurzel darueber in einem HMAC-signierten Marker.
Umgesetzt nach den vier Bedingungen aus dem Pentest:
1. Blaetter ueber den vollen Zeileninhalt inkl. id und hashVersion, nicht ueber
den 7-Feld-V1-Hash - sonst lebte die Luecke im Siegel weiter.
2. Wurzel signiert (steht im Marker, der selbst V3/HMAC ist). Ohne
AUDIT_HMAC_KEY wird das Siegeln abgelehnt.
3. Bereich fix auf [1 ... v3FromId-1] statt Live-Abfrage hashVersion < 3. Sonst
haette ein Up-Flip der Grenzzeile sie aus der geprueften Menge gedraengt.
4. Pruefung je id: vorhanden, weiterhin Altbestand, Inhalt == Blatt, dazu
Wurzelabgleich.
Neuer Endpunkt POST /audit-logs/seal-backlog (audit:admin, confirm SEAL).
/verify meldet den Siegelzustand im Klartext, auch wenn kein Siegel existiert.
Verifiziert in separater Wegwerf-DB mit Mischbestand (6xV1, 5xV2, 5xV3): ohne
Siegel ist der Angriff unsichtbar, mit Siegel wird er erkannt und die Zeile
benannt; der Up-Flip der Grenzzeile wird ebenfalls erkannt. Wegwerf-DB danach
geloescht, Dev-Daten unberuehrt. tsc + vite build gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
verifyIntegrity vertraute Loeschungs-Manifesten bedingungslos, ohne zu pruefen,
ob die Traegerzeile 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
aendern, und damit eigene Loeschungen als "erklaert" ausweisen. Damit fiel
zugleich die Eskalation an signierten Zeilen aus - Anker UND Versionsgrenze
umgangen.
Fix: Ein Manifest zaehlt nur, wenn die Traegerzeile laut Versionsgrenze Stufe 3
sein muss, dies auch deklariert, und ihre HMAC-Signatur mit einem
konfigurierten Schluessel aufgeht. Ohne Schluessel gibt es keine gueltigen
Traeger - Luecken bleiben dann unerklaert, die sichere Richtung.
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, Traegerzeile
selbst beanstandet; legitimes signiertes Manifest erklaert die Luecke
weiterhin. tsc gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST /audit-logs/rehash rechnet die Kette mit dem HMAC-Schluessel neu und macht
sie damit wieder stimmig - auch wenn sie vorher berechtigte Beanstandungen
enthielt. Der Anker schuetzt 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: Vor dem Rehash wird verifyIntegrity() erhoben und samt Kettenkopf im
Marker gesichert - Anzahl geprueft, Listen der manipulierten Zeilen, der
Ketten-Luecken, der Luecken ohne dokumentierte Loeschung, der nicht pruefbaren.
Dazu ausloesender Benutzer und IP statt pauschal "system". Der Marker entsteht
nach dem Rehash, ist Teil der neuen Kette und signiert.
Fix 2: rehash verlangt {"confirm":"REHASH"}, cleanup verlangt
{"confirm":"CLEANUP"}. Beide wurden bei blinder Methoden-Erkundung per POST
unbeabsichtigt ausgeloest; ein tastender Aufruf laeuft jetzt in 400.
Verifiziert: blinder POST -> 400 ohne Wirkung; mit Bestaetigung laeuft der
Rehash und der Marker enthaelt Ausloeser, Vorbefund (1 manipuliert, 7 Luecken
mit exakten IDs) und Kettenkopf.
Hinweis: Der Test hat auf der DEV-Datenbank real rehasht, die dortigen
historischen Beanstandungen sind damit geglaettet. Staging/Prod unberuehrt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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. Das hebelte die Kern-Garantie von R164-02 aus:
Diebstahl faellt bei der naechsten Nutzung auf.
Fix: Kulanz idempotent. Die jti des Nachfolgers wird bereits 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 vorlegt, kollidiert zwangslaeufig und loest den
Familien-Widerruf aus. Fehlt der Nachfolger, wird bewusst nicht ersatzweise
rotiert (das waere wieder der Fork), sondern fail-closed als Replay gewertet.
Verifiziert (PoC nachgebaut): T0 legit -> TA, T0 replayt -> TC, TA.jti ==
TC.jti - kein Fork mehr. Ueber HTTP: zwei Tabs beide erfolgreich auf derselben
Linie; gestohlener Token nach Fensterablauf -> 401, Familie widerrufen,
Angreifer-Linie tot, SUSPICIOUS/CRITICAL gemeldet. Regression: 40 parallel ->
4 erfolgreich auf einer Linie, seriell 1-4 ok und 5. Replay, Token ohne jti
fail-closed, Logout widerruft. tsc gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.env.example zeigt jetzt wie bei den anderen Secrets einen sichtbaren
Platzhalter an der Variablen statt eines leeren Werts - verstaendlicher, aber
mit Schutz dagegen: ein nicht ersetzter Platzhalter (enthaelt < oder >, oder
beginnt mit change/dein/your/hier) gilt NICHT als Schluessel. Sonst wuerde mit
einem oeffentlich im Repository stehenden Wert gesiegelt, was Sicherheit
vortaeuscht. Das Backend warnt in dem Fall im Log und laesst das Siegel aus.
Verifiziert: mit Platzhalter geschriebene Zeile bleibt hashVersion 2.
Dabei aufgefallen und dokumentiert: Wird das Siegel nach Aktivierung wieder
abgeschaltet (Schluessel fehlt, z. B. beim Rebuild verloren), sind die in
dieser Zeit entstandenen Eintraege ungesiegelt und werden beanstandet, sobald
der Schluessel zurueck ist. Das ist Absicht - ein ungesiegelter Eintrag
inmitten gesiegelter ist von einer Faelschung nicht zu unterscheiden. Warnung
in README und .env.example.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die leeren Werte bei AUDIT_HMAC_KEY/AUDIT_HMAC_KEY_OLD wirkten wie ein
Versehen. Sie sind Absicht: leer bedeutet "Siegel aus" (fail-safe), und ein
Beispielwert waere hier gefaehrlicher als keiner - er stuende oeffentlich im
Repository, und jeder koennte damit Eintraege siegeln, waehrend der Betreiber
sich gesiegelt waehnt. Anders als bei JWT_SECRET, wo ein Platzhalter sinnvoll
ist, weil die Anwendung ohne Wert gar nicht laeuft.
Entsprechender Hinweis jetzt direkt ueber beiden Variablen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frage aus dem Betrieb: bestehende Installation hat noch keinen
AUDIT_HMAC_KEY - was passiert beim nachtraeglichen Setzen? Antwort jetzt in
README und .env.example: setzen, neu starten, fertig. Bestehende Eintraege
bleiben unveraendert gueltig, neue werden gesiegelt, alt und neu koexistieren
ohne Fehlalarm. Nachgemessen auf gemischtem Bestand (4903 x V1, 57 x V2,
67 x V3): 0 Beanstandungen. Rueckwirkend siegeln ist nicht moeglich.
Dabei zwei Fehler in der eigenen Doku gefunden und korrigiert:
1. Behauptet war, nach dem Leeren von AUDIT_HMAC_KEY_OLD seien alte Eintraege
"nicht mehr pruefbar". Tatsaechlich werden sie als MANIPULIERT gemeldet -
ein falscher Schluessel ist von einer Faelschung nicht zu unterscheiden.
Gemessen: 67 Eintraege als manipuliert. Nur wenn GAR KEIN Schluessel
gesetzt ist, gilt "nicht pruefbar". Doku entsprechend korrigiert, inkl.
Warnung, das Feld nicht voreilig zu leeren.
2. AUDIT_HMAC_KEY_OLD bot nur einen Platz. Beim ZWEITEN Wechsel waeren alle
mit dem ersten Schluessel gesiegelten Eintraege faelschlich als
manipuliert erschienen (reproduziert: 67). Das Feld nimmt jetzt eine
kommagetrennte Liste entgegen; verifiziert: mit beiden Alt-Schluesseln
0 Beanstandungen, mit nur dem juengsten 67.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README und .env.example erklaeren jetzt zuerst in Alltagssprache, was das
Audit-Log ist, wogegen der Schluessel schuetzt und was passiert, wenn man ihn
weglaesst oder verliert - inklusive Bild vom Notarstempel. Die technische
Ebene bleibt vollstaendig erhalten, in der README als aufklappbarer Block
(Hash-Versionen 1/2/3, Version-Floor, Loeschungs-Manifest, Rueckgabefelder von
/verify, bekannte Grenze) und in .env.example als eigener Abschnitt.
Ausserdem dokumentiert: Schluesselwechsel ueber AUDIT_HMAC_KEY_OLD ohne
Rehash, pro Umgebung ein eigener Schluessel, und die Warnung zu den beiden
Endpunkten mit Nebenwirkung (rehash/cleanup).
AUDIT_HMAC_KEY zusaetzlich in die Production-Checkliste aufgenommen.
Korrigiert: Die Integritaetspruefung ist NICHT ueber die Oberflaeche
erreichbar (verifyIntegrity wird von keiner Komponente genutzt) - die Doku
zeigt jetzt den API-Aufruf. Rechte, Endpunkte und Rueckgabefelder gegen den
Code geprueft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Rotation war 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 Sitzungsfamilie (neue Tabelle RefreshTokenRecord). Beim
Einloesen wird die jti verbraucht; taucht sie erneut auf, wird die gesamte
Familie widerrufen und der Vorfall als SUSPICIOUS/CRITICAL gemeldet. Der Token
selbst wird nicht gespeichert - die Signatur authentifiziert ihn bereits, und
ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.
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.
Das 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 unbenutzt
lasen.
Verifiziert: 90 parallele Requests -> nur 4 erfolgreich (1 + Kulanz 3), 27 als
Replay erkannt, alle Folge-Tokens tot; 2 parallele Tabs weiterhin erfolgreich;
gestohlener Token spaeter erneut abgewiesen; Logout widerruft die Familie;
ueber HTTP kommt SUSPICIOUS/CRITICAL an. tsc + vite build gruen.
Deploy-Hinweis: Refresh-Tokens ohne jti (Bestand vor dem Deploy) werden
fail-closed abgewiesen - alle angemeldeten Nutzer muessen sich einmalig neu
anmelden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 ausserhalb der Datenbank. Ohne ihn laesst sich keine gueltige
Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr.
Fail-safe: Ohne Schluessel wird weiter Version 2 geschrieben, es faellt nichts
aus. Signierte Zeilen gelten dann als nicht pruefbar (unverifiableEntries) und
ausdruecklich nicht als manipuliert. AUDIT_HMAC_KEY_OLD erlaubt einen
Schluesselwechsel ohne Rehash.
Restluecke der Versionsgrenze geschlossen: Wird die FRUEHESTE Zeile einer Stufe
herabgestuft, wandert MIN(id) mit - die Grenze allein haette den Downgrade
durchgewunken (der erste Testlauf fiel genau darauf durch). Der Nachfolger ist
jedoch HMAC-signiert und sein previousHash ohne Schluessel nicht faelschbar;
eine unerklaerte Luecke vor einer signierten Zeile gilt deshalb als Befund.
Verifiziert: Inhalt geaendert -> erkannt; Downgrade 3->2 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.
AUDIT_HMAC_KEY in .env.example dokumentiert. Der Schluessel selbst liegt nur
lokal in .env (gitignored).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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. Ergebnis: Fehl-Login als Erfolg
getarnt, Pruefung meldet "gueltig". An der letzten Zeile der Kette entsteht
dabei 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 ab, gilt die
Zeile selbst als manipuliert. Geprueft wird immer mit dem erwarteten Verfahren.
Die Grenze laesst sich durch Herabstufen einzelner Zeilen nicht verschieben.
Zusaetzlich konsultiert die Pruefung 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 mit Nachfolger erkannt, Downgrade der
Tail-Zeile erkannt, Gegenrichtung (V1 faelschlich als V2) erkannt, keine
Falschmeldungen auf Bestandsdaten. tsc + vite build gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
R166-01 (HIGH): Der 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 hingen
am selben Vorgaenger. Meine vorherige Messung war zu schwach: sie suchte Luecken
zwischen Nachbarn, nicht Forks. Fix: einzeiliger Mutex AuditChainLock mit
FOR UPDATE (InnoDB-Zeilensperren fallen erst beim COMMIT) plus
isolationLevel ReadCommitted. Belegt im Direktvergleich mit geweitetem Fenster:
Release-vor-Commit forkt, Zeilensperre nicht.
R166-02 (MEDIUM): Der Hash deckte nur 7 Felder ab. changesBefore/After, success,
ipAddress, resourceLabel, dataSubjectId, userId/customerId waren ungeschuetzt -
ein Einzeledit dort blieb unsichtbar. Fix: hashVersion + generateHashV2 ueber
alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash
gueltig. Verifiziert: 5/5 zuvor ungeschuetzte Felder werden jetzt erkannt.
R166-03 (LOW): "kein Cookie" (normaler Erstbesuch) wurde als HIGH/abgelehnt
gefuehrt - jetzt eigener Ausgang mit LOW. Nur echte Ablehnung bleibt HIGH.
R166-04 (LOW, pre-existing): GET /retention-policies wurde von GET /:id
verschluckt. Konkrete Routen jetzt vor der Parameter-Route.
Design-Empfehlungen: runRetentionCleanup schreibt ein Loeschungs-Manifest
(ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag - Luecken
ausserhalb bleiben erklaerungsbeduerftig. rehashAll schreibt einen Marker.
Verifiziert: 50 parallele Schreiber -> 50/50, 0 Forks, alle V2, manipuliert 0,
Luecken unveraendert 7. tsc + vite build gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 dadurch wertlos, dasselbe Muster wie beim
Refresh-Rauschen.
Fix: Rueckgabe um tamperedEntries (Inhalt nachtraeglich veraendert, ernst) und
chainGaps (Verkettung unterbrochen durch parallele Schreibvorgaenge oder
geloeschte Zeilen, meist harmlos) erweitert. invalidEntries bleibt als Summe
erhalten. Controller formuliert die Meldung eindeutig, Frontend-API-Typ
nachgezogen.
Problem 2 (Aufbewahrung): Token-Refreshes landen seit der Entrauschung 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. Fix: Regel Authentication/LOW mit 90 Tagen, als
idempotente Migration und im Seed.
Sensitivitaet steuert die Aufbewahrung und ist keine Alarmstufe - normale
Logins und Zugriffe auf Bankdaten/Ausweise bleiben bewusst CRITICAL, ein
Herabstufen wuerde still die Aufbewahrungsfrist verlaengern.
Verifiziert: Live-Test gegen Dev-DB - echte Manipulation einer Zeile wird als
manipuliert erkannt und nicht mit Luecken verwechselt, Ketten-Luecken bleiben
bei 7, Originalzustand exakt wiederhergestellt. tsc + vite build gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 - die Kette zerriss (Bruchstellen im Bestand vom
05.05. und 07.05.2026).
Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen benannten
MySQL-Lock (GET_LOCK). Der Lock liegt in der DB und wirkt daher auch ueber
mehrere App-Instanzen hinweg. Release im finally, weil benannte Locks nicht
transaktional sind - sonst wandert die Sperre mit der Verbindung zurueck in den
Pool und blockiert alle weiteren Schreiber.
Verworfener erster Ansatz: SELECT ... FOR UPDATE auf das Kettenende nimmt Gap-/
Next-Key-Locks, die mit den gleichzeitigen INSERTs kollidieren - gemessen gingen
38 von 40 parallelen Eintraegen durch Deadlocks verloren, still verschluckt vom
catch. Ein fehlender Audit-Eintrag ist unsichtbar und damit gefaehrlicher als
ein sichtbarer Kettenbruch.
Verifiziert: 100 parallele Schreiber -> 100/100 geschrieben, 0 neue Brueche
(444 ms); Folge-Schreiber in 6 ms, IS_FREE_LOCK frei (kein Lock-Leak).
Ungueltige Zeilen bleiben bei den 7 historischen. tsc gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
verifyIntegrity meldete 3107 von 4630 Zeilen als "manipuliert" - davon 3100
Fehlalarme, exakt die Zeilen mit resourceId = NULL aus 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.
Sicherheitsrelevant, weil ein staendig grundlos ausloesender Alarm ignoriert
wird - echte Manipulation ginge im Laerm unter.
Fix: generateHashLegacy() reproduziert das alte Schreibverhalten,
verifyIntegrity akzeptiert Altbestand ueber diesen Fallback (nur geprueft,
wenn die aktuelle Variante nicht passt). Bewusst KEIN Rehash - der wuerde die
Manipulations-Beweiskraft der Vergangenheit zerstoeren. Gespeicherte Hashes
bleiben unangetastet.
Verifiziert: ungueltig 3107 -> 7 (echte Ketten-Brueche). Adversarial
gegengetestet: Manipulation an userEmail/action/endpoint/createdAt/resourceId
wird bei alten wie neuen Zeilen zu 100 % erkannt (10/10). tsc gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Entrauschung aus de0d6bd war live wirkungslos - aber nicht wegen eines
Deploy-Miss, sondern weil der Code nie erreicht wurde: 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). Saemtliche
path.includes('/auth/...')-Checks liefen ins Leere -> Fallback POST->CREATE mit
Default-Sensitivitaet CRITICAL.
Betraf nicht nur den neuen TOKEN_REFRESH: LOGIN/LOGOUT/LOGIN_FAILED waren im
Audit-Stream seit jeher generisch (pre-existing), ebenso das endpoint-Feld.
Der SecurityEvent-Stream war nie betroffen (eigene emit-Calls), daher lief das
Alerting korrekt.
Fix: vollen Pfad einmal synchron beim Eintritt festhalten (req.originalUrl,
wird von Express nie mutiert) und downstream ausschliesslich diesen nutzen -
determineAction, generateHumanLabel, extractDataSubjectId, manuallyLoggedPaths
und endpoint. TOKEN_REFRESH zusaetzlich in die "immer loggen"-Ausnahme.
Verifiziert (E2E mit echter Middleware gegen Dev-DB): TOKEN_REFRESH/LOW,
TOKEN_REFRESH/HIGH, LOGIN/CRITICAL, LOGIN_FAILED/CRITICAL, LOGOUT/CRITICAL,
alle mit vollem endpoint-Pfad. tsc gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fehlgeschlagener /auth/refresh (Replay/Brute-Force auf geraubte
Refresh-Tokens) wurde als TOKEN_REFRESH/LOW geloggt und entging der
Alarmierung - ein Angreifer konnte von /login auf /refresh ausweichen,
um unter der LOGIN_FAILED-Schwelle zu bleiben.
Audit-Actions speisen die Alert-Engine nicht (die zaehlt SecurityEvent
via emit). Fix daher an zwei Ebenen:
- Detection: refresh()-Catch emittiert TOKEN_REJECTED -> greift die
bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP -> CRITICAL). Severity
wie Access-Token: abgelaufen/revoked = LOW (kein Sofort-Alert),
ungueltige Signatur/Manipulation = HIGH. auth.service reicht dafuer
err.code REFRESH_EXPIRED/REFRESH_INVALID durch. "Kein Cookie" emittiert
bewusst nicht (normaler Erstbesuch).
- Audit-Triage: fehlgeschlagener Refresh -> Sensitivitaet HIGH statt LOW
+ Label "Token-Refresh abgelehnt". Action bleibt TOKEN_REFRESH
(semantisch ein Refresh, kein Login).
Verifiziert: tsx-Test abgelaufen->LOW, manipuliert/garbage->HIGH; tsc gruen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
POST /auth/refresh wurde als CREATE / "Anmeldung erstellt" / CRITICAL /
anonymous geloggt und sah damit wie eine anonyme Login-Flut aus. Es ist
aber der regulaere Silent-Refresh des Frontend-Interceptors (Access-Token
lebt nur im Speicher -> nach Reload/401 einmaliger Cookie-Refresh).
- Neuer AuditAction-Wert TOKEN_REFRESH (Migration 20260818120000,
idempotentes MODIFY COLUMN)
- determineAction() mappt /auth/refresh -> TOKEN_REFRESH, Label
"Sitzung verlaengert (Token erneuert)", Sensitivitaet explizit LOW
(statt Default Authentication -> CRITICAL)
- LOGIN/LOGOUT/LOGIN_FAILED bleiben unveraendert CRITICAL
- Frontend: Filter-Option + dezente Badge-Farbe + Typ-Union
- anonymous bewusst beibehalten (Endpoint ohne authenticate-Middleware)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
createContract/updateContract spreadeten energyDetails/tvDetails/
carInsuranceDetails/mobileDetails (via ...mobileData) roh an Prisma.
Injizierte id/contractId konnten ein Detail-Objekt auf einen Fremdvertrag
reparenten oder den PK frei setzen (stilles 200 statt 400) - MEDIUM
(Integritaet; kein Cross-Tenant, staff-only, Portal 403).
Fix: Feld-Whitelists (pickEnergyScalars/pickMobileScalars/pickTvScalars/
pickCarInsuranceScalars, analog R158) an allen Spread-Stellen in create+update.
internet war bereits explizit (preparedInternetData) - safe. Whitelists
programmatisch gegen die DB-Spalten abgeglichen (minus id/contractId/
verschluesselt) - alle Diffs leer.
Verifiziert: energyDetails{basePrice:99.99, id:999999, contractId:fremd}
-> basePrice aktualisiert, ecd.id + contractId unveraendert (kein Reparenting).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bestandskunden ohne E-Mail bleiben editierbar. Beim Anlegen ist E-Mail
weiterhin Pflicht (Frontend required + Backend createCustomer). Die
Domain-Pruefung (keine verwaltete Provider-Domain) greift unveraendert bei
create UND update, falls eine E-Mail gesetzt wird.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ContractTask.contractId nullable (Migration). Neuer staff-only Endpoint
POST /tasks fuer allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag gibt
es keinen Kunden -> visibleInPortal serverseitig immer false, Portal-Reply
403 bei contractloser Aufgabe, getAllTasks-Portal-Filter schliesst sie
automatisch aus (kein contract-Match).
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)" ohne Vertrags-Link/
Zum-Vertrag-Button.
Verifiziert: contractlose Aufgabe -> contractId null, visibleInPortal
erzwungen false (auch wenn true geschickt); mit Vertrag weiterhin waehlbar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Die private Kunden-E-Mail (Customer.email) darf nicht auf einer der bei den
E-Mail-Providern konfigurierten Domains (bzw. Subdomains) liegen - sonst
traegt man versehentlich eine unserer verwalteten Weiterleitungs-/Mailbox-
Adressen als private Adresse ein. Zudem ist E-Mail jetzt Pflichtfeld.
Backend: createCustomer/updateCustomer pruefen Pflicht + Domain (neue Helper
getConfiguredEmailDomains/emailUsesDomain im emailProvider-Service);
email aus nullableFields entfernt. Frontend: E-Mail-Feld required + Hinweis.
Verifiziert: Provider-Domain + Subdomain (case-insensitiv) verboten,
Fremd-Domains erlaubt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Neues Feld EnergyContractDetails.noBonusDesired (Boolean, default false) +
Migration. Checkbox im Vertragsformular (Strom/Gas, bei den Bonus-Feldern),
Anzeige im Vertragsdetail wenn gesetzt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MaLo-ID (Marktlokation) gehoert zur (Liefer-)Adresse, nicht zum Vertrag.
Address bekommt maloIdElectricity + maloIdGas (getrennte Marktlokationen je
Sparte), pflegbar im AddressModal (nur Lieferadresse). Im Vertrag ist die
MaLo-ID jetzt ein Lesefeld, das je nach Vertragstyp die MaLo der gewaehlten
Lieferadresse zeigt; ContractDetail/-Modal ebenso.
Schema + Migration 20260814100000: 2 Spalten (idempotent) + Daten-Migration
(bestehende EnergyContractDetails.maloId -> jeweilige Lieferadresse,
ELECTRICITY->maloIdElectricity / GAS->maloIdGas). Migrationslogik verifiziert.
Dabei einen selbst verursachten Regressions-Bug gefixt: beim R156-Umbau waren
die 10 owner*-Adressfelder aus der Address-Whitelist gefallen -> Eigentuemer-
Sektion speicherte seit cb21a2c nicht mehr. Address-Whitelist jetzt via
Pick-Helper, programmatisch gegen alle DB-Spalten abgeglichen (owner* + MaLo
drin, id/customerId/Timestamps raus). BankCard/Document gegengeprueft: ok
(nur documentPath bewusst upload-only ausgeschlossen).
Verifiziert: tsc+build gruen; owner + maloId speichern wieder, Injection
(id/customerId) blockiert; Daten-Migration Strom->Strom / Gas->Gas.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Beim Umstieg auf die Feld-Whitelist (94e4fde) ist "Vertragsnummer beim
Anbieter" (contractNumberAtProvider) durchgerutscht - ich hatte nur die
SalesPlatform-Variante + customerNumberAtProvider. Folge: Staff konnte die
Anbieter-Vertragsnummer nicht mehr neu setzen/aendern (still gedroppt),
bestehende Werte blieben (Prisma nullt abwesende Felder nicht). Reine
funktionale Regression, kein Security-Issue (Weglassen ist fail-safe).
Whitelist jetzt programmatisch gegen ALLE DB-Spalten abgeglichen (SHOW COLUMNS
minus bewusste Ausschluesse = Whitelist, beide Diffs leer) - kein weiteres
Feld fehlt. Verifiziert: contractNumberAtProvider persistiert wieder.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Letzter Spread-Endpunkt: createContract/updateContract reichten rohen
...contractData an Prisma durch (via as any im Controller). customerId ist
zwar legitim aenderbar (Kunden-Select aktiv), aber id/contractNumber/
createdAt/updatedAt/portalPasswordEncrypted und die cancellation*Path-Felder
waren so mit-injizierbar. Jetzt explizite Feld-Whitelist (pickContractScalars),
konsistent zur R156-Haertung von BankCard/Address/Document.
Whitelist autoritativ aus den DB-Spalten abgeleitet - der ContractCreateData-
Typ ist unvollstaendig: previousProviderId/previousContractNumber/
previousCustomerNumber/nextReviewDate sind echte Formularfelder, die sonst
still gebrochen waeren. cancellation*Path bleiben bewusst draussen (nur ueber
die Upload-/Delete-Endpunkte setzbar).
Verifiziert: legit Felder (inkl. der 4 zuvor untypisierten) persistieren;
injizierte id/contractNumber/cancellationLetterPath werden ignoriert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Die create/update-Services reichten rohen req.body an Prisma durch, wodurch
customerId (Owner) und id (PK) per Update mutierbar waren - staff-only, kein
Cross-Tenant-Bruch, aber echte Integritaetsschwaeche; mit dem neuen
cardNumber-Feld liegt zudem Finanz-PII auf dieser Flaeche.
Fix: explizite Feld-Whitelist im Service (create+update) fuer BankCard,
Address und IdentityDocument - nur benannte Spalten gehen an Prisma, kein
...data/req.body-Spread mehr. Controller-Helper pickBankCardFields haelt
zusaetzlich die Audit-Logs sauber (keine Phantom-Eintraege injizierter Keys).
Verifiziert: updateBankCard mit {customerId:99999, id:88888, bogusField, ...}
-> id+customerId unveraendert, nur cardNumber gesetzt, Fremdfelder ignoriert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Schema: BankCard.cardNumber (String?, optional) + Migration
20260813100000_bank_card_number (ADD COLUMN IF NOT EXISTS), auf Dev
angewandt + prisma generate; Prod via migrate deploy. Eingabefeld
"Kartennummer" im Bankkarten-Modal (Kundenakte).
Vertragsansicht (ContractDetail) und Vertrag bearbeiten (ContractForm)
zeigen jetzt bei Bankkarte zusaetzlich BIC/Bank/Kartennummer/Ablaufdatum
und bei Ausweis Behoerde/Ausstellung/Ablaufdatum sowie Geburtsort +
Geburtsdatum des Kunden - jeweils mit Copy-Button und nur wenn gesetzt.
Im Form je Select in eigenem div gewrappt (Grid-Alignment).
Verifiziert: tsc + vite build gruen, cardNumber Round-Trip (update->read),
Contract-Include liefert alle Felder inkl. customer.birthDate/birthPlace.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Der Pentester-Helfer soll nicht ins Produktions-Image und darf nie gegen
Prod laufen:
- .dockerignore schliesst backend/scripts/seed-magic-byte-test.ts aus dem
Build-Context aus -> Datei liegt nicht mehr im Prod-Container (bleibt im
Repo fuer Dev/Staging-Checkout).
- Script bricht bei NODE_ENV=production im "create"-Modus hart ab (exit 1);
"cleanup" bleibt erlaubt, damit man immer aufraeumen kann.
Verifiziert: create@production -> Abbruch exit 1; cleanup@production laeuft.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
backend/scripts/seed-magic-byte-test.ts legt eine dedizierte TEST-Bankkarte
(Marker im accountHolder) mit einer getarnten Datei an: .pdf-Endung, aber
SVG-mit-<script>-Inhalt (Non-Whitelist-Magic-Byte, Stored-XSS-Payload).
Damit kann der Pentester den Magic-Byte-Mismatch->attachment-Zweig des
Download-Endpoints live ausloesen (auf Staging fehlte bisher ein
Non-Whitelist-Upload). Echte Kundendaten werden nicht angefasst; cleanup
entfernt Karte + Datei.
Modi: create [--customer <id>] | cleanup.
Verifiziert (create -> Controller-Integrationstest -> cleanup): inline
angefragt -> Content-Disposition attachment + nosniff (nicht inline) + Log;
ohne disposition -> attachment; fremder Portal-User -> 403.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getAllContracts durchsucht jetzt auch simCards.cardUser - zusaetzlich zu
Rufnummer/SIM-Nummer/IMEI. R150-Randnotiz, fachlich gewuenscht (Staff sucht
nach Karteninhaber). Portal-Suche bleibt durch das bestehende customerIds-
Scoping begrenzt.
Verifiziert: Suche nach gesetztem cardUser findet den Vertrag, Kontroll-
Suche 0 Treffer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Beide Vertragslisten (Kundenakte-Baum + Hauptmenue /contracts) zeigen bei
Mobilfunkvertraegen mit Rufnummer jetzt zusaetzlich Karteninhaber
(SimCard.cardUser der angezeigten SIM, nur wenn gesetzt) und Netz
(mobileNetwork -> Telekom/Vodafone/Telefonica (o2)) inline. Vertraege mit
Kuendigungsbestaetigung (cancellationConfirmationDate) bekommen eine eigene
Zeile in roter, fetter Schrift.
Backend: getContractTreeForCustomer + getAllContracts selektieren nun
mobileNetwork + cardUser (Tree zusaetzlich cancellationConfirmationDate).
Shared-Helper getContractTypeInfo um cardUser/network + mobileNetworkLabel
erweitert. Beide Listen rendern ueber den Tree-Endpoint.
Verifiziert: tsc + vite build gruen, Tree-Service liefert die Felder,
Helper-Mapping getestet (Vodafone/Telekom/o2, leerer Karteninhaber ausgelassen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Es gab bislang keinen generellen Limiter - nur Login/Passwort-Reset/
Staff-ReAuth/Consent; authentifizierte Endpoints waren gegen Enumeration/
DoS ungedrosselt. Neuer apiBackstopRateLimiter auf alle /api-Requests,
vor den Routern gemountet (ergaenzt die feineren Limiter, ersetzt sie nicht).
Key = nur IPv6-/56-normalisierte IP - bewusst nicht IP+User, da der User-
Claim hier nur unverifiziert lesbar waere (authenticate laeuft erst pro
Route) und ein Angreifer sonst per Fake-userId beliebig Buckets erzeugen
koennte. Limit per Env API_RATE_LIMIT_PER_MIN (Default 1200/min, Floor 60),
/api/health ausgenommen. Kein SecurityEvent pro Block (Flood-Amplification).
Deckt zugleich den offenen IPv6-Rate-Limit-Test ausserhalb der Auth-Pfade ab.
Verifiziert: 60x200 dann 429, health bleibt 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>