Compare commits

...
52 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 5 cca242119b R188/R189-01 aus dem Pentest umgesetzt - Klasse statt Instanz
Beide Findings der Pentesterin waren berechtigt. Ihre Patches liessen
sich nicht anwenden (Basis 791711c, seitdem 42 Commits, sync-roles.ts
kollidiert), und an zwei Stellen greifen sie zu kurz.

R188 - ungueltige IDs im Pfad
-----------------------------
Gemeldet: GET /api/users/:id gibt bei nicht-numerischer ID 500 statt 400.
Ihr Fix schliesst nebenbei mehr, als sie beansprucht: parseInt('12abc')
ergibt 12, also lieferte /api/users/12abc bisher Benutzer 12 aus.

Es waren aber 181 ungepruefte Stellen in 19 Controllern, nicht eine. 181
Einzel-Guards waeren genau der Fehler aus R186-01 gewesen - drei
Filterlisten, die dasselbe bedeuten sollten und auseinanderliefen.
Stattdessen router.param(), an einer Stelle fuer alle 33 Router
registriert, ueber einen mounte()-Helfer, der Pruefung und Einhaengen
zusammenbindet.

Antwort ist 404, nicht 400: Ein Pfadsegment, das keine ID sein kann,
benennt keine Ressource. Der bestehende Praezedenzfall in
provider.controller.ts (Pentest Mai 2026) hatte es genauso entschieden.

Dabei eine aeltere Heuristik abgeloest (Pentest Runde 7). Ihr eigener
Kommentar nannte den Grund fuer sie - "app.param() greift nicht auf in
Sub-Router gemounteten Routes" - und genau das loest mounte(). Sie war zu
eng (/users/abc ging durch und endete als 500) und zu weit (ein
Einstellungs-Schluessel 12abc unter :key wurde geblockt, obwohl das keine
ID ist), und sie antwortete 400, wo jetzt 404 steht.

R189-01 - DSGVO-Rechte ohne Traeger
------------------------------------
Gemeldet: gdpr:* und audit:read/export haengen an DSGVO und Developer,
die Admin-Rolle hat sie nicht, und nach einem frischen Seed war DSGVO
keinem Konto zugewiesen. Auskunft nach Art. 15 und Loeschung nach Art. 17
konnte niemand ausfuehren.

Seed weist admin@admin.com jetzt zusaetzlich die DSGVO-Rolle zu; die
Admin-Rolle selbst bleibt ohne diese Rechte, die Trennung aus R186 bleibt
also erhalten. Label ehrlich gemacht.

Beim Pruefen ihres Patches ein eigener Fund: seed.ts vergab an die
DSGVO-Rolle weiterhin audit:* komplett, inklusive audit:admin - die
Buendelung, die fc6f39e aufgeloest hat. Ich hatte damals zwei Listen
gefunden und die dritte uebersehen. Gerettet hat es nur die Reihenfolge
im Containerstart; ein einzelnes `npm run db:seed` brachte sie zurueck.

Der Seed hilft nur bei Neuinstallation (update: {}). Deshalb zusaetzlich
eine Wache beim Start: Gibt es fuer gdpr:export, gdpr:delete oder
audit:read kein aktives Konto, steht das mit Handlungsanweisung im Log -
Erkennung der ABWESENHEIT einer Faehigkeit, wie beim Heartbeat. Bewusst
nur melden, nicht automatisch vergeben.

Getestet ueber HTTP gegen eine Wegwerf-Instanz: alle ID-Varianten quer
ueber sechs Controller, Nicht-ID-Parameter unveraendert, frischer Seed,
Wache mit und ohne vergebene Rechte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:17:12 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 19:27:49 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 19:25:45 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 19:07:12 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 18:53:13 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 18:30:44 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 17:59:25 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 17:23:24 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 17:16:27 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 17:05:24 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 12:56:53 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 12:41:34 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 11:46:26 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 09:41:11 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 09:11:13 +02:00
duffyduckandClaude Opus 5 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>
2026-08-26 09:07:26 +02:00
duffyduckandClaude Opus 5 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>
2026-08-24 13:09:08 +02:00
duffyduckandClaude Opus 5 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>
2026-08-24 11:59:06 +02:00
duffyduckandClaude Opus 5 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>
2026-08-24 10:45:26 +02:00
duffyduckandClaude Opus 5 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>
2026-08-24 10:09:28 +02:00
duffyduckandClaude Opus 5 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>
2026-08-24 10:01:32 +02:00
duffyduckandClaude Opus 5 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>
2026-08-22 20:18:54 +02:00
duffyduckandClaude Opus 5 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>
2026-08-22 19:58:30 +02:00
duffyduckandClaude Opus 5 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>
2026-08-22 19:51:10 +02:00
duffyduckandClaude Opus 5 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>
2026-08-22 19:31:46 +02:00
duffyduckandClaude Opus 5 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>
2026-08-22 18:59:11 +02:00
duffyduckandClaude Opus 5 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>
2026-08-22 18:54:45 +02:00
duffyduckandClaude Opus 5 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>
2026-08-22 18:51:45 +02:00
duffyduckandClaude Opus 5 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>
2026-08-22 17:56:36 +02:00
duffyduckandClaude Opus 5 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>
2026-08-22 10:59:07 +02:00
duffyduckandClaude Opus 5 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>
2026-08-22 09:10:02 +02:00
duffyduckandClaude Opus 5 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>
2026-08-21 22:29:20 +02:00
duffyduckandClaude Opus 5 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>
2026-08-21 22:10:36 +02:00
duffyduckandClaude Opus 5 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>
2026-08-21 21:46:02 +02:00
duffyduckandClaude Opus 5 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>
2026-08-21 21:09:58 +02:00
duffyduckandClaude Opus 5 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>
2026-08-21 20:26:55 +02:00
duffyduckandClaude Opus 5 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>
2026-08-21 16:13:54 +02:00
duffyduckandClaude Opus 5 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>
2026-08-21 15:20:55 +02:00
duffyduckandClaude Opus 5 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>
2026-08-21 14:45:10 +02:00
duffyduckandClaude Opus 5 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>
2026-08-21 14:20:32 +02:00
duffyduckandClaude Opus 5 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>
2026-08-19 22:12:51 +02:00
duffyduckandClaude Opus 5 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>
2026-08-19 21:42:48 +02:00
duffyduckandClaude Opus 5 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>
2026-08-19 21:16:08 +02:00
duffyduckandClaude Opus 5 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>
2026-08-19 20:17:51 +02:00
duffyduckandClaude Opus 5 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>
2026-08-19 19:46:23 +02:00
duffyduckandClaude Opus 5 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>
2026-08-19 19:43:59 +02:00
duffyduckandClaude Opus 5 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>
2026-08-19 19:40:43 +02:00
duffyduckandClaude Opus 5 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>
2026-08-19 19:36:03 +02:00
duffyduckandClaude Opus 5 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>
2026-08-19 19:29:34 +02:00
duffyduckandClaude Opus 5 ab0d6214f2 Audit-Siegel: Platzhalter zaehlt nicht als Schluessel + Betriebsfalle dokumentiert
.env.example zeigt jetzt wie bei den anderen Secrets einen sichtbaren
Platzhalter an der Variablen statt eines leeren Werts - verstaendlicher, aber
mit Schutz dagegen: ein nicht ersetzter Platzhalter (enthaelt < oder >, oder
beginnt mit change/dein/your/hier) gilt NICHT als Schluessel. Sonst wuerde mit
einem oeffentlich im Repository stehenden Wert gesiegelt, was Sicherheit
vortaeuscht. Das Backend warnt in dem Fall im Log und laesst das Siegel aus.
Verifiziert: mit Platzhalter geschriebene Zeile bleibt hashVersion 2.

Dabei aufgefallen und dokumentiert: Wird das Siegel nach Aktivierung wieder
abgeschaltet (Schluessel fehlt, z. B. beim Rebuild verloren), sind die in
dieser Zeit entstandenen Eintraege ungesiegelt und werden beanstandet, sobald
der Schluessel zurueck ist. Das ist Absicht - ein ungesiegelter Eintrag
inmitten gesiegelter ist von einer Faelschung nicht zu unterscheiden. Warnung
in README und .env.example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:24:59 +02:00
duffyduckandClaude Opus 5 6de3a91aa7 .env.example: erklaert, warum beim Audit-Siegel kein Beispielwert steht
Die leeren Werte bei AUDIT_HMAC_KEY/AUDIT_HMAC_KEY_OLD wirkten wie ein
Versehen. Sie sind Absicht: leer bedeutet "Siegel aus" (fail-safe), und ein
Beispielwert waere hier gefaehrlicher als keiner - er stuende oeffentlich im
Repository, und jeder koennte damit Eintraege siegeln, waehrend der Betreiber
sich gesiegelt waehnt. Anders als bei JWT_SECRET, wo ein Platzhalter sinnvoll
ist, weil die Anwendung ohne Wert gar nicht laeuft.

Entsprechender Hinweis jetzt direkt ueber beiden Variablen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:21:33 +02:00
duffyduckandClaude Opus 5 fb0915df12 Audit-Siegel: Umstiegsweg dokumentiert + Rotations-Fussangel entschaerft
Frage aus dem Betrieb: bestehende Installation hat noch keinen
AUDIT_HMAC_KEY - was passiert beim nachtraeglichen Setzen? Antwort jetzt in
README und .env.example: setzen, neu starten, fertig. Bestehende Eintraege
bleiben unveraendert gueltig, neue werden gesiegelt, alt und neu koexistieren
ohne Fehlalarm. Nachgemessen auf gemischtem Bestand (4903 x V1, 57 x V2,
67 x V3): 0 Beanstandungen. Rueckwirkend siegeln ist nicht moeglich.

Dabei zwei Fehler in der eigenen Doku gefunden und korrigiert:

1. Behauptet war, nach dem Leeren von AUDIT_HMAC_KEY_OLD seien alte Eintraege
   "nicht mehr pruefbar". Tatsaechlich werden sie als MANIPULIERT gemeldet -
   ein falscher Schluessel ist von einer Faelschung nicht zu unterscheiden.
   Gemessen: 67 Eintraege als manipuliert. Nur wenn GAR KEIN Schluessel
   gesetzt ist, gilt "nicht pruefbar". Doku entsprechend korrigiert, inkl.
   Warnung, das Feld nicht voreilig zu leeren.

2. AUDIT_HMAC_KEY_OLD bot nur einen Platz. Beim ZWEITEN Wechsel waeren alle
   mit dem ersten Schluessel gesiegelten Eintraege faelschlich als
   manipuliert erschienen (reproduziert: 67). Das Feld nimmt jetzt eine
   kommagetrennte Liste entgegen; verifiziert: mit beiden Alt-Schluesseln
   0 Beanstandungen, mit nur dem juengsten 67.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:14:24 +02:00
