77aeb69aebc553a91de3a55e438b6bc6729c2d81
457
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
3a50a40ad5 |
Versiegeln auffindbar machen: Titel und Inhaltsverzeichnis
Der Abschnitt war vollstaendig, aber nicht zu finden. Er hiess "Wenn der erste Lauf exit=2 meldet" - also nach dem Symptom benannt, nicht nach der Aufgabe - und stand in Zeile 89 einer 706-Zeilen-Datei ohne Inhaltsverzeichnis. Wer "wie versiegle ich" suchte, fand ihn nicht; genau das ist dem Betreiber passiert, obwohl der Text bei ihm lag. Jetzt "Den Altbestand versiegeln (einmalig, im CRM)", dazu ein Inhaltsverzeichnis nach Anlass gegliedert: einrichten, im Betrieb, wenn Alarm kommt, zum Nachlesen. Mit einem ausdruecklichen Hinweis, dass der Versiegelungs-Abschnitt eigenstaendig ist und auch ohne Gegenbuch gilt. Der Verweis im Haupt-README zeigt jetzt direkt auf den Anker statt den Abschnittstitel zu zitieren - so laeuft er beim naechsten Umbenennen nicht ins Leere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e76b4ace88 |
status.txt zeigt die Zusammenfassung, nicht die erste beliebige Zeile
Die Grund-Zeile nahm bei fehlendem Alarm schlicht die erste Ausgabezeile. Beim ersten Prod-Lauf nach dem Versiegeln stand dort deshalb die einmalige Notiz "Grundlage fuer die Rehash-Ueberwachung wird gesetzt" statt des Ergebnisses "OK: Checkpoint N erstellt" - eine Randnotiz verdeckte die Zusammenfassung. Jetzt: bei Alarm die ALARM-Zeile, sonst die OK-Zeile, und erst wenn beides fehlt die erste Zeile ueberhaupt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ed676ca4eb |
Versiegeln dokumentiert: Abschnitt aktualisiert und im Haupt-README verlinkt
Der Abschnitt in tools/audit-notary/README.md war an drei Stellen ueberholt. Er sagte "nimm dein Administratorkonto" - seit der Aufteilung von DSGVO und Audit-Betrieb hat die Admin-Rolle gar keine Audit-Rechte mehr, das Recht kommt jetzt ueber den Haken "Audit-Betrieb". Er kannte weder das RESEAL-Gate noch die Rehash-Anzeige noch den Zustand "leer". Neu geschrieben, mit dem, was die Praxis gezeigt hat: - Wer darf es, und warum nicht ueber DSGVO oder Admin - Vier Felder, die vor dem Siegeln stimmen muessen - allen voran rehashes:[], denn nach einer Neuberechnung sagt chainGaps nichts mehr ueber die Zeit davor - Den HTTP-Code beim Pruefen IMMER mit ausgeben: Token laufen nach 15 Minuten ab, und ohne den Code liest sich eine 401 wie ein fehlender Eintrag. Genau das ist uns bei der Vorbereitung des Prod-Siegels passiert und haette fast zu einem falschen Stopp gefuehrt. - Die drei Punkte zur Gegenpruefung danach, inklusive "intakt" statt "leer" - Der Hinweis, dass ein unversiegeltes CRM nicht "bewacht mit Warnung" ist, sondern unbewacht: Das Gegenbuch bricht vor dem Anhaengen ab. Ausserdem im Haupt-README verlinkt. Wer AUDIT_HMAC_KEY auf einer bestehenden Installation setzt, hat zwangslaeufig einen Altbestand - erfuhr davon aber nur, wenn er ein Gegenbuch betreibt. Die Anleitung gilt auch ohne. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
389fd30094 |
Build-Fehler im Benutzerformular behoben
Zwei Typfehler aus dem vorigen Commit: Der Formular-Reset beim Anlegen eines neuen Benutzers kannte isServiceAccount nicht, und der Typ von userApi.create ebenfalls nicht. Ursache meines Uebersehens: Ich hatte mit 'npx vite build' geprueft, das Projekt nutzt aber 'npm run build' = 'tsc && vite build'. Die Typpruefung lief bei mir damit gar nicht mit. Ab jetzt npm run build. 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>
|
||
|
|
41671cbb96 |
Dienstkonto-Anmeldungen als Routine statt CRITICAL (Pentest R182)
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> |
||
|
|
1f53a3304b |
Doku: Erster Start des Gegenbuchs als Drei-Schritt-Ablauf
Die Genesis-Bestaetigung stand nur als Nebensatz in der Anleitung. Jetzt als eigener Abschnitt mit den drei Schritten (freigeben, starten, wieder leeren), der erwarteten Ausgabe und der Begruendung, warum das Leeren wichtig ist: Bleibt die Zeile gesetzt, wuerde nach einem Verlust des Datenverzeichnisses stillschweigend eine neue Grundlage gesetzt statt nachgefragt. 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> |
||
|
|
89d617ab70 |
Doku: Gegenbuch in der Haupt-README, zwei Betriebsarten getrennt
Zwei Luecken, beide beim Nachlesen aufgefallen: 1. Die Haupt-README erwaehnte das Gegenbuch mit keinem Wort - ausgerechnet dort, wo der Betreiber nach einem git pull nachschaut. Jetzt ein Abschnitt direkt nach dem Audit-Siegel: wozu es gut ist, die Richtung der Verbindung (Gegenbuch holt, CRM kennt es nicht), die vier Befehle zum Einrichten und der Verweis auf die ausfuehrliche Anleitung. Ausdruecklich als optional gekennzeichnet - ohne Gegenbuch bleibt der Schutz in der Anwendung vollstaendig. Projektbaum ergaenzt. 2. Die Notar-README stammte aus der Zeit mit externem Git-Server. Fuer den lokalen Betrieb standen darin Anforderungen, die es dort gar nicht gibt - "Force-Push serverseitig sperren, das ist Pflicht" ist ohne Server sinnlos und verwirrt. Jetzt eine Entscheidungstabelle ganz oben (lokal = Normalfall vs. externes Repository = zusaetzliche Haertung), und alles Server-Bezogene gesammelt unter einer eigenen Ueberschrift mit dem Hinweis, dass es im lokalen Betrieb uebersprungen werden kann. 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> |
||
|
|
655d20db23 |
.env.example: Caddy-Standard "aus" unmissverstaendlich gemacht
Der Schalter stand bereits standardmaessig auf aus (COMPOSE_PROFILES leer),
aber eine leere Zeile liest sich wie ein vergessener Wert - genau diese Frage
kam beim Audit-Schluessel schon einmal auf. Jetzt steht der Standard an der
Zeile selbst ("leer lassen = Caddy aus") und im Kommentarblock mit Pfeil
markiert. Die drei Caddy-Werte darunter sind als "nur bei aktivem Caddy
noetig" gekennzeichnet.
Vorher geprueft, dass Kommentare hinter dem Wert von compose sauber
abgeschnitten werden (WERT=3010 # Kommentar -> 3010) - die Datei nutzt diesen
Stil bereits an anderer Stelle. Anschliessend mit der woertlichen Zeile aus
.env.example gegengetestet: nur der ungeschuetzte Dienst startet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ffeb4dbfd8 |
Caddy per Schalter in der .env statt per CLI-Flag
Frage aus dem Betrieb: laesst sich ein Compose-Abschnitt ueber eine Variable in der .env zu- und abschalten, statt ihn auszukommentieren? Bedingte Bloecke kennt Compose nicht - aber COMPOSE_PROFILES darf in der .env stehen und aktiviert das Profil, ohne dass ein Flag noetig ist. COMPOSE_PROFILES= -> Caddy wird nicht angelegt (Standard) COMPOSE_PROFILES=caddy -> Caddy startet bei `docker-compose up -d` mit Vorteil gegenueber `--profile caddy`: der Schalter wirkt auch bei down, logs und ps, wo das Flag leicht vergessen wird und dann verwaiste Container zurueckbleiben. Empirisch geprueft mit einem separaten Testprojekt (compose 1.29.2): ohne den Eintrag startete nur der ungeschuetzte Dienst, mit Eintrag beide. Dokumentiert in .env.example, im Kommentar am caddy-Dienst und in der README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
22501f4650 |
Nur noch eine docker-compose.yml und eine .env
Der zweite Stack unter docker/ war ein Duplikat aus Februar, das nie mitgepflegt wurde - und genau deshalb schwer zu finden und leicht falsch zu bedienen. Statt ihn zu loeschen (Caddy ist fuer Betreiber ohne eigenen Reverse-Proxy zurecht gewuenscht) ist er jetzt in den Hauptstack integriert: - caddy als optionaler Dienst mit `profiles: ["caddy"]` in docker-compose.yml. Ohne `--profile caddy` wird er nicht einmal angelegt - empirisch geprueft mit einem separaten Testprojekt: `up -d` startete nur den ungeschuetzten Dienst. Der laufende Stack bleibt damit unveraendert. - Caddyfile in den Projektstamm verschoben, proxy-Ziel auf den Dienstnamen `opencrm` angepasst, Zertifikate unter ./data/caddy wie alle anderen Daten. - DOMAIN, CADDY_DIR und CADDY_CONFIG_DIR in die .env.example aufgenommen. - docker/ entfernt (Dockerfile, entrypoint.sh, docker-compose.yml, .env.example, README.md). Das dortige Dockerfile basierte noch auf Alpine - genau die Variante, von der das Projekt wegen Prisma-/TLS-Problemen bewusst auf node:20-slim gewechselt ist. Das gepflegte backend/Dockerfile baut Frontend und Backend ebenso. - README: Abschnitt "Docker (Produktion)" ersetzt durch "Betrieb mit eigenem SSL", inkl. Hinweisen zu Let's-Encrypt-Limits, DNS-Voraussetzung, HTTPS_ENABLED und dem weiterhin offenen App-Port. Projektbaum und .dockerignore nachgezogen. Geprueft: docker-compose config gueltig, caddy validate gueltig, laufende Container unberuehrt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ee83b09ed8 |
Caddy-Stack (docker/) auf Stand gebracht statt entfernt
Der Stack bleibt bewusst erhalten: Er richtet sich an Betreiber OHNE eigenen Reverse-Proxy, weil Caddy das Zertifikat selbst holt und erneuert. Er war allerdings seit Februar stehengeblieben und reichte zehn Variablen nicht an den Container durch - teils sicherheitsrelevant. Behoben: - HTTPS_ENABLED ergaenzt (Default true, Caddy terminiert TLS). Fehlte bisher komplett, dadurch waere der Refresh-Cookie OHNE Secure-Attribut gesetzt worden und trust proxy falsch gewesen. - JWT_EXPIRES_IN Default von 7d auf 15m korrigiert - der Wert galt dem ACCESS-Token und stammte aus der Zeit vor dem Access-/Refresh-Pattern. Ein Access-Token mit einer Woche Lebensdauer im Browser-Speicher macht das XSS-Fenster unnoetig gross. - JWT_REFRESH_EXPIRES_IN, CORS_ORIGINS, LISTEN_ADDR, SSRF_BLOCK_PRIVATE_IPS ergaenzt. JWT_REFRESH_EXPIRES_IN stand bereits in docker/.env.example, wurde aber nie durchgereicht - dieselbe Fehlerklasse wie beim Audit-Siegel. - Caddyfile: gzip fuer /api/* deaktiviert (BREACH, CVE-2013-3587). Die Konfiguration komprimierte bisher alles, obwohl die README das fuer die API ausdruecklich ausschliesst. Statische Assets bleiben komprimiert. Mit `caddy validate` geprueft. - docker/.env.example um die neuen Variablen erweitert, dazu ein Hinweis auf Sonderzeichen im DB-Passwort (dieser Stack setzt die DATABASE_URL direkt zusammen, anders als der Entrypoint im Projektstamm). - README benennt jetzt, wann welcher Stack der richtige ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0ae6b13d6c |
Audit-Siegel: Schluessel erreicht jetzt tatsaechlich den Container
Ohne diesen Fix waere das Siegel im Docker-Betrieb wirkungslos geblieben - die Variable wurde nirgends an den Container durchgereicht. docker-compose.yml listet die Umgebungsvariablen einzeln auf und speist sie aus der .env im Projektstamm; ein env_file gibt es nicht, backend/.env wird vom Container also gar nicht gelesen. Dokumentiert und ergaenzt hatte ich bisher nur backend/.env.example - also die Datei, die fuer den Container irrelevant ist. - AUDIT_HMAC_KEY und AUDIT_HMAC_KEY_OLD in docker-compose.yml und in docker/docker-compose.yml aufgenommen (beide mit :- Default, damit ohne Wert weiterhin fail-safe ungesiegelt geschrieben wird) - beide Variablen samt Erklaerung in die .env.example im Projektstamm und in docker/.env.example uebernommen - README benennt jetzt explizit, welche Datei fuer welchen Betrieb gilt (Stamm-.env bei Docker, backend/.env nur ohne Container) Verifiziert: `docker-compose config` loest AUDIT_HMAC_KEY korrekt auf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |