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>
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>
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>
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>