46 changed files with 5972 additions and 668 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ backend/dist
.vscode
.idea
*.md
!docker/README.md
# Environment files (use Docker environment instead)
.env
+45
View File
@@ -53,6 +53,51 @@ JWT_REFRESH_EXPIRES_IN=7d
# Generieren: openssl rand -hex 32
ENCRYPTION_KEY=change-this-to-64-hex-characters-please-rotate-before-production-xx
# ---------- Optionaler Reverse-Proxy (Caddy) ----------
# Brauchst du nur, wenn du KEINEN eigenen Reverse-Proxy hast (nginx, Nginx
# Proxy Manager, Traefik) und Caddy das SSL übernehmen soll.
#
# EIN/AUS über diesen Schalter nichts auskommentieren:
# leer -> Caddy AUS, wird nicht einmal angelegt ← Standard
# caddy -> Caddy startet bei jedem `docker-compose up -d` mit
# Der Schalter wirkt auch bei `down`, `logs` usw. im Gegensatz zum
# Kommandozeilen-Flag `--profile caddy`, das man leicht vergisst.
COMPOSE_PROFILES= # leer lassen = Caddy aus
# Die folgenden drei Werte werden NUR gebraucht, wenn oben `caddy` steht.
# Ohne Caddy kannst du sie ignorieren.
DOMAIN=crm.example.com
CADDY_DIR=./data/caddy
CADDY_CONFIG_DIR=./data/caddy-config
# ---------- Siegel für das Audit-Log ----------
# OpenCRM protokolliert, wer wann welche Daten gesehen oder geändert hat die
# Beweisspur bei Streit oder Datenschutz-Nachfragen. Dieser Schlüssel versieht
# jeden neuen Eintrag mit einem Siegel, das sich nur damit erzeugen lässt. Er
# liegt AUSSERHALB der Datenbank; wer nur die Datenbank in die Hände bekommt,
# kann damit nichts fälschen, ohne dass es auffliegt.
#
# Generieren: openssl rand -hex 32 (pro Umgebung ein eigener Wert)
#
# Ohne Wert läuft alles normal weiter, nur eben ungesiegelt nichts fällt aus.
# Nachträglich setzen ist jederzeit möglich: bestehende Einträge bleiben gültig,
# ab dem Neustart werden neue gesiegelt (keine Fehlalarme dadurch).
#
# ACHTUNG einmal gesetzt, nicht wieder entfernen: Einträge, die ohne Schlüssel
# entstehen, werden beanstandet, sobald er zurück ist (ein ungesiegelter Eintrag
# inmitten gesiegelter ist von einer Fälschung nicht zu unterscheiden).
# Schlüssel sichern wie ein Passwort geht er verloren, sind die damit
# gesiegelten Einträge dauerhaft nicht mehr prüfbar.
# Ausführlich erklärt in der README, Abschnitt "Audit-Siegel".
AUDIT_HMAC_KEY=
# Nur beim Schlüsselwechsel füllen: die FRÜHEREN Schlüssel, kommagetrennt und
# jüngster zuerst. Damit bleiben die bisherigen Einträge prüfbar, während neue
# schon mit dem neuen Schlüssel gesiegelt werden. Nicht voreilig leeren sonst
# werden die alten Einträge als "manipuliert" gemeldet.
# Beispiel: AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>
AUDIT_HMAC_KEY_OLD=
# Server
NODE_ENV=development
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
+12
View File
@@ -36,6 +36,18 @@ data/factory-defaults/*
data/backups/*
!data/backups/.gitkeep
# Gegenbuch (laeuft auf eigener Maschine): enthaelt das Buch selbst, den
# Signaturschluessel und den Beobachtungsspeicher. Nichts davon gehoert ins
# Repository - der Schluessel schon gar nicht.
tools/audit-notary/data/*
!tools/audit-notary/data/.gitkeep
!tools/audit-notary/data/prod/
tools/audit-notary/data/prod/*
!tools/audit-notary/data/prod/.gitkeep
!tools/audit-notary/data/staging/
tools/audit-notary/data/staging/*
!tools/audit-notary/data/staging/.gitkeep
# Factory-Defaults-Drop-Box (Export-ZIPs zwischen dev/prod hin und her)
factory-exports/*
!factory-exports/.gitkeep
+42
View File
@@ -0,0 +1,42 @@
# OpenCRM Caddyfile
# ===================
# Wird NUR gebraucht, wenn du den optionalen Caddy-Dienst startest:
# docker-compose --profile caddy up -d
# Caddy holt und erneuert dann das SSL-Zertifikat automatisch fuer ${DOMAIN}.
# Betreibst du bereits einen eigenen Reverse-Proxy (nginx, NPM, Traefik),
# brauchst du diese Datei nicht - dann laeuft alles ueber deinen Proxy.
{$DOMAIN:localhost} {
# Reverse proxy to OpenCRM app
reverse_proxy opencrm:3001
# Logging
log {
output stdout
format console
}
# Security headers
header {
# Clickjacking protection
X-Frame-Options "SAMEORIGIN"
# XSS protection
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
# Referrer policy
Referrer-Policy "strict-origin-when-cross-origin"
}
# Gzip compression NUR fuer statische Assets, NICHT fuer /api/*.
# Komprimierte API-Antworten ermoeglichen die BREACH-Attacke
# (CVE-2013-3587): aus der Antwortgroesse liessen sich unter bestimmten
# Bedingungen Geheimnisse aus JSON-Responses ableiten. Statische Dateien
# sind davon nicht betroffen und bleiben komprimiert.
@nicht_api not path /api/*
encode @nicht_api gzip
# Handle file uploads (increase body limit)
request_body {
max_size 50MB
}
}
+177 -35
View File
@@ -337,19 +337,88 @@ Hände bekommt, kann damit nichts fälschen, ohne dass es auffliegt.
openssl rand -hex 32
```
Den Wert in die `.env` der jeweiligen Umgebung eintragen. Wichtig: **pro
Umgebung ein eigener Schlüssel** (Entwicklung, Test, Produktion) und
niemals ins Git-Repository.
**In welche Datei?** Das hängt davon ab, wie du OpenCRM betreibst:
| Betrieb | Datei |
|---|---|
| **Docker** (Standard) | **`.env` im Projektstamm** |
| Backend direkt, ohne Container (Entwicklung) | `backend/.env` |
Beim Docker-Betrieb reicht der Eintrag in der Stamm-`.env` `docker-compose.yml`
reicht die Variable an den Container weiter. Ein Eintrag in `backend/.env` wirkt
dort **nicht**, weil der Container diese Datei nicht liest.
Wichtig: **pro Umgebung ein eigener Schlüssel** (Entwicklung, Test, Produktion)
und niemals ins Git-Repository.
**Bestehende Installation: bisher kein Schlüssel gesetzt?**
Genau so ist es gedacht der Schlüssel kam mit einem Update dazu. Du kannst
ihn jederzeit nachträglich setzen:
1. Schlüssel erzeugen (`openssl rand -hex 32`), in die `.env` eintragen
2. Backend neu starten
3. Fertig mehr ist nicht nötig
Was dabei mit deinen **bereits vorhandenen** Einträgen passiert:
- Sie bleiben **unverändert** und weiterhin gültig. Es wird nichts neu
berechnet und nichts nachträglich gesiegelt.
- Ab dem Neustart werden **neue** Einträge gesiegelt. Alt und neu liegen
friedlich nebeneinander, die Prüfung erkennt für jeden Eintrag automatisch,
nach welchem Verfahren er zu prüfen ist.
- Es gibt **keine** Fehlalarme dadurch. (Nachgemessen auf einer Installation
mit 4903 alten, 57 mittleren und 67 gesiegelten Einträgen: 0 Beanstandungen.)
Rückwirkend lässt sich der Schutz nicht herstellen ältere Einträge bleiben
ungesiegelt. Das ist kein Fehler, sondern liegt in der Natur der Sache: Ein
Siegel kann man nur beim Anlegen vergeben, nicht nachträglich. Je früher du
den Schlüssel setzt, desto größer der geschützte Zeitraum.
> ⚠️ **Einmal gesetzt, nicht wieder abschalten.** Fehlt der Schlüssel später
> (z. B. weil er beim Container-Rebuild aus der `.env` verlorenging), läuft die
> Anwendung normal weiter aber die in dieser Zeit entstandenen Einträge sind
> ungesiegelt und werden von der Prüfung **beanstandet**, sobald der Schlüssel
> wieder da ist. Das ist Absicht: Ein ungesiegelter Eintrag inmitten
> gesiegelter ist von einer Fälschung nicht zu unterscheiden. Nimm den
> Schlüssel deshalb in deine Deploy-Checkliste auf.
Ein noch nicht ersetzter Platzhalter (`<hier-eigenen-wert-eintragen>`) zählt
bewusst **nicht** als Schlüssel sonst würde mit einem öffentlich bekannten
Wert gesiegelt. Das Backend schreibt in diesem Fall eine Warnung ins Log und
lässt das Siegel aus.
**Häufige Fragen**
| Frage | Antwort |
|---|---|
| Was, wenn ich den Schlüssel gar nicht setze? | Nichts fällt aus. Das Audit-Log läuft normal weiter, nur ohne dieses zusätzliche Siegel. |
| Was, wenn ich ihn verliere? | Die damit gesiegelten Einträge lassen sich nicht mehr prüfen. Sie gelten dann als **„nicht prüfbar"** ausdrücklich nicht als gefälscht. Kein Fehlalarm, aber der Nachweis für diesen Zeitraum ist weg. Deshalb: sichern wie ein Passwort. |
| Was, wenn ich ihn verliere? | Lässt du das Feld dann **leer**, gelten die gesiegelten Einträge als **„nicht prüfbar"** ausdrücklich nicht als gefälscht, also kein Fehlalarm. Der Nachweis für diesen Zeitraum ist aber weg. Deshalb: sichern wie ein Passwort. |
| Und wenn ich stattdessen einen **neuen** Schlüssel eintrage? | Dann werden die alten Einträge als **„manipuliert" gemeldet** das System kann einen falschen Schlüssel nicht von einer echten Fälschung unterscheiden. Trage den alten Schlüssel deshalb zusätzlich unter `AUDIT_HMAC_KEY_OLD` ein (siehe unten). |
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
| Verlangsamt das etwas? | Nein, spürbar nicht. |
**Wichtig bei einer bestehenden Installation: den Altbestand versiegeln**
Das Siegel gilt nur für Einträge, die **ab** dem Setzen des Schlüssels
geschrieben werden. Alles, was vorher im Protokoll steht, bleibt ungeschützt
Änderungen daran wären nicht erkennbar. Die Integritätsprüfung sagt das auch:
Hinweis: Der Altbestand ist nicht versiegelt Änderungen daran wären
nicht erkennbar. Behebbar mit POST /api/audit-logs/seal-backlog
{"confirm":"SEAL"}.
Das ist ein **einmaliger** Schritt: Er zieht ein Siegel über den vorhandenen
Bestand, sodass spätere Änderungen daran auffallen. Der Ablauf wer es darf
(Haken **„Audit-Betrieb"** in der Benutzerverwaltung), was vorher zu prüfen ist
und wie man das Ergebnis gegenprüft steht Schritt für Schritt unter
**[Den Altbestand versiegeln](tools/audit-notary/README.md#den-altbestand-versiegeln-einmalig-im-crm)**.
Die Anleitung liegt beim Gegenbuch, gilt aber **auch ohne** es ist ein
Vorgang im CRM.
Den Zustand siehst du jederzeit unter **Einstellungen → Audit-Protokoll**, ganz
oben.
**Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)**
Möchtest du den Schlüssel austauschen etwa weil du vermutest, dass er in
@@ -361,9 +430,27 @@ AUDIT_HMAC_KEY_OLD=<bisheriger Schlüssel>
```
Neue Einträge werden ab sofort mit dem neuen Schlüssel gesiegelt, die
bisherigen bleiben über den alten Schlüssel weiterhin prüfbar. Nach einer
Übergangszeit kann `AUDIT_HMAC_KEY_OLD` geleert werden danach sind die alten
Einträge allerdings nicht mehr prüfbar.
bisherigen bleiben über den alten Schlüssel weiterhin prüfbar.
> ⚠️ **`AUDIT_HMAC_KEY_OLD` nicht voreilig leeren.** Solange Einträge
> existieren, die mit einem alten Schlüssel gesiegelt wurden, muss dieser dort
> stehen bleiben. Entfernst du ihn, werden diese Einträge als **„manipuliert"
> gemeldet** nicht als „nicht prüfbar". Das System kann einen falschen
> Schlüssel nicht von einer echten Fälschung unterscheiden. Leeren kannst du
> das Feld gefahrlos erst, wenn die betroffenen Einträge durch die
> Aufbewahrungsfristen ohnehin gelöscht sind.
**Mehrfach gewechselt?** Das Feld nimmt mehrere Schlüssel kommagetrennt auf
vom jüngsten zum ältesten:
```env
AUDIT_HMAC_KEY=<aktueller Schlüssel>
AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>,<ganz alter>
```
Ohne das würde beim zweiten Wechsel der zuerst genutzte Schlüssel verloren
gehen und alle damit gesiegelten Einträge fälschlich als manipuliert
erscheinen.
**Prüfen, ob alles in Ordnung ist**
@@ -386,6 +473,50 @@ zwei Dinge:
Notiere dir die Zahl nach dem ersten Deploy: Bleibt sie konstant, ist alles
in Ordnung. Steigt sie, lohnt ein Blick.
### Gegenbuch optionaler Zusatzschutz auf zweitem Rechner
Das Audit-Siegel schützt die Einträge **innerhalb** der Anwendung. Es liegt aber
in derselben Datenbank, die es absichert: Wer vollen Zugriff auf den Server hat,
kommt am Ende auch an das Siegel.
Dagegen gibt es das **Gegenbuch**. Ein zweiter Rechner holt regelmäßig einen
kurzen Kontrollwert vom CRM und schreibt ihn mit. Wird später im CRM etwas
nachträglich verändert oder gelöscht, widerspricht das dem Gegenbuch und fällt
beim nächsten Durchlauf auf.
Die Richtung ist dabei entscheidend:
```
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read)
OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
```
Wer OpenCRM übernimmt, kommt damit nicht an das Gegenbuch. Das CRM braucht
dafür **keinerlei Konfiguration** es liefert nur einen lesbaren Prüfwert, der
keine Geheimnisse enthält.
**Einrichten** (auf einem anderen Rechner als dem CRM):
```bash
git clone <dieses Repository> opencrm
cd opencrm/tools/audit-notary
cp .env.example .env # CRM-Adresse und Dienstkonto eintragen
docker compose up -d
```
**Vorher im CRM anlegen:** eine Rolle mit ausschließlich dem Recht
`audit:read` und einen Benutzer damit. Das Gegenbuch meldet sich mit diesem
Konto bei jedem Durchlauf selbst an ein dauerhaftes Token gibt es nicht, weil
Zugangstoken nach 15 Minuten ablaufen.
Zwei Bücher auf einer Maschine etwa für Produktion und Test sind
vorgesehen. Alles Weitere in
[tools/audit-notary/README.md](tools/audit-notary/README.md).
> **Optional.** Ohne Gegenbuch bleibt der Schutz innerhalb der Anwendung
> vollständig erhalten. Es deckt zusätzlich den Fall ab, dass jemand den
> CRM-Server selbst übernimmt.
<details>
<summary><b>Technische Details</b> (für Entwickler/Admins)</summary>
@@ -750,39 +881,49 @@ docker-compose down -v # Container stoppen + Daten löschen
docker-compose logs -f # Logs anzeigen
```
### Docker (Produktion)
### Betrieb mit eigenem SSL (optionaler Caddy-Dienst)
Im `docker/` Verzeichnis liegt ein komplettes Produktions-Setup:
Es gibt **eine** `docker-compose.yml` und **eine** `.env` im Projektstamm.
Standardmässig bringt der Stack **keinen** Reverse-Proxy mit die Annahme ist,
dass du bereits einen hast (nginx, Nginx Proxy Manager, Traefik) und OpenCRM
dahinter hängst.
Hast du **keinen**, kannst du Caddy als optionalen Dienst dazuschalten. Er holt
und erneuert das SSL-Zertifikat automatisch:
Dafür gibt es einen Schalter in der `.env` nichts muss auskommentiert werden:
```env
COMPOSE_PROFILES=caddy # leer = kein Caddy (Standard)
DOMAIN=crm.example.de
HTTPS_ENABLED=true
```
```bash
cd docker
# Image bauen
docker-compose build
# Container starten
docker-compose up -d
# Logs anzeigen
docker-compose logs -f app
docker-compose up -d # startet jetzt zusätzlich Caddy
```
**Komponenten:**
- **MariaDB 10.11**: Datenbank
- **App**: Backend + Frontend in einem Container
- **Caddy**: Reverse-Proxy mit automatischem SSL
Solange `COMPOSE_PROFILES` leer ist, wird der Caddy-Dienst **gar nicht erst
angelegt** der Stack verhält sich exakt wie ohne ihn. Der Schalter wirkt auch
bei `down`, `logs` und `ps`, im Gegensatz zum Flag `--profile caddy`, das man
bei Folgebefehlen leicht vergisst.
**Umgebungsvariablen (`docker/.env`):**
```env
MYSQL_ROOT_PASSWORD=sicheres-root-passwort
MYSQL_DATABASE=opencrm
MYSQL_USER=opencrm
MYSQL_PASSWORD=sicheres-passwort
JWT_SECRET=sehr-langer-zufaelliger-string
ENCRYPTION_KEY=64-zeichen-hex-string
DOMAIN=crm.example.com
RUN_SEED=true # Nur beim ersten Start
```
**Was dabei zu beachten ist:**
- Die Zertifikate liegen unter `./data/caddy`. Diesen Ordner **nicht löschen**,
sonst werden bei jedem Start neue Zertifikate angefordert und du läufst in
die Mengenbegrenzung von Let's Encrypt.
- Die Domain muss vorher per DNS auf den Server zeigen, sonst schlägt die
Zertifikatsausstellung fehl.
- `HTTPS_ENABLED=true` setzen das aktiviert das `Secure`-Attribut am
Refresh-Cookie und die korrekte Proxy-Erkennung.
- Der direkte App-Port (`OPENCRM_PORT`, Standard 3010) bleibt weiterhin offen.
Wenn nur Caddy von aussen erreichbar sein soll, schliesse ihn per Firewall
oder setze ihn in der `.env` auf einen Wert, den du nicht freigibst.
Die Konfiguration liegt in der `Caddyfile` im Projektstamm. Sie liefert
Sicherheits-Header mit und komprimiert bewusst **nicht** die API-Antworten
(BREACH-Schutz, siehe Abschnitt zum Reverse-Proxy oben).
## Projektstruktur
@@ -834,7 +975,8 @@ opencrm/
│ │ ├── types/ # TypeScript-Typen
│ │ └── App.tsx # Haupt-Komponente
│ └── package.json
├── docker/ # Docker-Deployment
├── Caddyfile # Optionaler Reverse-Proxy (nur mit --profile caddy)
├── tools/audit-notary/ # Gegenbuch läuft auf einem zweiten Rechner
│ ├── Dockerfile # Multi-Stage Build
│ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy)
│ ├── Caddyfile # Reverse-Proxy mit SSL
+56 -11
View File
@@ -46,20 +46,51 @@ NODE_ENV=development
# Nichts faellt aus. Das Audit-Log laeuft normal weiter, nur eben ohne
# dieses zusaetzliche Siegel.
#
# Ich habe bisher keinen Schluessel - kann ich ihn nachtraeglich setzen?
# Ja. Schluessel erzeugen, eintragen, Backend neu starten - fertig.
# Bestehende Eintraege bleiben unveraendert gueltig, ab dem Neustart
# werden neue gesiegelt. Alt und neu liegen nebeneinander, es gibt
# dadurch KEINE Fehlalarme. Rueckwirkend siegeln geht nicht - je frueher
# gesetzt, desto groesser der geschuetzte Zeitraum.
#
# ACHTUNG: einmal gesetzt, nicht wieder abschalten.
# Fehlt der Schluessel spaeter (z. B. beim Container-Rebuild verlorengegangen),
# laeuft alles weiter - aber die in dieser Zeit entstandenen Eintraege sind
# ungesiegelt und werden beanstandet, sobald der Schluessel wieder da ist.
# Ein ungesiegelter Eintrag inmitten gesiegelter ist von einer Faelschung
# nicht zu unterscheiden. Also: in die Deploy-Checkliste aufnehmen.
#
# Was passiert, wenn ich ihn verliere?
# Die damit gesiegelten Eintraege lassen sich nicht mehr pruefen. Sie
# gelten dann als "nicht pruefbar" - NICHT als gefaelscht. Es gibt also
# keinen Fehlalarm, aber der Nachweis fuer diesen Zeitraum ist weg.
# Laesst du das Feld LEER, gelten die gesiegelten Eintraege als
# "nicht pruefbar" - NICHT als gefaelscht, also kein Fehlalarm. Der
# Nachweis fuer diesen Zeitraum ist aber weg.
# Traegst du stattdessen einen NEUEN Schluessel ein, ohne den alten unten
# zu hinterlegen, werden die alten Eintraege als "manipuliert" gemeldet -
# ein falscher Schluessel ist von einer Faelschung nicht zu unterscheiden.
# Deshalb: Schluessel sichern, so wie ein Passwort.
AUDIT_HMAC_KEY=
#
# Eigenen Wert erzeugen mit: openssl rand -hex 32
# Der Platzhalter unten zaehlt NICHT als Schluessel - laesst du ihn stehen,
# bleibt das Siegel aus (es wird also nicht versehentlich mit einem oeffentlich
# bekannten Wert gesiegelt). Leer lassen ist ebenfalls in Ordnung.
AUDIT_HMAC_KEY=<hier-eigenen-wert-eintragen>
# Nur voruebergehend beim Schluesselwechsel setzen.
# Frueher verwendete Schluessel - beim Wechsel hier eintragen.
# Moechtest du den Schluessel oben austauschen (z. B. weil du vermutest,
# dass er in falsche Haende geraten ist), trage den ALTEN Schluessel hier
# ein und den NEUEN oben. Dann bleiben die bisherigen Eintraege pruefbar,
# waehrend neue schon mit dem neuen Schluessel gesiegelt werden.
# Nach ein paar Wochen kann dieses Feld wieder geleert werden - danach
# sind die alten Eintraege allerdings nicht mehr pruefbar.
#
# ACHTUNG: Dieses Feld NICHT voreilig leeren. Solange Eintraege existieren,
# die mit einem alten Schluessel gesiegelt wurden, muessen sie hier stehen.
# Sonst werden diese Eintraege als "manipuliert" gemeldet (nicht als
# "nicht pruefbar"). Gefahrlos leeren kannst du erst, wenn die betroffenen
# Eintraege durch die Aufbewahrungsfristen ohnehin geloescht sind.
#
# Mehrfach gewechselt? Mehrere Schluessel kommagetrennt, juengster zuerst:
# AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>,<ganz alter>
#
# Beim ersten Einrichten leer lassen - erst beim Schluesselwechsel noetig.
AUDIT_HMAC_KEY_OLD=
# --- Technisch (fuer Entwickler/Admins) ---------------------------------
@@ -74,10 +105,24 @@ AUDIT_HMAC_KEY_OLD=
# Fail-safe : Ohne Schluessel schreibt der Dienst weiter Version 2. Bereits
# signierte Zeilen landen dann in `unverifiableEntries`,
# ausdruecklich NICHT in `tamperedEntries`.
# Rotation : AUDIT_HMAC_KEY_OLD wird bei der Pruefung zusaetzlich
# akzeptiert - Wechsel ohne Rehash. Ein Rehash waere ohnehin zu
# vermeiden, er wuerde die Beweiskraft der Vergangenheit
# ueberschreiben.
# Rotation : AUDIT_HMAC_KEY_OLD (kommagetrennte Liste) wird bei der
# Pruefung zusaetzlich akzeptiert - Wechsel ohne Rehash. Ein
# falscher/fehlender Alt-Schluessel liefert einen HMAC-Mismatch
# und damit einen Manipulations-Befund; das ist nicht von einer
# echten Faelschung unterscheidbar und daher Absicht.
# Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel
# UND Datenbank hat, kann die Kette konsistent neu rechnen.
# Erzeugung : openssl rand -hex 32 (256 Bit)
# ==================== DIENSTKONTO-WACHHUND ====================
# Nach wie vielen Minuten Stille eines Dienstkontos (z. B. des Gegenbuchs)
# ein Sicherheitsereignis erzeugt wird.
#
# Hintergrund: Die planmaessigen Anmeldungen selbst sind bewusst als Routine
# eingestuft, damit sie die CRITICAL-Stufe nicht entwerten. Das Signal ist
# deshalb die ABWESENHEIT: Wer das Gegenbuch stilllegt, setzt darauf, dass
# Stille nicht auffaellt.
#
# Faustregel: etwa das Dreifache des Gegenbuch-Takts. Bei stuendlichem Takt
# also 180. Ohne Dienstkonten im System passiert nichts.
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES=180
@@ -0,0 +1,16 @@
-- Bestandssiegel fuer den nicht signierbaren Altbestand (Pentest R171-02).
--
-- Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein Schreibzugriff auf
-- die Datenbank kann dort Felder wie `success` oder `resourceLabel` aendern,
-- ohne den gespeicherten Hash zu beruehren - die Pruefung meldet weiterhin
-- "gueltig". Live gezeigt: ein fehlgeschlagener Login liess sich unsichtbar in
-- einen erfolgreichen umschreiben.
--
-- Rueckwirkend signieren geht nicht. Stattdessen wird der Altbestand einmalig
-- versiegelt: je Zeile ein Blattwert ueber den vollen Inhalt, die Wurzel
-- darueber in einem HMAC-signierten Marker.
CREATE TABLE IF NOT EXISTS `AuditBacklogSeal` (
`auditLogId` INT NOT NULL,
`leafHash` VARCHAR(191) NOT NULL,
PRIMARY KEY (`auditLogId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,9 @@
-- Dienstkonto-Kennzeichen (Pentest R182).
--
-- Das Gegenbuch meldet sich stuendlich an. Diese Anmeldung wurde bisher als
-- Authentication/CRITICAL protokolliert - also das vorhersagbarste Ereignis im
-- System auf der hoechsten Stufe. Damit trainiert die Routine den Betreiber
-- darauf, CRITICAL wegzuklicken, und der erste ECHTE Vorfall erbt diesen
-- Reflex. Mit dem Kennzeichen werden solche Anmeldungen als Routine gefuehrt.
ALTER TABLE `User`
ADD COLUMN IF NOT EXISTS `isServiceAccount` BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,17 @@
-- Neuer SecurityEventType AUDIT_SEAL_CHANGED (Pentest R185-01).
--
-- Das Setzen oder Ersetzen des Bestandssiegels veraendert die Grundlage, gegen
-- die spaeter Manipulation nachgewiesen wird. Bisher landete das ausschliesslich
-- als CRITICAL-Zeile im Audit-Log - also in einem Kanal, den ein Mensch lesen
-- muss. Der Alarmkanal ist ein separater Store; ohne eigenen Ereignistyp gab es
-- dort gar keinen Eintrag, und `valid` bleibt bei einem ersetzenden Siegel
-- konstruktionsbedingt `true`.
--
-- MODIFY COLUMN ist idempotent (setzt die Enum-Definition, mehrfach ausfuehrbar).
ALTER TABLE `SecurityEvent`
MODIFY COLUMN `type` ENUM(
'LOGIN_FAILED','LOGIN_SUCCESS','RATE_LIMIT_HIT','ACCESS_DENIED',
'SSRF_BLOCKED','PASSWORD_RESET_REQUEST','PASSWORD_RESET_CONFIRM',
'LOGOUT','TOKEN_REJECTED','PERMISSION_CHANGED','AUDIT_SEAL_CHANGED',
'SUSPICIOUS'
) NOT NULL;
+23
View File
@@ -76,6 +76,11 @@ model User {
firstName String
lastName String
isActive Boolean @default(true)
/// Dienstkonto (z. B. das Gegenbuch). Meldet sich planmaessig und haeufig an.
/// Solche Anmeldungen werden im Audit-Log als Routine gefuehrt statt als
/// CRITICAL - sonst trainiert das vorhersagbarste Ereignis im System den
/// Betreiber darauf, die hoechste Stufe wegzuklicken (Pentest R182).
isServiceAccount Boolean @default(false)
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
// Passwort-Reset
@@ -1274,6 +1279,23 @@ model RefreshTokenRecord {
@@index([customerId])
}
/// Blattwerte des Bestandssiegels (Pentest R171-02 / R172).
///
/// Der Altbestand (Hash-Version 1/2) laesst sich nicht rueckwirkend signieren
/// ein Siegel vergibt man beim Anlegen. Bei V1 sind nur 7 von 24 Spalten
/// gehasht, ein DB-Schreiber kann dort z. B. `success` von false auf true
/// setzen, ohne den Hash zu beruehren: die Pruefung meldet weiterhin "gueltig".
///
/// Das Bestandssiegel schliesst das, ohne irgendetwas umzuschreiben: Zum
/// Zeitpunkt X wird je Altzeile ein Blattwert ueber den VOLLEN Inhalt
/// (alle Spalten inkl. id und hashVersion) gebildet; die Wurzel darueber
/// steht in einem HMAC-signierten Marker. Jede spaetere Aenderung an einer
/// Altzeile bricht Blatt und Wurzel.
model AuditBacklogSeal {
auditLogId Int @id
leafHash String
}
enum AuditAction {
CREATE
READ
@@ -1460,6 +1482,7 @@ enum SecurityEventType {
LOGOUT // expliziter Logout
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
AUDIT_SEAL_CHANGED // Bestandssiegel gesetzt oder ersetzt (Beweis-Grundlage)
SUSPICIOUS // generischer Catch-All
}
+29 -5
View File
@@ -106,7 +106,7 @@ async function main() {
update: {},
create: {
name: 'Admin',
description: 'Voller Zugriff auf alle Funktionen',
description: 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz dafür die separaten Rollen DSGVO und Audit-Betrieb)',
permissions: {
create: adminPermissions.map((p) => ({ permissionId: p.id })),
},
@@ -129,16 +129,29 @@ async function main() {
});
await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id));
// DSGVO - audit and gdpr permissions (hidden role, controlled via hasGdprAccess)
// DSGVO - Datenschutz-Verwaltung plus LESENDER Zugriff aufs Audit-Protokoll.
//
// Bewusst OHNE `audit:admin`: Wer das Protokoll beaufsichtigt, darf seine
// eigene Beweisgrundlage nicht ersetzen koennen (Pentest R186). Die
// eingreifenden Rechte liegen in der Rolle `Audit-Betrieb`.
//
// Diese Liste stand hier bis 09/2026 noch auf `audit:*` komplett und war
// damit die dritte Stelle, die denselben Rechtesatz beschrieb - neben
// `sync-roles.ts` und dem Notfallpfad in `user.service.ts`. Gerettet hat es
// nur die Reihenfolge im Container-Start (sync-roles laeuft danach und
// raeumt Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte die
// Buendelung zurueck.
const gdprPermissions = allPermissions.filter(
(p) => p.resource === 'audit' || p.resource === 'gdpr'
(p) =>
p.resource === 'gdpr' ||
(p.resource === 'audit' && (p.action === 'read' || p.action === 'export'))
);
const gdprRole = await prisma.role.upsert({
where: { name: 'DSGVO' },
update: {},
create: {
name: 'DSGVO',
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
permissions: {
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
},
@@ -265,8 +278,19 @@ async function main() {
password: hashedPassword,
firstName: 'Admin',
lastName: 'User',
// Zusaetzlich die DSGVO-Rolle (Pentest R189-01).
//
// Ohne sie kann nach einem frischen Seed NIEMAND eine Auskunft nach
// Art. 15 oder eine Loeschung nach Art. 17 ausfuehren - die Rechte
// haengen an DSGVO und Developer, und beide waren keinem Konto
// zugewiesen. Ein Ausfall mit Fristwirkung, ausgeloest durch nichts
// weiter als eine Neuinstallation.
//
// Die Trennung bleibt: Die Admin-ROLLE bekommt diese Rechte weiterhin
// nicht. Nur dieses eine Bootstrap-Konto traegt beide, damit ueberhaupt
// jemand handlungsfaehig ist.
roles: {
create: [{ roleId: adminRole.id }],
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
},
},
});
+49 -4
View File
@@ -93,9 +93,52 @@ async function main() {
// Developer: alles
const developerPermIds = allPermissions.map((p) => p.id);
// DSGVO: audit + gdpr komplett
// DSGVO: Datenschutz-Verwaltung plus LESENDEN Zugriff aufs Audit-Protokoll.
//
// Bewusst OHNE `audit:admin`. Diese Rolle beaufsichtigt das Protokoll - sie
// darf es nicht umschreiben koennen. Mit `audit:admin` haette ein
// DSGVO-Beauftragter `seal-backlog`, `rehash` und `cleanup`, also die Mittel,
// seine eigene Beweisgrundlage zu ersetzen. Wer prueft und wer eingreift,
// sind zwei Rollen (Pentest R186, im Anschluss an R184-02: falsche Domaene,
// zu breit gebuendelt).
const gdprPermIds = allPermissions
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
.filter(
(p) =>
p.resource === 'gdpr' ||
(p.resource === 'audit' && (p.action === 'read' || p.action === 'export')),
)
.map((p) => p.id);
// Audit-Betrieb: die eingreifenden Rechte am Protokoll - versiegeln,
// neu berechnen, aufraeumen, Aufbewahrung aendern. Plus `audit:read`, denn
// siegeln zu duerfen ohne das Ergebnis sehen zu koennen waere unbrauchbar.
//
// Eigene versteckte Rolle statt einem Anhaengsel an Admin: So weitet sich
// durch die Umstellung fuer KEIN bestehendes Konto etwas aus. Wer eingreifen
// koennen soll, bekommt es ausdruecklich - und diese Zuweisung ist selbst
// ein sichtbarer Vorgang.
const auditBetriebPermIds = allPermissions
.filter(
(p) => p.resource === 'audit' && (p.action === 'read' || p.action === 'admin'),
)
.map((p) => p.id);
// Gegenbuch: NUR audit:read.
//
// Der externe Notar ruft genau zwei Endpunkte auf, /audit-logs/checkpoint
// und /audit-logs/verify, und beide verlangen audit:read. Bis hierher gab es
// dafuer keine passende Rolle: Wer dem Dienstkonto Leserechte aufs Protokoll
// geben wollte, musste den DSGVO-Haken setzen - und der vergibt `audit:*`
// KOMPLETT, also auch `audit:admin` mit seal-backlog, rehash und cleanup.
//
// Damit haette ein Einbruch auf der Gegenbuch-Maschine nicht nur den
// Waechter gehabt, sondern gleich die Mittel, das Bewachte umzuschreiben -
// genau die Waesche aus R185-01, und genau die Trennung, wegen der das
// Gegenbuch ueberhaupt auf einer eigenen Maschine laeuft. Das Kennwort des
// Dienstkontos liegt dort im Klartext in der .env; es muss deshalb so wenig
// wert sein wie moeglich.
const gegenbuchPermIds = allPermissions
.filter((p) => p.resource === 'audit' && p.action === 'read')
.map((p) => p.id);
// Mitarbeiter: customers + contracts + read auf Stammdaten
@@ -132,11 +175,13 @@ async function main() {
.map((p) => p.id);
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
{ name: 'Admin', description: 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz dafür die separaten Rollen DSGVO und Audit-Betrieb)', permIds: adminPermIds },
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung', permIds: gdprPermIds },
{ name: 'Audit-Betrieb', description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern', permIds: auditBetriebPermIds },
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
{ name: 'Gegenbuch', description: 'Darf das Audit-Protokoll nur lesen und prüfen sonst nichts', permIds: gegenbuchPermIds },
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
];
+456 -60
View File
@@ -3,46 +3,119 @@ import { AuthRequest } from '../types/index.js';
import * as auditService from '../services/audit.service.js';
import { logChange } from '../services/audit.service.js';
import { AuditAction, AuditSensitivity } from '@prisma/client';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
/**
* Filterwerte aus der Query pruefen (Pentest R185-02).
*
* Vorher gingen `action` und `sensitivity` als roher String an die Enum-Spalte.
* Ein ungueltiger Wert liess Prisma auflaufen und der Handler antwortete 500.
* Das ist zweierlei: eine fehlende Validierung und ein Fehler-Orakel. Wer
* 200 gegen 500 vergleicht, liest die Enum-Mitglieder aus, ohne sie zu kennen.
* Ein ungueltiger Filter ist eine schlechte ANFRAGE, keine Server-Panne: 400.
*
* Dasselbe gilt fuer Datumsangaben (`new Date('foo')` ergibt Invalid Date und
* sprengt die Query erst in der Datenbank) und fuer Zahlen (`parseInt('x')`
* ergibt NaN).
*/
class FilterFehler extends Error {}
const AUDIT_ACTIONS = Object.values(AuditAction) as string[];
const AUDIT_SENSITIVITIES = Object.values(AuditSensitivity) as string[];
function pruefeEnum<T extends string>(
wert: unknown, erlaubt: string[], feld: string,
): T | undefined {
if (wert === undefined || wert === '') return undefined;
if (typeof wert !== 'string' || !erlaubt.includes(wert)) {
// Die erlaubten Werte stehen ohnehin in der Oberflaeche und im Schema
// sie zu nennen verraet nichts und erspart Rateversuche.
throw new FilterFehler(
`Ungültiger Wert für "${feld}". Erlaubt: ${erlaubt.join(', ')}.`,
);
}
return wert as T;
}
function pruefeDatum(wert: unknown, feld: string): Date | undefined {
if (wert === undefined || wert === '') return undefined;
const d = new Date(wert as string);
if (Number.isNaN(d.getTime())) {
throw new FilterFehler(`Ungültiges Datum für "${feld}".`);
}
return d;
}
function pruefeZahl(wert: unknown, feld: string): number | undefined {
if (wert === undefined || wert === '') return undefined;
const n = Number(wert);
if (!Number.isInteger(n) || n < 0) {
throw new FilterFehler(`Ungültige Zahl für "${feld}".`);
}
return n;
}
/** Freitextfelder begrenzen unbegrenzte LIKE-Muster sind teuer. */
function pruefeText(wert: unknown, feld: string, maxLaenge = 200): string | undefined {
if (wert === undefined || wert === '') return undefined;
if (typeof wert !== 'string' || wert.length > maxLaenge) {
throw new FilterFehler(`Ungültiger Wert für "${feld}" (max. ${maxLaenge} Zeichen).`);
}
return wert;
}
/**
* Filter aus der Query lesen EINE Stelle fuer Liste und Export (R186-01).
*
* Vorher pflegte jeder Endpunkt seine eigene Liste, und die des Exports war
* kuerzer: `userId`, `customerId`, `dataSubjectId`, `resourceId`, `success` und
* `search` wurden dort stillschweigend verworfen. Ein bewusst eingegrenzter
* Export „nur die Spur von Benutzer X“ fuer eine DSGVO-Auskunft oder eine
* Innentaeter-Pruefung lieferte damit das VOLLSTAENDIGE Protokoll aller
* Nutzer zurueck. Mit HTTP 200 und ohne jeden Hinweis: ein beruhigendes
* Signal ueber einem Ergebnis, das genau das Gegenteil dessen ist, was
* angefragt wurde. Auf einem datenminimierungspflichtigen Export ist das
* nicht nur ein fehlender Filter, sondern eine Weitergabe.
*
* Zwei Listen, die dasselbe bedeuten sollen, laufen frueher oder spaeter
* auseinander. Deshalb gibt es jetzt nur noch diese eine.
*/
function leseFilter(req: AuthRequest) {
const q = req.query;
return {
userId: pruefeZahl(q.userId, 'userId'),
customerId: pruefeZahl(q.customerId, 'customerId'),
dataSubjectId: pruefeZahl(q.dataSubjectId, 'dataSubjectId'),
action: pruefeEnum<AuditAction>(q.action, AUDIT_ACTIONS, 'action'),
sensitivity: pruefeEnum<AuditSensitivity>(q.sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'),
resourceType: pruefeText(q.resourceType, 'resourceType', 100),
resourceId: pruefeText(q.resourceId, 'resourceId', 100),
startDate: pruefeDatum(q.startDate, 'startDate'),
endDate: pruefeDatum(q.endDate, 'endDate'),
success: q.success !== undefined ? q.success === 'true' : undefined,
search: pruefeText(q.search, 'search'),
};
}
/**
* Audit-Logs mit Filtern abrufen
*/
export async function getAuditLogs(req: AuthRequest, res: Response) {
try {
const {
userId,
customerId,
dataSubjectId,
action,
sensitivity,
resourceType,
resourceId,
startDate,
endDate,
success,
search,
page,
limit,
} = req.query;
const result = await auditService.searchAuditLogs({
userId: userId ? parseInt(userId as string) : undefined,
customerId: customerId ? parseInt(customerId as string) : undefined,
dataSubjectId: dataSubjectId ? parseInt(dataSubjectId as string) : undefined,
action: action as AuditAction | undefined,
sensitivity: sensitivity as AuditSensitivity | undefined,
resourceType: resourceType as string | undefined,
resourceId: resourceId as string | undefined,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
success: success !== undefined ? success === 'true' : undefined,
search: search as string | undefined,
page: page ? parseInt(page as string) : 1,
limit: limit ? parseInt(limit as string) : 50,
...leseFilter(req),
page: pruefeZahl(req.query.page, 'page') || 1,
// Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
// Zug ziehen, an der Seitenlogik vorbei.
limit: Math.min(pruefeZahl(req.query.limit, 'limit') || 50, 200),
});
res.json({ success: true, ...result });
} catch (error) {
if (error instanceof FilterFehler) {
res.status(400).json({ success: false, error: error.message });
return;
}
console.error('Fehler beim Abrufen der Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
}
@@ -96,25 +169,8 @@ export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) {
*/
export async function exportAuditLogs(req: AuthRequest, res: Response) {
try {
const format = (req.query.format as 'json' | 'csv') || 'json';
const {
action,
sensitivity,
resourceType,
startDate,
endDate,
} = req.query;
const content = await auditService.exportAuditLogs(
{
action: action as AuditAction | undefined,
sensitivity: sensitivity as AuditSensitivity | undefined,
resourceType: resourceType as string | undefined,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
},
format
);
const format = req.query.format === 'csv' ? 'csv' : 'json';
const content = await auditService.exportAuditLogs(leseFilter(req), format);
if (format === 'csv') {
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
@@ -126,6 +182,10 @@ export async function exportAuditLogs(req: AuthRequest, res: Response) {
res.json({ success: true, data: JSON.parse(content) })
}
} catch (error) {
if (error instanceof FilterFehler) {
res.status(400).json({ success: false, error: error.message });
return;
}
console.error('Fehler beim Exportieren der Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
}
@@ -149,26 +209,147 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
const tampered = result.tamperedEntries.length;
const gaps = result.chainGaps.length;
const unexplained = result.unexplainedGaps.length;
// Beglaubigte Alt-Luecken sind kein offener Befund mehr, verschwinden aber
// auch nicht aus dem Bericht - sie werden eigens benannt.
const beglaubigt = result.attestedGaps.length;
const offeneGaps = gaps - beglaubigt;
const offeneUnexplained = result.unexplainedGaps.filter(
(id) => !result.attestedGaps.includes(id),
).length;
const luecken = gaps > 0
? `${gaps} strukturelle Lücken` +
(unexplained === 0
const luecken = offeneGaps > 0
? `${offeneGaps} strukturelle Lücke${offeneGaps === 1 ? '' : 'n'}` +
(offeneUnexplained === 0
? ' (alle durch protokollierte Löschungen erklärt)'
: unexplained < gaps
? `, davon ${unexplained} ohne dokumentierte Löschung`
: offeneUnexplained < offeneGaps
? `, davon ${offeneUnexplained} ohne dokumentierte Löschung`
: ' ohne dokumentierte Löschung')
: '';
// Der Satz erscheint IMMER, wenn beglaubigte Luecken existieren - auch
// neben einem Befund. Wer den Bericht liest, soll nie den Eindruck
// bekommen, die Kette sei lueckenlos, wenn sie es nicht ist.
const weitere = tampered > 0 || offeneGaps > 0 ? 'weitere ' : '';
const beglaubigtText =
beglaubigt === 0
? ''
: beglaubigt === 1
? ` Eine ${weitere}Lücke stammt aus der Zeit vor dem Bestandssiegel und ist darin als ` +
`Vorbefund beglaubigt (ID ${result.attestedGaps[0]}); der betroffene Eintrag selbst ` +
'ist unverändert.'
: ` ${beglaubigt} ${weitere}Lücken stammen aus der Zeit vor dem Bestandssiegel und sind ` +
`darin als Vorbefund beglaubigt (IDs ${result.attestedGaps.join(', ')}); ` +
'die betroffenen Einträge selbst sind unverändert.';
const unverifiable = result.unverifiableEntries.length;
const keinSchluessel = unverifiable > 0
? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.`
: '';
const siegel =
result.backlogSealStatus === 'gebrochen'
? ' ⚠ Bestandssiegel GEBROCHEN:' +
(result.backlogTampered.length
? ` ${result.backlogTampered.length} Alteinträge nachträglich verändert.`
: '') +
(result.backlogMissing.length
? ` ${result.backlogMissing.length} gesiegelte Einträge wurden ENTFERNT (${result.backlogMissing.join(', ')}).`
: '') +
(!result.backlogTampered.length && !result.backlogMissing.length
? ' Der Blattbestand des Siegels stimmt nicht mehr mit der signierten Wurzel überein.'
: '')
: result.backlogSealStatus === 'entfernt'
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht Änderungen am ' +
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
: result.backlogSealStatus === 'leer'
? ' Hinweis: Es besteht ein Bestandssiegel, das aber NICHTS umschließt ' +
'zum Zeitpunkt des Siegelns gab es keine unsignierten Alteinträge. Es sichert ' +
'also nichts ab. Das ist kein Fehler, aber auch keine Zusage.'
: result.backlogSealStatus === 'nicht_noetig'
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
: result.backlogSealStatus === 'kein_siegel'
? ' Hinweis: Der Altbestand ist nicht versiegelt Änderungen daran wären nicht erkennbar. ' +
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
: '';
// Eine Neuberechnung der Kette gehoert IMMER erwaehnt auch und gerade,
// wenn sonst alles grün ist. Nach einem Rehash ist die Kette
// zwangslaeufig stimmig, auch ueber Loeschungen hinweg. „Lueckenlos
// verkettet“ heisst dann nur noch „seit dem Rehash“, und wer das nicht
// mitliest, nimmt eine Entwarnung mit, die es so nicht gibt.
const rehashText = (() => {
if (result.rehashes.length === 0) return '';
const letzter = result.rehashes[result.rehashes.length - 1];
const datum = new Date(letzter.zeitpunkt).toLocaleString('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
});
const wieOft =
result.rehashes.length === 1
? 'Die Kette wurde einmal neu berechnet'
: `Die Kette wurde ${result.rehashes.length}× neu berechnet, zuletzt`;
// Nur benennen, was es zu benennen gibt. Bei sauberem Vorzustand ist die
// Auskunft „nichts uebertuencht“ selbst eine nuetzliche Information
// ein Rehash ueber einer unbeanstandeten Kette wiegt anders als einer
// ueber 700 Luecken.
const vb = letzter.vorbefund;
const uebertuencht = !vb
? ' Der Zustand vor dieser Neuberechnung ist nicht mehr feststellbar.'
: vb.manipuliert === 0 && vb.luecken === 0
? ' Die Kette war unmittelbar davor unbeanstandet es wurde nichts überdeckt.'
: ' Unmittelbar davor: ' +
`${vb.manipuliert} beanstandete${vb.manipuliert === 1 ? 'r Eintrag' : ' Einträge'}` +
` und ${vb.luecken} Lücke${vb.luecken === 1 ? '' : 'n'}. ` +
'Diese Spuren sind seitdem nicht mehr in der Kette sichtbar, sondern nur noch im ' +
`Vorbefund des Rehash-Eintrags (id ${letzter.id}).`;
const ohneSignatur = result.rehashes.some((r) => !r.signiert)
? ' Achtung: Mindestens ein Rehash-Eintrag trägt keine gültige Signatur.'
: '';
return (
`${wieOft} am ${datum}` +
(letzter.neuBerechnet !== null ? ` (${letzter.neuBerechnet} Einträge)` : '') +
'. Ein Rehash verknüpft alle Einträge neu die Aussage dieser Prüfung ' +
'reicht deshalb nur bis dorthin zurück, nicht weiter.' +
uebertuencht +
ohneSignatur
);
})();
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
const mehrfach =
result.backlogSealCount > 1
? ` Hinweis: Der Altbestand wurde ${result.backlogSealCount}× versiegelt der jüngste Marker gilt. ` +
'Jeder Marker enthält den Befund vor dem Siegeln; bei Zweifeln dort vergleichen.'
: '';
// Bei problematischem Siegel darf KEIN beruhigender Einstiegssatz stehen
// sonst entwertet er die nachfolgende Warnung (Pentest R174-01).
const siegelProblem =
result.backlogSealStatus === 'gebrochen' || result.backlogSealStatus === 'entfernt';
// „Keine Manipulation“ darf NICHT dastehen, solange `valid` falsch ist
// (Pentest R183-02). Nach einem Cleanup mit abgesenkter Aufbewahrung waren
// tausende Anmeldeprotokolle endgueltig geloescht die Luecken durch
// Tombstones „erklaert“, die Meldung las sich beruhigend, und der Befund
// stand nur noch im Feld `valid`. Wer die Prosa liest statt des Felds,
// klickt genau das weg, was ihn haette warnen sollen.
const message = tampered > 0
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
: gaps > 0
? `Keine Manipulation. ${luecken} Inhalte unverändert.`
: 'Alle Einträge sind unverändert und lückenlos verkettet';
? `${tampered} MANIPULIERTE Einträge gefunden` + (offeneGaps > 0 ? ` (zusätzlich ${luecken})` : '')
: siegelProblem
? 'Die Kette selbst ist rechnerisch stimmig, ABER:'
: offeneGaps > 0
? `Die Kette ist nicht mehr lückenlos: ${luecken}. Die verbliebenen Inhalte sind ` +
'unverändert aber gelöschte Einträge lassen sich naturgemäß nicht mehr prüfen. ' +
'Dokumentierte Löschungen sind erwartbar; unerwartete gehören nachgegangen.'
: beglaubigt > 0
? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' +
'entstanden.'
: result.rehashes.length > 0
? 'Alle Einträge sind unverändert und lückenlos verkettet allerdings erst ' +
'seit der letzten Neuberechnung.'
: 'Alle Einträge sind unverändert und lückenlos verkettet.';
res.json({
success: true,
@@ -182,9 +363,18 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
chainGaps: result.chainGaps,
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
unexplainedGaps: result.unexplainedGaps,
// Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt.
attestedGaps: result.attestedGaps,
// Protokollierte Neuberechnungen begrenzen die Reichweite der Aussage.
rehashes: result.rehashes,
tampered: tampered > 0,
message: message + keinSchluessel,
message: message + beglaubigtText + rehashText + keinSchluessel + siegel + mehrfach,
unverifiableEntries: result.unverifiableEntries,
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
backlogSealStatus: result.backlogSealStatus,
backlogTampered: result.backlogTampered,
backlogMissing: result.backlogMissing,
backlogSealCount: result.backlogSealCount,
},
});
} catch (error) {
@@ -198,7 +388,26 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
*/
export async function rehashAll(req: AuthRequest, res: Response) {
try {
const result = await auditService.rehashAll();
// Ausdrueckliche Bestaetigung verlangen (Pentest R170-01).
//
// Ein Rehash macht die Kette rechnerisch stimmig und setzt damit die
// Beweiskraft der Vergangenheit zurueck - das darf kein Nebeneffekt eines
// versehentlichen oder tastenden POST sein. Genau so wurde der Endpunkt
// bei einer Methoden-Erkundung unbeabsichtigt ausgeloest.
if (req.body?.confirm !== 'REHASH') {
res.status(400).json({
success: false,
error:
'Rehash setzt die Beweiskraft der bestehenden Einträge zurück und ist nicht ' +
'umkehrbar. Zum Bestätigen {"confirm":"REHASH"} mitsenden.',
});
return;
}
const result = await auditService.rehashAll({
userEmail: req.user?.email,
ipAddress: req.ip || (req.socket as any)?.remoteAddress,
});
res.json({
success: true,
data: result,
@@ -210,6 +419,131 @@ export async function rehashAll(req: AuthRequest, res: Response) {
}
}
/**
* Kontrollwert fuer das externe Gegenbuch (Notarisierung).
*
* Bewusst nur lesend und ohne Geheimnisse - die Gegenstelle braucht keinerlei
* Schreibrecht auf dieses System, und dieses System kennt den Schluessel der
* Gegenstelle nicht. Genau diese Trennung ist der Schutz.
*/
export async function getCheckpoint(req: AuthRequest, res: Response) {
try {
const roh = req.query.atId as string | undefined;
const atId = roh !== undefined ? parseInt(roh, 10) : undefined;
if (roh !== undefined && Number.isNaN(atId as number)) {
res.status(400).json({ success: false, error: 'atId muss eine Zahl sein' });
return;
}
const data = await auditService.getCheckpoint(atId);
res.json({ success: true, data });
} catch (error) {
console.error('Fehler beim Erstellen des Kontrollwerts:', error);
res.status(500).json({ success: false, error: 'Fehler beim Erstellen des Kontrollwerts' });
}
}
/**
* Altbestand versiegeln (Pentest R171-02).
*
* Einmalige, NICHT destruktive Aktion: schreibt nichts um, sondern haelt den
* Ist-Inhalt der nicht signierbaren Alteintraege fest.
*/
export async function sealBacklog(req: AuthRequest, res: Response) {
try {
// Erst nachsehen, ob schon ein Siegel steht (Pentest R185-01).
//
// Ein zweites Siegeln ist etwas grundlegend anderes als das erste: Es
// ERSETZT die Grundlage, gegen die Manipulation nachgewiesen wird. Wer den
// Altbestand per Datenbankzugriff beschneidet und danach neu siegelt,
// bekommt eine passende Wurzel und eine ueber den neuen Vorbefund
// beglaubigte Luecke - und `valid` steht wieder auf `true`. Deshalb
// verlangt das Ersetzen ein eigenes Wort und nicht dasselbe wie das
// Einrichten.
const vorher = await auditService.verifyIntegrity();
const siegelSteht =
vorher.backlogSealStatus === 'intakt' ||
vorher.backlogSealStatus === 'gebrochen' ||
vorher.backlogSealStatus === 'entfernt';
const erwartet = siegelSteht ? 'RESEAL' : 'SEAL';
if (req.body?.confirm !== erwartet) {
res.status(400).json({
success: false,
error: siegelSteht
? 'Es besteht bereits ein Bestandssiegel. Erneutes Siegeln ERSETZT die ' +
'bisherige Beweisgrundlage: bestehende Lücken werden neu beglaubigt und ' +
'ein Befund am Altbestand verschwindet aus der Prüfung. Das ist kein ' +
'Wartungsschritt. Zum Bestätigen {"confirm":"RESEAL"} mitsenden und ' +
'vorher klären, warum das bisherige Siegel nicht mehr passt.'
: 'Versiegelt den aktuellen Stand des Altbestands. Zum Bestätigen ' +
'{"confirm":"SEAL"} mitsenden.',
aktuellerSiegelzustand: vorher.backlogSealStatus,
});
return;
}
const result = await auditService.sealBacklog({
userEmail: req.user?.email,
ipAddress: req.ip || (req.socket as any)?.remoteAddress,
});
// In den ALARMKANAL, nicht nur ins Audit-Log (Pentest R185-01).
//
// Die CRITICAL-Zeile im Audit-Log gab es schon - aber die muss jemand
// lesen, und genau das ist die R183-02/R184-01-Klasse. Der automatische
// Rueckhalt des Gegenbuchs haengt an `valid`, und `valid` ueberlebt ein
// ersetzendes Siegel per Konstruktion. Der einzige maschinell erkennbare
// Anker ist der Wechsel der Wurzel - der gehoert dorthin, wo etwas von
// selbst passiert.
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'AUDIT_SEAL_CHANGED',
severity: siegelSteht ? 'CRITICAL' : 'HIGH',
message: siegelSteht
? `Bestandssiegel ERSETZT (${result.sealedCount} Alteinträge, id ${result.fromId}` +
`${result.toId}). Die bisherige Beweisgrundlage gilt nicht mehr; der Zustand ` +
`davor war "${vorher.backlogSealStatus}". Wenn das keine geplante Maßnahme war, ` +
'ist es ein Befund.'
: `Bestandssiegel erstmals gesetzt (${result.sealedCount} Alteinträge, id ` +
`${result.fromId}${result.toId}). Ab jetzt fallen Änderungen am Altbestand auf.`,
ipAddress: ctx.ipAddress,
userId: req.user?.userId,
userEmail: req.user?.email,
endpoint: ctx.endpoint,
details: {
ersetzt: siegelSteht,
zustandVorher: vorher.backlogSealStatus,
wurzelVorher: vorher.backlogSealRoot,
wurzelNachher: result.root,
befundVorher: {
manipuliert: vorher.tamperedEntries,
luecken: vorher.chainGaps,
altbestandVeraendert: vorher.backlogTampered,
altbestandFehlend: vorher.backlogMissing,
},
},
});
res.json({
success: true,
data: result,
message:
`${result.sealedCount} Alteinträge (id ${result.fromId}${result.toId}) versiegelt. ` +
'Spätere Änderungen an diesen Einträgen fallen ab sofort auf.' +
(siegelSteht
? ' ACHTUNG: Dies war ein ERSETZENDES Siegel die vorherige Beweisgrundlage ' +
'gilt nicht mehr.'
: ''),
});
} catch (error) {
console.error('Fehler beim Versiegeln des Altbestands:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Versiegeln',
});
}
}
/**
* Retention-Policies abrufen
*/
@@ -234,6 +568,48 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
}
const { retentionDays, description, legalBasis, isActive } = req.body;
// Die Richtlinie bestimmt, WAS der Cleanup vernichtet sie ist die
// geladene Waffe, der Cleanup nur der Abzug (Pentest R183-01). Bisher war
// ausgerechnet der Abzug gegatet und die Waffe frei zugaenglich: Ein
// `retentionDays: 0` ging ohne Bestaetigung durch und wurde nur als MEDIUM
// protokolliert, waehrend seine Wirkung CRITICAL ist. Die ausloesende Tat
// war damit leiser als die Folge und lag unter der Schwelle, bei der
// jemand hinsieht.
const bisher = (await auditService.getRetentionPolicies()).find((p) => p.id === id);
if (!bisher) {
return res.status(404).json({ success: false, error: 'Aufbewahrungsrichtlinie nicht gefunden' });
}
const neueTage = typeof retentionDays === 'number' ? retentionDays : bisher.retentionDays;
// Untergrenze: Auth-Eintraege sind der Einbruchsbeleg schlechthin. Eine
// Aufbewahrung von faktisch null macht sie mit dem naechsten Cleanup
// spurlos entfernbar ueber einen sanktionierten Pfad.
const UNTERGRENZE: Record<string, number> = { Authentication: 30, AuditLog: 30 };
const grenze = UNTERGRENZE[bisher.resourceType];
if (grenze !== undefined && neueTage < grenze) {
return res.status(400).json({
success: false,
error:
`Für ${bisher.resourceType} sind mindestens ${grenze} Tage Aufbewahrung vorgeschrieben ` +
`(angefragt: ${neueTage}). Diese Einträge belegen Anmeldungen und Zugriffe eine ` +
`Aufbewahrung nahe null macht sie beim nächsten Cleanup spurlos entfernbar.`,
});
}
// Absenken ist die gefaehrliche Richtung und braucht dieselbe ausdrueckliche
// Bestaetigung wie der Cleanup selbst.
const senktAb = neueTage < bisher.retentionDays;
if (senktAb && req.body?.confirm !== 'SHORTEN') {
return res.status(400).json({
success: false,
error:
`Das verkürzt die Aufbewahrung von ${bisher.retentionDays} auf ${neueTage} Tage. ` +
'Beim nächsten Cleanup werden dadurch Einträge endgültig gelöscht, die heute noch ' +
'da sind. Zum Bestätigen {"confirm":"SHORTEN"} mitsenden.',
});
}
const policy = await auditService.updateRetentionPolicy(id, {
retentionDays,
description,
@@ -244,7 +620,15 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
await logChange({
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
resourceId: id.toString(),
label: `Aufbewahrungsrichtlinie aktualisiert`,
// Absenkung wird wie ihre Folge eingestuft: CRITICAL, nicht MEDIUM.
sensitivity: senktAb ? 'CRITICAL' : undefined,
label: senktAb
? `Aufbewahrung VERKÜRZT für ${bisher.resourceType}` +
(bisher.sensitivity ? `/${bisher.sensitivity}` : '') +
`: ${bisher.retentionDays}${neueTage} Tage`
: `Aufbewahrungsrichtlinie aktualisiert`,
before: { retentionDays: bisher.retentionDays },
details: { retentionDays: neueTage },
});
res.json({ success: true, data: policy });
@@ -259,6 +643,18 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
*/
export async function runRetentionCleanup(req: AuthRequest, res: Response) {
try {
// Auch hier ausdrueckliche Bestaetigung: Cleanup loescht Audit-Zeilen
// endgueltig und reisst dabei die Kette auf (Pentest R170-01).
if (req.body?.confirm !== 'CLEANUP') {
res.status(400).json({
success: false,
error:
'Cleanup löscht Audit-Einträge endgültig. Zum Bestätigen ' +
'{"confirm":"CLEANUP"} mitsenden.',
});
return;
}
const result = await auditService.runRetentionCleanup();
res.json({
+140 -2
View File
@@ -3,7 +3,9 @@ import bcrypt from 'bcryptjs';
import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { AUDIT_OPS_ROLLE } from '../services/user.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
@@ -99,7 +101,7 @@ export async function createUser(req: Request, res: Response): Promise<void> {
}
}
export async function updateUser(req: Request, res: Response): Promise<void> {
export async function updateUser(req: AuthRequest, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
// `permissions` und `password` darf der generische Update nicht
@@ -161,9 +163,89 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
...beforeUser,
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE),
}
: null;
// Der Audit-Betrieb-Haken vergibt die EINGREIFENDEN Rechte am Protokoll
// (versiegeln, neu berechnen, aufraeumen, Aufbewahrung). Ihn zu setzen ist
// kein gewoehnliches Benutzer-Update: Er entscheidet, wer die
// Beweisgrundlage ersetzen darf.
//
// Eine eigene Rechte-Huerde davorzusetzen scheitert am Henne-Ei-Problem -
// nach der Aufteilung haelt zunaechst niemand `audit:admin`, und dann
// koennte ihn auch niemand vergeben. Stattdessen wird die Vergabe LAUT:
// CRITICAL im Protokoll und zusaetzlich in den Alarmkanal, so wie beim
// Dienstkonto-Kennzeichen (R184-01).
const setztAuditBetrieb =
data.hasAuditOpsAccess !== undefined &&
before !== null &&
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true;
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
// Routine erscheinen. Es bekommt deshalb dieselbe Behandlung wie das
// Absenken einer Aufbewahrungsfrist Bestaetigung, laute Protokollierung,
// und kein Selbstbedienen.
const setztDienstkonto =
typeof (data as any).isServiceAccount === 'boolean' &&
(data as any).isServiceAccount !== before?.isServiceAccount;
const aktiviertDienstkonto = setztDienstkonto && (data as any).isServiceAccount === true;
// R184-02: Das Kennzeichen ist ein Audit-Governance-Eingriff, kein
// gewoehnliches Benutzerfeld es ist Geschwister von retention-shorten,
// seal-backlog, rehash und cleanup, die alle `audit:admin` verlangen. Es
// haengt aber an der Route mit `users:update`. Heute deckungsgleich, weil
// nur die Administratorrolle beides hat aber jede kuenftige Rolle mit
// „Benutzer bearbeiten“ wuerde still „Login-Alarme herunterstufen“ erben.
// Deshalb hier zusaetzlich auf die Domaene pruefen, in der der Schaden
// liegt.
if (setztDienstkonto && !(req.user?.permissions || []).includes('audit:admin')) {
res.status(403).json({
success: false,
error:
'Das Dienstkonto-Kennzeichen ändert, wie Anmeldungen im Audit-Log bewertet werden. ' +
'Dafür genügt „Benutzer bearbeiten“ nicht es erfordert die Berechtigung ' +
'audit:admin, wie das Verkürzen von Aufbewahrungsfristen auch.',
} as ApiResponse);
return;
}
if (setztDienstkonto && req.user?.userId === userId) {
res.status(403).json({
success: false,
error:
'Das Dienstkonto-Kennzeichen lässt sich nicht am eigenen Konto setzen oder entfernen. ' +
'Es stuft Anmeldungen dieses Kontos als Routine ein wer das für sich selbst täte, ' +
'könnte die eigenen Anmeldungen unauffällig machen. Bitte von einem anderen ' +
'Administrator vornehmen lassen.',
} as ApiResponse);
return;
}
// Gate in BEIDE Richtungen (Pentest R184-01). Das Entfernen war ungegatet
// und es ist der gefaehrlichere Weg: Ein un-geflaggtes Konto faellt aus der
// Heartbeat-Wache, weil diese am Live-Kennzeichen haengt. Genau das
// „stilllegen und auf Stille setzen“, gegen das der Wachhund gebaut wurde.
if (setztDienstkonto && req.body?.confirm !== 'SERVICE_ACCOUNT') {
res.status(400).json({
success: false,
error: aktiviertDienstkonto
? `Damit werden künftige Anmeldungen von ${before?.email ?? 'diesem Konto'} im ` +
'Audit-Log als Routine geführt statt als kritisches Ereignis. Das ist für ' +
'planmäßig arbeitende Dienste gedacht (etwa das Gegenbuch) für ein Konto, das ' +
'ein Mensch benutzt, wäre es eine Tarnung. ' +
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.'
: `Damit fällt ${before?.email ?? 'dieses Konto'} aus der Überwachung heraus: Es wird ` +
'dann nicht mehr gemeldet, wenn es sich nicht mehr anmeldet. Bei einem laufenden ' +
'Dienst wie dem Gegenbuch hieße das, sein Ausfall bliebe unbemerkt. ' +
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.',
} as ApiResponse);
return;
}
const user = await userService.updateUser(userId, data as any);
if (user) {
// Audit: Geänderte Felder ermitteln und loggen
@@ -172,6 +254,8 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
const fieldLabels: Record<string, string> = {
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
hasAuditOpsAccess: 'Audit-Betrieb (versiegeln, aufräumen, Aufbewahrung)',
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
};
for (const [key, newVal] of Object.entries(data)) {
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
@@ -191,9 +275,63 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
await logChange({
req, action: 'UPDATE', resourceType: 'User',
resourceId: user.id.toString(),
label: changeList ? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}` : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
label: setztDienstkonto
? `Dienstkonto-Kennzeichen ${aktiviertDienstkonto ? 'GESETZT' : 'entfernt'} für ` +
`${user.email} Anmeldungen werden künftig ` +
`${aktiviertDienstkonto ? 'als Routine' : 'wieder als kritisch'} geführt`
: changeList
? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}`
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined,
details: Object.keys(changes).length > 0 ? changes : undefined,
});
// Zusaetzlich in den Alarmkanal (Pentest R184-01). Eine CRITICAL-Zeile
// im Audit-Log muss jemand LESEN das ist die R183-02-Klasse. Das
// Entfernen des Kennzeichens nimmt das Konto aus der Heartbeat-Wache;
// ab dann faellt sein Ausbleiben nicht mehr auf. Diese Aenderung
// gehoert deshalb dorthin, wo automatisch reagiert wird.
if (setztDienstkonto) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PERMISSION_CHANGED',
severity: 'CRITICAL',
message: aktiviertDienstkonto
? `Konto ${user.email} als Dienstkonto markiert seine Anmeldungen gelten ab ` +
'jetzt als Routine statt als kritisches Ereignis.'
: `Konto ${user.email} ist KEIN Dienstkonto mehr es fällt damit aus der ` +
'Überwachung heraus: Ein Ausbleiben seiner Anmeldungen wird nicht mehr gemeldet.',
ipAddress: ctx.ipAddress,
userId: req.user?.userId,
userEmail: req.user?.email,
endpoint: ctx.endpoint,
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
});
}
if (setztAuditBetrieb) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PERMISSION_CHANGED',
severity: 'CRITICAL',
message: aktiviertAuditBetrieb
? `Konto ${user.email} darf ab jetzt am Audit-Protokoll EINGREIFEN: versiegeln, ` +
'neu berechnen, aufräumen, Aufbewahrung ändern. Damit kann es die Grundlage ' +
'ersetzen, gegen die Manipulation nachgewiesen wird.'
: `Konto ${user.email} darf nicht mehr am Audit-Protokoll eingreifen.`,
ipAddress: ctx.ipAddress,
userId: req.user?.userId,
userEmail: req.user?.email,
endpoint: ctx.endpoint,
details: {
betroffenesKonto: user.email,
aktiviert: aktiviertAuditBetrieb,
selbstvergabe: user.id === req.user?.userId,
},
});
}
} else {
await logChange({
req, action: 'UPDATE', resourceType: 'User',
+70 -54
View File
@@ -1,4 +1,4 @@
import express from 'express';
import express, { Router } from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import helmet from 'helmet';
@@ -69,8 +69,11 @@ import { startContractStatusScheduler } from './services/contractStatusScheduler
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
import monitoringRoutes from './routes/monitoring.routes.js';
import { registriereIdPruefung } from './middleware/routeIds.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
import { pruefePflichtrechte } from './services/pflichtrechte.service.js';
import { authenticate } from './middleware/auth.js';
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
@@ -325,69 +328,76 @@ app.use('/api', (_req, res, next) => {
next();
});
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
// wurde kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` also `6abc`,
// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
// Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die
// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes".
//
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$`
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
// nicht geblockt.
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
app.use('/api', (req, res, next) => {
for (const seg of req.path.split('/')) {
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
return;
}
}
next();
});
// Genau das loest `mounte()` weiter unten die Pruefung wird an jedem Router
// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
// zwar in beide Richtungen:
//
// Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
// ID-Parameter und laesst nur kanonische Zahlen zu.
// Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
//
// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt zwei Antworten
// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
// Großzügige Obergrenze pro IP ergänzt die feineren Limiter (Login etc.),
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
app.use('/api', apiBackstopRateLimiter);
// Router einhängen IMMER über `mounte`, nie über `app.use` direkt.
//
// `mounte` haengt die zentrale Pruefung numerischer Pfad-Parameter an (R188)
// und montiert danach. Wer hier kuenftig `app.use` schreibt, umgeht sie
// stillschweigend deshalb steht die Pruefung im selben Handgriff wie das
// Einhaengen und nicht in einer zweiten Liste, die man vergessen kann.
const mounte = (pfad: string, router: Router): void => {
app.use(pfad, registriereIdPruefung(router));
};
// Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes);
mounte('/api/public/consent', consentPublicRoutes);
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/customers', customerRoutes);
app.use('/api/addresses', addressRoutes);
app.use('/api/bank-cards', bankcardRoutes);
app.use('/api/documents', documentRoutes);
app.use('/api/meters', meterRoutes);
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
app.use('/api/contracts', contractRoutes);
app.use('/api/credit-notes', creditNoteRoutes);
app.use('/api/company-profile', companyProfileRoutes);
app.use('/api/platforms', platformRoutes);
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
app.use('/api/contract-durations', contractDurationRoutes);
app.use('/api/providers', providerRoutes);
app.use('/api/tariffs', tariffRoutes);
app.use('/api/users', userRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/developer', developerRoutes);
app.use('/api/contract-categories', contractCategoryRoutes);
app.use('/api', contractTaskRoutes);
app.use('/api/settings', appSettingRoutes);
app.use('/api/email-providers', emailProviderRoutes);
app.use('/api', cachedEmailRoutes);
app.use('/api/energy-details', invoiceRoutes);
app.use('/api', contractHistoryRoutes);
app.use('/api/audit-logs', auditLogRoutes);
app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes);
app.use('/api/monitoring', monitoringRoutes);
mounte('/api/auth', authRoutes);
mounte('/api/customers', customerRoutes);
mounte('/api/addresses', addressRoutes);
mounte('/api/bank-cards', bankcardRoutes);
mounte('/api/documents', documentRoutes);
mounte('/api/meters', meterRoutes);
mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
mounte('/api/contracts', contractRoutes);
mounte('/api/credit-notes', creditNoteRoutes);
mounte('/api/company-profile', companyProfileRoutes);
mounte('/api/platforms', platformRoutes);
mounte('/api/cancellation-periods', cancellationPeriodRoutes);
mounte('/api/contract-durations', contractDurationRoutes);
mounte('/api/providers', providerRoutes);
mounte('/api/tariffs', tariffRoutes);
mounte('/api/users', userRoutes);
mounte('/api/upload', uploadRoutes);
mounte('/api/developer', developerRoutes);
mounte('/api/contract-categories', contractCategoryRoutes);
mounte('/api', contractTaskRoutes);
mounte('/api/settings', appSettingRoutes);
mounte('/api/email-providers', emailProviderRoutes);
mounte('/api', cachedEmailRoutes);
mounte('/api/energy-details', invoiceRoutes);
mounte('/api', contractHistoryRoutes);
mounte('/api/audit-logs', auditLogRoutes);
mounte('/api/gdpr', gdprRoutes);
mounte('/api/email-logs', emailLogRoutes);
mounte('/api/pdf-templates', pdfTemplateRoutes);
mounte('/api/birthdays', birthdayRoutes);
mounte('/api/factory-defaults', factoryDefaultsRoutes);
mounte('/api/monitoring', monitoringRoutes);
// Health check BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
@@ -487,6 +497,12 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N
const LISTEN_ADDR = process.env.LISTEN_ADDR
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
starteHeartbeatMonitor();
// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01).
void pruefePflichtrechte();
app.listen(PORT as number, LISTEN_ADDR, () => {
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
+21 -1
View File
@@ -167,6 +167,21 @@ const ACTION_LABELS: Record<string, string> = {
TOKEN_REFRESH: 'Sitzung verlängert',
};
/**
* Ist das die planmaessige Anmeldung eines Dienstkontos? (Pentest R182)
*
* Vorhersagbare Ereignisse sind miserable Alarme und exzellente Grundlinien.
* Das Gegenbuch meldet sich stuendlich an als CRITICAL gefuehrt trainiert
* genau diese Routine den Betreiber darauf, die hoechste Stufe wegzuklicken,
* und der erste ECHTE Vorfall erbt den Reflex. Der Eintrag bleibt (man soll
* sehen, dass das Gegenbuch arbeitet), aber als Routine.
*/
function istDienstkontoAnmeldung(responseBody: unknown, success: boolean): boolean {
if (!success || !responseBody || typeof responseBody !== 'object') return false;
const data = (responseBody as { data?: { user?: { isServiceAccount?: boolean } } }).data;
return data?.user?.isServiceAccount === true;
}
/**
* Unterscheidet die drei Ausgaenge von POST /auth/refresh (Pentest R166-03).
*
@@ -219,6 +234,9 @@ function generateHumanLabel(
// Auth
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
const email = req.body?.email || '';
if (action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, true)) {
return `Dienstkonto ${email} angemeldet (planmäßig)`;
}
return action === 'LOGIN'
? `Benutzer ${email} hat sich angemeldet`
: `Anmeldung fehlgeschlagen für ${email}`;
@@ -486,7 +504,9 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
sensitivity: action === 'TOKEN_REFRESH'
? (refreshOutcome(responseBody, responseSuccess) === 'rejected' ? 'HIGH' : 'LOW')
: undefined,
: action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, responseSuccess)
? 'LOW'
: undefined,
resourceType: mapping.type,
resourceId,
resourceLabel,
+106
View File
@@ -0,0 +1,106 @@
import { Request, Response, NextFunction, Router, RequestParamHandler } from 'express';
import { ApiResponse } from '../types/index.js';
/**
* Numerische Pfad-Parameter zentral pruefen (Pentest R188).
*
* Ausgangslage: In den Controllern stand 181-mal `parseInt(req.params.<x>)`
* ohne Pruefung. Das hatte zwei Folgen, und die zweite ist die unangenehmere:
*
* 1. Ein nicht-numerisches Segment ergab `NaN`, Prisma lief auf und der
* Handler antwortete mit **500**. `GET /api/users/permissions` etwa
* trifft `/:id` und war damit ein Serverfehler statt „gibt es nicht".
*
* 2. `parseInt` liest so weit, wie es kann: `parseInt('12abc')` ist **12**.
* `GET /api/users/12abc` lieferte also Benutzer 12 aus. Eine schlampig
* geratene ID wurde stillschweigend zu einer gueltigen gemacht.
*
* Die naheliegende Antwort waere gewesen, an allen 181 Stellen einen Guard
* einzusetzen. Genau daran haben wir uns in dieser Reihe mehrfach die Finger
* verbrannt: Was an vielen Stellen gepflegt werden muss, laeuft auseinander
* (zuletzt R186-01, drei Filterlisten, die dasselbe bedeuten sollten). Deshalb
* eine Stelle statt 181 und eine neue Route ist automatisch mit abgedeckt.
*
* Umgesetzt ueber `router.param()`: Express ruft den Callback, bevor der
* Handler laeuft, und nur fuer Routen, die den Parameter wirklich benutzen.
*
* **Antwort ist 404, nicht 400.** Ein Pfad-Segment, das keine ID sein kann,
* benennt keine Ressource das ist „nicht gefunden", nicht „falsch gefragt".
* Der Codebestand hat es an der einzigen bereits abgesicherten Stelle
* (`provider.controller.ts`, Pentest Mai 2026) genauso entschieden. Nebenbei
* verraet eine einheitliche 404 einem Probierenden nicht, ob ein Endpunkt
* existiert und eine Zahl erwartet oder ob es den Pfad gar nicht gibt.
*/
/**
* Parameternamen, die eine Datenbank-ID bezeichnen abgeglichen mit allen
* Routendateien. Bewusst NICHT dabei und deshalb unberuehrt: `consentType`,
* `filename`, `hash`, `key`, `localPart`, `name`, `tableName`.
*
* Die Namensregel „heisst `id` oder endet auf `Id`" gilt im gesamten Bestand;
* eine neue Route mit `:fooId` gehoert hier ergaenzt.
*/
const ID_PARAMETER = [
'id',
'contractId',
'contractMeterId',
'customerId',
'documentId',
'ecdId',
'emailId',
'entryId',
'invoiceId',
'meterId',
'phoneNumberId',
'providerId',
'readingId',
'referralId',
'representativeId',
'simCardId',
'subtaskId',
'taskId',
] as const;
/** Obergrenze von MySQL INT darueber gibt es keine Zeile, nur einen Fehler. */
const MAX_ID = 2147483647;
/**
* Gueltig ist ausschliesslich eine kanonische positive Ganzzahl.
*
* Fuehrende Nullen werden abgelehnt: `007` und `7` wuerden dieselbe Zeile
* bezeichnen, und zwei Schreibweisen fuer dieselbe Ressource sind eine
* unnoetige Einladung etwa fuer Zaehler, Zwischenspeicher oder Sperren, die
* auf dem Pfad als Schluessel arbeiten.
*/
export function istGueltigeId(wert: string): boolean {
if (!/^[1-9][0-9]*$/.test(wert)) return false;
const n = Number(wert);
return Number.isSafeInteger(n) && n <= MAX_ID;
}
const pruefeIdParameter: RequestParamHandler = (
_req: Request,
res: Response,
next: NextFunction,
wert: unknown,
) => {
if (typeof wert === 'string' && istGueltigeId(wert)) {
next();
return;
}
// Bewusst ohne Angabe, WELCHER Parameter beanstandet wurde: Die Meldung
// soll nicht zur Landkarte werden, welche Endpunkte welche IDs erwarten.
res.status(404).json({ success: false, error: 'Nicht gefunden' } as ApiResponse);
};
/**
* Haengt die Pruefung an einen Router. Namen, die der Router gar nicht
* verwendet, kosten nichts Express ruft den Callback nur fuer Parameter,
* die in einer getroffenen Route vorkommen.
*/
export function registriereIdPruefung<T extends Router>(router: T): T {
for (const name of ID_PARAMETER) {
router.param(name, pruefeIdParameter);
}
return router;
}
+17 -1
View File
@@ -16,12 +16,25 @@ router.use(authenticate);
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
// Audit-Logs exportieren
router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs);
//
// Verlangt `audit:export`, nicht `audit:read`. Die Berechtigung stand im
// Katalog und in der Rollenverwaltung, gatete aber NICHTS - jeder Leser
// konnte das vollstaendige Protokoll in einem Zug herausziehen.
//
// Das ist etwas anderes als Blaettern: Der Export liefert `changesBefore` und
// `changesAfter`, also die vollstaendigen Vorher/Nachher-Datensaetze, dazu
// `resourceLabel` mit Klartextnamen, IP-Adressen und User-Agents. Aufgefallen
// am Dienstkonto des Gegenbuchs: Es soll ausschliesslich Pruefwerte lesen -
// und konnte Personendaten exportieren.
router.get('/export', requirePermission('audit:export'), auditLogController.exportAuditLogs);
// Retention-Policies
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
// Kontrollwert für das externe Gegenbuch (nur lesend, ohne Geheimnisse)
router.get('/checkpoint', requirePermission('audit:read'), auditLogController.getCheckpoint);
// Audit-Logs für einen Kunden (DSGVO)
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
@@ -31,6 +44,9 @@ router.post('/verify', requirePermission('audit:read'), auditLogController.verif
// Hash-Kette reparieren
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
// Altbestand versiegeln (nicht destruktiv)
router.post('/seal-backlog', requirePermission('audit:admin'), auditLogController.sealBacklog);
// Retention-Cleanup manuell ausführen
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
+697 -28
View File
@@ -15,6 +15,12 @@ export async function logChange(opts: {
label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen"
details?: Record<string, unknown>; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' }
customerId?: number;
/// Ueberschreibt die aus dem Ressourcentyp abgeleitete Stufe. Noetig, wenn
/// eine Aktion gefaehrlicher ist als ihr Typ vermuten laesst - etwa das
/// Verkuerzen einer Aufbewahrungsfrist, dessen Folge CRITICAL ist
/// (Pentest R183-01).
sensitivity?: AuditSensitivity;
before?: Record<string, unknown>;
}) {
try {
const user = opts.req?.user;
@@ -32,6 +38,8 @@ export async function logChange(opts: {
httpMethod: opts.req?.method || '',
ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown',
dataSubjectId: opts.customerId,
sensitivity: opts.sensitivity,
changesBefore: opts.before,
changesAfter: opts.details,
});
} catch (error) {
@@ -222,14 +230,44 @@ export interface AuditHashV2Input {
* `AUDIT_HMAC_KEY_OLD` erlaubt einen Schluesselwechsel ohne Rehash bei der
* Pruefung wird zusaetzlich gegen den alten Schluessel getestet.
*/
function auditHmacKey(): string | null {
const k = process.env.AUDIT_HMAC_KEY;
return k && k.trim().length > 0 ? k : null;
/**
* Ein nicht ersetzter Platzhalter aus `.env.example` (z. B.
* `<hier-eigenen-wert-eintragen>`) darf NICHT als Schluessel gelten. Sonst
* wuerde mit einem oeffentlich im Repository stehenden Wert gesiegelt das
* waere schlechter als gar kein Siegel, weil es Sicherheit vortaeuscht.
*/
function istPlatzhalter(k: string): boolean {
return k.includes('<') || k.includes('>') || /^(change|dein|your|hier)/i.test(k);
}
function auditHmacKeyOld(): string | null {
const k = process.env.AUDIT_HMAC_KEY_OLD;
return k && k.trim().length > 0 ? k : null;
let platzhalterGemeldet = false;
function auditHmacKey(): string | null {
const k = process.env.AUDIT_HMAC_KEY?.trim();
if (!k) return null;
if (istPlatzhalter(k)) {
if (!platzhalterGemeldet) {
platzhalterGemeldet = true;
console.warn(
'[Audit] AUDIT_HMAC_KEY enthält noch den Platzhalter aus .env.example ' +
'das Audit-Siegel bleibt deaktiviert. Eigenen Wert erzeugen: openssl rand -hex 32',
);
}
return null;
}
return k;
}
/**
* Frueher verwendete Schluessel. Kommagetrennt, damit auch ein ZWEITER Wechsel
* moeglich ist, ohne die zuerst signierten Eintraege zu verlieren - mit nur
* einem Platz wuerden die aeltesten sonst als "manipuliert" gemeldet.
*/
function auditHmacKeysOld(): string[] {
return (process.env.AUDIT_HMAC_KEY_OLD || '')
.split(',')
.map((k) => k.trim())
.filter((k) => k.length > 0 && !istPlatzhalter(k));
}
function generateHashV3(data: AuditHashV2Input, key: string): string {
@@ -585,10 +623,261 @@ export async function getAuditLogsByDataSubject(customerId: number) {
/**
* Verifiziert die Integrität der Hash-Kette
*/
/**
* Blattwert einer Altbestands-Zeile fuer das Bestandssiegel (Pentest R171-02).
*
* Bewusst ueber den VOLLEN Zeileninhalt inkl. `id` und `hashVersion` nicht
* ueber den gespeicherten 7-Feld-Hash der Version 1. Sonst lebte die Luecke im
* Siegel weiter: genau die 17 ungehashten Spalten waeren wieder frei
* editierbar. `hashVersion` gehoert mit hinein, damit ein Hochstufen der Zeile
* (V1→V2, ohne Schluessel nachrechenbar) das Blatt bricht, statt sie klammheimlich
* aus dem Siegelbereich zu schieben.
*/
function backlogLeaf(row: Record<string, unknown>): string {
const feld = (v: unknown) =>
v instanceof Date ? v.toISOString() : v === undefined ? null : v;
// Feste Reihenfolge Teil des Blattwerts.
const payload = [
'id', 'userId', 'userEmail', 'userRole', 'customerId', 'isCustomerPortal',
'action', 'sensitivity', 'resourceType', 'resourceId', 'resourceLabel',
'endpoint', 'httpMethod', 'ipAddress', 'userAgent', 'changesBefore',
'changesAfter', 'changesEncrypted', 'dataSubjectId', 'legalBasis', 'success',
'errorMessage', 'durationMs', 'createdAt', 'hash', 'previousHash',
'hashVersion',
].map((k) => [k, feld(row[k])]);
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
/** Wurzel ueber die Blaetter in id-Reihenfolge. */
function backlogRoot(leaves: Array<{ auditLogId: number; leafHash: string }>): string {
const sortiert = [...leaves].sort((a, b) => a.auditLogId - b.auditLogId);
return crypto
.createHash('sha256')
.update(JSON.stringify(sortiert.map((l) => [l.auditLogId, l.leafHash])))
.digest('hex');
}
/** Endpoint-Kennung des Siegel-Markers. */
const BACKLOG_SEAL_ENDPOINT = '/api/audit-logs/seal-backlog';
/**
* Eigener Ressourcentyp fuer den Siegel-Marker (Pentest R173-01).
*
* Der Marker wurde urspruenglich als `AuditLog` / `CREATE` /
* `endpoint=/api/audit-logs/seal-backlog` gesucht. Genau diese Signatur
* erzeugt aber die generische Audit-Middleware, wenn jemand den Endpunkt
* aufruft als Protokollzeile des Requests selbst, mit hoeherer id und ohne
* `changesAfter`. Der Selektor griff dann diese Zeile statt des echten
* Markers, `root` war `undefined`, und die Pruefung meldete dauerhaft
* „gebrochen“. Ein Ressourcentyp, den die Middleware nie vergibt (sie leitet
* ihn aus dem Pfad ab → `AuditLog`), macht den Marker eindeutig.
*/
const BACKLOG_SEAL_RESOURCE = 'AuditBacklogSeal';
/**
* Versiegelt den Altbestand einmalig.
*
* Der Bereich ist FIX auf `[1 … v3FromId-1]` gebunden, nicht auf eine
* Live-Abfrage `hashVersion < 3`. Sonst koennte ein Angreifer die Grenzzeile
* hochstufen und sie damit aus der geprueften Menge draengen sie erschiene
* dann nur als harmlose Luecke. Zeilen ab `v3FromId` deckt die Versionsgrenze
* ab (sie MUESSEN V3 sein), es bleibt also keine Naht dazwischen.
*/
/**
* Kontrollwert fuer die externe Notarisierung (Gegenbuch).
*
* Alle bisherigen Anker leben IM System: Hash, Versionsgrenze, Manifest,
* Bestandssiegel, Siegel-Marker. Wer Schreibzugriff auf die Datenbank hat,
* sitzt am Ende immer schon auf der Ebene, die beweisen soll zuletzt gezeigt
* in R174-01. Ein Gegenbuch an einem fremden Ort durchbricht das.
*
* Der Wert enthaelt bewusst `maxId`: Ein blosser Kopf-Hash erkennt zwar
* Umschreiben, aber kein ABSCHNEIDEN am Ende. Mit ankertem `maxId` ist
* „juengster beglaubigter maxId > aktueller maxId“ der Beweis, dass Zeilen
* verschwunden sind.
*
* `atId` erlaubt der Gegenstelle, einen FRUEHER beglaubigten Kopf erneut
* abzufragen und zu reproduzieren ohne das koennte sie nur den aktuellen
* Stand sehen und nichts nachrechnen.
*
* Enthaelt keine Geheimnisse: nur Hashes und Zaehlstaende.
*/
export async function getCheckpoint(atId?: number): Promise<{
ts: string;
maxId: number | null;
chainHead: string | null;
sealRoot: string | null;
sealLeafCount: number;
atId?: number;
atHash?: string | null;
}> {
const kopf = await prisma.auditLog.findFirst({
orderBy: { id: 'desc' },
select: { id: true, hash: true },
});
// BEWUSST KEIN verifyIntegrity() hier (Pentest R175-02): das laeuft ueber
// alle Zeilen und kostet bei grossen Logs Sekunden je Aufruf ein
// authentifizierter Leser koennte damit die Datenbank in die Knie zwingen.
// Gebraucht wird von der Vollpruefung ohnehin nur die Siegel-Wurzel, und die
// steht im juengsten gueltigen Marker. Der Kopf-Hash kommt direkt aus der
// Kopfzeile.
const c3 = await prisma.auditLog.aggregate({
where: { hashVersion: { gte: 3 } },
_min: { id: true },
});
const v3FromId = c3._min.id;
const schluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
(k): k is string => !!k,
);
const markerKandidaten = await prisma.auditLog.findMany({
where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT },
orderBy: { id: 'desc' },
take: 20,
});
let sealRoot: string | null = null;
if (schluessel.length && v3FromId !== null) {
for (const r of markerKandidaten) {
if (r.id < v3FromId || r.hashVersion < 3) continue;
if (!schluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k))) continue;
try {
const m = JSON.parse(r.changesAfter || '{}');
if (typeof m.root === 'string' && m.root.length > 0) {
sealRoot = m.root;
break;
}
} catch {
/* naechster Kandidat */
}
}
}
const blattAnzahl = await prisma.auditBacklogSeal.count();
const ergebnis: {
ts: string;
maxId: number | null;
chainHead: string | null;
sealRoot: string | null;
sealLeafCount: number;
atId?: number;
atHash?: string | null;
} = {
ts: new Date().toISOString(),
maxId: kopf?.id ?? null,
chainHead: kopf?.hash ?? null,
sealRoot,
// Reicht der Gegenstelle, um das Verschwinden des Siegels zu bemerken:
// Blaetter ohne Wurzel = Marker entfernt.
sealLeafCount: blattAnzahl,
};
if (atId !== undefined && Number.isFinite(atId)) {
const zeile = await prisma.auditLog.findUnique({
where: { id: atId },
select: { hash: true },
});
ergebnis.atId = atId;
// null bedeutet: die beglaubigte Zeile existiert nicht mehr.
ergebnis.atHash = zeile?.hash ?? null;
}
return ergebnis;
}
export async function sealBacklog(
ausgeloestVon?: { userEmail?: string; ipAddress?: string },
): Promise<{ sealedCount: number; fromId: number; toId: number; root: string }> {
if (!auditHmacKey()) {
throw new Error(
'Bestandssiegel erfordert einen konfigurierten AUDIT_HMAC_KEY ohne ihn liesse sich die Wurzel nachrechnen.',
);
}
const c3 = await prisma.auditLog.aggregate({
where: { hashVersion: { gte: 3 } },
_min: { id: true },
});
const v3FromId = c3._min.id;
if (v3FromId === null) {
throw new Error(
'Noch keine signierten Einträge vorhanden bitte zuerst AUDIT_HMAC_KEY setzen und das Backend neu starten.',
);
}
const toId = v3FromId - 1;
if (toId < 1) {
throw new Error('Kein Altbestand vorhanden es gibt nichts zu versiegeln.');
}
// Zustand VOR dem Siegeln festhalten (R173-03).
const vorher = await verifyIntegrity();
const vorbefund = {
vorherigesSiegel:
vorher.backlogSealStatus === 'kein_siegel'
? null
: { status: vorher.backlogSealStatus, root: vorher.backlogSealRoot },
befund: {
manipuliert: vorher.tamperedEntries,
altbestand_veraendert: vorher.backlogTampered,
altbestand_fehlend: vorher.backlogMissing,
ketten_luecken: vorher.chainGaps,
},
};
const rows = await prisma.auditLog.findMany({
where: { id: { lte: toId } },
orderBy: { id: 'asc' },
});
const leaves = rows.map((r) => ({
auditLogId: r.id,
leafHash: backlogLeaf(r as unknown as Record<string, unknown>),
}));
const root = backlogRoot(leaves);
await prisma.$transaction(async (tx) => {
await tx.auditBacklogSeal.deleteMany({});
for (let i = 0; i < leaves.length; i += 500) {
await tx.auditBacklogSeal.createMany({ data: leaves.slice(i, i + 500) });
}
}, { timeout: 60000, maxWait: 20000 });
// Marker ist selbst HMAC-signiert (Version 3) damit ist die Wurzel
// signiert, ohne dass es einen zweiten Signaturpfad braucht.
//
// Vorzustand mitschreiben (Pentest R173-03): Ein erneutes Siegeln wuerde
// sonst eine zwischenzeitliche Manipulation absorbieren und die gefaelschte
// Zeile als echt zertifizieren mit noch weniger Spur als ein Rehash. Der
// Marker haelt deshalb fest, was VOR dem Siegeln galt: der Befund und die
// Wurzel des vorherigen Siegels.
await createAuditLog({
userEmail: ausgeloestVon?.userEmail || 'system',
userRole: 'System',
action: 'CREATE',
resourceType: BACKLOG_SEAL_RESOURCE,
resourceLabel:
`Bestandssiegel erstellt: ${leaves.length} Alteinträge (id 1${toId})` +
(vorbefund.vorherigesSiegel
? ' ERNEUTES Siegeln, vorheriges Siegel wird ersetzt'
: ''),
endpoint: BACKLOG_SEAL_ENDPOINT,
httpMethod: 'POST',
ipAddress: ausgeloestVon?.ipAddress || 'system',
sensitivity: 'CRITICAL',
changesBefore: vorbefund,
changesAfter: { fromId: 1, toId, count: leaves.length, root },
success: true,
});
return { sealedCount: leaves.length, fromId: 1, toId, root };
}
export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
valid: boolean;
checkedCount: number;
/** Alle beanstandeten Zeilen (tampered + chainGaps) Abwaertskompatibilitaet. */
/**
* Alle noch offenen Beanstandungen: veraenderte Zeilen plus Luecken, die
* NICHT vom Bestandssiegel beglaubigt sind. Nur an dieser Liste haengt
* `valid` beglaubigte Alt-Luecken stehen in `attestedGaps`.
*/
invalidEntries: number[];
/**
* ERNST: Der Inhalt der Zeile passt nicht mehr zu ihrem Hash jemand hat
@@ -613,6 +902,45 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
* eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log.
*/
unverifiableEntries: number[];
/**
* Teilmenge von `chainGaps`, die beim Versiegeln des Altbestands bereits
* bestand und im signierten Siegel-Marker als Vorbefund festgehalten ist.
* Diese Luecken sind BEGLAUBIGT: sie bleiben sichtbar, kippen `valid` aber
* nicht mehr (siehe ausfuehrliche Begruendung an der Berechnung unten).
*/
attestedGaps: number[];
/**
* Protokollierte Neuberechnungen der Kette (Rehash), aelteste zuerst.
*
* Ein Rehash verknuepft alle Eintraege neu. Danach ist die Kette
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg, die vorher als
* Luecken sichtbar waren. „Lueckenlos verkettet“ heisst nach einem Rehash
* also nur noch: seit dem Rehash. Wer das nicht mitliest, nimmt eine
* Entwarnung mit, die es nicht gibt.
*/
rehashes: Array<{
id: number;
zeitpunkt: string;
neuBerechnet: number | null;
/** Marker mit gueltiger HMAC-Signatur? Siehe Kommentar an der Auswertung. */
signiert: boolean;
/** Befund unmittelbar VOR dem Rehash - was also uebertuencht wurde. */
vorbefund: { manipuliert: number; luecken: number } | null;
}>;
/**
* Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
* gueltiger Marker mehr der Anker wurde herausgeloest (Pentest R174-01).
*/
backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
backlogTampered: number[];
/** Gesiegelte Zeilen, die nicht mehr existieren Beweismaterial entfernt. */
backlogMissing: number[];
/** Anzahl gueltiger Siegel-Marker; >1 bedeutet erneutes Siegeln. */
backlogSealCount: number;
/** Wurzel des aktuell gueltigen Siegels. */
backlogSealRoot: string | null;
}> {
const where: Prisma.AuditLogWhereInput = {};
@@ -690,16 +1018,36 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
return 1;
};
// Dokumentierte Loeschungen einlesen: Luecken, die auf einen protokollierten
// Retention-Cleanup zurueckgehen, sind erklaert alle anderen nicht. Vorher
// war das Manifest rein informativ und wurde von der Pruefung ignoriert,
// wodurch sich eine boeswillige Loeschung als "harmloser Gap" tarnen konnte.
// Dokumentierte Loeschungen einlesen NUR aus signierten, selbst gueltigen
// Traegerzeilen (Pentest R171-01).
//
// Vorher wurde jedem Manifest bedingungslos geglaubt. Das Manifest steht in
// `changesAfter`, und dieses Feld ist erst ab Hash-Version 2 mitgehasht auf
// V1-Altzeilen also voellig unauthentifiziert. Ein Angreifer konnte daher in
// eine BELIEBIGE V1-Zeile ein erfundenes Manifest schreiben, ohne deren Hash
// zu veraendern, und damit eigene Loeschungen und Luecken als "erklaert"
// ausweisen. Das hebelte zugleich die Eskalation an signierten Zeilen aus
// also Anker UND Versionsgrenze.
//
// Deshalb zaehlt ein Manifest nur, wenn seine Traegerzeile
// 1. laut Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert, und
// 2. ihre HMAC-Signatur mit einem konfigurierten Schluessel aufgeht.
// Ohne Schluessel gibt es keine gueltigen Traeger dann bleiben Luecken
// unerklaert, was die sichere Richtung ist.
const deletionRanges: Array<{ from: number; to: number }> = [];
const manifestRows = await prisma.auditLog.findMany({
const manifestKandidaten = await prisma.auditLog.findMany({
where: { resourceType: 'AuditLog', action: 'DELETE', endpoint: '/api/audit-logs/cleanup' },
select: { changesAfter: true },
});
for (const row of manifestRows) {
const manifestSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
(k): k is string => !!k,
);
for (const row of manifestKandidaten) {
if (erwarteteVersion(row.id) !== 3 || row.hashVersion < 3) continue;
if (manifestSchluessel.length === 0) continue;
const signaturOk = manifestSchluessel.some(
(k) => row.hash === generateHashV3(row as unknown as AuditHashV2Input, k),
);
if (!signaturOk) continue;
try {
const parsed = JSON.parse(row.changesAfter || '{}');
for (const m of parsed.manifest || []) {
@@ -714,11 +1062,269 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
const gapErklaert = (prevId: number, curId: number) =>
deletionRanges.some((r) => r.from <= curId && r.to >= prevId);
// ---------------------------------------------------------------------
// Protokollierte Neuberechnungen (Rehash) einsammeln.
//
// Ein Rehash macht die Kette rechnerisch stimmig - auch dort, wo vorher
// Loeschungen als Luecken sichtbar waren. Genau das ist auf Staging passiert:
// zwei Cleanups mit Aufbewahrung 0 entfernten 3155 Eintraege, der folgende
// Rehash liess rund 700 Kettenluecken verschwinden, und die Pruefung meldete
// danach „unveraendert und lueckenlos verkettet“. Wahr - und trotzdem das
// Gegenteil dessen, was ein Leser mitnimmt.
//
// Umgekehrte Beweislast als bei Manifest und Siegel: Dort zaehlen NUR
// signierte Traeger, weil ein gefaelschter Marker Luecken wegerklaeren
// koennte - Misstrauen ist die sichere Richtung. Hier erzeugt ein Marker eine
// WARNUNG. Wuerden wir nur signierte gelten lassen, koennte jemand einen
// Rehash unsichtbar machen, indem er dessen Signatur zerstoert. Deshalb
// zaehlt hier jeder auswertbare Marker; ob er signiert ist, wird nur
// mitgeteilt.
const rehashKandidaten = await prisma.auditLog.findMany({
where: { resourceType: 'AuditLog', action: 'UPDATE', endpoint: '/api/audit-logs/rehash' },
orderBy: { id: 'asc' },
});
const rehashSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
(k): k is string => !!k,
);
const rehashes: Array<{
id: number;
zeitpunkt: string;
neuBerechnet: number | null;
signiert: boolean;
vorbefund: { manipuliert: number; luecken: number } | null;
}> = [];
for (const row of rehashKandidaten) {
let neuBerechnet: number | null = null;
let vorbefund: { manipuliert: number; luecken: number } | null = null;
try {
const nach = JSON.parse(row.changesAfter || '{}');
if (typeof nach.neuBerechnet !== 'number') continue; // kein Rehash-Marker
neuBerechnet = nach.neuBerechnet;
const vor = JSON.parse(row.changesBefore || '{}');
if (Array.isArray(vor.manipuliert) && Array.isArray(vor.ketten_luecken)) {
vorbefund = {
manipuliert: vor.manipuliert.length,
luecken: vor.ketten_luecken.length,
};
}
} catch {
continue;
}
const signiert =
row.hashVersion >= 3 &&
rehashSchluessel.some(
(k) => row.hash === generateHashV3(row as unknown as AuditHashV2Input, k),
);
rehashes.push({
id: row.id,
zeitpunkt: row.createdAt.toISOString(),
neuBerechnet,
signiert,
vorbefund,
});
}
const tamperedEntries: number[] = [];
const chainGaps: number[] = [];
const unexplainedGaps: number[] = [];
const unverifiableEntries: number[] = [];
// ---------------------------------------------------------------------
// Bestandssiegel pruefen (Pentest R171-02)
//
// Der Altbestand ist nicht signierbar: bei Version 1 sind nur 7 von 24
// Spalten gehasht. Wurde er versiegelt, wird hier je Zeile geprueft:
// vorhanden, immer noch Altbestand (hashVersion < 3) und Inhalt unveraendert.
// Der Marker muss selbst signiert und gueltig sein - sonst waere das Siegel
// ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01).
const backlogTampered: number[] = [];
const backlogMissing: number[] = [];
// Luecken, die das Siegel als bereits vorhanden beglaubigt (siehe unten).
const beglaubigteLuecken = new Set<number>();
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
let backlogSealRoot: string | null = null;
const siegelKandidaten = await prisma.auditLog.findMany({
where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT },
orderBy: { id: 'desc' },
});
const siegelSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
(k): k is string => !!k,
);
// Nur signierte Marker MIT auswertbarem Inhalt zaehlen. Der zweite Teil ist
// die Absicherung gegen Zeilen, die zufaellig dieselbe Signatur tragen, aber
// gar kein Siegel beschreiben (Pentest R173-01).
const hatSiegelInhalt = (r: { changesAfter: string | null }) => {
try {
const m = JSON.parse(r.changesAfter || '{}');
return typeof m.toId === 'number' && typeof m.root === 'string' && m.root.length > 0;
} catch {
return false;
}
};
const gueltigeSiegel = siegelSchluessel.length
? siegelKandidaten.filter(
(r) =>
erwarteteVersion(r.id) === 3 &&
r.hashVersion >= 3 &&
hatSiegelInhalt(r) &&
siegelSchluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k)),
)
: [];
const siegel = gueltigeSiegel[0];
const backlogSealCount = gueltigeSiegel.length;
// Gegen-Check (Pentest R174-01): Der Siegelzustand hing ausschliesslich am
// Marker und den kann ein DB-Schreiber OHNE Schluessel loeschen. Danach
// meldete die Pruefung `kein_siegel`, also Entwarnung, ununterscheidbar von
// „nie versiegelt“. Steht der Marker am Ketten-Ende (genau der Zustand
// direkt nach dem einmaligen Siegeln beim Deploy), riss dabei nicht einmal
// eine Luecke: das Ergebnis war `valid: true` trotz gefaelschter gesiegelter
// Zeile. Die Blaetter blieben verwaist zurueck und wurden nie konsultiert.
//
// Deshalb gilt jetzt: Blaetter vorhanden, aber kein gueltiger Marker =
// Siegel ENTFERNT und damit ein Befund nicht „nie versiegelt“.
const blattAnzahl = await prisma.auditBacklogSeal.count();
const unsignierteAnzahl = await prisma.auditLog.count({ where: { hashVersion: { lt: 3 } } });
if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) {
backlogSealStatus = 'entfernt';
} else if (!siegel && blattAnzahl === 0 && unsignierteAnzahl === 0) {
// Es gibt gar keinen Altbestand: Entweder ist alles signiert, oder das Log
// beginnt erst mit der Signierung. Dann ist „nicht versiegelt“ kein Mangel
// (Pentest R183-03) die bisherige Warnung liess sich nicht aufloesen,
// weil seal-backlog zu Recht ablehnte. Eine Warnung, die der Betreiber
// nicht beheben kann, lernt er zu ignorieren.
//
// Die Bedingung haengt bewusst an der Zahl der UNSIGNIERTEN Zeilen und
// nicht mehr an `v3FromId === null`. Letzteres bedeutet naemlich das
// genaue Gegenteil: dass ueberhaupt nichts signiert ist etwa weil
// AUDIT_HMAC_KEY fehlt. Ein vollstaendig unsigniertes Log meldete damit
// „Bestandssiegel nicht noetig“, also Entwarnung fuer genau den Zustand
// mit der geringsten Beweiskraft. Jetzt bleibt es bei „kein_siegel“; die
// Fehlermeldung von seal-backlog nennt den fehlenden Schluessel als
// naechsten Schritt, die Warnung ist also aufloesbar.
backlogSealStatus = 'nicht_noetig';
}
if (siegel) {
try {
const meta = JSON.parse(siegel.changesAfter || '{}');
const bis: number = meta.toId;
const blaetter = await prisma.auditBacklogSeal.findMany();
const erwartet = new Map(blaetter.map((b) => [b.auditLogId, b.leafHash]));
const altzeilen = await prisma.auditLog.findMany({
where: { id: { lte: bis } },
orderBy: { id: 'asc' },
});
const vorhanden = new Map(altzeilen.map((r) => [r.id, r]));
for (const [id, blatt] of erwartet) {
const zeile = vorhanden.get(id);
if (!zeile) {
// FEHLENDE gesiegelte Zeile ist ein Siegelbruch, keine blosse Luecke
// (Pentest R173-02). Vorher wurde hier uebersprungen: das Loeschen
// eines Einbruchsbelegs erschien nur als "unerklaerte Luecke"
// ununterscheidbar von den als meist harmlos dokumentierten
// Race-Luecken , waehrend der dedizierte Indikator "intakt" meldete.
backlogMissing.push(id);
continue;
}
if (zeile.hashVersion >= 3 || backlogLeaf(zeile as unknown as Record<string, unknown>) !== blatt) {
backlogTampered.push(id);
}
}
// Wurzel gegenrechnen: faengt auch Zeilen ab, die dem Siegel
// nachtraeglich hinzugefuegt oder daraus entfernt wurden.
const wurzelJetzt = backlogRoot(blaetter);
backlogSealRoot = meta.root;
backlogSealStatus =
backlogTampered.length === 0 && backlogMissing.length === 0 && wurzelJetzt === meta.root
? 'intakt'
: 'gebrochen';
// Ein Siegel ueber NULL Blaettern ist rechnerisch tadellos und schuetzt
// nichts: es gab zum Zeitpunkt des Siegelns keinen Altbestand. „intakt“
// zu melden waere formal richtig und trotzdem irrefuehrend - es liest
// sich als Schutzzusage. Der Pentester hat Stagings Leersiegel genau so
// missverstanden, und das ist der rote Faden im Kleinen: ein Signal, das
// beruhigt, wo nichts abgesichert ist. Eigener Zustand, gleiche Wertung
// wie „nicht noetig“ - kein Befund, aber auch keine Zusage.
if (backlogSealStatus === 'intakt' && blaetter.length === 0) {
backlogSealStatus = 'leer';
}
// ---------------------------------------------------------------
// Beglaubigte Alt-Luecken
//
// Die Race-Luecken aus der Zeit vor dem Sperr-Fix lassen sich nicht
// mehr heilen: die Verkettung ist gebrochen, die Inhalte sind aber
// unversehrt. Wuerden sie `valid` dauerhaft auf false halten, meldete
// das Gegenbuch stuendlich Alarm, ohne dass es je etwas zu tun gaebe -
// und genau daran stirbt jede Warnung. Ein Signal, das immer schreit,
// warnt nicht mehr.
//
// Deshalb gilt eine Luecke als BEGLAUBIGT, wenn beides zutrifft:
// 1. sie liegt im versiegelten Bereich (id <= toId), und
// 2. sie steht im Vorbefund des Siegel-Markers, also im Zustand, den
// der Betreiber beim Siegeln ausdruecklich festgeschrieben hat.
// Der Vorbefund liegt in `changesBefore` und ist ab Version 3 mit-
// gehasht - die Liste laesst sich also ohne Schluessel nicht nachtraeg-
// lich erweitern (gleiche Absicherung wie beim Manifest, R171-01).
//
// Beglaubigt heisst NICHT verschwunden: die Luecken bleiben in
// `chainGaps` und werden weiter berichtet. Sie zaehlen nur nicht mehr
// als offener Befund. Alles andere schlaegt unveraendert an - eine
// NEUE Luecke, eine veraenderte oder entfernte Altzeile, ein gebroche-
// nes Siegel. Bei nicht intaktem Siegel wird gar nichts beglaubigt.
if (backlogSealStatus === 'intakt') {
try {
const roh = siegel.changesEncrypted
? decrypt(siegel.changesBefore || '')
: siegel.changesBefore || '{}';
const vorbefund = JSON.parse(roh);
for (const id of vorbefund?.befund?.ketten_luecken || []) {
if (typeof id === 'number' && id <= bis) beglaubigteLuecken.add(id);
}
} catch {
// Unlesbarer Vorbefund beglaubigt nichts - die sichere Richtung.
}
}
} catch {
backlogSealStatus = 'gebrochen';
}
}
tamperedEntries.push(...backlogTampered, ...backlogMissing);
// ---------------------------------------------------------------------
// Fehlender ANFANG des Protokolls.
//
// Die Verkettung wird zeilenweise gegen die Vorgaengerin geprueft - die
// erste Zeile hat keine, also fiel bisher NICHTS auf, wenn ein
// zusammenhaengender Anfang 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 eines Protokolls wird ohne
// Vorgaenger geschrieben und traegt deshalb einen leeren `previousHash`.
// Traegt die erste vorhandene Zeile einen Wert, hat es eine Vorgaengerin
// gegeben - und die ist weg.
//
// Nur bei Pruefung des GESAMTEN Bereichs; bei `fromId` ist ein gefuellter
// `previousHash` selbstverstaendlich und kein Befund.
if (fromId === undefined && logs.length > 0 && logs[0].previousHash) {
chainGaps.push(logs[0].id);
// Ein protokolliertes Loeschungs-Manifest erklaert auch diese Luecke; die
// Pruefung laeuft ueber denselben Weg wie bei jeder anderen.
if (!gapErklaert(0, logs[0].id)) {
unexplainedGaps.push(logs[0].id);
if (erwarteteVersion(logs[0].id) === 3) {
tamperedEntries.push(logs[0].id);
}
}
}
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
@@ -765,17 +1371,18 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
};
if (erwartet === 3) {
// HMAC-signiert: ohne Schluessel ist die Zeile nicht pruefbar. Das als
// "manipuliert" zu melden waere ein Fehlalarm daher eigener Topf.
const key = auditHmacKey();
const keyOld = auditHmacKeyOld();
if (!key && !keyOld) {
// HMAC-signiert: ohne JEDEN Schluessel ist die Zeile nicht pruefbar.
// Das als "manipuliert" zu melden waere ein Fehlalarm daher eigener
// Topf. Ein FALSCHER Schluessel ist dagegen nicht von einer Faelschung
// zu unterscheiden und wird bewusst als Befund gemeldet.
const kandidaten = [auditHmacKey(), ...auditHmacKeysOld()].filter(
(k): k is string => !!k,
);
if (kandidaten.length === 0) {
unverifiableEntries.push(log.id);
continue;
}
// keyOld deckt den Zeitraum vor einem Schluesselwechsel ab.
hashOk = (!!key && log.hash === generateHashV3(inhalt, key))
|| (!!keyOld && log.hash === generateHashV3(inhalt, keyOld));
hashOk = kandidaten.some((k) => log.hash === generateHashV3(inhalt, k));
} else {
hashOk = log.hash === generateHashV2(inhalt);
}
@@ -822,23 +1429,67 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
}
}
const invalidEntries = [...tamperedEntries, ...chainGaps].sort((a, b) => a - b);
// Eskalierte Luecken stehen sowohl in tamperedEntries als auch in chainGaps;
// ohne Entdopplung zaehlte dieselbe Zeile zweimal (Pentest R171-03).
//
// Beglaubigte Alt-Luecken zaehlen NICHT als offener Befund (siehe die
// Begruendung an `beglaubigteLuecken`). Sie bleiben in `chainGaps`
// sichtbar und stehen zusaetzlich in `attestedGaps`.
const attestedGaps = chainGaps.filter((id) => beglaubigteLuecken.has(id));
const offeneLuecken = chainGaps.filter((id) => !beglaubigteLuecken.has(id));
const invalidEntries = [...new Set([...tamperedEntries, ...offeneLuecken])].sort((a, b) => a - b);
// Ein gebrochenes oder entferntes Siegel muss `valid` kippen, auch wenn keine
// einzelne Zeile beanstandet ist sonst bliebe der stille Anker-Verlust
// unsichtbar (R174-01).
const siegelInOrdnung =
backlogSealStatus === 'intakt' ||
backlogSealStatus === 'leer' ||
backlogSealStatus === 'kein_siegel' ||
backlogSealStatus === 'nicht_noetig';
return {
valid: invalidEntries.length === 0,
valid: invalidEntries.length === 0 && siegelInOrdnung,
checkedCount: logs.length,
invalidEntries,
tamperedEntries,
chainGaps,
unexplainedGaps,
unverifiableEntries,
attestedGaps,
rehashes,
backlogSealStatus,
backlogTampered,
backlogMissing,
backlogSealCount,
backlogSealRoot,
};
}
/**
* Hash-Kette komplett neu berechnen (Reparatur)
*/
export async function rehashAll(): Promise<{ rehashedCount: number }> {
export async function rehashAll(
ausgeloestVon?: { userEmail?: string; ipAddress?: string },
): Promise<{ rehashedCount: number }> {
// ZUSTAND VOR DEM REHASH SICHERN (Pentest R170-01)
//
// Ein Rehash macht die Kette rechnerisch wieder stimmig auch dann, wenn sie
// vorher berechtigte Beanstandungen enthielt. Der bisherige Marker hielt nur
// fest, DASS rehasht wurde, nicht WAS dabei verschwand. Wer `audit:admin`
// besitzt, konnte damit Spuren glattziehen, ohne dass hinterher erkennbar
// war, welche.
//
// Deshalb wird der Befund samt Kettenkopf VOR dem Rehash erhoben und im
// Marker mitgeschrieben. Der Marker entsteht nach dem Rehash, ist selbst
// Teil der neuen Kette und signiert; entfernen liesse er sich nur unter
// Hinterlassung einer Luecke.
const vorher = await verifyIntegrity();
const kopf = await prisma.auditLog.findFirst({
orderBy: { id: 'desc' },
select: { id: true, hash: true, hashVersion: true, createdAt: true },
});
const logs = await prisma.auditLog.findMany({
orderBy: { id: 'asc' },
select: {
@@ -894,15 +1545,33 @@ export async function rehashAll(): Promise<{ rehashedCount: number }> {
// haengt sich an die neu berechnete Kette; entfernen liesse er sich nur unter
// Hinterlassung einer Luecke.
await createAuditLog({
userEmail: 'system',
userEmail: ausgeloestVon?.userEmail || 'system',
userRole: 'System',
action: 'UPDATE',
resourceType: 'AuditLog',
resourceLabel: `Hash-Kette neu berechnet (${count} Einträge) Beweiskraft der Vergangenheit zurückgesetzt`,
resourceLabel:
`Hash-Kette neu berechnet (${count} Einträge) Beweiskraft der Vergangenheit zurückgesetzt` +
(vorher.valid
? ' Kette war vorher unbeanstandet'
: ` vorher beanstandet: ${vorher.tamperedEntries.length} manipuliert, ` +
`${vorher.chainGaps.length} Lücken`),
endpoint: '/api/audit-logs/rehash',
httpMethod: 'POST',
ipAddress: 'system',
ipAddress: ausgeloestVon?.ipAddress || 'system',
sensitivity: 'CRITICAL',
// Befund VOR dem Rehash ohne das waere nach dem Rehash nicht mehr
// nachvollziehbar, was uebertuencht wurde.
changesBefore: {
geprueft: vorher.checkedCount,
manipuliert: vorher.tamperedEntries,
ketten_luecken: vorher.chainGaps,
luecken_ohne_dokumentierte_loeschung: vorher.unexplainedGaps,
nicht_pruefbar: vorher.unverifiableEntries,
kettenkopf: kopf
? { id: kopf.id, hash: kopf.hash, hashVersion: kopf.hashVersion, createdAt: kopf.createdAt }
: null,
},
changesAfter: { neuBerechnet: count },
success: true,
});
+70 -13
View File
@@ -45,9 +45,16 @@ const REFRESH_REUSE_MAX = 3;
/** Legt einen neuen Sitzungs-Refresh-Token an (neue Familie). */
export async function issueRefreshToken(
payload: JwtPayload,
opts: { userId?: number; customerId?: number; isCustomerPortal: boolean; familyId?: string },
opts: {
userId?: number;
customerId?: number;
isCustomerPortal: boolean;
familyId?: string;
/** Beim Rotieren bereits beim Einloesen reservierte jti des Nachfolgers. */
jti?: string;
},
): Promise<string> {
const jti = crypto.randomUUID();
const jti = opts.jti || crypto.randomUUID();
const familyId = opts.familyId || crypto.randomUUID();
const token = signRefreshToken(payload, jti, familyId);
const decoded: any = jwt.decode(token);
@@ -99,7 +106,13 @@ async function pruneExpiredRefreshTokens(): Promise<void> {
* Wirft mit `code = 'REFRESH_REPLAY'`, wenn ein bereits eingeloester Token
* erneut auftaucht - der Controller meldet das als Sicherheitsvorfall.
*/
async function consumeRefreshJti(decoded: any): Promise<{ familyId: string }> {
async function consumeRefreshJti(decoded: any): Promise<{
familyId: string;
/** Frische Rotation: Datensatz fuer diese jti muss noch angelegt werden. */
issueJti?: string;
/** Kulanz: dieser bereits ausgestellte Nachfolger wird erneut ausgegeben. */
reuseJti?: string;
}> {
const jti: string | undefined = decoded?.jti;
const fam: string | undefined = decoded?.fam;
@@ -129,17 +142,21 @@ async function consumeRefreshJti(decoded: any): Promise<{ familyId: string }> {
// sehen und durchgelassen (im Test kamen 90 gleichzeitige Requests
// ausnahmslos durch). Deshalb wird der Zustandswechsel als bedingtes UPDATE
// ausgefuehrt - die Datenbank entscheidet, wer zuerst war.
// Die jti des Nachfolgers wird SCHON HIER festgelegt und im selben UPDATE
// hinterlegt. Dadurch weiss eine spaetere Wiedervorlage, welcher Nachfolger
// bereits ausgestellt wurde - Grundlage der idempotenten Kulanz (R168-01).
const nachfolgerJti = crypto.randomUUID();
const beansprucht = await prisma.refreshTokenRecord.updateMany({
where: { jti, usedAt: null, revokedAt: null },
data: { usedAt: new Date() },
data: { usedAt: new Date(), replacedByJti: nachfolgerJti },
});
if (beansprucht.count === 1) {
return { familyId: rec.familyId };
return { familyId: rec.familyId, issueJti: nachfolgerJti };
}
// Bereits eingeloest. Innerhalb des engen Kulanzfensters und nur begrenzt oft
// tolerieren (parallele Tabs) - ebenfalls als bedingtes UPDATE, damit die
// Obergrenze unter Last wirklich haelt.
// Bereits eingeloest. Innerhalb des engen Kulanzfensters begrenzt tolerieren
// (parallele Tabs) - als bedingtes UPDATE, damit die Obergrenze unter Last
// wirklich haelt.
const fensterAb = new Date(Date.now() - REFRESH_REUSE_GRACE_MS);
const toleriert = await prisma.refreshTokenRecord.updateMany({
where: {
@@ -151,7 +168,32 @@ async function consumeRefreshJti(decoded: any): Promise<{ familyId: string }> {
data: { reuseCount: { increment: 1 } },
});
if (toleriert.count === 1) {
return { familyId: rec.familyId };
// IDEMPOTENT: denselben, bereits ausgestellten Nachfolger zurueckgeben -
// NICHT erneut rotieren (Pentest R168-01).
//
// Vorher entstand bei jeder Kulanz-Wiedervorlage eine frische Linie 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 - also dauerhaft unsichtbar blieb.
//
// Jetzt laufen parallele Tabs auf DIESELBE Linie zusammen. Wer den Token
// spaeter erneut vorlegt (also ausserhalb des Fensters), kollidiert
// zwangslaeufig und loest den Familien-Widerruf aus.
const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
if (aktuell?.replacedByJti) {
const nachfolger = await prisma.refreshTokenRecord.findUnique({
where: { jti: aktuell.replacedByJti },
});
if (nachfolger?.revokedAt) {
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
err.code = 'REFRESH_REVOKED';
throw err;
}
return { familyId: rec.familyId, reuseJti: aktuell.replacedByJti };
}
// Kein Nachfolger hinterlegt: nicht ersatzweise rotieren (das waere genau
// der Fork). Fail-closed als Replay behandeln.
}
// Weder frei noch tolerierbar: War der Token zwischenzeitlich widerrufen
@@ -291,6 +333,9 @@ export async function login(email: string, password: string) {
permissions: Array.from(permissions),
customerId: user.customerId,
isCustomerPortal: false,
// Wird von der Audit-Middleware gelesen, um planmaessige
// Dienstkonto-Anmeldungen als Routine einzustufen (R182).
isServiceAccount: user.isServiceAccount,
},
};
}
@@ -444,7 +489,20 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
}
// Einmalverwendung durchsetzen und Sitzungsfamilie bestimmen (R164-02).
// Wirft bei Replay danach ist die gesamte Familie widerrufen.
const { familyId } = await consumeRefreshJti(decoded);
const rotation = await consumeRefreshJti(decoded);
// Bei Kulanz wird derselbe, bereits ausgestellte Nachfolger erneut signiert -
// ohne neuen Datensatz, damit keine zweite Linie entsteht (R168-01).
const naechsterRefreshToken = async (
payload: JwtPayload,
subjekt: { userId?: number; customerId?: number; isCustomerPortal: boolean },
): Promise<string> =>
rotation.reuseJti
? signRefreshToken(payload, rotation.reuseJti, rotation.familyId)
: issueRefreshToken(payload, {
...subjekt,
familyId: rotation.familyId,
jti: rotation.issueJti,
});
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
// Mitarbeiter
@@ -476,7 +534,7 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
accessToken: signAccessToken(payload),
// Nachfolger bleibt in derselben Familie ein Replay des Vorgaengers
// sprengt damit auch alle daraus entstandenen Tokens.
refreshToken: await issueRefreshToken(payload, { userId: user.id, isCustomerPortal: false, familyId }),
refreshToken: await naechsterRefreshToken(payload, { userId: user.id, isCustomerPortal: false }),
user: {
id: user.id,
email: user.email,
@@ -507,10 +565,9 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
};
return {
accessToken: signAccessToken(payload),
refreshToken: await issueRefreshToken(payload, {
refreshToken: await naechsterRefreshToken(payload, {
customerId: customer.id,
isCustomerPortal: true,
familyId,
}),
user: portalUser,
};
@@ -0,0 +1,83 @@
import prisma from '../lib/prisma.js';
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
/**
* Wachhund auf den AUSBLEIBENDEN Heartbeat (Pentest R182/R183).
*
* Dienstkonten allen voran das Gegenbuch melden sich in festem Takt an.
* Diese Anmeldungen sind bewusst als Routine eingestuft, damit sie die
* CRITICAL-Stufe nicht entwerten. Damit fehlt aber die andere Haelfte: Wer das
* Gegenbuch stilllegt, setzt genau darauf, dass STILLE nicht auffaellt. Ein
* gestopptes Gegenbuch ist sonst nicht von „laeuft ruhig“ zu unterscheiden.
*
* Das Sicherheitssignal ist deshalb nicht die Anwesenheit der Anmeldung,
* sondern ihre Abweichung: bleibt sie aus, wird es gemeldet.
*
* Bewusste Zurueckhaltung: Ohne jemals gesehene Anmeldung gibt es keine
* Grundlinie dann wird geschwiegen statt geraten. Und pro Ausfall wird nur
* einmal gemeldet, nicht bei jedem Durchlauf.
*/
const PRUEFTAKT_MS = 15 * 60 * 1000;
const MAX_ALTER_MINUTEN = Number.parseInt(process.env.SERVICE_ACCOUNT_MAX_SILENCE_MINUTES || '180', 10);
export async function pruefeHeartbeats(): Promise<void> {
const konten = await prisma.user.findMany({
where: { isServiceAccount: true, isActive: true },
select: { id: true, email: true },
});
if (konten.length === 0) return;
const grenze = new Date(Date.now() - MAX_ALTER_MINUTEN * 60 * 1000);
for (const konto of konten) {
const letzte = await prisma.auditLog.findFirst({
where: { userId: konto.id, action: 'LOGIN' },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
// Nie angemeldet = keine Grundlinie. Schweigen statt raten.
if (!letzte) continue;
if (letzte.createdAt >= grenze) continue;
// Pro Ausfall nur einmal melden.
const schonGemeldet = await prisma.securityEvent.findFirst({
where: {
type: 'SUSPICIOUS',
userEmail: konto.email,
createdAt: { gte: letzte.createdAt },
message: { contains: 'Dienstkonto' },
},
});
if (schonGemeldet) continue;
const stillSeit = Math.round((Date.now() - letzte.createdAt.getTime()) / 60000);
await emitSecurityEvent({
type: 'SUSPICIOUS',
severity: 'CRITICAL',
message:
`Dienstkonto ${konto.email} meldet sich seit ${stillSeit} Minuten nicht mehr ` +
`(erwartet mindestens alle ${MAX_ALTER_MINUTEN} Minuten). ` +
'Entweder steht der zugehörige Dienst etwa das Gegenbuch oder er wurde stillgelegt. ' +
'Stille ist hier kein guter Zustand: Ohne laufendes Gegenbuch fällt eine nachträgliche ' +
'Änderung am Audit-Log nicht mehr auf.',
userEmail: konto.email,
userId: konto.id,
endpoint: 'heartbeat-monitor',
});
}
}
export function starteHeartbeatMonitor(): void {
if (!Number.isFinite(MAX_ALTER_MINUTEN) || MAX_ALTER_MINUTEN <= 0) {
console.warn('[Heartbeat] SERVICE_ACCOUNT_MAX_SILENCE_MINUTES ungültig Wachhund bleibt aus.');
return;
}
const lauf = () => {
pruefeHeartbeats().catch((e) => console.error('[Heartbeat] Fehler:', e));
};
// Nicht sofort beim Start: Nach einem Neustart darf ein kurz zurueckliegender
// Ausfall nicht doppelt melden, und die DB soll erst oben sein.
setTimeout(lauf, 60_000);
setInterval(lauf, PRUEFTAKT_MS).unref();
}
@@ -0,0 +1,82 @@
import prisma from '../lib/prisma.js';
/**
* Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand
* ausueben kann (Pentest R189-01).
*
* Hintergrund: `gdpr:export`, `gdpr:delete` und `gdpr:admin` haengen an den
* Rollen DSGVO und Developer nicht an der Admin-Rolle. Nach einem frischen
* Seed war keine der beiden einem Konto zugewiesen. Ergebnis: Eine Auskunft
* nach Art. 15 oder eine Loeschung nach Art. 17 konnte NIEMAND ausfuehren,
* obwohl mehrere „Admin"-Konten existierten. Ein Ausfall mit Fristwirkung,
* ausgeloest durch nichts weiter als eine Neuinstallation.
*
* Der Seed weist das Recht jetzt zu aber nur bei NEUINSTALLATION. Auf einer
* laufenden Datenbank aendert er nichts, und niemand merkt es, bis es darauf
* ankommt. Deshalb diese Wache: Sie erkennt die ABWESENHEIT einer Faehigkeit,
* so wie der Heartbeat das Ausbleiben eines Dienstkontos erkennt.
*
* Bewusst nur eine Meldung, keine automatische Vergabe: Rechte zu verteilen,
* ohne dass ein Mensch es veranlasst hat, waere der groessere Fehler.
*/
/** Rechte, deren Fehlen ein rechtliches und kein technisches Problem ist. */
const PFLICHTRECHTE: Array<{ resource: string; action: string; wofuer: string }> = [
{ resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' },
{ resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' },
{ resource: 'audit', action: 'read', wofuer: 'Prüfung des Audit-Protokolls' },
];
export async function pruefePflichtrechte(): Promise<void> {
try {
const fehlend: string[] = [];
for (const recht of PFLICHTRECHTE) {
const traeger = await prisma.user.count({
where: {
isActive: true,
roles: {
some: {
role: {
permissions: {
some: {
permission: { resource: recht.resource, action: recht.action },
},
},
},
},
},
},
});
if (traeger === 0) {
fehlend.push(`${recht.resource}:${recht.action} (${recht.wofuer})`);
}
}
if (fehlend.length === 0) return;
console.warn(
'\n' +
'========================================================================\n' +
' ACHTUNG: Für folgende Rechte gibt es KEIN aktives Konto:\n' +
fehlend.map((f) => ` ${f}\n`).join('') +
'\n' +
' Das ist kein Fehler der Anwendung, sondern eine Lücke in der\n' +
' Rechtevergabe und sie fällt erst auf, wenn eine Frist läuft.\n' +
'\n' +
' Beheben: In der Benutzerverwaltung bei einem verantwortlichen Konto\n' +
' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' +
' Datenschutz-Verwaltung). Für Eingriffe am Protokoll versiegeln,\n' +
' aufräumen, Aufbewahrung ändern zusätzlich „Audit-Betrieb".\n' +
'========================================================================\n',
);
} catch (err) {
// Eine Wache darf den Start nicht verhindern. Aber schweigen darf sie
// auch nicht: „nicht geprüft" ist nicht dasselbe wie „nichts gefunden".
console.warn(
'[Pflichtrechte] Prüfung nicht möglich der Zustand der Rechtevergabe ist ' +
'damit UNBEKANNT, nicht in Ordnung:',
err instanceof Error ? err.message : err,
);
}
}
+85 -5
View File
@@ -44,6 +44,7 @@ export async function getAllUsers(filters: UserFilters) {
firstName: true,
lastName: true,
isActive: true,
isServiceAccount: true,
customerId: true,
whatsappNumber: true,
telegramUsername: true,
@@ -64,9 +65,10 @@ export async function getAllUsers(filters: UserFilters) {
]);
// Get hidden role IDs
const [developerRole, gdprRole] = await Promise.all([
const [developerRole, gdprRole, auditOpsRole] = await Promise.all([
prisma.role.findFirst({ where: { name: 'Developer' } }),
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }),
]);
return {
@@ -77,11 +79,15 @@ export async function getAllUsers(filters: UserFilters) {
const hasGdprAccess = gdprRole
? u.roles.some((ur) => ur.roleId === gdprRole.id)
: false;
const hasAuditOpsAccess = auditOpsRole
? u.roles.some((ur) => ur.roleId === auditOpsRole.id)
: false;
return {
...u,
roles: u.roles.map((r) => r.role),
hasDeveloperAccess,
hasGdprAccess,
hasAuditOpsAccess,
};
}),
pagination: buildPaginationResponse(page, limit, total),
@@ -97,6 +103,7 @@ export async function getUserById(id: number) {
firstName: true,
lastName: true,
isActive: true,
isServiceAccount: true,
customerId: true,
whatsappNumber: true,
telegramUsername: true,
@@ -144,6 +151,7 @@ export async function createUser(data: {
customerId?: number;
hasDeveloperAccess?: boolean;
hasGdprAccess?: boolean;
hasAuditOpsAccess?: boolean;
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
@@ -170,6 +178,7 @@ export async function createUser(data: {
firstName: true,
lastName: true,
isActive: true,
isServiceAccount: true,
customerId: true,
roles: {
include: { role: true },
@@ -187,6 +196,10 @@ export async function createUser(data: {
await setUserGdprAccess(user.id, true);
}
if (data.hasAuditOpsAccess) {
await setUserAuditOpsAccess(user.id, true);
}
return user;
}
@@ -198,16 +211,18 @@ export async function updateUser(
firstName?: string;
lastName?: string;
isActive?: boolean;
isServiceAccount?: boolean;
roleIds?: number[];
customerId?: number;
hasDeveloperAccess?: boolean;
hasGdprAccess?: boolean;
hasAuditOpsAccess?: boolean;
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
}
) {
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data;
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
// Check if this would remove the last admin
const isBeingDeactivated = userData.isActive === false;
@@ -344,6 +359,10 @@ export async function updateUser(
await setUserGdprAccess(id, hasGdprAccess);
}
if (hasAuditOpsAccess !== undefined) {
await setUserAuditOpsAccess(id, hasAuditOpsAccess);
}
return getUserById(id);
}
@@ -403,6 +422,16 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
}
}
/**
* Name der versteckten Rolle fuer eingreifende Audit-Rechte.
*
* Getrennt von `DSGVO`, weil Aufsicht und Eingriff nicht dieselbe Rolle sein
* duerfen: Wer das Protokoll beaufsichtigt, darf seine Beweisgrundlage nicht
* ersetzen koennen (Pentest R186). Die Rolle wird von `sync-roles.ts` beim
* Containerstart angelegt.
*/
export const AUDIT_OPS_ROLLE = 'Audit-Betrieb';
// Helper to set GDPR access for a user
async function setUserGdprAccess(userId: number, enabled: boolean) {
// Get or create DSGVO role
@@ -411,17 +440,24 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
});
if (!gdprRole) {
// Create DSGVO role with all audit:* and gdpr:* permissions
// Rechte-Satz identisch zu sync-roles.ts: gdpr komplett, vom Audit-
// Protokoll nur LESEN und EXPORTIEREN. Ohne `audit:admin` - sonst
// brächte dieser Notfallpfad genau die Bündelung zurück, die wir gerade
// aufgelöst haben (zwei Listen, die dasselbe bedeuten sollen, laufen
// auseinander).
const gdprPermissions = await prisma.permission.findMany({
where: {
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
OR: [
{ resource: 'gdpr' },
{ resource: 'audit', action: { in: ['read', 'export'] } },
],
},
});
gdprRole = await prisma.role.create({
data: {
name: 'DSGVO',
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
permissions: {
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
},
@@ -453,6 +489,50 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
}
}
/**
* Eingreifende Audit-Rechte setzen: versiegeln, neu berechnen, aufraeumen,
* Aufbewahrung aendern. Bewusst getrennt vom DSGVO-Haken (siehe
* `AUDIT_OPS_ROLLE`).
*/
async function setUserAuditOpsAccess(userId: number, enabled: boolean) {
let rolle = await prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } });
if (!rolle) {
// Rechte-Satz identisch zu sync-roles.ts.
const rechte = await prisma.permission.findMany({
where: { resource: 'audit', action: { in: ['read', 'admin'] } },
});
rolle = await prisma.role.create({
data: {
name: AUDIT_OPS_ROLLE,
description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern',
permissions: { create: rechte.map((p) => ({ permissionId: p.id })) },
},
});
}
const hatRolle = await prisma.userRole.findFirst({
where: { userId, roleId: rolle.id },
});
if (enabled && !hatRolle) {
await prisma.userRole.create({ data: { userId, roleId: rolle.id } });
// Token invalidieren bei Rechteaenderung
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} else if (!enabled && hatRolle) {
await prisma.userRole.delete({
where: { userId_roleId: { userId, roleId: rolle.id } },
});
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
}
}
export async function deleteUser(id: number) {
// Check if user is an admin
const user = await prisma.user.findUnique({
+8
View File
@@ -640,6 +640,10 @@ const USER_UPDATABLE_FIELDS = [
'firstName',
'lastName',
'isActive',
// Kennzeichnet planmaessig arbeitende Konten (z. B. das Gegenbuch). Ihre
// Anmeldungen werden als Routine statt CRITICAL gefuehrt, dafuer wacht der
// Heartbeat-Monitor ueber ihr AUSBLEIBEN (Pentest R182/R183).
'isServiceAccount',
'whatsappNumber',
'telegramUsername',
'signalNumber',
@@ -650,6 +654,10 @@ const USER_UPDATABLE_FIELDS = [
// stehen, damit pick() sie nicht aus dem Request entfernt.
'hasGdprAccess',
'hasDeveloperAccess',
// Eingreifende Audit-Rechte (versiegeln/aufraeumen/Aufbewahrung), bewusst
// getrennt von hasGdprAccess: Aufsicht und Eingriff sind zwei Rollen
// (Pentest R186). Mappt auf die versteckte Rolle 'Audit-Betrieb'.
'hasAuditOpsAccess',
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
// Nicht: password wird über dedizierten Endpoint POST /users/:id/password
// gesetzt (Pentest Runde 12 (2026-05-18) MITTEL: generisches User-Update
+34
View File
@@ -62,6 +62,12 @@ services:
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m}
JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
# Siegel für das Audit-Log: signiert jeden Eintrag mit einem Schlüssel,
# der außerhalb der Datenbank liegt. Ohne Wert läuft alles normal weiter,
# nur ungesiegelt (fail-safe). AUDIT_HMAC_KEY_OLD nur beim Wechsel füllen
# (kommagetrennte Liste früherer Schlüssel).
AUDIT_HMAC_KEY: ${AUDIT_HMAC_KEY:-}
AUDIT_HMAC_KEY_OLD: ${AUDIT_HMAC_KEY_OLD:-}
NODE_ENV: production
PORT: 3001
LISTEN_ADDR: 0.0.0.0
@@ -84,6 +90,34 @@ services:
# Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen)
- ${BANKDATA_DIR:-./data/bankdata}:/app/bankdata
# Optionaler Reverse-Proxy mit automatischem SSL nur fuer Betreiber OHNE
# eigenen Proxy. Wird standardmaessig NICHT angelegt.
#
# Einschalten ueber die .env (kein Auskommentieren noetig):
# COMPOSE_PROFILES=caddy
# Danach reicht wie gewohnt `docker-compose up -d`. Alternativ einmalig per
# Flag: `docker-compose --profile caddy up -d`.
# Wer nginx / Nginx Proxy Manager / Traefik davor hat, laesst das Feld leer.
caddy:
image: caddy:2-alpine
container_name: opencrm-caddy
restart: unless-stopped
profiles: ["caddy"]
depends_on:
- opencrm
environment:
DOMAIN: ${DOMAIN:-localhost}
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
# Zertifikate liegen wie alle Daten unter ./data/ nicht loeschen,
# sonst werden bei jedem Start neue Zertifikate angefordert
# (Let's-Encrypt-Limits!).
- ${CADDY_DIR:-./data/caddy}:/data
- ${CADDY_CONFIG_DIR:-./data/caddy-config}:/config
adminer:
image: adminer:latest
container_name: opencrm-adminer
-29
View File
@@ -1,29 +0,0 @@
# OpenCRM Docker Environment
# ============================
# Copy this file to .env and adjust the values
# Domain (for Caddy SSL certificate)
# Use your actual domain, e.g., crm.example.com
DOMAIN=localhost
# Database
DB_ROOT_PASSWORD=change-this-root-password
DB_NAME=opencrm
DB_USER=opencrm
DB_PASSWORD=change-this-password
# JWT Authentication
# Generate with: openssl rand -base64 32
JWT_SECRET=change-this-to-a-secure-random-string
# Access-Token kurz (XSS-Schutz, Token lebt nur im JS-Memory).
# Refresh-Token lang im httpOnly-Cookie.
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Encryption Key (for portal credentials)
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=change-this-to-a-32-byte-hex-key
# First Install: Set to "true" to seed database on first startup
# After first successful start, set back to "false"
RUN_SEED=true
-34
View File
@@ -1,34 +0,0 @@
# OpenCRM Caddyfile
# ===================
# Replace {$DOMAIN} with your actual domain or use environment variable
# For local development without SSL, use localhost:80
{$DOMAIN:localhost} {
# Reverse proxy to OpenCRM app
reverse_proxy app:3001
# Logging
log {
output stdout
format console
}
# Security headers
header {
# Clickjacking protection
X-Frame-Options "SAMEORIGIN"
# XSS protection
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
# Referrer policy
Referrer-Policy "strict-origin-when-cross-origin"
}
# Gzip compression
encode gzip
# Handle file uploads (increase body limit)
request_body {
max_size 50MB
}
}
-97
View File
@@ -1,97 +0,0 @@
# ===================================
# Stage 1: Build Frontend
# ===================================
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
# Copy package files
COPY frontend/package*.json ./
# Install dependencies
RUN npm ci
# Copy frontend source
COPY frontend/ ./
# Build frontend
RUN npm run build
# ===================================
# Stage 2: Build Backend
# ===================================
FROM node:20-alpine AS backend-builder
WORKDIR /app/backend
# Install OpenSSL for Prisma
RUN apk add --no-cache openssl
# Copy package files
COPY backend/package*.json ./
# Install dependencies (including dev for build)
RUN npm ci
# Copy backend source
COPY backend/ ./
# Generate Prisma client
RUN npx prisma generate
# Build TypeScript
RUN npm run build
# ===================================
# Stage 3: Production Runtime
# ===================================
FROM node:20-alpine AS production
WORKDIR /app
# Install OpenSSL for Prisma runtime and netcat for DB health check
RUN apk add --no-cache openssl netcat-openbsd
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S opencrm -u 1001 -G nodejs
# Copy backend package files
COPY backend/package*.json ./
# Install production dependencies + tsx for seed script
RUN npm ci --omit=dev && npm install tsx
# Copy Prisma schema and generate client
COPY backend/prisma ./prisma
RUN npx prisma generate
# Copy built backend
COPY --from=backend-builder /app/backend/dist ./dist
# Copy built frontend to public directory
COPY --from=frontend-builder /app/frontend/dist ./public
# Copy seed file for factory reset
COPY backend/prisma/seed.ts ./prisma/
# Copy entrypoint script
COPY docker/entrypoint.sh ./entrypoint.sh
# Create directories for uploads and backups
RUN mkdir -p uploads backups && \
chmod +x entrypoint.sh && \
chown -R opencrm:nodejs /app
# Switch to non-root user
USER opencrm
# Expose port
EXPOSE 3001
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1
# Start the application with entrypoint
ENTRYPOINT ["./entrypoint.sh"]
-139
View File
@@ -1,139 +0,0 @@
# OpenCRM Docker Deployment
## Schnellstart
1. **Umgebungsvariablen konfigurieren:**
```bash
cd docker
cp .env.example .env
nano .env # Sichere Werte setzen
```
2. **Container starten (erster Start mit RUN_SEED=true):**
```bash
docker compose up -d
```
Beim ersten Start wird automatisch:
- Auf die Datenbank gewartet
- Migrationen ausgeführt
- Seed-Daten geladen (wenn `RUN_SEED=true`)
3. **Nach erfolgreicher Installation:**
```bash
# RUN_SEED in .env auf false setzen
sed -i 's/RUN_SEED=true/RUN_SEED=false/' .env
```
4. **Anwendung aufrufen:**
- Mit Domain: `https://your-domain.com`
- Lokal: `http://localhost`
5. **Login:**
- E-Mail: `admin@admin.com`
- Passwort: `admin`
## Architektur
```
┌─────────────┐
│ Caddy │
│ (SSL/TLS) │
│ :80/:443 │
└──────┬──────┘
┌──────▼──────┐
│ OpenCRM │
│ (Node.js) │
│ :3001 │
└──────┬──────┘
┌──────▼──────┐
│ MariaDB │
│ :3306 │
└─────────────┘
```
## Befehle
### Container verwalten
```bash
# Starten
docker compose up -d
# Stoppen
docker compose down
# Logs anzeigen
docker compose logs -f app
# Neustart
docker compose restart app
```
### Datenbank
```bash
# Migration ausführen
docker compose exec app npx prisma migrate deploy
# Seed-Daten laden
docker compose exec app npx tsx prisma/seed.ts
# Prisma Studio (Datenbank-UI)
docker compose exec app npx prisma studio
```
### Backup & Restore
```bash
# Backup-Verzeichnis ist unter /app/backups gemountet
# Backups werden über die Anwendung erstellt/wiederhergestellt
```
### Update
```bash
# Image neu bauen und Container aktualisieren
docker compose build --no-cache
docker compose up -d
# Migrationen werden automatisch beim Start ausgeführt
```
## Volumes
| Volume | Beschreibung |
|--------|--------------|
| `mariadb_data` | Datenbank-Dateien |
| `uploads_data` | Hochgeladene Dokumente |
| `backups_data` | Backup-Dateien |
| `caddy_data` | SSL-Zertifikate |
| `caddy_config` | Caddy-Konfiguration |
## SSL-Zertifikat
Caddy holt automatisch ein Let's Encrypt Zertifikat wenn:
- Die Domain in `.env` korrekt gesetzt ist
- Port 80 und 443 von außen erreichbar sind
- DNS auf den Server zeigt
Für lokale Entwicklung mit `DOMAIN=localhost` wird ein selbstsigniertes Zertifikat verwendet.
## Troubleshooting
### Container startet nicht
```bash
docker compose logs app
```
### Datenbank-Verbindung fehlgeschlagen
```bash
# Warten bis MariaDB bereit ist
docker compose logs db
```
### SSL-Zertifikat Probleme
```bash
docker compose logs caddy
# Caddy-Daten zurücksetzen
docker compose down
docker volume rm opencrm_caddy_data opencrm_caddy_config
docker compose up -d
```
-88
View File
@@ -1,88 +0,0 @@
version: '3.8'
services:
# ===================================
# MariaDB Database
# ===================================
db:
image: mariadb:10.11
container_name: opencrm-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${DB_NAME:-opencrm}
MYSQL_USER: ${DB_USER:-opencrm}
MYSQL_PASSWORD: ${DB_PASSWORD:-opencrm123}
volumes:
- mariadb_data:/var/lib/mysql
networks:
- opencrm-network
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
timeout: 5s
retries: 3
# ===================================
# OpenCRM Application
# ===================================
app:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: opencrm-app
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3001
DATABASE_URL: mysql://${DB_USER:-opencrm}:${DB_PASSWORD:-opencrm123}@db:3306/${DB_NAME:-opencrm}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}
RUN_SEED: ${RUN_SEED:-false}
volumes:
- uploads_data:/app/uploads
- backups_data:/app/backups
networks:
- opencrm-network
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
start_period: 60s
interval: 30s
timeout: 10s
retries: 3
# ===================================
# Caddy Reverse Proxy (with auto SSL)
# ===================================
caddy:
image: caddy:2-alpine
container_name: opencrm-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- opencrm-network
depends_on:
app:
condition: service_healthy
networks:
opencrm-network:
driver: bridge
volumes:
mariadb_data:
uploads_data:
backups_data:
caddy_data:
caddy_config:
-40
View File
@@ -1,40 +0,0 @@
#!/bin/sh
set -e
echo "=== OpenCRM Startup ==="
# Wait for database to be ready
echo "Waiting for database connection..."
MAX_RETRIES=30
RETRY_COUNT=0
while ! nc -z db 3306 2>/dev/null; do
RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "Error: Database not available after $MAX_RETRIES attempts"
exit 1
fi
echo " Attempt $RETRY_COUNT/$MAX_RETRIES - waiting..."
sleep 2
done
echo "Database is ready!"
# Run migrations
echo "Running database migrations..."
npx prisma migrate deploy
# Seed database if RUN_SEED is set (first install)
if [ "$RUN_SEED" = "true" ]; then
echo "Seeding database..."
npx tsx prisma/seed.ts
fi
# Einmal-Bereinigung für Pentest-Reste (Runde 12): XSS-Strings aus
# Customer/User-Feldern entfernen + unbekannte AppSettings löschen.
# Idempotent läuft bei jedem Container-Start ohne Risiko.
echo "Running data cleanup..."
npx tsx prisma/cleanup-xss-and-mass-assignment.ts || echo " (Cleanup übersprungen, nicht-kritisch)"
# Start the application
echo "Starting OpenCRM server..."
exec node dist/index.js
+1133
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,209 @@
import { useQuery } from '@tanstack/react-query';
import { auditLogApi, IntegrityResult } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import {
ShieldCheck, ShieldAlert, ShieldQuestion, RefreshCw, Loader2, ChevronDown, ChevronUp,
} from 'lucide-react';
import { useState } from 'react';
/**
* Statuskarte fuer die Unversehrtheit des Audit-Protokolls.
*
* Die Prueflast steigt mit der Groesse des Protokolls (die Pruefung liest die
* gesamte Kette). Deshalb laeuft sie beim Oeffnen der Seite EINMAL und wird
* fuenf Minuten lang wiederverwendet; wer sofort neu pruefen will, nutzt den
* Knopf.
*/
type Ampel = 'ok' | 'befund' | 'hinweis' | 'unbekannt';
function ampelFuer(r: IntegrityResult): Ampel {
if (!r.valid) return 'befund';
// Gueltig, aber nicht vollstaendig pruefbar: kein gruenes Licht vortaeuschen.
if (r.unverifiableEntries.length > 0) return 'unbekannt';
if (r.backlogSealStatus === 'kein_siegel') return 'hinweis';
return 'ok';
}
const AMPEL_STIL: Record<Ampel, { box: string; titel: string; text: string }> = {
ok: { box: 'bg-green-50 border-green-200', titel: 'text-green-900', text: 'text-green-800' },
befund: { box: 'bg-red-50 border-red-200', titel: 'text-red-900', text: 'text-red-800' },
hinweis: { box: 'bg-yellow-50 border-yellow-200',titel: 'text-yellow-900', text: 'text-yellow-800' },
unbekannt: { box: 'bg-gray-50 border-gray-200', titel: 'text-gray-900', text: 'text-gray-700' },
};
function AmpelIcon({ ampel }: { ampel: Ampel }) {
const c = 'w-7 h-7 shrink-0';
if (ampel === 'ok') return <ShieldCheck className={`${c} text-green-600`} />;
if (ampel === 'befund') return <ShieldAlert className={`${c} text-red-600`} />;
if (ampel === 'hinweis') return <ShieldAlert className={`${c} text-yellow-600`} />;
return <ShieldQuestion className={`${c} text-gray-500`} />;
}
const AMPEL_TITEL: Record<Ampel, string> = {
ok: 'Protokoll unversehrt',
befund: 'Befund das gehört angesehen',
hinweis: 'Unversehrt, aber ungeschützter Altbestand',
unbekannt: 'Nicht vollständig prüfbar',
};
const SIEGEL_TEXT: Record<IntegrityResult['backlogSealStatus'], string> = {
intakt: 'versiegelt und intakt',
leer: 'Siegel vorhanden, aber ohne Inhalt',
kein_siegel: 'nicht versiegelt',
nicht_noetig: 'nicht nötig (alles signiert)',
gebrochen: 'GEBROCHEN',
entfernt: 'ENTFERNT',
};
/** Zeigt eine Zahl nur, wenn sie ungleich null ist sonst bleibt es ruhig. */
function Zahl({ label, ids, ton }: { label: string; ids: number[]; ton: string }) {
if (ids.length === 0) return null;
const gekuerzt = ids.length > 12 ? `${ids.slice(0, 12).join(', ')} … (+${ids.length - 12})` : ids.join(', ');
return (
<div className="flex items-baseline gap-2 text-sm">
<span className={`font-medium ${ton}`}>{label}:</span>
<span className="text-gray-600">{ids.length}</span>
<span className="text-gray-400 font-mono text-xs break-all">({gekuerzt})</span>
</div>
);
}
export default function AuditIntegrityCard() {
const [offen, setOffen] = useState(false);
const { data, isFetching, isError, error, refetch } = useQuery({
queryKey: ['audit-integrity'],
queryFn: () => auditLogApi.verifyIntegrity(),
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: false,
});
const ergebnis = data?.data;
return (
<Card
className="mb-6"
title="Unversehrtheit des Protokolls"
actions={
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
{isFetching
? <Loader2 className="w-4 h-4 mr-2 animate-spin" />
: <RefreshCw className="w-4 h-4 mr-2" />}
Neu prüfen
</Button>
}
>
{isFetching && !ergebnis && (
<div className="flex items-center gap-3 text-gray-500">
<Loader2 className="w-5 h-5 animate-spin" />
<span>Die Kette wird geprüft </span>
</div>
)}
{isError && (
<div className="flex items-start gap-4 p-4 rounded-lg border bg-gray-50 border-gray-200">
<ShieldQuestion className="w-7 h-7 shrink-0 text-gray-500" />
<div>
<p className="font-medium text-gray-900">Prüfung nicht möglich</p>
<p className="text-sm text-gray-700 mt-1">
{error instanceof Error ? error.message : 'Unbekannter Fehler'}
</p>
<p className="text-sm text-gray-600 mt-2">
Das ist <strong>keine</strong> Entwarnung der Zustand der Kette ist damit
schlicht unbekannt.
</p>
</div>
</div>
)}
{ergebnis && (() => {
const ampel = ampelFuer(ergebnis);
const stil = AMPEL_STIL[ampel];
return (
<>
<div className={`flex items-start gap-4 p-4 rounded-lg border ${stil.box}`}>
<AmpelIcon ampel={ampel} />
<div className="min-w-0">
<p className={`font-medium ${stil.titel}`}>
{AMPEL_TITEL[ampel]}
{ampel === 'ok' && ergebnis.rehashes.length > 0 && (
<span className="font-normal"> seit der letzten Neuberechnung</span>
)}
</p>
<p className={`text-sm mt-1 ${stil.text}`}>{ergebnis.message}</p>
<p className="text-sm text-gray-500 mt-2">
{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft ·
{' '}Altbestand: {SIEGEL_TEXT[ergebnis.backlogSealStatus]}
{ergebnis.backlogSealCount > 1 && ` · ${ergebnis.backlogSealCount}× versiegelt`}
</p>
</div>
</div>
<button
type="button"
onClick={() => setOffen(o => !o)}
className="mt-3 flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
>
{offen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
Einzelheiten
</button>
{offen && (
<div className="mt-3 space-y-2 border-t pt-3">
<Zahl label="Nachträglich verändert" ids={ergebnis.tamperedEntries} ton="text-red-700" />
<Zahl label="Gesiegelte Alteinträge verändert" ids={ergebnis.backlogTampered} ton="text-red-700" />
<Zahl label="Gesiegelte Alteinträge entfernt" ids={ergebnis.backlogMissing} ton="text-red-700" />
<Zahl label="Verkettung unterbrochen" ids={ergebnis.chainGaps} ton="text-yellow-700" />
<Zahl label="davon ohne dokumentierte Löschung" ids={ergebnis.unexplainedGaps} ton="text-yellow-700" />
<Zahl label="davon vom Siegel beglaubigt" ids={ergebnis.attestedGaps} ton="text-gray-700" />
<Zahl label="Ohne Schlüssel nicht prüfbar" ids={ergebnis.unverifiableEntries} ton="text-gray-700" />
{ergebnis.tamperedEntries.length === 0 &&
ergebnis.chainGaps.length === 0 &&
ergebnis.unverifiableEntries.length === 0 &&
ergebnis.rehashes.length === 0 && (
<p className="text-sm text-gray-500 italic">Nichts zu berichten.</p>
)}
{ergebnis.rehashes.length > 0 && (
<div className="pt-2 border-t mt-2">
<p className="text-sm font-medium text-gray-700">Neuberechnungen der Kette</p>
{ergebnis.rehashes.map((r) => (
<p key={r.id} className="text-sm text-gray-600 mt-1">
{new Date(r.zeitpunkt).toLocaleString('de-DE')} · Eintrag {r.id}
{r.neuBerechnet !== null && ` · ${r.neuBerechnet} Einträge neu verkettet`}
{r.vorbefund && (
<span className="text-gray-500">
{' '}· davor: {r.vorbefund.manipuliert} beanstandet,{' '}
{r.vorbefund.luecken} Lücken
</span>
)}
{!r.signiert && (
<span className="text-red-700"> · ohne gültige Signatur</span>
)}
</p>
))}
<p className="text-xs text-gray-500 mt-2">
Ein Rehash verknüpft alle Einträge neu. Was davor als Lücke oder
Beanstandung sichtbar war, ist danach nicht mehr in der Kette zu sehen
nur noch im Vorbefund des jeweiligen Eintrags. Die Aussage dieser Prüfung
reicht deshalb bis zur letzten Neuberechnung zurück, nicht weiter.
</p>
</div>
)}
<p className="text-xs text-gray-500 pt-2">
<strong>Verändert</strong> heißt: der Inhalt einer bestehenden Zeile passt nicht
mehr zu ihrer Prüfsumme das ist ernst.
{' '}<strong>Verkettung unterbrochen</strong> heißt: die Zeilen selbst sind
unversehrt, aber ein Glied fehlt oder wurde parallel geschrieben.
{' '}<strong>Beglaubigt</strong> sind Lücken, die beim Versiegeln des Altbestands
bereits bestanden und im signierten Siegel festgehalten sind.
</p>
</div>
)}
</>
);
})()}
</Card>
);
}
+30 -13
View File
@@ -1,12 +1,14 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { auditLogApi, AuditLogSearchParams, authApi } from '../../services/api';
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import AuditIntegrityCard from './AuditIntegrityCard';
import { ArrowLeft, Download, Eye, Shield, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
const ACTION_OPTIONS = [
@@ -278,6 +280,10 @@ function DetailModal({ log, onClose }: DetailModalProps) {
export default function AuditLogs() {
const navigate = useNavigate();
// Export haengt an `audit:export`, Lesen an `audit:read` ein Konto darf
// blaettern duerfen, ohne das gesamte Protokoll herausziehen zu koennen.
// Ohne diese Abfrage stuenden hier Knoepfe, die zuverlaessig 403 liefern.
const { hasPermission } = useAuth();
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<AuditLogSearchParams>({
page: 1,
@@ -307,11 +313,16 @@ export default function AuditLogs() {
const downloadToken = await authApi.getDownloadToken();
const params = new URLSearchParams();
params.set('format', 'csv');
if (filters.action) params.set('action', filters.action);
if (filters.sensitivity) params.set('sensitivity', filters.sensitivity);
if (filters.resourceType) params.set('resourceType', filters.resourceType);
if (filters.startDate) params.set('startDate', filters.startDate);
if (filters.endDate) params.set('endDate', filters.endDate);
// ALLE aktiven Filter mitgeben (Pentest R186-01). Vorher standen hier
// nur fuenf wer im Suchfeld eingrenzte und dann CSV klickte, bekam
// stillschweigend das gesamte Protokoll statt seiner Auswahl.
// `page`/`limit` gehoeren nicht dazu: der Export ist bewusst
// vollstaendig ueber die gefilterte Menge.
for (const [schluessel, wert] of Object.entries(filters)) {
if (schluessel === 'page' || schluessel === 'limit') continue;
if (wert === undefined || wert === null || wert === '') continue;
params.set(schluessel, String(wert));
}
window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank');
} else {
const result = await auditLogApi.export({ ...filters, format });
@@ -341,6 +352,8 @@ export default function AuditLogs() {
<h1 className="text-2xl font-bold">Audit-Protokoll</h1>
</div>
<AuditIntegrityCard />
{/* Filter */}
<Card className="mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
@@ -385,14 +398,18 @@ export default function AuditLogs() {
<RefreshCw className="w-4 h-4 mr-2" />
Aktualisieren
</Button>
<Button variant="secondary" onClick={() => handleExport('json')}>
<Download className="w-4 h-4 mr-2" />
JSON
</Button>
<Button variant="secondary" onClick={() => handleExport('csv')}>
<Download className="w-4 h-4 mr-2" />
CSV
</Button>
{hasPermission('audit:export') && (
<Button variant="secondary" onClick={() => handleExport('json')}>
<Download className="w-4 h-4 mr-2" />
JSON
</Button>
)}
{hasPermission('audit:export') && (
<Button variant="secondary" onClick={() => handleExport('csv')}>
<Download className="w-4 h-4 mr-2" />
CSV
</Button>
)}
</div>
</div>
</Card>
@@ -37,6 +37,7 @@ const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
{ value: 'LOGOUT', label: 'Logout' },
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
{ value: 'AUDIT_SEAL_CHANGED', label: 'Bestandssiegel gesetzt/ersetzt' },
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
];
+65 -4
View File
@@ -121,7 +121,7 @@ export default function UserList() {
<td className="py-3 px-4">{user.email}</td>
<td className="py-3 px-4">
<div className="flex gap-1 flex-wrap">
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role: any) => (
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role: any) => (
<Badge key={role.id || role.name} variant="info">
{role.name}
</Badge>
@@ -246,7 +246,9 @@ function UserModal({
roleIds: [] as number[],
isActive: true,
hasDeveloperAccess: false,
hasAuditOpsAccess: false,
hasGdprAccess: false,
isServiceAccount: false,
whatsappNumber: '',
telegramUsername: '',
signalNumber: '',
@@ -263,10 +265,12 @@ function UserModal({
currentPassword: '',
firstName: user.firstName,
lastName: user.lastName,
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(r.name)).map((r: any) => r.id) || [],
isActive: (user as any).isActive ?? true,
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
hasAuditOpsAccess: (user as any).hasAuditOpsAccess ?? false,
hasGdprAccess: (user as any).hasGdprAccess ?? false,
isServiceAccount: (user as any).isServiceAccount ?? false,
whatsappNumber: (user as any).whatsappNumber || '',
telegramUsername: (user as any).telegramUsername || '',
signalNumber: (user as any).signalNumber || '',
@@ -281,7 +285,9 @@ function UserModal({
roleIds: [],
isActive: true,
hasDeveloperAccess: false,
hasAuditOpsAccess: false,
hasGdprAccess: false,
isServiceAccount: false,
whatsappNumber: '',
telegramUsername: '',
signalNumber: '',
@@ -323,7 +329,16 @@ function UserModal({
roleIds: formData.roleIds,
isActive: formData.isActive,
hasDeveloperAccess: formData.hasDeveloperAccess,
hasAuditOpsAccess: formData.hasAuditOpsAccess,
hasGdprAccess: formData.hasGdprAccess,
isServiceAccount: formData.isServiceAccount,
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
// Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken
// im Formular IST diese Bestätigung.
// Das Kennzeichen senkt bzw. hebt die Bewertung der Anmeldungen dieses
// Kontos. Der Server verlangt in BEIDE Richtungen eine ausdrückliche
// Bestätigung; der Haken im Formular ist diese Bestätigung.
confirm: 'SERVICE_ACCOUNT',
whatsappNumber: formData.whatsappNumber || undefined,
telegramUsername: formData.telegramUsername || undefined,
signalNumber: formData.signalNumber || undefined,
@@ -358,7 +373,16 @@ function UserModal({
lastName: formData.lastName,
roleIds: formData.roleIds,
hasDeveloperAccess: formData.hasDeveloperAccess,
hasAuditOpsAccess: formData.hasAuditOpsAccess,
hasGdprAccess: formData.hasGdprAccess,
isServiceAccount: formData.isServiceAccount,
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
// Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken
// im Formular IST diese Bestätigung.
// Das Kennzeichen senkt bzw. hebt die Bewertung der Anmeldungen dieses
// Kontos. Der Server verlangt in BEIDE Richtungen eine ausdrückliche
// Bestätigung; der Haken im Formular ist diese Bestätigung.
confirm: 'SERVICE_ACCOUNT',
whatsappNumber: formData.whatsappNumber || undefined,
telegramUsername: formData.telegramUsername || undefined,
signalNumber: formData.signalNumber || undefined,
@@ -470,7 +494,7 @@ function UserModal({
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
<div className="space-y-2">
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role) => (
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role) => (
<label key={role.id} className="flex items-center gap-2">
<input
type="checkbox"
@@ -487,6 +511,22 @@ function UserModal({
</div>
<label className="block text-sm font-medium text-gray-700 mt-4 mb-2">Zusätzliche Berechtigungen</label>
<div className="space-y-2">
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={formData.isServiceAccount}
onChange={(e) => setFormData({ ...formData, isServiceAccount: e.target.checked })}
className="mt-1 rounded border-blue-300 text-blue-600 focus:ring-blue-500"
/>
<span>
<span className="font-medium">Dienstkonto</span>
<span className="block text-sm text-gray-500">
Für Konten, die sich planmäßig und regelmäßig anmelden etwa das Gegenbuch.
Ihre Anmeldungen erscheinen im Audit-Log als Routine statt als kritisches
Ereignis. Dafür wird gemeldet, wenn ein solches Konto <em>ausbleibt</em>.
</span>
</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
@@ -498,7 +538,28 @@ function UserModal({
<Shield className="w-4 h-4 text-blue-600" />
DSGVO-Zugriff
</span>
<span className="text-sm text-gray-500">(Audit-Logs, Datenschutz)</span>
<span className="text-sm text-gray-500">(Audit-Protokoll lesen, Datenschutz)</span>
</label>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={formData.hasAuditOpsAccess}
onChange={(e) => setFormData({ ...formData, hasAuditOpsAccess: e.target.checked })}
className="mt-1 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
/>
<span>
<span className="flex items-center gap-1">
<Shield className="w-4 h-4 text-amber-600" />
Audit-Betrieb
<span className="text-sm text-gray-500">(versiegeln, aufräumen, Aufbewahrung)</span>
</span>
<span className="block text-sm text-gray-500 mt-0.5">
Erlaubt Eingriffe am Audit-Protokoll: Bestandssiegel setzen oder
ersetzen, aufräumen, Aufbewahrungsfristen ändern. Bewusst getrennt
vom DSGVO-Zugriff wer das Protokoll beaufsichtigt, soll seine
eigene Beweisgrundlage nicht ersetzen können.
</span>
</span>
</label>
<label className="flex items-center gap-2">
<input
+41 -4
View File
@@ -1548,11 +1548,12 @@ export const userApi = {
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
return res.data;
},
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean;
hasAuditOpsAccess?: boolean; isServiceAccount?: boolean; confirm?: string; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
const res = await api.post<ApiResponse<User>>('/users', data);
return res.data;
},
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[] }) => {
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[]; isServiceAccount?: boolean; hasGdprAccess?: boolean; hasDeveloperAccess?: boolean; hasAuditOpsAccess?: boolean; confirm?: string }) => {
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
return res.data;
},
@@ -1734,6 +1735,42 @@ export interface AuditLogSearchParams {
search?: string;
}
/** Ergebnis der Integritaetspruefung des Audit-Protokolls. */
export interface IntegrityResult {
valid: boolean;
checkedCount: number;
/** Noch offene Beanstandungen nur hieran haengt `valid`. */
invalidEntries: number[];
/** ERNST: Inhalt einer bestehenden Zeile wurde nachtraeglich veraendert. */
tamperedEntries: number[];
/** Verkettung unterbrochen; die Zeilen selbst koennen unversehrt sein. */
chainGaps: number[];
/** Luecken ohne protokolliertes Loeschungs-Manifest. */
unexplainedGaps: number[];
/** Alt-Luecken, die das Bestandssiegel als bereits vorhanden beglaubigt. */
attestedGaps: number[];
/**
* Protokollierte Neuberechnungen der Kette. Nach einem Rehash ist die Kette
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg. Die Aussage der
* Pruefung reicht dann nur bis zur letzten Neuberechnung zurueck.
*/
rehashes: Array<{
id: number;
zeitpunkt: string;
neuBerechnet: number | null;
signiert: boolean;
vorbefund: { manipuliert: number; luecken: number } | null;
}>;
/** Signierte Zeilen, die ohne AUDIT_HMAC_KEY nicht pruefbar sind. */
unverifiableEntries: number[];
backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
backlogTampered: number[];
backlogMissing: number[];
backlogSealCount: number;
tampered: boolean;
message: string;
}
export const auditLogApi = {
search: async (params?: AuditLogSearchParams) => {
const res = await api.get<ApiResponse<AuditLog[]>>('/audit-logs', { params });
@@ -1752,7 +1789,7 @@ export const auditLogApi = {
return res.data;
},
verifyIntegrity: async () => {
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; unexplainedGaps: number[]; unverifiableEntries: number[]; tampered: boolean; message: string }>>('/audit-logs/verify');
const res = await api.post<ApiResponse<IntegrityResult>>('/audit-logs/verify');
return res.data;
},
rehash: async () => {
@@ -1848,7 +1885,7 @@ export interface EmailLog {
export type SecurityEventType =
| 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED'
| 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM'
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS';
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'AUDIT_SEAL_CHANGED' | 'SUSPICIOUS';
export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
+96
View File
@@ -0,0 +1,96 @@
# ============================================================
# Gegenbuch für OpenCRM
# ============================================================
# Diese Datei gehört auf den Gegenbuch-Rechner NICHT auf den CRM-Server.
#
# Was hier passiert: Der Rechner holt regelmäßig einen kurzen Kontrollwert von
# OpenCRM ab und schreibt ihn in ein Buch, das nur hier liegt. Wird später im
# CRM etwas nachträglich verändert, widerspricht das dem Buch.
# ============================================================
# Welche Bücher sollen laufen?
# ============================================================
# Gültige Werte: prod | staging | prod,staging | (leer = keins)
# Genau so geschrieben nicht "production" oder "test".
#
# Nur Staging testen: COMPOSE_PROFILES=staging
# Später Prod dazunehmen: COMPOSE_PROFILES=prod,staging
# Danach: docker compose up -d (der laufende Dienst bleibt unberührt)
COMPOSE_PROFILES=staging
# Steht in den Einträgen des Buchs. Beliebiger Text, nur Kosmetik.
NOTAR_EMAIL=gegenbuch@example.de
# ============================================================
# Produktion
# ============================================================
# Adresse der OpenCRM-Instanz, von der geholt wird.
# Gültig: vollständige URL mit https:// und OHNE Schrägstrich am Ende.
PROD_CRM_URL=https://crm.example.de
# Zugang: ein eigenes Benutzerkonto im CRM, das NUR das Recht "audit:read" hat.
# Wie man es anlegt, steht in der README unter "Zugang einrichten".
#
# Das Gegenbuch meldet sich damit bei jedem Durchlauf selbst an. Ein fest
# hinterlegtes Token gibt es bewusst nicht Zugangstoken laufen nach
# 15 Minuten ab und wären beim nächsten Durchlauf längst ungültig.
PROD_CRM_EMAIL=gegenbuch@deine-domain.de
PROD_CRM_PASSWORD=
# Wie oft geprüft wird, in Sekunden.
# Gültig: ganze Zahl > 0. Üblich: 3600 (stündlich), 900 (viertelstündlich)
# Kürzer heißt: kleineres Zeitfenster, in dem eine Änderung unbemerkt bliebe.
PROD_INTERVAL=3600
# Beim ALLERERSTEN Start einmalig setzen, danach wieder leeren.
# Gültige Werte: true | (leer)
# Grund: Die erste Eintragung legt fest, was als Ausgangszustand gilt das
# soll nicht versehentlich passieren.
PROD_GENESIS_ACK=
# Normalerweise leer lassen.
# Gültige Werte: true | (leer)
# Nur nötig, wenn das Datenverzeichnis verlorenging (z. B. gelöscht) UND du
# geklärt hast, warum. Siehe README, Abschnitt "Wenn das Gedächtnis fehlt".
PROD_ADOPT_ACK=
# Normalerweise leer lassen.
# Gültige Werte: die neue Siegelwurzel (mind. 16 Zeichen) | (leer)
# Das Gegenbuch schlägt Alarm, wenn sich die Wurzel des Bestandssiegels
# ändert denn ein erneutes Siegeln ersetzt die Grundlage, gegen die
# Manipulation nachgewiesen wird. War der Wechsel gewollt, hier die Wurzel
# eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
# Bewusst KEIN "true": ein stehen gelassener Wert passt beim nächsten
# Wechsel nicht mehr und kann darum keinen weiteren stillschweigend
# durchwinken.
PROD_SEAL_ACK=
# Normalerweise leer lassen.
# Gültige Werte: die ID des Rehash-Eintrags | (leer)
# Das Gegenbuch schlägt Alarm, wenn die Hash-Kette neu berechnet wurde. Ein
# Rehash verknüpft alle Einträge neu Lücken, die eine Löschung sichtbar
# gemacht hätten, verschwinden dabei aus der Kette, und die Prüfung im CRM
# meldet danach wieder „lückenlos". War die Neuberechnung geplant, hier die
# ID eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
PROD_REHASH_ACK=
# Wo das Buch liegt relativ zu diesem Verzeichnis.
# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel).
PROD_DIR=./data/prod
# ============================================================
# Test / Staging
# ============================================================
# Gleiche Regeln wie oben, eigenes Konto und eigenes Verzeichnis.
STAGING_CRM_URL=https://staging.example.de
STAGING_CRM_EMAIL=gegenbuch@deine-domain.de
STAGING_CRM_PASSWORD=
STAGING_INTERVAL=3600
STAGING_GENESIS_ACK=
STAGING_ADOPT_ACK=
STAGING_SEAL_ACK=
STAGING_REHASH_ACK=
STAGING_DIR=./data/staging
+27
View File
@@ -0,0 +1,27 @@
# Gegenbuch läuft auf einem EIGENEN Rechner, nicht dort, wo OpenCRM läuft.
#
# Der Container holt lesend einen Kontrollwert vom CRM (HTTPS + Token mit
# audit:read) und legt seine Einträge in einem Git-Repository auf DIESER
# Maschine ab. Er braucht keinen SSH-Zugang zum CRM, keine Datenbank, keine
# Shell dort. Umgekehrt kennt OpenCRM dieses Gegenbuch überhaupt nicht.
FROM node:20-slim
# git für das Buch, openssh-client für die Signaturen (ssh-keygen).
RUN apt-get update && apt-get install -y --no-install-recommends \
git openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY notary.mjs /opt/notary/notary.mjs
COPY entrypoint.sh /opt/notary/entrypoint.sh
RUN chmod +x /opt/notary/entrypoint.sh
# Der Container STARTET als root aber nur, um die Rechte auf dem
# Datenverzeichnis geradezuziehen. Danach gibt der entrypoint die Privilegien
# ab und arbeitet als unprivilegierter Benutzer weiter.
#
# Grund: Das Datenverzeichnis kommt als Bind-Mount vom Host. Wer das Projekt
# als root geklont hat, hat dort root-eigene Verzeichnisse ein Container, der
# direkt als UID 1000 startet, kann darin nichts anlegen.
WORKDIR /gegenbuch
ENTRYPOINT ["/opt/notary/entrypoint.sh"]
+737
View File
@@ -0,0 +1,737 @@
# Gegenbuch (externe Notarisierung)
Alle Schutzmechanismen im CRM selbst Hash-Kette, Versionsgrenze,
Löschungs-Manifest, Bestandssiegel liegen in **derselben Datenbank**, die sie
absichern sollen. Wer dort schreiben kann, sitzt am Ende immer schon auf der
Ebene, die den Beweis führt. Genau das hat der Pentest über mehrere Runden
Schicht für Schicht gezeigt.
Das Gegenbuch durchbricht das: Ein **zweiter Rechner** holt regelmäßig einen
kurzen Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und schreibt
ihn signiert fort. Wird später im CRM etwas nachträglich verändert,
widerspricht das dem Gegenbuch.
## Was du hier suchst
**Einrichten**
[Betriebsart wählen](#zwei-betriebsarten--erst-hier-entscheiden) ·
[mit Docker](#einrichten-mit-docker-empfohlen) ·
[erster Start](#erster-start--drei-schritte) ·
[Zugangskonto im CRM](#zugang-einrichten-das-brauchst-du-vorher) ·
[ohne Docker](#einrichten-ohne-docker)
**Im Betrieb**
[**Altbestand versiegeln**](#den-altbestand-versiegeln-einmalig-im-crm) ·
[Rückgabecodes](#rückgabecodes) ·
[Überwachung](#überwachung) ·
[wo die Daten liegen](#wo-die-daten-liegen)
**Wenn Alarm kommt**
[Siegelwurzel hat gewechselt](#wenn-sich-die-siegelwurzel-ändert-alarm-und-warum) ·
[Kette wurde neu berechnet](#wenn-die-kette-neu-berechnet-wurde-ebenfalls-alarm) ·
[Zurückspulen erkannt](#der-rewind-wächter) ·
[Gedächtnis fehlt](#wenn-das-gedächtnis-trotzdem-fehlt)
**Zum Nachlesen**
[was erkannt wird](#was-das-skript-erkennt) ·
[was nicht ehrlich](#was-es-nicht-leistet--ehrlich) ·
[Prüfmodus für Auditoren](#prüfmodus-für-auditoren)
> **Nur schnell versiegeln?** Der Abschnitt
> [Altbestand versiegeln](#den-altbestand-versiegeln-einmalig-im-crm) ist
> eigenständig und gilt **auch ohne Gegenbuch** es ist ein Vorgang im CRM,
> nicht hier.
## Zwei Betriebsarten erst hier entscheiden
| | **Lokal** (Normalfall) | **Mit externem Repository** |
|---|---|---|
| Das Buch liegt | auf dem Gegenbuch-Rechner | zusätzlich auf einem dritten Server |
| Aufwand | Docker starten, fertig | privates Git-Repo, Schlüssel, Server-Regeln |
| Schützt gegen | jemand verändert Daten **im CRM** | zusätzlich: jemand übernimmt den **Gegenbuch-Rechner** |
**Für die allermeisten Installationen ist „lokal" die richtige Wahl.** Der
Schutz, um den es geht nachträgliche Änderungen im CRM auffliegen zu lassen
steht damit vollständig. Die zweite Variante deckt einen Angreifer ab, der
zusätzlich den Gegenbuch-Rechner übernimmt; sie kostet spürbar mehr Einrichtung
und laufende Aufmerksamkeit.
Diese Anleitung beschreibt zuerst den lokalen Betrieb. Alles zur zweiten
Variante steht gesammelt unter **„Zusätzliche Härtung"** weiter unten wer
lokal betreibt, kann diesen ganzen Teil überspringen.
## Die eine nicht verhandelbare Bedingung
**Der Signaturschlüssel und das Push-Recht dürfen nicht in den Deploy-Secrets
des CRM liegen.** Sonst ist der zweite Schlüssel im selben Zugriffsbereich wie
der erste es sieht nach doppeltem Boden aus und ist keiner. Das CRM braucht
für dieses Verfahren **gar nichts** zu wissen: Es liefert nur einen lesbaren
Kontrollwert, der keine Geheimnisse enthält.
## Einrichten mit Docker (empfohlen)
Auf dem **Gegenbuch-Rechner** nicht auf dem CRM-Server:
```bash
git clone <dieses Repository> opencrm
cd opencrm/tools/audit-notary
cp .env.example .env
# .env ausfüllen: CRM-Adresse und Token eintragen
docker compose up -d
```
### Erster Start drei Schritte
Beim allerersten Lauf meldet der Container `exit=4` und verlangt eine
Bestätigung. Das ist Absicht: Der erste Eintrag legt fest, was als
Ausgangszustand gilt.
```bash
# 1. In der .env freigeben
STAGING_GENESIS_ACK=true
# 2. Neu starten (kein --build nötig, nur die Umgebung ändert sich)
docker compose up -d && docker compose logs -f
# Erwartet: "OK: Checkpoint 1 erstellt" und exit=3
# exit=3 ist hier richtig beim ersten Mal gibt es nichts zu vergleichen.
# 3. Wieder leeren und erneut starten
STAGING_GENESIS_ACK=
docker compose up -d
```
Ab dem nächsten Durchlauf steht dort `exit=0 (in Ordnung)`.
**Warum Schritt 3 wichtig ist:** Bleibt die Zeile auf `true`, würde der
Container nach einem Verlust des Datenverzeichnisses stillschweigend eine neue
Grundlage setzen, statt zu fragen genau davor schützt die Abfrage.
Für Produktion später dasselbe mit `PROD_GENESIS_ACK`. Schnell nachsehen ohne
Logs: `cat data/staging/status.txt`.
Beim ersten Start einmalig `PROD_GENESIS_ACK=true` setzen (und danach wieder
leeren) die erste Eintragung legt fest, was als Ausgangszustand gilt, und das
soll nicht versehentlich passieren.
**Zwei Bücher auf einer Maschine** sind vorgesehen: `prod` und `staging` sind
getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`.
### Den Altbestand versiegeln (einmalig, im CRM)
Ein CRM, das schon länger läuft, hat fast immer einen **Altbestand** Einträge
aus der Zeit, bevor das Protokoll signiert wurde. Solange der nicht versiegelt
ist, meldet die Prüfung `valid: false`, das Gegenbuch schlägt zu Recht Alarm
und, wichtig: **es beglaubigt so lange gar nichts.** Es bricht vor dem Anhängen
ab, weil ein Checkpoint über einen ungeklärten Zustand diesen mitbeglaubigen
würde. Ein unversiegeltes CRM ist also nicht „bewacht mit Warnung", sondern
unbewacht.
Das Siegeln ist **einmalig** und passiert im CRM, nicht hier.
#### Wer darf das
Das Recht `audit:admin`. Das bekommt man über den Haken **„Audit-Betrieb"** in
der Benutzerverwaltung **nicht** über die Admin-Rolle (die hat bewusst keine
Audit-Rechte) und **nicht** über den DSGVO-Haken (der darf lesen, nicht
eingreifen). Das Gegenbuch-Dienstkonto hat es erst recht nicht.
```bash
CRM=https://<crm>
read -s -p "Passwort: " PASS; echo
AT=$(curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.token')
curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.user.permissions[]' | grep audit
```
Muss `audit:read` **und** `audit:admin` zeigen.
#### Erst nachsehen, was du festschreibst
Das Siegel hält den **aktuellen** Zustand fest, samt aller vorhandenen Lücken.
Wer blind siegelt, beglaubigt womöglich eine Lücke, die von einer Löschung
stammt. Der Schritt ist praktisch einwegs: Danach ist die Beglaubigung tragend,
und ein Siegel wieder zu entfernen erzeugt den Zustand `entfernt` also selbst
einen Befund.
```bash
B="Authorization: Bearer $AT"
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
| jq '.data | {valid, checkedCount, rehashes, chainGaps, unexplainedGaps,
tamperedEntries, backlogSealStatus}'
```
Vier Dinge müssen stimmen:
| Feld | Erwartung | Warum |
|---|---|---|
| `rehashes` | `[]` | Eine Neuberechnung verknüpft alles neu danach ist die Kette *zwangsläufig* stimmig, auch über Löschungen hinweg. Steht hier etwas, sagt `chainGaps` nichts über die Zeit davor aus. |
| `tamperedEntries` | `[]` | Veränderter Inhalt gehört geklärt, nicht beglaubigt. |
| `chainGaps` | erklärbar | siehe unten |
| `backlogSealStatus` | `kein_siegel` | sonst ist es kein Erst-Siegeln (siehe nächster Abschnitt) |
**Lücken einordnen.** Sieh dir die betroffenen IDs und ihre Nachbarn an:
```bash
for id in <lücke-1> <nachbar> …; do
printf '%-6s ' "$id"
curl -s -o /tmp/r.json -w 'HTTP %{http_code} ' "$CRM/api/audit-logs/$id" -H "$B"
jq -r 'if .success then (.data|"\(.createdAt[0:19]) v\(.hashVersion) \(.action)/\(.resourceType)") else .error end' /tmp/r.json
done
```
> **Den HTTP-Code mit ausgeben, immer.** Zugangstoken laufen nach 15 Minuten ab.
> Ohne den Code liest sich eine abgelehnte Anfrage (401) wie ein fehlender
> Eintrag ein Prüfwerkzeug, das ein verweigertes Lesen als Löschung meldet.
> Uns ist genau das passiert, mitten in der Vorbereitung eines Prod-Siegels.
Liegen die Lücken jeweils **innerhalb einer Sekunde** zusammen mit ihren
Nachbarn, betreffen nur `Authentication` und fehlt kein Eintrag, sind es
Schreibkollisionen aus parallelen Anfragen harmlos, historisch, nicht mehr
reproduzierbar. Fehlt dagegen ein Eintrag oder passt eine Lücke nicht in dieses
Bild: **erst klären, dann siegeln.**
*(Ein abgeschnittener **Anfang** des Protokolls wird seit dem Anfangs-Detektor
mitgeprüft: Die erste Zeile eines Protokolls trägt einen leeren `previousHash`;
trägt die erste vorhandene Zeile einen Wert, fehlt eine Vorgängerin. Das
erscheint als ganz niedrige Lücke und kippt `valid` du musst es nicht selbst
suchen.)*
#### Siegeln
```bash
curl -s -X POST $CRM/api/audit-logs/seal-backlog -H "$B" \
-H 'Content-Type: application/json' -d '{"confirm":"SEAL"}' | jq
```
`SEAL` ist das Erst-Siegeln. Besteht bereits ein Siegel, verlangt der Endpunkt
stattdessen `RESEAL` siehe nächster Abschnitt. Notiere `root` aus der Antwort.
#### Danach: drei Punkte gegenprüfen
```bash
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
| jq '.data | {valid, chainGaps, attestedGaps, backlogSealStatus, backlogSealCount}'
SID=$(curl -s "$CRM/api/audit-logs?resourceType=AuditBacklogSeal&limit=1" -H "$B" | jq -r '.data[0].id')
curl -s "$CRM/api/audit-logs/$SID" -H "$B" \
| jq '{vorbefund:.data.changesBefore.befund.ketten_luecken, siegel:.data.changesAfter}'
```
1. `valid: true`, und `chainGaps` = `attestedGaps` = die bekannten Lücken
2. `backlogSealStatus: "intakt"` **nicht** `leer`. `leer` heißt: Das Siegel
umschließt nichts, es gab keinen Altbestand. Kein Fehler, aber auch keine
Zusage.
3. Die Lücken stehen im `changesBefore.befund.ketten_luecken` des Markers
genau daran hängt die Beglaubigung.
Die Lücken verschwinden also **nicht** aus dem Bericht. Sie zählen nur nicht
mehr als offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte
Altzeile und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus.
#### Zuletzt: das Gegenbuch quittieren
Der nächste Lauf meldet **einmal** `exit=2` („Erstmals ein Bestandssiegel
gesetzt") erwartet, kein Befund. Wurzel aus `data/<instanz>/status.txt` in
`PROD_SEAL_ACK` bzw. `STAGING_SEAL_ACK` eintragen, `docker compose up -d`, nach
dem Lauf mit `exit=0` wieder leeren. Details im nächsten Abschnitt.
Ab da schreibt das Gegenbuch wieder Checkpoints fort und setzt im selben Lauf
die Grundlage für die Rehash-Überwachung.
### Wenn sich die Siegelwurzel ändert: Alarm, und warum
Erneutes Siegeln **ersetzt** die Grundlage, gegen die Manipulation nachgewiesen
wird. Wer eine Altzeile per Datenbankzugriff entfernt und danach neu siegelt,
bekommt eine passende Wurzel und eine beglaubigte Lücke — und die Prüfung im
CRM meldet wieder `valid: true`. Der Wechsel der Wurzel ist die einzige Spur
davon, die eine Maschine sehen kann.
Deshalb ist er ein **Alarm** (exit 2), kein Hinweis. Vorher stand hier eine
Zeile Prosa, während der Rückgabecode auf 0 blieb — also genau das Muster, das
wir dem CRM selbst zweimal angekreidet haben.
Der Alarm nennt die alte und die neue Wurzel samt Blattzahl. Ein Sprung von
`10 Blätter` auf `9 Blätter` sagt sofort, was passiert ist.
**War der Wechsel gewollt** (typisch: dein einmaliges Erstsiegeln), bestätigst
du ihn mit der Wurzel, die der Alarm ausgibt:
```bash
# in der .env
PROD_SEAL_ACK=72062c88a8b6e2b53b30496b483885cd
docker compose up -d # ein Lauf die neue Wurzel wird beglaubigt
PROD_SEAL_ACK= # danach wieder leeren
```
Bestätigt wird bewusst **nicht** mit `true`, sondern mit der Wurzel selbst.
Ein versehentlich stehen gelassener Wert passt beim nächsten Wechsel nicht mehr
und kann deshalb keinen weiteren Austausch stillschweigend durchwinken.
**War er nicht gewollt**, sieh im CRM nach: das Sicherheits-Ereignis
`AUDIT_SEAL_CHANGED` (Einstellungen → Monitoring) und die CRITICAL-Zeile zu
`/api/audit-logs/seal-backlog` im Audit-Protokoll nennen Konto, Zeitpunkt und
den Befund, der vor dem Siegeln galt.
Im CRM selbst ist erneutes Siegeln zusätzlich gegatet: es verlangt
`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der
Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten.
### Wenn die Kette neu berechnet wurde: ebenfalls Alarm
Ein **Rehash** verknüpft alle Einträge neu. Danach ist die Kette
zwangsläufig stimmig — auch über Löschungen hinweg, die vorher als Lücken
sichtbar gewesen wären. Die Reihenfolge `cleanup``rehash` macht aus einem
beschnittenen Protokoll ein scheinbar makelloses, und die Prüfung im CRM meldet
danach wieder „lückenlos verkettet". Beides braucht nur `audit:admin`, keinen
Datenbankzugriff.
Genau das ist im Betrieb vorgekommen: Auf einer Testinstanz wurden 3.155
Einträge gelöscht und anschließend neu berechnet. Die Prüfung war danach grün,
die 656 Kettenlücken standen nur noch im Vorbefund des Rehash-Eintrags — den
niemand liest. Das Gegenbuch war der einzige Zeuge.
Deshalb ist eine neue Neuberechnung seit der letzten Beglaubigung ein **Alarm**
(exit 2). Er nennt Zeitpunkt, Zahl der betroffenen Zeilen und den Befund, der
unmittelbar davor galt:
```
ALARM: Die Hash-Kette wurde neu berechnet (1 neuer Vorgang seit der letzten Beglaubigung).
zuletzt: 2026-08-26T16:28:25.467Z (Eintrag 13, 9 Zeilen)
Befund unmittelbar davor: 1 beanstandet, 1 Lücken
NOTARY_REHASH_ACK=13
```
**War die Neuberechnung gewollt**, bestätigst du sie mit der genannten ID:
```bash
# in der .env
PROD_REHASH_ACK=13
docker compose up -d
PROD_REHASH_ACK= # danach wieder leeren
```
Auch hier wird nicht mit `true` bestätigt, sondern mit einem Wert, der zum
Vorgang gehört. IDs steigen streng — ein stehen gelassener Wert passt bei der
nächsten Neuberechnung nicht mehr.
**Warum ein eigener Melder, wo doch schon der Kettenkopf verglichen wird?**
Eine Neuberechnung ändert jeden Hash, der beglaubigte Kopf stimmt also
ohnehin nicht mehr — der Alarm käme auch so. Aber als *Nebenwirkung*, nicht als
gebaute Warnung: Verschöbe sich der Anker irgendwann, wäre der Melder lautlos
weg. Und die Meldung hieße „Eintrag wurde verändert" statt „die Kette wurde neu
berechnet" — die Wirkung statt der Ursache. Deshalb hängt der Alarm an der
Sache selbst und steht **vor** dem Kopf-Vergleich; nach einer bestätigten
Neuberechnung wird dieser übersprungen, weil der veränderte Kopf dann die
erwartete Folge ist.
Ein fehlender beglaubigter Eintrag bleibt davon unberührt und alarmiert immer:
Bestätigt wird die Neuberechnung, nicht das Verschwinden von Zeilen.
### Zugang einrichten (das brauchst du vorher)
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM kein Token. Der
Grund: Zugangstoken laufen nach 15 Minuten ab und wären beim nächsten
stündlichen Durchlauf längst ungültig. Das Gegenbuch meldet sich deshalb bei
jedem Lauf selbst an.
Im CRM, als Administrator:
1. **Benutzer anlegen**, z. B. `gegenbuch@deine-domain.de`, mit einem langen,
zufälligen Passwort.
2. Ihm die Rolle **`Gegenbuch`** geben **nur diese**. Sie bringt genau ein
Recht mit: `audit:read`.
3. Zusätzlich **„Dienstkonto"** ankreuzen. Dann gelten seine Anmeldungen als
Routine statt als kritisches Ereignis und sein *Ausbleiben* wird gemeldet.
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
> **Weder den DSGVO- noch den Audit-Betrieb-Haken setzen.** Der DSGVO-Haken
> gibt zusätzlich Leserechte auf personenbezogene Daten und den Export; der
> Haken „Audit-Betrieb" gibt `audit:admin` mit `seal-backlog`, `rehash` und
> `cleanup`. Ein Einbruch auf dieser Maschine hätte damit nicht nur den
> Wächter, sondern gleich die Mittel, das Bewachte umzuschreiben. Das Passwort
> steht hier im Klartext in der `.env`; es muss so wenig wert sein wie möglich.
Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** keine
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
die Zugangsdaten abhandenkommen, ist damit nichts anzufangen.
Gegenprobe nach dem Einrichten **200, dann dreimal 403**:
```bash
B="Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w 'checkpoint %{http_code}\n' https://<crm>/api/audit-logs/checkpoint -H "$B"
curl -s -o /dev/null -w 'export %{http_code}\n' https://<crm>/api/audit-logs/export -H "$B"
curl -s -o /dev/null -w 'seal-backlog %{http_code}\n' -X POST https://<crm>/api/audit-logs/seal-backlog -H "$B" -H 'Content-Type: application/json' -d '{}'
curl -s -o /dev/null -w 'kunden %{http_code}\n' https://<crm>/api/customers -H "$B"
```
Der Export gehört ausdrücklich dazu: Er liefert `changesBefore`/`changesAfter`,
also die vollständigen Vorher/Nachher-Datensätze samt Klartextnamen. Prüfwerte
lesen und das Protokoll herausziehen sind zwei verschiedene Dinge deshalb
hängt der Export an `audit:export`, das die Rolle `Gegenbuch` nicht hat.
Für Produktion und Test jeweils ein eigenes Konto in der jeweiligen Instanz.
> Jede Anmeldung erscheint im Audit-Log der jeweiligen Instanz. Das ist so
> gewollt: Man sieht, dass das Gegenbuch arbeitet und wenn es aufhört, fällt
> auch das auf.
### Wer redet mit wem
```
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read)
OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
```
Das ist der eigentliche Schutz. Das Gegenbuch **holt** es lässt sich nichts
schicken. OpenCRM kennt weder Adresse noch Schlüssel des Gegenbuchs. Wer
OpenCRM übernimmt, kommt hier nicht heran.
Das Token kann ausschließlich Prüfwerte lesen: keine Kundendaten, keine
Änderungen. Selbst wenn es abhandenkommt, ist damit nichts anzufangen.
Der Signaturschlüssel wird beim ersten Start **auf dem Gegenbuch-Rechner
erzeugt** und verlässt ihn nie.
### Wo die Daten liegen
Im Projektverzeichnis, nicht in Docker-Volumes:
```
tools/audit-notary/data/prod/
buch/ das Gegenbuch selbst (ein Git-Repository)
schluessel/ Signaturschlüssel wird beim ersten Start erzeugt
beobachtungen.jsonl was zuletzt gesehen wurde
status.txt Ergebnis des letzten Durchlaufs
```
Der Inhalt ist vom Repository ausgenommen der Signaturschlüssel gehört dort
nicht hinein.
**Zu Dateirechten:** Der Container startet kurz als `root`, setzt das
Datenverzeichnis auf den Arbeitsbenutzer um und gibt die Privilegien dann ab.
Das ist nötig, weil das Verzeichnis vom Host kommt wer das Projekt als `root`
geklont hat, hätte sonst ein Verzeichnis, in das der Container nicht schreiben
darf. Passiert automatisch, du musst nichts tun. Ein anderer Zielbenutzer geht
über `PUID`/`PGID`. Die Verzeichnisse selbst sind über `.gitkeep` vorhanden, damit
sie nach einem `git clone` schon existieren und Docker sie nicht als `root`
anlegt.
**Ins Backup gehört genau dieses Verzeichnis.** Geht es verloren, beginnt die
Beobachtung von vorn und der nächste Lauf sagt das ausdrücklich, statt „alles
gut" zu melden.
### Überwachung
Jeder Durchlauf schreibt seinen Stand nach `data/<instanz>/status.txt`:
```
2026-08-22T16:49:50+00:00 exit=0 in Ordnung
```
**Alles außer `exit=0` gehört angesehen.** Wer eine Überwachung hat, greift
diese Datei ab; wer keine hat, schaut regelmäßig mit `docker compose logs`
hinein. Ein Alarm, den niemand liest, ist keiner.
### Was dieser Betrieb abdeckt und was nicht
**Abgedeckt:** Jemand verändert oder löscht nachträglich Einträge im CRM
auch mit direktem Datenbankzugriff. Das widerspricht dem Gegenbuch und fällt
beim nächsten Durchlauf auf.
**Nicht abgedeckt:** Jemand übernimmt den Gegenbuch-Rechner selbst. Dagegen
hülfe nur eine zusätzliche Ablage außerhalb (z. B. ein privates Git-Repository
auf einem dritten Server) das ist vorbereitet, aber für die meisten
Installationen mehr Aufwand als Nutzen. Die Erfolgsmeldung benennt diese Grenze
bei jedem Lauf, damit sie nicht in Vergessenheit gerät.
---
---
# Zusätzliche Härtung: externes Repository
> **Alles ab hier gilt nur für die zweite Betriebsart.** Wer das Gegenbuch
> lokal auf einer eigenen Maschine betreibt der Normalfall, siehe oben kann
> diesen gesamten Abschnitt überspringen. Die Anforderungen darin (Git-Server,
> Rewind-Sperre, geschützte Refs) beziehen sich auf ein zusätzliches Repository
> auf einem dritten Server und existieren im lokalen Betrieb nicht.
Sinn der Variante: Beim lokalen Betrieb liegt das Buch auf demselben Rechner
wie der Signaturschlüssel. Wer diesen Rechner übernimmt, kann beides
manipulieren. Ein zusätzliches Repository auf einem dritten Server, das nur
Anhängen erlaubt, schließt auch das vorausgesetzt, dessen Regeln stimmen.
## Einrichten ohne Docker
Auf einem **anderen** Rechner als dem CRM-Server:
```bash
git clone git@host:dein/audit-gegenbuch.git
cd audit-gegenbuch
export CRM_URL=https://crm.example.de
export CRM_TOKEN=<Access-Token eines Benutzers mit audit:read>
node /pfad/zu/notary.mjs
```
Als Cronjob, stündlich:
```cron
0 * * * * cd /pfad/zum/gegenbuch && CRM_URL=https://crm.example.de \
CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1
```
**Force-Push serverseitig sperren das ist Pflicht, nicht Empfehlung.**
Konkret muss der Server **non-fast-forward-Pushes verbieten** (Rewind-Schutz),
nicht nur „irgendeine" Branch-Protection. Der Grund ist nicht offensichtlich:
Das Skript prüft jede Signatur. Aber ein Angreifer mit Force-Push-Recht muss
gar nichts fälschen er kann die Reihe schlicht auf einen **älteren, echt
signierten Stand zurückspulen** und die Datenbank passend kürzen. Alle
Signaturen bleiben gültig, der Fingerabdruck stimmt, die Nummerierung ist
lückenlos. Kryptographisch ist daran nichts auszusetzen; es fehlt nur das Ende.
Ein Notar-Rechner, der die höhere Nummer noch lokal kennt, merkt es. Ein
**frischer Klon merkt es nicht** und das ist ausgerechnet der Auditoren-Fall.
Deshalb zusätzlich:
```bash
# Die zuletzt bekannte Nummer mitgeben dann fällt ein Rewind auch ohne
# lokalen Zustand auf. Das Skript nennt sie am Ende jedes Laufs.
NOTARY_MIN_SEQ=42 node notary.mjs --check
```
## Der Rewind-Wächter
Das Skript merkt sich bei jedem Lauf den beobachteten Remote-Kopf in einer
Datei **außerhalb des Klons** (Standard `~/.opencrm-notary/beobachtungen.jsonl`,
per `NOTARY_STATE_FILE` änderbar). Beim nächsten Lauf muss der neue Kopf ein
Nachfahre des alten sein. Ist er das nicht, wurde zurückgespult und zwar
**unabhängig davon, ob es einen serverseitigen Schutz gibt**.
Das ist der Grund, warum hier *kein* Probe-Push stattfindet: Ein solcher Test
würde die eigene Push-Identität messen, nicht die des Angreifers (Bypass-Rechte
für Administratoren gehen genau dann auseinander, wenn es zählt), er gälte nur
für den geprobten Ref, und die bestätigende Beobachtung wäre derselbe Vorgang
wie der Schaden. Deshalb wird das **Ereignis** erkannt statt die Regel
abgefragt.
### Das Gedächtnis liegt im Server, nicht nur auf diesem Rechner
Jeder verankerte Checkpoint bekommt zusätzlich einen eigenen unveränderlichen
Ref `refs/notary/seq-N`. Der überlebt einen Force-Push auf `main`, weil er ein
**anderer Ref** ist. Damit lässt sich die höchste je existierende Nummer aus dem
Server rekonstruieren auch wenn der lokale Beobachtungsspeicher fehlt.
Warum das nötig ist: Läge das Gedächtnis nur lokal, müsste ein Angreifer nach
einem Zurückspulen bloß diese Datei löschen. Der nächste Lauf meldete dann
„erste Beobachtung" **ein einziges** Code-3, das im Alltag neben echten
Remote-Ausfällen untergeht und würde anschließend den zurückgespulten Stand
als Ausgangspunkt übernehmen. Ab da wäre alles dauerhaft grün. Ein gutartiger
Standardzustand, den ein Angreifer gezielt herstellen kann, ist kein sicherer
Standardzustand.
> ### ⚠ Ohne serverseitigen Schutz dieser Refs bringt das Verfahren nichts
>
> `refs/notary/*` muss auf dem Server **gegen Löschen und Überschreiben**
> gesperrt sein. Das ist keine Empfehlung, sondern die tragende Voraussetzung
> genauso wie die Rewind-Sperre auf `main`.
>
> Der Grund ist eine unangenehme Asymmetrie: Das Skript kennt nur den
> **höchsten noch vorhandenen** Anker, nicht den höchsten je erzeugten. Wer
> *alle* Anker löscht, fliegt auf (Code 4). Wer nur die **obersten** löscht und
> einen niedrigeren stehen lässt, senkt damit den Vergleichswert selbst und
> in-repo ist das von „gab es nie" nicht zu unterscheiden.
>
> **Die Notar-Identität braucht dort eine eng gefasste Ausnahme:** Sie muss
> `refs/notary/*` **anlegen** dürfen, aber ausdrücklich **nicht löschen oder
> überschreiben**. Sonst hebelt eine kompromittierte Notar-Identität genau den
> Schutz aus, um den es hier geht. Server-seitig ist das unterscheidbar ein
> `pre-receive`-Hook sieht beim Anlegen die Null-OID als alten Wert:
>
> ```sh
> # refs/notary/*: nur anlegen, niemals ändern oder löschen
> case "$ref" in refs/notary/*)
> [ "$old" = "0000000000000000000000000000000000000000" ] || exit 1 ;;
> esac
> ```
>
> Deshalb begründen Anker **keine** Unversehrtheit. Sie können ein
> Zurückspulen *widerlegen* (wenn ein hoher Anker nicht mehr im Kopf steckt),
> aber nie belegen, dass nichts fehlt. Ein `--check` aus frischem Klon erkennt
> eine Anker-Löschung **nicht** es meldet dann Code 3 mit Vorbehalt, nicht 0.
### Wenn das Gedächtnis trotzdem fehlt
Das Skript unterscheidet zwei Fälle der Unterschied entscheidet alles:
| Lage | Bedeutung | Verhalten |
|---|---|---|
| kein Speicher, **keine** notarisierte Historie | echte Erstinbetriebnahme | einmalig `NOTARY_GENESIS_ACK=true`, sonst Code 4 |
| kein Speicher, **aber** Historie vorhanden | Gedächtnis ging *nach* der Etablierung verloren | **Code 4**, keine stille Übernahme; erst nach `NOTARY_ADOPT_ACK=true` |
Eine Baseline zu setzen heißt „ich verbürge mich, *das* ist der wahre Kopf".
Dieser Akt wird nicht automatisch wiederholt.
Zwei Konsequenzen für den Betrieb:
- **Die Datei gehört nicht in den Klon** und sollte möglichst auf getrenntem
Speicher liegen. Geht sie verloren, beginnt die Beobachtung von vorn der
erste Lauf danach meldet ehrlich „erste Beobachtung", nicht „alles gut".
- **Der allererste Lauf endet mit Code 3.** Was man nie gesehen hat, kann man
nicht vergleichen. Das ist kein Fehler, sondern die ehrliche Auskunft.
**`NOTARY_MIN_SEQ` ersetzt die serverseitige Sperre nicht.** Der Wert ist eine
*Untergrenze* und immer nur so frisch wie deine letzte Beobachtung. Wer
stündlich beglaubigt, aber wöchentlich prüft, läuft mit einem Wert herum, der
um rund 168 hinterherhinkt ein Zurückspulen in dieses Fenster bliebe
unbemerkt und der gewaschene Stand würde sogar ausdrücklich bestätigt.
Ist der Rewind-Schutz auf dem Server tatsächlich eingerichtet, bestätige das:
```bash
NOTARY_REWIND_PROTECTED=true
```
**Nur diese Zusicherung** blendet den Vorbehalt aus nicht die bloße Angabe
einer Zahl. Solange sie fehlt, endet jeder Lauf mit **Code 3** statt 0:
„nicht abschließend feststellbar". Das ist Absicht, denn ein Cronjob liest den
Rückgabecode, nicht den Fließtext ein Hinweis, der nur in der Ausgabe steht,
erreicht die Überwachung nie.
## Der entscheidende Punkt: es wird tatsächlich geprüft
Die erste Fassung dieses Skripts hat signiert aber **nie eine Signatur
geprüft**. Es las seine Wahrheit aus der lokalen Arbeitsdatei. Wer den
Notar-Rechner beschreiben konnte, hat ihm damit beliebige „beglaubigte"
Zeilen untergeschoben und einen abgeschnittenen Datenbestand als neue Wahrheit
festgeschrieben, ohne Alarm. Signieren ohne prüfenden Leser ist wertlos.
Deshalb gilt jetzt:
- Wahrheitsquelle ist der **signierte Commit-Baum** (bevorzugt der Remote-Kopf),
nicht die Arbeitsdatei
- **Jeder** Commit, der das Gegenbuch ändert, muss eine gültige Signatur tragen
- Weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen
- Geschrieben wird erst nach erfolgreichem, signiertem Commit scheitert er,
wird die Datei zurückgerollt
## Voraussetzungen, die wirklich Voraussetzungen sind
- **`allowed_signers` muss den Notar-Schlüssel enthalten.** Das Skript
akzeptiert ausschließlich Signaturen, die git als `G` bewertet bei
SSH-Signaturen heißt das wörtlich „Schlüssel steht in `allowed_signers`".
Ein fremder, selbst erzeugter Schlüssel liefert `U` und wird abgelehnt.
*(Bei GPG statt SSH liefert auch ein legitimer Schlüssel ohne Ownertrust ein
`U` dann Ownertrust setzen, nicht `U` wieder zulassen.)*
- **Ein Remote muss erreichbar sein.** Ist er es nicht, bricht das Skript mit
Code 3 ab, statt dem lokalen Stand zu glauben. Ein Gegenbuch, dessen
beglaubigter Stand sich nicht feststellen lässt, gibt kein grünes Licht.
- **Genau ein Schlüssel zählt, nicht die ganze Liste.** `allowed_signers` kann
mehrere Principals enthalten ein Kollege, ein Alt-Schlüssel, eine global
geteilte Datei. Jeder davon würde sonst als „gültig" durchgehen und könnte
das Gegenbuch umschreiben. Deshalb prüft das Skript zusätzlich den
**Fingerabdruck** des Signierschlüssels.
Er wird automatisch aus `user.signingkey` abgeleitet. Lässt er sich nicht
bestimmen, bricht das Skript ab statt stillschweigend die ganze Liste zu
akzeptieren. Explizit setzen geht auch:
```bash
export NOTARY_SIGNER_FINGERPRINT=$(git log -1 --format=%GF)
```
Wichtig: exakt der `%GF`-Wert (`SHA256:…`), nicht die vollständige Ausgabe
von `ssh-keygen -lf`.
## Rückgabecodes
| Code | Bedeutung |
|---|---|
| 0 | alles in Ordnung, Checkpoint angehängt (bzw. Prüfung bestanden) |
| 1 | Betriebsfehler (Konfiguration, Commit oder Push fehlgeschlagen) |
| 2 | **Befund** Widerspruch zwischen CRM und Gegenbuch, ungültige Signatur, oder die Wurzel des Bestandssiegels hat sich geändert (`NOTARY_SEAL_ACK`) |
| 5 | **Anker unvollständig** die Kette ist gültig, aber `refs/notary/seq-N` fehlt. Reparierbar durch einen Notar-Schreiblauf |
| 4 | **Wächter-Gedächtnis fehlt** Erstinbetriebnahme unbestätigt, oder Speicher nach der Etablierung verloren |
| 3 | beglaubigter Stand nicht abschließend feststellbar Remote fehlt/unerreichbar, erste Beobachtung, Zurückspulen nicht ausschließbar, **oder** Checkpoint erstellt aber nicht verankert |
Für Cron gilt: **jeder** Code außer 0 gehört gemeldet. Code 2 ist der Alarm,
Code 3 heißt „ich weiß es nicht" und das ist ausdrücklich kein Freibrief.
## Prüfmodus für Auditoren
```bash
node notary.mjs --check
```
Führt alle Kontrollen aus, **verändert nichts** und braucht kein Schreibrecht
auch dann nicht, wenn dabei ein fehlender Anker auffällt. Der wird gemeldet
(Code 5), nicht repariert; Reparieren ist Sache des Notar-Laufs.
Das ist kein Detail: Die Serversperre für `refs/notary/*` lehnt Schreibzugriffe
ja gerade ab. Würde der Prüfmodus dort nachtragen wollen, bekäme jeder
read-only prüfende Auditor dauerhaft einen Fehler auf einer völlig gültigen
Kette und könnte ihn nicht beheben.
## Anhängen ist selbst ein Beglaubigungsakt
Ein Schreiblauf erweitert nicht nur die Kette er signiert damit zugleich über
alles darunter. Deshalb prüft das Skript **vor** dem Anhängen und verweigert
es, wenn die Grundlage nicht feststeht. Sonst wäre das Anhängen selbst das
Waschmittel: eine frische Signatur über einen ungeklärten Vorzustand beglaubigt
diesen mit.
Ebenso gilt: **erstellt ist nicht verankert.** Klappt der Push nicht, endet der
Lauf mit Code 3 und der ausdrücklichen Auskunft „erstellt, aber NICHT
verankert" niemals mit 0. Der nächste Lauf hält den ungepushten Commit dann
an, bis er geklärt ist.
## Was das Skript erkennt
| Angriff | Erkennung |
|---|---|
| Beglaubigter Eintrag nachträglich verändert | Hash an der beglaubigten Position stimmt nicht mehr |
| Einträge am Ende abgeschnitten | aktuelle höchste ID kleiner als die beglaubigte |
| Bestandssiegel verschwunden | vorher beglaubigt, jetzt nicht mehr vorhanden |
| Gegenbuch selbst gekürzt | Lücke in der fortlaufenden Nummer |
| **Reihe zurückgespult (Force-Push)** | **Remote-Kopf ist kein Nachfahre des zuletzt beobachteten** |
| Gegenbuch lokal manipuliert | Arbeitsdatei weicht vom signierten Stand ab |
| Untergeschobener Commit | Commit ohne gültige Signatur in der Historie |
| Nie gepushte lokale Commits | Abgleich gegen den Remote-Kopf |
| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen |
| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt `valid` allein bleibt dabei `true`** |
| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine |
| **`cleanup` + `rehash` ohne erneutes Siegeln** (Wäsche ohne Datenbankzugriff) | **neue Neuberechnung seit der letzten Beglaubigung `valid` und Kette sind danach makellos** |
Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt
nichts an** der manipulierte Zustand wird also nicht als neue Wahrheit
festgeschrieben. Für Cron heißt das: Exit-Code überwachen, sonst geht der Alarm
im Log unter.
## Was es nicht leistet ehrlich
- **Restfenster:** Einträge zwischen dem letzten Kontrollwert und jetzt sind
noch nicht beglaubigt und am Ende fälsch- oder kürzbar. Häufigerer Lauf
verkleinert das Fenster, beseitigt es aber nicht. Das gilt für jedes
periodische Verfahren dieser Art.
- **Stiller Ausfall:** Läuft der Cronjob nicht mehr, entsteht keine Warnung im
CRM dort ist ja nichts konfiguriert. Der Ausfall muss auf dem
Gegenbuch-Rechner überwacht werden (z. B. Alarm, wenn der jüngste Commit
älter als zwei Intervalle ist).
- **`NOTARY_SIGN=false`** existiert nur für Tests ohne hinterlegten Schlüssel
und verlangt zusätzlich `NOTARY_INSECURE_ACK=…`. Der Reflex „Cron hakt, also
Signatur abschalten" soll bewusst nicht ausreichen er würde eine
ungeschützte Kette festschreiben.
- **Der Remote-Server muss Force-Push verhindern.** Das Skript prüft die
Signaturen, aber wenn jemand die Historie am Server überschreiben kann,
ersetzt er auch die signierten Commits durch eigene. Branch-Protection ist
Teil des Schutzes, nicht Zubehör.
+7
View File
@@ -0,0 +1,7 @@
Hier liegen die Gegenbuch-Daten (je Instanz ein Unterverzeichnis):
data/prod/ Buch, Signaturschlüssel, Beobachtungsspeicher, status.txt
data/staging/ dasselbe für die Testinstanz
Der Inhalt wird bewusst NICHT im Repository geführt er enthält den
Signaturschlüssel. Dieses Verzeichnis gehört ins Backup.
+3
View File
@@ -0,0 +1,3 @@
Gegenbuch-Daten der Produktivinstanz.
Inhalt wird nicht im Repository gefuehrt (enthaelt den Signaturschluessel).
Dieses Verzeichnis gehoert ins Backup.
+2
View File
@@ -0,0 +1,2 @@
Gegenbuch-Daten der Testinstanz.
Inhalt wird nicht im Repository gefuehrt.
+63
View File
@@ -0,0 +1,63 @@
# Gegenbuch für OpenCRM
# =====================
# Gehört auf einen EIGENEN Rechner nicht dorthin, wo OpenCRM läuft.
#
# Richtung der Verbindung (das ist der eigentliche Schutz):
# Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Token nur mit audit:read)
# OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
#
# Wer OpenCRM übernimmt, kommt damit nicht an dieses Buch heran. Deshalb holt
# das Gegenbuch selbst, statt sich etwas schicken zu lassen.
#
# Start:
# cp .env.example .env # Werte eintragen
# docker compose up -d
#
# Zwei Bücher auf einer Maschine sind vorgesehen (Produktion und Test): zwei
# getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
# Welche laufen, steuert COMPOSE_PROFILES in der .env.
services:
prod:
build: .
container_name: gegenbuch-prod
restart: unless-stopped
profiles: ["prod"]
environment:
INSTANZ: prod
CRM_URL: ${PROD_CRM_URL}
CRM_EMAIL: ${PROD_CRM_EMAIL}
CRM_PASSWORD: ${PROD_CRM_PASSWORD}
NOTARY_INTERVAL: ${PROD_INTERVAL:-3600}
NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost}
# Nur beim allerersten Lauf einmalig auf true, danach wieder leeren:
NOTARY_GENESIS_ACK: ${PROD_GENESIS_ACK:-}
# Nur nach geklärtem Verlust des Beobachtungsspeichers, siehe README:
NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-}
# Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel:
NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-}
# Nur nach einer GEWOLLTEN Neuberechnung, mit der ID des Rehash-Eintrags:
NOTARY_REHASH_ACK: ${PROD_REHASH_ACK:-}
volumes:
# Buch, Schlüssel, Beobachtungsspeicher und Statusdatei.
# Dieses Verzeichnis ist das Gegenbuch es gehört ins Backup.
- ${PROD_DIR:-./data/prod}:/gegenbuch
staging:
build: .
container_name: gegenbuch-staging
restart: unless-stopped
profiles: ["staging"]
environment:
INSTANZ: staging
CRM_URL: ${STAGING_CRM_URL}
CRM_EMAIL: ${STAGING_CRM_EMAIL}
CRM_PASSWORD: ${STAGING_CRM_PASSWORD}
NOTARY_INTERVAL: ${STAGING_INTERVAL:-3600}
NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost}
NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-}
NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-}
NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-}
NOTARY_REHASH_ACK: ${STAGING_REHASH_ACK:-}
volumes:
- ${STAGING_DIR:-./data/staging}:/gegenbuch
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# Gegenbuch-Schleife: einrichten, dann in festem Takt beglaubigen.
set -uo pipefail
: "${INSTANZ:?INSTANZ fehlt (z. B. prod oder staging)}"
: "${CRM_URL:?CRM_URL fehlt von welcher OpenCRM-Instanz soll geholt werden?}"
: "${CRM_EMAIL:?CRM_EMAIL fehlt Dienstkonto im CRM mit dem Recht audit:read}"
: "${CRM_PASSWORD:?CRM_PASSWORD fehlt Passwort dieses Dienstkontos}"
INTERVALL="${NOTARY_INTERVAL:-3600}"
BUCH=/gegenbuch/buch
SCHLUESSEL=/gegenbuch/schluessel
export NOTARY_STATE_FILE=/gegenbuch/beobachtungen.jsonl
export NOTARY_ALLOW_LOCAL=true # Buch liegt auf dieser Maschine
export NOTARY_PUSH=false # es gibt kein Ziel zum Pushen
# --- Rechte geraderuecken, dann Privilegien abgeben ------------------------
# Laeuft der Container als root, gehoert das Bind-Mount-Verzeichnis vermutlich
# ebenfalls root (typisch, wenn das Projekt als root geklont wurde). Wir setzen
# es auf den Arbeitsbenutzer um und starten uns selbst als dieser neu.
BENUTZER_UID="${PUID:-1000}"
BENUTZER_GID="${PGID:-1000}"
if [ "$(id -u)" = "0" ]; then
if ! chown -R "$BENUTZER_UID:$BENUTZER_GID" /gegenbuch 2>/dev/null; then
echo "[$INSTANZ] HINWEIS: Rechte auf /gegenbuch liessen sich nicht setzen." >&2
echo "[$INSTANZ] Falls es gleich an fehlenden Schreibrechten scheitert, auf dem Host:" >&2
echo "[$INSTANZ] chown -R $BENUTZER_UID:$BENUTZER_GID <datenverzeichnis>" >&2
fi
exec setpriv --reuid="$BENUTZER_UID" --regid="$BENUTZER_GID" --init-groups \
--inh-caps=-all "$0" "$@"
fi
echo "[$INSTANZ] Gegenbuch startet Quelle: $CRM_URL, Takt: ${INTERVALL}s"
# --- Signaturschlüssel: einmalig auf DIESER Maschine erzeugen ---------------
# Er verlässt den Rechner nie. OpenCRM kennt ihn nicht.
mkdir -p "$SCHLUESSEL"
if [ ! -f "$SCHLUESSEL/id_ed25519" ]; then
echo "[$INSTANZ] Erzeuge Signaturschlüssel..."
ssh-keygen -t ed25519 -f "$SCHLUESSEL/id_ed25519" -N "" -C "gegenbuch-$INSTANZ" -q
fi
chmod 600 "$SCHLUESSEL/id_ed25519"
if [ ! -f "$SCHLUESSEL/allowed_signers" ]; then
echo "gegenbuch-$INSTANZ namespaces=\"git\" $(cat "$SCHLUESSEL/id_ed25519.pub")" \
> "$SCHLUESSEL/allowed_signers"
fi
# --- Buch anlegen, falls noch nicht vorhanden ------------------------------
if [ ! -d "$BUCH/.git" ]; then
echo "[$INSTANZ] Lege Gegenbuch an..."
mkdir -p "$BUCH"
git -C "$BUCH" init -q -b main
fi
cd "$BUCH" || exit 1
git config user.email "${NOTAR_EMAIL:-gegenbuch@localhost}"
git config user.name "Gegenbuch $INSTANZ"
git config gpg.format ssh
git config user.signingkey "$SCHLUESSEL/id_ed25519.pub"
git config gpg.ssh.allowedSignersFile "$SCHLUESSEL/allowed_signers"
# Ein erster Commit, damit es eine Historie gibt, an die angehängt werden kann.
if ! git rev-parse HEAD >/dev/null 2>&1; then
git commit -q -S --allow-empty -m "Gegenbuch $INSTANZ angelegt"
fi
# --- Schleife ---------------------------------------------------------------
# Jeder Rückgabecode außer 0 gehört angesehen. Der letzte Stand steht
# zusätzlich in status.txt, damit eine Überwachung ihn abgreifen kann, ohne
# Logs zu durchsuchen.
while true; do
ZEIT=$(date -Iseconds)
# Ausgabe mitschneiden, um den GRUND in die Statusdatei zu bekommen.
#
# Vorher stand dort nur das Etikett ("BEFUND"), und das ist fuer jeden
# Exit-2 dasselbe - ein echter Widerspruch sieht darin aus wie eine
# quittierpflichtige Erstsiegelung. Wer nur die Statusdatei liest, kann
# nicht entscheiden, ob er handeln muss. Genau die Sorte Signal, gegen die
# dieses Werkzeug gebaut ist.
node /opt/notary/notary.mjs > /tmp/lauf.log 2>&1
CODE=$?
cat /tmp/lauf.log
case $CODE in
0) LAGE="in Ordnung" ;;
1) LAGE="Betriebsfehler (Konfiguration, CRM nicht erreichbar, Commit)" ;;
2) LAGE="BEFUND Widerspruch zwischen CRM und Gegenbuch" ;;
3) LAGE="nicht abschliessend feststellbar (u. a. erster Lauf)" ;;
4) LAGE="Gedaechtnis fehlt Bestaetigung noetig, siehe README" ;;
5) LAGE="Anker unvollstaendig" ;;
*) LAGE="unbekannter Code" ;;
esac
echo "[$INSTANZ] $ZEIT exit=$CODE ($LAGE)"
# Die aussagekraeftigste Zeile mitnehmen, nicht einfach die erste.
#
# Bei Alarm ist das die ALARM-Zeile. Sonst die Zusammenfassung ("OK: …") -
# denn ein Lauf kann davor beilaeufige Hinweise ausgeben (etwa das einmalige
# Setzen einer Ueberwachungs-Grundlage), und die verdeckten sonst das
# Ergebnis. Erst wenn beides fehlt, die erste Zeile ueberhaupt.
GRUND=$(grep -m1 '^ALARM: ' /tmp/lauf.log | sed 's/^ALARM: //')
[ -z "$GRUND" ] && GRUND=$(grep -m1 '^OK: ' /tmp/lauf.log)
[ -z "$GRUND" ] && GRUND=$(grep -m1 -v '^[[:space:]]*$' /tmp/lauf.log)
{
printf '%s exit=%s %s\n' "$ZEIT" "$CODE" "$LAGE"
[ -n "$GRUND" ] && printf 'Grund: %s\n' "$GRUND"
} > /gegenbuch/status.txt
sleep "$INTERVALL"
done
+1106
View File
File diff suppressed because it is too large Load Diff