0ef28c7a87a815f101507375681ea89acdf0412a
217
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0ef28c7a87 |
Aufraeumrunde vor Etappe 2: Fehlerhygiene, Admin-Kriterium, Portal-Riegel
Drei Nachrangpunkte aus dem Pentest, vor dem Schneiden des Rechtekatalogs.
1. R192-01 projektweit. Das Muster `error instanceof Error ? error.message`
stand 124-mal in 26 Controllern und konnte ueberall Serverpfade,
Spaltennamen und Bibliotheksinterna ausliefern. Zentral geloest statt
124-mal einzeln - das waere die Falle aus R186-01 und R188 gewesen:
utils/fehlerAntwort.ts mit antworteAufFehler(), jetzt 123 Aufrufe in 26
Dateien und eine Regel.
Die Unterscheidung laeuft ueber die Fehlerklasse. Neue Basisklasse
FachlicherFehler fuer alles, dessen Wortlaut fuer den Aufrufer bestimmt
ist; ApiError, RechteEskalationError, RollenSperrError,
UngueltigeEingabeError, FilterFehler und ReferralError stammen davon ab.
Ein blankes Error gilt weiter als absichtlich. Alles andere - TypeError,
Prisma, JWT-Bibliothek - wird 500 mit allgemeiner Auskunft, Einzelheiten
ins Protokoll.
Mit gefunden: Sechs Stellen in cachedEmail.controller interpolierten die
interne Meldung in den Antworttext; das haette kein Filter erwischt, der
nur das Feld ersetzt. Und POST /auth/refresh gab den Wortlaut der
JWT-Bibliothek zurueck ("jwt malformed", "invalid signature") - der sagt
einem Angreifer, woran sein Token gescheitert ist.
2. Admin-Heuristik. Bisher galt "wer users:delete hat, ist Admin" - ein
Zufallsmerkmal. Bewusst NICHT auf den Rollennamen umgestellt, wie
vorgeschlagen: Eine selbst gebaute Rolle mit users:update verwaltet
tatsaechlich, ein Namenskriterium wuerde sie uebersehen, und dann liesse
sich der letzte Admin loeschen, obwohl die Faehigkeit erhalten bliebe.
Geschuetzt wird jetzt die Faehigkeit selbst: users:update und
roles:manage. Wer der letzte Traeger ist, kann sie nicht verlieren - durch
Rollenwechsel, Deaktivierung oder Loeschung. Die Meldung nennt die
Faehigkeit beim Namen. Nebenbei der letzte Cost-10-Rest im
Kennwort-Zuruecksetzen.
3. Portal-Kunden. Korrektur meiner eigenen Einordnung: Das war kein
Migrationsrueckstand, sondern eine gewollte Trennung. Kunden bekommen
niemals operative Rechte; die Portalansicht ist dafuer nicht gebaut und
prueft es nicht. Die zwei Kopien des festen Arrays sind jetzt eine
Konstante PORTAL_RECHTE mit einem Kommentar, der die Absicht benennt.
Dazu ein harter Riegel im Gate: requirePermission schneidet die Rechte
eines Portal-Zugangs auf PORTAL_RECHTE zu, unabhaengig davon, was sein
Token behauptet. Heute wirkungslos, morgen die Sicherung - bisher haette
eine unbedachte Zeile in der Token-Erzeugung gereicht. Und eine Startwache,
die jedes Nicht-Lese-Recht in dieser Liste meldet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f964610af7 |
R193-02: Truthiness-Bypass bei den drei Haken (HIGH)
Befund der Pentesterin, Prod-Blocker. Der Eskalations-Guard fragte
`=== true`, die Zuweisung fragte auf Truthiness. Zwischen diesen beiden
Lesarten passte der Angriff: {"hasDeveloperAccess":"ja"} sah fuer den
Guard nach keinem Rechtezuwachs aus (kein 403) und fuer die Zuweisung nach
"gesetzt" (Rolle drauf). Ein Konto mit users:update, das developer:access
nicht haelt, konnte damit einem anderen Konto Vollzugriff geben.
Damit war die Kernentscheidung aus Etappe 1 ausgehebelt: developer:access
und audit:admin sollten ausdruecklich nur ueber die Kommandozeile
erstmalig vergebbar sein. Der Bypass machte sie wieder ueber ein
Browser-Feld erreichbar.
Es ist genau das Muster, das ich der Pentesterin eine Runde vorher selbst
beschrieben habe - eine Absicherung, die nur in einer von zwei Kopien
ankommt, ist keine. Nur dass es diesmal nicht zwei Dateien waren, sondern
zwei Auffassungen desselben Wertes in derselben Datei.
normalisiereHaken() prueft die drei Felder einmal streng auf Boolean,
alles andere 400. Guard und Zuweisung arbeiten danach auf demselben
geprueften Objekt - es gibt nur noch eine Lesart. Zusaetzlich liest
setzeVersteckteRolle strikt === true / === false, damit sich die Lesart
auch dann nicht spaltet, wenn die Funktion kuenftig von woanders gerufen
wird.
Gleiche Bauart nebenan: isActive und isServiceAccount steuern ebenfalls
ein Gate, das strikt auf boolean prueft - ein "ja" rutschte daran vorbei,
ohne das Gate auszuloesen (u.a. die audit:admin-Huerde fuers
Dienstkonto-Kennzeichen), und lief dann in einen Prisma-Fehler. Kein
Bypass, aber ein 500 fuer eine falsche Eingabe. Jetzt alle fuenf
Boolean-Felder einheitlich geprueft.
Nachgeprueft: ihre vier Vektoren plus 30 weitere Kombinationen ueber alle
drei Haken - alle 400, Rollen unveraendert. Derselbe Angriff ueber
POST /users - 400, kein Konto angelegt. Gegenprobe: Wer gdpr:* selbst
haelt, setzt den Haken mit echtem true weiterhin erfolgreich.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
75d009138d |
R192-01: Fehlermeldungen, die fuer Entwickler geschrieben sind
Befund der Pentesterin: PUT /users/:id {"roleIds":{}} gab 400 mit dem
rohen JS-Fehler "object is not iterable". Kein Wipe, keine Eskalation -
dieselbe Familie wie der Prisma-Pfad-Leak aus R190-01.
Zwei Ursachen. Erstens spreizte normalisiereRollenIds die roleIds ohne
Array.isArray-Guard. Zweitens reichte die Fehlerabbildung jede
error.message durch.
Die Unterscheidung laeuft jetzt ueber die Fehlerklasse, nicht ueber eine
Heuristik auf dem Text: Ein blankes Error werfen wir absichtlich und mit
einer Meldung fuer Menschen ("Der letzte Admin kann nicht..."). TypeError
und Verwandte sind Programmierfehler, Prisma-Fehler kommen von aussen -
beides sagt dem Aufrufer nichts Nuetzliches und einem Angreifer zu viel.
Unerwartetes wird 500 statt 400: Ein Eingabefehler-Code waere die naechste
Meldung, die sich falsch ausgibt.
Gegengeprueft, dass mit dem Leck nicht auch das Nuetzliche wegfaellt:
"Rolle kann nicht geloescht werden, da sie 1 Benutzern zugewiesen ist" ->
400, Systemrollen-Sperre -> 403 mit Klartext, Eskalation -> 403 mit den
konkret fehlenden Rechten. {}, "abc", 5, true als roleIds -> 400 "roleIds
muss eine Liste sein", Zustand unveraendert. R190-01/R191-01 gruen.
Offen und groesser als der Befund: Das Muster
`error instanceof Error ? error.message` steht 124-mal in 26
Controller-Dateien. Behoben ist es nur im Benutzer- und Rollenpfad.
Dieselbe Lage wie bei R188 und gehoert genauso zentral geloest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
702e630ee5 |
R191-01: Ein PUT, eine Klammer
Befund der Pentesterin nach dem R190-01-Fix. Nur der Rollentausch lag in
der Transaktion, die drei Haken liefen danach in eigenen Schreibvorgaengen.
Ein Multi-Feld-PUT war damit nicht gesamt-atomar: Bricht ein Haken-Write
ab, steht der Rollenstand schon und die Haken halb. Kein Verlust, keine
Eskalation, aber ein Zwischenstand, den niemand angefordert hat. Ein PUT
ist ein Vorgang, also gehoert er in eine Klammer.
Die gesamte Schreibphase von updateUser liegt jetzt in einer Transaktion -
Benutzerdaten, Rollentausch, alle drei Haken. createUser ebenso.
Beim Umbau mitgefunden: Die drei Haken-Helfer legten die Rolle bei Bedarf
selbst an, falls sie fehlte. Das war eine vierte Stelle, die definierte,
was "Developer" bedeutet - mit einem anderen Rechtesatz als der Katalog
(nur developer:access statt allem) und ohne isSystem. Eine so entstandene
Rolle waere ueber die Rollenverwaltung aenderbar gewesen. Der Notfallpfad
ist weg: sync-roles legt die Rollen bei jedem Start an, die Startwache
meldet ihr Fehlen, und fehlt sie hier doch, bricht der Vorgang mit klarer
Ansage ab. Drei fast gleiche Funktionen wurden dabei eine.
int4-Ueberlauf (kosmetischer Nebenbefund): IDs jenseits des INT-Bereichs
werden nicht mehr abgefragt, sondern als unbekannt gemeldet.
createUser hasht jetzt mit Cost 12 statt 10, wie seed.ts. Wieder eine
Haertung, die nur in einer von zwei Kopien angekommen war.
Nachgeprueft: Rollback bewiesen, indem die DSGVO-Rolle voruebergehend
umbenannt und {roleIds:[23], hasGdprAccess:true} geschickt wurde - 400 mit
Klartext, Rollen unveraendert. Typvektoren "22", 4.5, 1e3, true, null,
Riesenzahl alle 400 ohne Wipe. R190-01-Regression erneut gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b2963be482 |
R190-01: Rollentausch zerstoerte, wo er ablehnte
Befund der Pentesterin. updateUser machte deleteMany + createMany ohne Transaktion und ohne skipDuplicates. Eine doppelte roleId ([4,4]) oder eine erfundene ([99999]) kam durch die Teilmengenregel - sie bringt keine Rechte mit, faellt dort also nicht auf - und schlug erst beim Schreiben fehl, nach dem Loeschen. Antwort HTTP 400, Konto danach ohne jede Rolle. Die Antwort log ueber sich selbst: "400" liest sich als abgelehnt, nichts passiert. Zerstoert wurde trotzdem. Damit war die Letzter-Admin-Sperre umgangen, ohne sie anzugreifen: Sie prueft die Absicht (die Admin-Rolle steht im Body, also "bleibt Admin"), der Schreibvorgang scheiterte danach. Aussperrung, Rueckweg nur per CLI. Auch versehentlich ausloesbar durch doppelte IDs aus dem Frontend. Es war meine eigene Haertung, die ich nicht mitgenommen habe: updateRole hatte Dedup und skipDuplicates seit Etappe 1, das Geschwister updateUser/createUser nicht. Genau das Muster, das in derselben Runde dreimal aufgeraeumt wurde - eine Absicherung, die nur in einer von zwei Kopien ankommt. normalisiereRollenIds() prueft jetzt vor jeder Entscheidung und jedem Schreibvorgang gegen die Datenbank, dedupliziert, und der Tausch laeuft in einer Transaktion. Die Letzter-Admin-Sperre arbeitet damit auf einer Liste, die auch einloesbar ist. Nebenbefund beim Nachstellen: Die rohe Prisma-Fehlermeldung ging wortwoertlich an den Client, inklusive Dateipfad des Servers. Wird jetzt protokolliert statt ausgeliefert. Nachgestellt: [22,22] -> 200 mit erhaltener Rolle; [99999] und [22,99999] -> 400 mit Rollen unveraendert; [-1] -> 400; [22,23] -> 200 korrekt gesetzt. createUser ebenso, abgelehnte Anlagen hinterlassen kein Konto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9f879759d8 |
CLI-Skripte: DATABASE_URL wieder aufbaubar machen
Die drei neuen Wartungsskripte bauten sich einen eigenen PrismaClient und gingen damit an src/lib/prisma.ts vorbei. Dort wird DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt - und genau das ist bei `docker compose exec <dienst> npx tsx prisma/<skript>.ts` der Fall: Der Entrypoint exportiert sie nur in den Serverprozess, eine neue Shell erbt sie nicht. Ergebnis war "Environment variable not found: DATABASE_URL". Der Kommentar in src/lib/prisma.ts beschreibt genau diesen Fall; die Loesung war da, ich habe sie nur umgangen. Betroffen war auch rolle-zuweisen.ts. Das ist der Weg, den die Startwache im Klartext nennt, wenn kein Konto mehr die Pflichtrechte haelt - er haette in dem Moment versagt, fuer den er gebaut ist. Geprueft mit entzogener DATABASE_URL und nur DB_*-Teilen in der Umgebung: rechte-report, rolle-zuweisen --liste und sync-roles laufen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
77aeb69aeb |
Rechtemodell Etappe 1: toter Katalog begradigt, Selbst-Erhoehung geschlossen
Vorarbeit fuer die Rollen-Oberflaeche. Eine Checkbox-Liste ueber einem Katalog, der nicht stimmt, waere schlimmer als gar keine. 18 von 50 Rechten bewachten nichts: tariffs:*, cancellation-periods:*, contract-durations:* und email-providers:* standen im Katalog und waren anhakbar - die Routen prueften in Wahrheit providers:*, platforms:* und settings:*. Die Routen gaten jetzt granular; eine rein additive Migration vererbt jedes neue Recht an jede Rolle, die bisher das Sammelrecht hatte. Nachgerechnet: keine Rolle hat etwas verloren. Der Katalog stand dreifach und divergent - seed.ts, sync-roles.ts und, abweichend, factoryReset. Die dritte Kopie kannte weder audit:* noch gdpr:* und legte DSGVO, Audit-Betrieb und Gegenbuch gar nicht an: Nach einem Werksreset konnte niemand mehr eine Auskunft nach Art. 15 ausfuehren, und aufgefallen waere es erst, wenn eine Frist laeuft. Jetzt eine Quelle: src/config/rechte-katalog.ts. Im selben Code: factoryReset setzte das Admin-Kennwort fest auf "admin" mit bcrypt-Cost 10 - genau das, was seed.ts seit Pentest Runde 12 verbietet. Dieselbe Haertung war nur in einer der beiden Kopien angekommen. Jetzt zufaellig, Cost 12, einmalig im Log. Selbst-Erhoehung geschlossen: neues Recht roles:manage, getrennt von users:*, dazu die Teilmengenregel in rechte.service.ts - niemand kann ein Recht weitergeben, das er selbst nicht haelt. Sie greift auf allen vier Wegen: Rolle anlegen, Rolle aendern, Rollen zuweisen und die drei Haken. Der Haken-Weg war der wichtigste: Er brauchte nur users:create, ein zweites Konto mit "Entwicklerzugriff" anlegen und sich damit anmelden - die Developer-Rolle traegt alle Rechte. Geprueft wird der Zuwachs, nicht der Endzustand, damit Entziehen erlaubt bleibt; und die Rechte des Handelnden kommen frisch aus der Datenbank, nicht aus dem bis zu 15 Minuten alten Token. Systemrollen gesperrt (Role.isSystem): Admin liess sich bisher umbenennen oder leeren - und die versteckten Rollen haengen an ihrem Namen. Role.isHidden ersetzt die im Frontend hartkodierte Namensliste, in der "Gegenbuch" fehlte. Rechteaenderung wirkt sofort: updateRole/deleteRole melden alle Traeger ab. Werksreset und Backup-Restore verlangen zusaetzlich roles:manage - beide loeschen alle Rollen und legen ein frisches admin@admin.com an. Rollenpflege war der einzige Eingriff in die Rechtevergabe ohne SecurityEvent, obwohl sie viele Konten auf einmal trifft. Jetzt PERMISSION_CHANGED - ebenso fuer die bisher stummen Haken DSGVO und Entwicklerzugriff. Aussperr-Ausweg: prisma/rolle-zuweisen.ts. Umgeht die Regel bewusst - wer Shell-Zugang hat, hat ohnehin die Datenbank; ein gestohlener Web-Zugang hat ihn nicht. Schreibt ueber die Hash-Kette, nicht roh. Die Startwache nennt den Befehl im Klartext. Geprueft gegen Dev-DB und frische Wegwerf-DB: 7 Eskalationswege alle 403, 2 Gegenproben erlaubt; 6 Sperrtests auf Systemrollen alle 403, eigene Rollen weiter aenderbar; Stammdaten-Lesen unveraendert 200; Rechteaenderung sofort 401; Migration und sync-roles dreimal identisch; db:seed auf bestehender DB ohne Wirkung auf die Rollenmatrix; frische Installation mit allen 8 Systemrollen und ohne Hinweise. Beim Deploy: Die Migration beendet alle Sitzungen einmalig. Noetig, weil die Rechte im Token stehen - sonst liefen bis zu 15 Minuten 403er. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cca242119b |
R188/R189-01 aus dem Pentest umgesetzt - Klasse statt Instanz
Beide Findings der Pentesterin waren berechtigt. Ihre Patches liessen sich nicht anwenden (Basis |
||
|
|
eb0580ac54 |
Entfernter Protokoll-Anfang wurde nicht erkannt
Gefunden bei der Vorbereitung des Prod-Siegels. Die Verkettung wird zeilenweise gegen die Vorgaengerin geprueft - die erste Zeile hat keine, also fiel bisher nichts auf, wenn ein zusammenhaengender Anfang des Protokolls entfernt wurde. Kein Kettenbruch, kein Befund, valid blieb gruen. Das ist die stillste Loeschung von allen: Wer die aeltesten Eintraege loswerden will, muss nur vorne anfangen. Erkennbar ist es trotzdem. Die allererste Zeile wird ohne Vorgaenger geschrieben und traegt einen leeren previousHash; traegt die erste VORHANDENE Zeile einen Wert, hat es eine Vorgaengerin gegeben und die ist weg. Wird jetzt als Kettenluecke an dieser Zeile gefuehrt, mit derselben Manifest- und Beglaubigungslogik wie jede andere Luecke - eine dokumentierte Loeschung erklaert sie also weiterhin. Nur bei Pruefung des Gesamtbereichs: mit fromId ist ein gefuellter previousHash selbstverstaendlich. Gegengeprueft, kein Fehlalarm. Getestet ueber HTTP gegen eine Wegwerf-DB: vollstaendiges Protokoll -> valid:true; erste drei Zeilen entfernt -> vorher unveraendert valid:true, jetzt valid:false mit chainGaps:[4], wegen Hash-Version 3 zusaetzlich als Manipulation eskaliert. Auf Prod ausgeschlossen: Das Protokoll beginnt bei ID 1 (07.05.2026, Inbetriebnahme), kein Cleanup, keine Neuberechnung. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2ca6ed2f70 |
Neuberechnung der Kette als eigene Gegenbuch-Alarmbedingung (R186 Frage b)
Der Tester fragte praezise: Loest cleanup -> rehash OHNE erneutes Siegeln, ueber NICHT versiegelten Inhalt, etwas Automatisches aus - oder nur die Prosa in verify, die ein Mensch lesen muss? Gemessen statt behauptet: Es loeste bereits aus, aber als Nebenwirkung. Ein Rehash aendert jeden Hash, also stimmt der beglaubigte Kettenkopf nicht mehr und der bestehende Vergleich schlug an. Das funktioniert, ist aber ein Zufallstreffer - verschoebe sich der Anker, waere der Melder lautlos weg. Und die Meldung hiess "Eintrag wurde veraendert" statt "die Kette wurde neu berechnet", also Wirkung statt Ursache. Jetzt haengt der Alarm an der Sache selbst: Das Gegenbuch fuehrt rehashCount/rehashLast im Buch mit und meldet jede neue Neuberechnung seit der letzten Beglaubigung mit exit 2, samt Zeitpunkt, Zeilenzahl und dem Befund, der unmittelbar davor galt. Dieselbe Lehre wie R184-01 (Gate am Ausloeser statt an der Wirkung) und R185-01 (Wurzelwechsel statt valid). Reihenfolge geaendert, und das war noetig: Der neue Melder steht VOR dem Kopf-Hash-Vergleich, sonst haette immer die unpraezisere Meldung gewonnen. Und der Kopf-Vergleich wird nach einer bestaetigten Neuberechnung uebersprungen - sonst waere die Bestaetigung wertlos, weil ein Rehash den Kopf zwangslaeufig aendert. Beim Bauen aufgefallen, nicht im Entwurf. NOTARY_REHASH_ACK wird mit der ID des Rehash-Eintrags bestaetigt, nicht mit true; IDs steigen streng, ein stehen gelassener Wert passt beim naechsten Vorgang nicht mehr. Ein fehlender beglaubigter Eintrag alarmiert weiterhin immer: bestaetigt wird die Neuberechnung, nicht das Verschwinden von Zeilen. Getestet mit echtem Gegenbuch gegen ein echtes CRM, SSH-signiertes lokales Buch: Waesche ohne Datenbankzugriff -> CRM meldet valid:true und chainGaps:[], Gegenbuch exit 2 mit der Neuberechnung als Ursache. Falsche Ack-ID weiter exit 2, richtige exit 0, Folgelauf ruhig. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
df442bb1a0 |
verify meldet, dass die Kette neu berechnet wurde
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 loeschten 3155 Eintraege (id 1-5356), danach lief ein Rehash. Seither meldet die Pruefung "Alle Eintraege sind unveraendert und lueckenlos verkettet". Wahr - und praktisch das Gegenteil dessen, was ein Leser mitnimmt. Der Rehash verknuepft alles neu; die rund 700 Kettenluecken, 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 Loeschung nicht mehr feststellbar. verifyIntegrity sammelt jetzt die Rehash-Marker; die Antwort enthaelt rehashes[] mit Zeitpunkt, Anzahl, Signatur und Vorbefund. Die Meldung nennt sie IMMER, auch im gruenen Fall, und der Einstiegssatz lautet dann "...lueckenlos verkettet - allerdings erst seit der letzten Neuberechnung". valid bleibt unberuehrt. Ein Rehash ist eine legitime Massnahme; ihn dauerhaft als Befund zu fuehren waere der Dauer-Alarm, den wir mit den beglaubigten Luecken gerade beseitigt haben. Melden, nicht alarmieren. Umgekehrte Beweislast als bei Manifest und Siegel: Dort zaehlen nur signierte Traeger, weil ein gefaelschter Marker Luecken wegerklaeren koennte. Hier erzeugt ein Marker eine Warnung - wuerden nur signierte zaehlen, koennte man einen Rehash unsichtbar machen, indem man seine Signatur zerstoert. Deshalb zaehlt jeder auswertbare Marker; eine fehlende Signatur wird zusaetzlich gemeldet. Getestet ueber HTTP gegen eine Wegwerf-DB mit echtem Rehash ueber den regulaeren Endpunkt: sauberer Vorzustand, Loeschung+Rehash (der Staging-Ablauf im Kleinen), und zerstoerte Signatur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
31c4c209e4 |
status.txt nennt den Grund, nicht nur das Etikett
In der Statusdatei des Gegenbuchs stand fuer 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 ausschliesslich die Statusdatei liest, und genau dafuer ist sie da
("damit eine Ueberwachung 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.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
fc6f39eba0 |
Aufsicht und Eingriff getrennt: neuer Haken "Audit-Betrieb"
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> |
||
|
|
909e523634 |
Siegel ueber null Blaettern meldet nicht mehr "intakt"
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> |
||
|
|
23505afc05 |
audit:export gatete nichts - Export hing an audit:read
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> |
||
|
|
d2460fa7c0 |
Rolle "Gegenbuch": Leserecht aufs Audit-Protokoll ohne audit:admin
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>
|
||
|
|
ef2411ebe4 |
Eingegrenzter Export lieferte alles (Pentest R186-01, MEDIUM)
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> |
||
|
|
e81a83ae8f |
Siegelwechsel ist ein Alarm, kein Hinweis (Pentest R185-01/-02)
R185-01 (MEDIUM): Die Flanke, die wir selbst gemeldet hatten, hat der
Tester live bestaetigt. seal-backlog war beim ZWEITEN Aufruf genauso
gegatet wie beim ersten ({"confirm":"SEAL"} -> 200), und das Ereignis
landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
automatische Rueckhalt des Gegenbuchs haengt an `valid` - und `valid`
ueberlebt ein ersetzendes Siegel per Konstruktion. Angriff: Altzeile per
DB-Zugriff loeschen, neu siegeln, Luecke ist beglaubigt, valid wieder
true. Live reproduziert.
Zwei Schichten, in seiner Reihenfolge:
1. Alarmkanal. Neuer SecurityEventType AUDIT_SEAL_CHANGED (Migration
20260826120000). Erstes Siegeln HIGH, Ersetzen CRITICAL - geht damit
ueber sendPendingCriticalAlerts sofort per Mail raus. Die Details
halten Wurzel vorher/nachher und den vollstaendigen Vorbefund fest.
2. Gate. Steht bereits ein Siegel, verlangt der Endpunkt
{"confirm":"RESEAL"} statt SEAL, mit einem Text, der sagt, was dabei
verloren geht. Ein Austausch der Beweisgrundlage soll nicht dasselbe
Wort haben wie das Einrichten.
Und im Gegenbuch selbst: dort stand fuer den Wurzelwechsel ein
console.warn, waehrend der Rueckgabecode auf 0 blieb - also exakt das
Muster, das wir dem CRM zweimal angekreidet haben (R179, R183-02), im
Werkzeug, das dagegen gebaut wurde. Jetzt exit 2, mit alter und neuer
Wurzel samt Blattzahl; "10 Blaetter -> 9 Blaetter" zeigt die Loeschung
sofort. Auch die Erstsiegelung meldet sich, statt stillschweigend
uebernommen zu werden.
Aufloesbar gemacht: der Alarm bricht ab, BEVOR angehaengt wird - ohne
Bestaetigungsweg haette auch ein legitimes Siegeln fuer immer alarmiert
(R183-03-Falle). Neu ist NOTARY_SEAL_ACK, bewusst nicht "true", sondern
die Wurzel selbst (mind. 16 Zeichen): ein stehen gelassener Wert passt
beim naechsten Wechsel nicht mehr und kann keinen weiteren Austausch
durchwinken.
R185-02 (LOW): GET /api/audit-logs?action=<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>
|
||
|
|
7af6b7591b |
Integritaetsstatus des Audit-Protokolls in der Oberflaeche
Der Zustand der Hash-Kette war bisher nur per POST /api/audit-logs/verify einsehbar - also praktisch nur fuer das Gegenbuch und fuer jemanden mit curl. Jetzt steht er oben auf Einstellungen -> Audit-Protokoll. Vier Zustaende statt gruen/rot: unversehrt; Befund; unversehrt aber ungeschuetzter Altbestand (kein_siegel); nicht vollstaendig pruefbar (signierte Zeilen ohne AUDIT_HMAC_KEY). Die beiden mittleren sind bewusst nicht gruen - ein Protokoll mit unversiegeltem Altbestand ist rechnerisch stimmig, aber am Altbestand unbemerkt aenderbar, und ein Protokoll, das mangels Schluessel nicht pruefbar ist, ist schlicht ungeprueft. Beides als "alles in Ordnung" zu zeigen waere genau die Klasse Fehler, die diese Runde behandelt hat. Schlaegt die Pruefung selbst fehl, steht dort ausdruecklich, dass das keine Entwarnung ist. Aufklappbare Einzelheiten trennen die unterschiedlich schweren Kategorien: nachtraeglich veraendert (ernst) / Verkettung unterbrochen / davon ohne dokumentierte Loeschung / davon vom Siegel beglaubigt / ohne Schluessel nicht pruefbar - mit Erklaerung im Klartext. Die Pruefung liest die gesamte Kette; sie laeuft daher einmal beim Oeffnen der Seite und wird 5 Minuten wiederverwendet. Bewusst read-only: kein Siegel- oder Rehash-Knopf, denn diese Eingriffe verlangen audit:admin und eine ausdrueckliche Bestaetigung und gehoeren nicht neben eine Statusanzeige, die man im Vorbeigehen anklickt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ecaeae48d4 |
Beglaubigte Alt-Luecken: Dauer-Alarm im Gegenbuch beendet
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>
|
||
|
|
e504b8be96 |
Dienstkonto-Flag: Gate in beide Richtungen, richtige Rechte-Domaene (R184)
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> |
||
|
|
b3a9ef6372 |
Dienstkonto-Kennzeichen gegatet und laut protokolliert (Pentest R184)
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> |
||
|
|
b696980793 |
Dienstkonto-Kennzeichen in der Benutzerverwaltung setzbar machen
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> |
||
|
|
910c94daa1 |
Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)
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>
|
||
|
|
601fb03b22 |
Gegenbuch-Container: Rechte am Bind-Mount selbst geraderuecken
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. Der .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 weiterhin unprivilegiert. Schlaegt das chown fehl (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, erzeugte Dateien gehoeren 1000:1000, der private Schluessel liegt mit 0600. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1eb65809ec |
Gegenbuch: Dienstkonto statt Token, .env mit gueltigen Werten
Blocker behoben: Ich hatte ein dauerhaftes API-Token vorausgesetzt - das gibt es in OpenCRM nicht. Zugangstoken leben 15 Minuten, der Gegenbuch-Container waere nach dem ersten Durchlauf gestorben. Aufgefallen erst durch die Frage des Betreibers, 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 (falsches Passwort, Anmelde-Bremse, CRM nicht erreichbar) werden unterschieden und im Klartext gemeldet. .env.example nennt jetzt zu jedem Schalter die gueltigen Werte - bisher liess sich nur raten, ob es prod oder production heisst. Einschliesslich des Falls "erst nur Staging testen, Prod spaeter dazunehmen". 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 faellt auch auf, wenn das Gegenbuch aufhoert zu arbeiten. Verifiziert gegen eine Attrappe, die wie das echte CRM eine Anmeldung verlangt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cb9f1f5fce |
Gegenbuch: Daten im Projektverzeichnis statt in Docker-Volumes
Projektkonvention wie beim Hauptstack: Bind-Mount auf tools/audit-notary/data/<instanz>/ statt benannter Volumes. Wichtiger Nebeneffekt, der vorher fehlte: Das Verzeichnis war nicht von der Versionsverwaltung ausgenommen - Signaturschluessel und Gegenbuch waeren beim naechsten Commit im Repository gelandet. Jetzt ist der Inhalt ignoriert, waehrend die Verzeichnisse selbst ueber .gitkeep bestehen bleiben. Letzteres ist noetig, weil Docker fehlende Bind-Mount-Ziele als root anlegt und der Container als UID 1000 laeuft - der erste Start waere sonst am Schreibrecht gescheitert. Verifiziert mit echtem docker compose gegen eine CRM-Attrappe: Buch, Schluessel, Beobachtungsspeicher und status.txt landen unter tools/audit-notary/data/prod/, Normalbetrieb exit 0. Testcontainer, Image, Attrappe und .env danach entfernt; nur die drei .gitkeep bleiben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a62c51b7c7 |
Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine
Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer eigenen Maschine fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch liegt lokal. 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. tools/audit-notary/ enthaelt jetzt Dockerfile, entrypoint.sh, docker-compose.yml und .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. Lokaler Betrieb ist jetzt ein vollwertiger Modus statt eines Testschalters. Die Erfolgsmeldung benennt bei jedem Lauf, was abgedeckt ist und was nicht - statt der frueheren pauschalen Formulierung "kein Manipulationsschutz", die im Einsatz auf eigener Maschine schlicht falsch war. Verifiziert mit echtem Docker-Build gegen eine CRM-Attrappe: Genesis ohne Bestaetigung -> Code 4; mit Bestaetigung Normalbetrieb exit 0; Eintrag veraendert -> Alarm exit 2; Eintraege geloescht -> Alarm exit 2; Siegel verschwunden -> Alarm. Behoben beim Bauen: useradd -u 1000 || true verschluckte, dass UID 1000 im Node-Image vergeben ist - der Container startete gar nicht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
773033936d |
Gegenbuch: Pruefmodus schreibt nicht mehr, Widerspruch aufgeloest (R181)
R181-01: Der Pruefmodus sagte zu, nichts zu veraendern und kein Schreibrecht zu brauchen - und pushte trotzdem, weil ankerNachziehen() in jedem Modus lief. Ein read-only Audit mutierte damit still das geteilte Substrat. R181-02: Ein Widerspruch zwischen den eigenen Fixes. R180-01 erhebt die Serversperre auf refs/notary/* zur tragenden Pflicht, R180-02 verlangt dort Schreibrecht zur Selbstheilung. Sobald je ein Anker fehlte, bekam jeder read-only pruefende Auditor dauerhaft einen Fehler auf einer voellig gueltigen Kette, den er nicht beheben konnte. Fix: Reparieren nur im Notar-Schreiblauf, im Pruefmodus wird der fehlende Anker gemeldet. Eigener Rueckgabecode 5: "Anker unvollstaendig" ist nicht "nicht feststellbar" - die Kette ist gueltig, nur das Substrat-Gedaechtnis unvollstaendig, ein benannter reparierbarer Defekt. Dieselbe Trennung wie bei Genesis/Adoption. Empirisch beantwortet: Die Notar-Identitaet laesst sich eng auf das Anlegen von refs/notary/* beschraenken, ohne Loeschen oder Ueberschreiben - serverseitig unterscheidbar an der Null-OID. Mit pre-receive-Hook verifiziert: Backfill greift, Loeschen und Force-Overwrite bleiben abgewiesen. Hook als Beispiel in der README. Verifiziert: read-only --check mit fehlendem Anker -> Code 5, nichts gepusht; Notar-Schreiblauf unter derselben ACL -> Anker nachgetragen, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a954f0f736 |
Gegenbuch: Anker belegen nichts mehr, Anker-Verlust wird laut (R180-01/-02)
R180-01: 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, aber nie Unversehrtheit belegen. Der Code-4-Diskriminator haengt nicht mehr an ihnen und greift nur im Schreiblauf. R180-02: Der Anker-Push-Fehlschlag war nur eine Warnung mit exit 0 - ausgerechnet bei der tragenden Eigenschaft. Der Normalbetrieb senkte damit den Hoechststand still um eins. Fix: exit 3 bei Fehlschlag, und jeder Lauf zieht fehlende Anker nach, bevor er etwas als OK meldet. Verifiziert: Teil-Loeschung mit frischem Auditoren-Klon -> exit 3 mit Vorbehalt statt exit 0; Anker-Push per Hook abgelehnt -> exit 3 statt Hinweis; Folgelauf traegt den fehlenden Anker nach. README: Der Schutz von refs/notary/* gegen Loeschen und Ueberschreiben ist von der Fussnote zur tragenden Voraussetzung erhoben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1a252c5059 |
Gegenbuch: Gedaechtnis ins Substrat verlegt, Verlust laut behandelt (R180)
"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 gekoppelt. Fix: Jeder verankerte Checkpoint bekommt einen eigenen Ref refs/notary/seq-N. Der ueberlebt einen Force-Push auf den Zweig - die hoechste je existierende Nummer ist damit aus dem Server rekonstruierbar. Geprueft wird, ob der hoechste verankerte Checkpoint noch im aktuellen Kopf enthalten ist und ob die Reihe mindestens so lang ist wie verankert. Laute Verlustbehandlung mit Diskriminator "traegt der Remote schon Checkpoints?": keine Historie -> Genesis, einmalig NOTARY_GENESIS_ACK; Historie vorhanden aber kein Gedaechtnis -> Anomalie, Code 4, keine stille Adoption, erst nach NOTARY_ADOPT_ACK. Verifiziert: Genesis ohne Bestaetigung -> Code 4; mit Bestaetigung Kette aufgebaut samt refs/notary/seq-1..5; Rewind -> exit 2 auch nach Loeschen des lokalen Speichers und bei jedem Folgelauf (vorher: ein exit 3, danach dauerhaft gruen); zusaetzlich Anker-Refs geloescht -> Code 4 statt stiller Uebernahme. Dokumentiert: Die Anker-Refs muessen serverseitig ebenfalls vor Loeschen und Ueberschreiben geschuetzt sein, sonst verschiebt sich das Problem eine Ebene. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b6b6f7c0a7 |
Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (R179 a/b)
(a) Probe-Push verworfen. Er haette nur den geprobten Ref beurteilt, die eigene Push-Identitaet gemessen statt die des Angreifers (Bypass-Rechte fuer Admins gehen genau dann auseinander, wenn es zaehlt), nur einen Zeitpunkt abgedeckt - und einen zerstoerungsfreien Force-Push gibt es nicht: die bestaetigende Beobachtung waere derselbe Vorgang wie der Schaden. Stattdessen ein Fast-Forward-Waechter: Der beobachtete Remote-Kopf wird ausserhalb des Klons festgehalten; beim naechsten Lauf muss der neue Kopf ein Nachfahre des alten sein. Das erkennt das Ereignis statt die Regel abzufragen und wirkt unabhaengig von serverseitigem Schutz. Ein belegter Fast-Forward gilt als Nachweis und blendet den Rewind-Vorbehalt aus. (b) Code 3 als Tor vor dem Anhaengen statt als Status danach: Der Schreiblauf signiert mit dem neuen Checkpoint zugleich ueber den Bestand darunter - ist die Basis ungeklaert, waere das Anhaengen selbst das Waschmittel. Grundlage nicht feststellbar -> nichts anhaengen, exit 3. Erster Lauf -> Basislinie, ehrlich gemeldet, exit 3. Anhaengen geklappt, Push gescheitert -> exit 3 mit "erstellt, aber NICHT verankert". 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 (bisher stilles Gruen); kaputtes Push-Ziel -> "erstellt, aber nicht verankert" exit 3, Folgelauf haelt den ungepushten Commit fail-closed an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8d1ffc0df8 |
Gegenbuch: Unsicherheit erreicht jetzt den Rueckgabecode (Pentest R179)
R179-b: Der Rewind-Vorbehalt stand in der Ausgabe, der Exit blieb 0. Die 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, eine Ebene hoeher. Fix: Code 0 nur bei belegter Gewissheit (Rewind-Schutz zugesichert oder Mindesthoehe erfuellt), sonst Code 3 - bewusst nicht mit dem Manipulationsalarm 2 verschmolzen. R179-01: NOTARY_MIN_SEQ ist eine Untergrenze, kein Ist-Stand. Ein veralteter Wert liess einen Teil-Rewind darueber lautlos durch, und die blosse Praesenz einer Zahl blendete den Vorbehalt aus - MIN_SEQ=0 war ein Freibrief, ein Tippfehler wurde still verschluckt. Ein veralteter Anker erzeugte damit ein selbstbewussteres Ergebnis als gar keiner. Fix: Der Vorbehalt haengt allein an NOTARY_REWIND_PROTECTED=true und benennt bei gesetztem MIN_SEQ dessen Grenze; MIN_SEQ <= 0 oder unparsbar fuehrt zu exit 1 statt stiller Annahme. Verifiziert gegen den Pentest-Aufbau (10 Checkpoints, Rewind auf 7, DB passend gekuerzt, frischer Klon): nicht gesetzt -> exit 3 (vorher 0); 10 und 8 -> Alarm 2; 7 veraltet -> 0 mit Vorbehalt (vorher ohne); 0 und xyz -> exit 1 (vorher stilles 0); Rewind-Schutz zugesichert -> 0 ohne Vorbehalt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d50d8f6036 |
Gegenbuch: Rewind auf signierten Praefix benennbar gemacht (Pentest R178-01)
Das 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 - ausgerechnet im dokumentierten Pruef-Fall. Das laesst sich im Skript nicht kryptographisch erkennen, die Historie ist echt. Deshalb zwei Dinge statt eines Scheinfixes: NOTARY_MIN_SEQ als Bezugspunkt (ist die Reihe kuerzer, Alarm; jeder Lauf nennt die Nummer), und ohne diesen Bezugspunkt sagt die Erfolgsmeldung ausdruecklich, dass ein Zurueckspulen nicht erkennbar war. README: serverseitiger Rewind-Schutz (non-fast-forward verbieten) jetzt als Pflicht formuliert, samt Begruendung und dem Hinweis, dass ein frischer Klon den Rewind nicht sieht. Kleinkram: NOTARY_SIGNER_FINGERPRINT wird beim Einlesen getrimmt (ein Zeilenumbruch loeste 4/4 Fehlalarme aus, die auf den korrekten Fingerabdruck zeigten); die "nie gepusht"-Meldung priorisiert Untersuchen statt Pushen. Beim Testen selbst gefunden: der erste git-Aufruf war ungeschuetzt und warf bei kaputtem Klon einen Stacktrace. Verifiziert: Rewind 3->1 mit gekuerzter DB -> frischer Klon ohne Bezugspunkt OK mit Vorbehalt, mit NOTARY_MIN_SEQ=3 -> Alarm exit 2; Pin mit Zeilenumbruch kein Fehlalarm; regulaerer Lauf unveraendert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8d2dfb8be1 |
Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (R177)
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 konnte das Gegenbuch waschen und force-pushen, und %G? war G. R176-01 hatte "jeder selbst erzeugte Schluessel" geschlossen, "jeder erlaubte Schluessel" blieb offen. Fix: Pin wird aus user.signingkey abgeleitet; 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 dauerhaft rot, behebbar nur per Force-Push, den die Branch-Protection gerade verhindern soll. Fix: Pin-Abgleich am frischen Commit vor dem Push, bei Abweichung Ruecknahme. Kleinkram: NOTARY_ALLOW_LOCAL faerbt Erfolgsmeldungen ein und pusht nicht mehr ins Leere; CRM-Fehler liefern eine erklaerende Zeile statt Node-Stacktrace. Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff -> Alarm exit 2 auch ohne gesetzten Pin; Schreiblauf mit falschem Schluessel -> zurueckgerollt, nichts gepusht; CRM nicht erreichbar/401 -> saubere Meldung; Lokalmodus eingefaerbt; saubere Historie ohne Fehlalarm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5952fb1894 |
Gegenbuch: nur vertrauenswuerdige Signaturen, fail-closed ohne Remote (R176)
R176-01 (HIGH): Das Signatur-Gate akzeptierte %G? = G ODER U. Bei SSH-Signaturen bedeutet U woertlich "gute Signatur, aber kein passender Principal" - der Schluessel steht also NICHT in allowed_signers. Damit passierte jeder selbst erzeugte Schluessel das Gate und der einzige In-System-Vertrauensanker war wirkungslos. End-to-end reproduziert: Gegenbuch mit fremdem Schluessel re-signiert und force-gepusht -> "OK", exit 0. Vorbedingung war nur Remote-Schreibrecht, kein Host-Zugriff. Fix: nur G an beiden Stellen, dazu 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 Ausfalls gruenes Licht auf nicht notarisiertem Zustand. Fix: fail-closed mit Code 3, ebenso ohne konfigurierten Remote (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 gegen echten Remote: Angriff mit fremdem Key + Force-Push -> Alarm exit 2 (vorher OK); Remote unerreichbar -> exit 3; kein Remote -> exit 3; falscher Fingerabdruck-Pin -> Alarm; saubere Historie ohne Fehlalarm. Rueckgabecodes in der README dokumentiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
375d4ae1e2 |
Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)
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> |
||
|
|
f3ded9afbc |
Gegenbuch: externe Notarisierung der Audit-Kette
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> |
||
|
|
2d55fd23f9 |
Siegel-Entfernung wird erkannt (Pentest R174-01, HIGH)
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> |
||
|
|
ac62198a01 |
Bestandssiegel betriebstauglich gemacht (Pentest R173-01/-02/-03)
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>
|
||
|
|
2932598c98 |
Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02)
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> |
||
|
|
2b0af772a2 |
Manifest-Kanal abgesichert (Pentest R171-01 HIGH, R171-03 LOW)
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> |
||
|
|
32c9efacda |
rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)
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>
|
||
|
|
791711ca58 |
Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01)
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> |
||
|
|
9fcab6f17e |
Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)
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> |
||
|
|
044a12f73e |
Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)
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> |
||
|
|
1a349d142e |
hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)
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> |
||
|
|
f2a4baacdb |
Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)
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> |
||
|
|
c7d6b6de7e |
Audit-Pruefung: "manipuliert" von "Luecke" getrennt + Retention fuer Routine-Auth
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> |
||
|
|
477a850a91 |
Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)
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> |