Compare commits

..
10 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
20 changed files with 1259 additions and 126 deletions
+21
View File
@@ -398,6 +398,27 @@ lässt das Siegel aus.
| 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
+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 }],
},
},
});
+30 -4
View File
@@ -93,9 +93,34 @@ 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.
@@ -150,9 +175,10 @@ 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 },
+54 -2
View File
@@ -262,6 +262,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
? ' ⚠ 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'
@@ -269,6 +273,49 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
'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 =
@@ -299,7 +346,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
: beglaubigt > 0
? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' +
'entstanden.'
: 'Alle Einträge sind unverändert und lückenlos verkettet.';
: 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,
@@ -315,8 +365,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
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 + beglaubigtText + keinSchluessel + siegel + mehrfach,
message: message + beglaubigtText + rehashText + keinSchluessel + siegel + mehrfach,
unverifiableEntries: result.unverifiableEntries,
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
backlogSealStatus: result.backlogSealStatus,
+42 -1
View File
@@ -3,6 +3,7 @@ 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';
@@ -162,9 +163,26 @@ export async function updateUser(req: AuthRequest, 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
@@ -236,6 +254,7 @@ export async function updateUser(req: AuthRequest, 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)) {
@@ -265,7 +284,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
sensitivity: setztDienstkonto ? 'CRITICAL' : undefined,
sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined,
details: Object.keys(changes).length > 0 ? changes : undefined,
});
@@ -291,6 +310,28 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
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',
+65 -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,9 +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 ====================
@@ -326,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
@@ -491,6 +500,8 @@ const LISTEN_ADDR = process.env.LISTEN_ADDR
// 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}`);
+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;
}
+123 -2
View File
@@ -909,12 +909,30 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
* 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' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
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. */
@@ -1044,6 +1062,68 @@ 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[] = [];
@@ -1061,7 +1141,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
const backlogMissing: number[] = [];
// Luecken, die das Siegel als bereits vorhanden beglaubigt (siehe unten).
const beglaubigteLuecken = new Set<number>();
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
let backlogSealRoot: string | null = null;
const siegelKandidaten = await prisma.auditLog.findMany({
@@ -1164,6 +1244,17 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
? '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
//
@@ -1206,6 +1297,34 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
}
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];
@@ -1325,6 +1444,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
// unsichtbar (R174-01).
const siegelInOrdnung =
backlogSealStatus === 'intakt' ||
backlogSealStatus === 'leer' ||
backlogSealStatus === 'kein_siegel' ||
backlogSealStatus === 'nicht_noetig';
@@ -1337,6 +1457,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
unexplainedGaps,
unverifiableEntries,
attestedGaps,
rehashes,
backlogSealStatus,
backlogTampered,
backlogMissing,
@@ -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,
);
}
}
+81 -5
View File
@@ -65,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 {
@@ -78,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),
@@ -146,6 +151,7 @@ export async function createUser(data: {
customerId?: number;
hasDeveloperAccess?: boolean;
hasGdprAccess?: boolean;
hasAuditOpsAccess?: boolean;
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
@@ -190,6 +196,10 @@ export async function createUser(data: {
await setUserGdprAccess(user.id, true);
}
if (data.hasAuditOpsAccess) {
await setUserAuditOpsAccess(user.id, true);
}
return user;
}
@@ -206,12 +216,13 @@ export async function updateUser(
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;
@@ -348,6 +359,10 @@ export async function updateUser(
await setUserGdprAccess(id, hasGdprAccess);
}
if (hasAuditOpsAccess !== undefined) {
await setUserAuditOpsAccess(id, hasAuditOpsAccess);
}
return getUserById(id);
}
@@ -407,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
@@ -415,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 })),
},
@@ -457,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({
+4
View File
@@ -654,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
+216
View File
@@ -97,6 +97,222 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔢 R188: Ungültige IDs im Pfad zentral statt 181-mal** (2026-09-03)
- Meldung der Pentesterin: `GET /api/users/:id` gibt bei nicht-numerischer
ID **500** statt 400 (`/api/users/permissions` trifft `/:id`). Ihr Patch
setzt einen Guard in `getUser`.
- **Berechtigt und größer als gemeldet.** Ihr Fix schließt nebenbei etwas,
das sie nicht beansprucht: `parseInt('12abc')` ergibt **12**, also lieferte
`GET /api/users/12abc` bisher Benutzer 12 aus. Und es waren **181**
ungeprüfte Stellen in 19 Controllern, nicht eine.
- 181 Einzel-Guards wären genau der Fehler aus R186-01 gewesen (drei
Filterlisten, die auseinanderliefen). Stattdessen `router.param()`, an
**einer** Stelle für alle 33 Router registriert über einen `mounte()`-
Helfer, der Prüfung und Einhängen zusammenbindet. Wer künftig `app.use`
schreibt statt `mounte`, umgeht sie nicht versehentlich, sondern sichtbar.
- **Antwort ist 404, nicht 400:** Ein Pfadsegment, das keine ID sein kann,
benennt keine Ressource. Der bestehende Präzedenzfall
(`provider.controller.ts`, Pentest Mai 2026) hatte es genauso entschieden.
- **Eine ältere Heuristik abgelöst** (Pentest Runde 7): Sie blockte
`^\d+[a-zA-Z]+$` im Pfad ihr eigener Kommentar nannte den Grund,
`app.param()` greift nicht auf in Sub-Router gemounteten Routes", und
genau das löst `mounte()`. Sie war **zu eng** (`/users/abc` ging durch →
500) und **zu weit** (ein Einstellungs-Schlüssel `12abc` unter `:key` wurde
geblockt, obwohl das keine ID ist), und sie antwortete 400, wo jetzt 404
steht zwei Antworten für dieselbe Eingabeklasse.
- Getestet über HTTP: `abc`, `permissions`, `12abc`, `6abc`, `0`, `-1`,
`007`, `1e3`, Überlauf, `1;DROP` → alle **404**; `1` → 200. Quer über
sechs Controller gleich. Nicht-ID-Parameter (`roles/list`,
`permissions/list`, `settings/:key`) unverändert.
- Dateien: `backend/src/middleware/routeIds.ts` (neu), `backend/src/index.ts`
- [x] **⚖️ R189-01: DSGVO-Rechte hingen an keinem Konto** (2026-09-03)
- Meldung: `gdpr:*` und `audit:read/export` hängen an den Rollen DSGVO und
Developer die Admin-Rolle hat sie **nicht**, und nach einem frischen Seed
war DSGVO **keinem Konto** zugewiesen. Auskunft nach Art. 15 und Löschung
nach Art. 17 konnte damit niemand ausführen. Ausfall mit Fristwirkung.
- Seed weist `admin@admin.com` jetzt zusätzlich die DSGVO-Rolle zu. Die
Admin-**Rolle** bekommt diese Rechte weiterhin nicht die Trennung aus
R186 bleibt, nur das eine Bootstrap-Konto trägt beides.
- Label ehrlich gemacht: „Voller Zugriff auf alle **Fachfunktionen** (ohne
Audit & Datenschutz dafür die separaten Rollen DSGVO und Audit-Betrieb)".
- **Eigener Fund beim Prüfen ihres Patches:** `seed.ts` vergab an die
DSGVO-Rolle weiterhin `audit:*` **komplett**, inklusive `audit:admin`
also die Bündelung, die in `fc6f39e` aufgelöst wurde. Ich hatte damals zwei
Listen gefunden (`sync-roles.ts`, `user.service.ts`) und die dritte
übersehen. Gerettet hat es nur die Reihenfolge im Containerstart; ein
einzelnes `npm run db:seed` brachte sie zurück. Korrigiert.
- **Der Seed hilft aber nur bei Neuinstallation** (`update: {}`). Deshalb
zusätzlich eine Wache beim Start: Gibt es für `gdpr:export`, `gdpr:delete`
oder `audit:read` **kein aktives Konto**, steht das mit Handlungsanweisung
im Log. Erkennt die *Abwesenheit* einer Fähigkeit dasselbe Muster wie der
Heartbeat. Bewusst nur melden, nicht automatisch vergeben.
- Getestet: frischer Seed → Admin hat `audit:read, audit:export, gdpr:*` und
**kein** `audit:admin`. Rolle entzogen → Warnung erscheint mit allen drei
Rechten. Rolle zurück → still.
- Dateien: `backend/prisma/seed.ts`, `backend/prisma/sync-roles.ts`,
`backend/src/services/pflichtrechte.service.ts` (neu), `backend/src/index.ts`
- [x] **🕳️ Entfernter Protokoll-ANFANG wurde nicht erkannt** (2026-08-26)
- Gefunden bei der Vorbereitung des Prod-Siegels: Die Verkettung wird
zeilenweise gegen die Vorgängerin geprüft die **erste** Zeile hat keine,
also fiel bisher **nichts** auf, wenn ein zusammenhängender Anfang des
Protokolls entfernt wurde. Kein Kettenbruch, kein Befund, `valid` blieb
grün. Die stillste Löschung von allen: Wer die ältesten Einträge loswerden
will, muss nur vorne anfangen.
- Erkennbar ist es trotzdem: Die allererste Zeile wird ohne Vorgänger
geschrieben und trägt einen leeren `previousHash`. Trägt die erste
**vorhandene** Zeile einen Wert, hat es eine Vorgängerin gegeben und die
ist weg. Wird jetzt als Kettenlücke an dieser Zeile geführt, mit derselben
Manifest- und Beglaubigungslogik wie jede andere Lücke.
- Nur bei Prüfung des Gesamtbereichs; mit `fromId` ist ein gefüllter
`previousHash` selbstverständlich (gegengeprüft, kein Fehlalarm).
- Getestet über HTTP: vollständiges Protokoll → `valid:true`; erste drei
Zeilen entfernt → **vorher** unverändert `valid:true`, **jetzt**
`valid:false`, `chainGaps:[4]`, wegen Hash-Version 3 zusätzlich als
Manipulation eskaliert.
- Auf Prod ausgeschlossen: Das Protokoll beginnt bei ID 1 (07.05.2026,
Inbetriebnahme), kein Cleanup, keine Neuberechnung.
- Datei: `backend/src/services/audit.service.ts`
- [x] **⏱️ Neuberechnung der Kette ist jetzt eine eigene Gegenbuch-Alarmbedingung (Pentest R186, Frage b)** (2026-08-26)
- Der Tester fragte präzise: Löst `cleanup``rehash` **ohne** erneutes
Siegeln, über **nicht versiegelten** Inhalt, etwas **Automatisches** aus
oder nur die Prosa in `verify`, die ein Mensch lesen muss?
- **Gemessen statt behauptet:** Es löste bereits aus, aber als *Nebenwirkung*.
Ein Rehash ändert jeden Hash, also stimmt der beglaubigte Kettenkopf nicht
mehr und der bestehende Vergleich schlug an. Funktioniert ist aber ein
Zufallstreffer: Verschöbe sich der Anker, wäre der Melder lautlos weg. Und
die Meldung hieß „Eintrag wurde verändert" statt „die Kette wurde neu
berechnet", also Wirkung statt Ursache.
- Jetzt hängt der Alarm an der Sache selbst: Das Gegenbuch führt
`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 Auslöser, nicht an der Wirkung) und R185-01 (Wurzelwechsel statt
`valid`).
- **Reihenfolge geändert, und das war nötig:** Der neue Melder steht **vor**
dem Kopf-Hash-Vergleich, sonst hätte immer die unpräzisere Meldung
gewonnen. Und der Kopf-Vergleich wird nach einer *bestätigten*
Neuberechnung übersprungen sonst wäre die Bestätigung wertlos gewesen,
weil ein Rehash den Kopf zwangsläufig ändert. Beim Bauen aufgefallen, nicht
im Entwurf.
- `NOTARY_REHASH_ACK` wird mit der **ID des Rehash-Eintrags** bestätigt, nicht
mit `true`. IDs steigen streng ein stehen gelassener Wert passt beim
nächsten Vorgang nicht mehr. Analog zu `NOTARY_SEAL_ACK`.
- Ein **fehlender** beglaubigter Eintrag alarmiert weiterhin immer: Bestätigt
wird die Neuberechnung, nicht das Verschwinden von Zeilen.
- Bestandsbücher ohne das neue Feld alarmieren nicht rückwirkend, sondern
setzen die Grundlage mit Ausgabe, statt es stillschweigend zu tun.
- **Getestet mit echtem Gegenbuch gegen ein echtes CRM** (SSH-signiertes
lokales Buch): Wäsche ohne Datenbankzugriff (3 Zeilen gelöscht → Rehash)
→ CRM meldet `valid:true`, `chainGaps:[]`; Gegenbuch **exit 2** und nennt
die Neuberechnung als Ursache. Falsche Ack-ID → weiter exit 2, richtige →
exit 0 mit neuer Grundlage, Folgelauf ruhig.
- Dateien: `tools/audit-notary/notary.mjs`,
`tools/audit-notary/{docker-compose.yml,.env.example,README.md}`
- [x] **🔍 `verify` meldet jetzt, dass die Kette neu berechnet wurde** (2026-08-26)
- **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 löschten **3.155** Einträge (id 15356), danach lief ein
**Rehash**. Seither meldet die Prüfung „Alle Einträge sind unverändert und
**lückenlos** verkettet".
- Wahr und praktisch das Gegenteil dessen, was ein Leser mitnimmt. Der
Rehash verknüpft alles neu; die rund **700** Kettenlücken, 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 Löschung
nicht mehr feststellbar.
- `verifyIntegrity` sammelt jetzt die Rehash-Marker; die Antwort enthält
`rehashes[]` (Zeitpunkt, Anzahl, Signatur, Vorbefund). Die Meldung nennt
sie **immer**, auch im grünen Fall, und der Einstiegssatz lautet dann
„…lückenlos verkettet allerdings erst seit der letzten Neuberechnung".
- **`valid` bleibt unberührt.** Ein Rehash ist eine legitime Maßnahme; ihn
dauerhaft als Befund zu führen wäre der Dauer-Alarm, den wir mit den
beglaubigten Lücken gerade beseitigt haben. Melden, nicht alarmieren.
- **Umgekehrte Beweislast als bei Manifest und Siegel:** Dort zählen nur
signierte Träger, weil ein gefälschter Marker Lücken *wegerklären* könnte.
Hier erzeugt ein Marker eine *Warnung* würden nur signierte zählen,
könnte man einen Rehash unsichtbar machen, indem man seine Signatur
zerstört. Deshalb zählt jeder auswertbare Marker; fehlende Signatur wird
zusätzlich gemeldet.
- Getestet über HTTP gegen eine Wegwerf-DB, mit echtem Rehash über den
regulären Endpunkt: sauberer Vorzustand → „es wurde nichts überdeckt";
Löschung + Rehash (der Staging-Ablauf im Kleinen) → `chainGaps` fällt von
1 auf 0, die Meldung nennt Zeitpunkt und Vorbefund; zerstörte Signatur →
Marker wird weiterhin gemeldet, mit Hinweis.
- Dateien: `backend/src/services/audit.service.ts`,
`backend/src/controllers/auditLog.controller.ts`,
`frontend/src/services/api.ts`,
`frontend/src/pages/settings/AuditIntegrityCard.tsx`
- [x] **📄 `status.txt` nennt jetzt den Grund, nicht nur das Etikett** (2026-08-26)
- In der Statusdatei des Gegenbuchs stand für **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 nur die
Statusdatei liest (und dafür ist sie da „damit eine Überwachung 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.
- Datei: `tools/audit-notary/entrypoint.sh`
- [x] **⚖️ Aufsicht und Eingriff getrennt: neuer Haken „Audit-Betrieb"** (2026-08-26)
- 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 können. Dieselbe Klasse wie R184-02: falsche
Domäne, zu breit gebündelt.
- **Der naheliegende Fix wäre falsch gewesen.** `audit:admin` einfach aus
der DSGVO-Rolle zu streichen hätte es heimatlos gemacht: Die Admin-Rolle
ist ausdrücklich **ohne** `audit`/`gdpr` gebaut, einzige verbleibende
Quelle wäre der Entwicklerzugriff der *alles* gibt. Prod versiegeln
hätte dann Vollzugriff vorausgesetzt.
- Deshalb eine eigene versteckte Rolle **`Audit-Betrieb`**
(`audit:read` + `audit:admin`), zugewiesen über eine Checkbox wie
DSGVO/Entwickler. DSGVO behält `audit:read` + `audit:export` + `gdpr:*`.
**Für kein bestehendes Konto weitet sich etwas aus** im Gegenteil, es
wird enger, und wer eingreifen können soll, bekommt es ausdrücklich.
- **Keine zusätzliche Rechte-Hürde davor**, weil das am Henne-Ei-Problem
scheitert: Nach der Aufteilung hält zunächst niemand `audit:admin`, könnte
ihn also auch niemand vergeben. Stattdessen wird die Vergabe **laut**
CRITICAL im Protokoll **und** `PERMISSION_CHANGED`/CRITICAL im Alarmkanal,
inklusive 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 Bündelung stillschweigend zurückgebracht hätte.
- **⚠️ Beim Deploy:** Bestehende DSGVO-Konten verlieren `audit:admin`. Wer
Prod versiegeln will, muss sich vorher „Audit-Betrieb" ankreuzen.
- **Gemeldet, nicht geändert:** Jeder mit `users:update` (also Admin) kann
sich DSGVO oder Entwicklerzugriff selbst vergeben Entwickler heißt
*alle* Rechte. Das ist vorbestehend und gehört ins Rollenmodell-Thema des
Pentesters.
- Dateien: `backend/prisma/sync-roles.ts`,
`backend/src/services/user.service.ts`,
`backend/src/controllers/user.controller.ts`,
`backend/src/utils/sanitize.ts`,
`frontend/src/pages/users/UserList.tsx`,
`frontend/src/services/api.ts`, `tools/audit-notary/README.md`
- [x] **🏷️ Siegel über null Blättern meldet nicht mehr „intakt"** (2026-08-26)
- Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand
vorfand, ist rechnerisch tadellos und schützt **nichts**. Gemeldet wurde
trotzdem `intakt` formal richtig, aber es liest sich als Schutzzusage.
- Der Pentester hat Stagings Leersiegel genau so missverstanden und hielt es
für zahnhaltig. Das ist der rote Faden im Kleinen: ein Signal, das
beruhigt, wo nichts abgesichert ist.
- Eigener Zustand `leer` mit eigenem Text („besteht ein Bestandssiegel, das
aber NICHTS umschließt … kein Fehler, aber auch keine Zusage").
Wertung wie `nicht_noetig`: kippt `valid` nicht.
- Dateien: `backend/src/services/audit.service.ts`,
`backend/src/controllers/auditLog.controller.ts`,
`frontend/src/services/api.ts`,
`frontend/src/pages/settings/AuditIntegrityCard.tsx`
- [x] **🔒 `audit:export` gatete nichts Export hing an `audit:read`** (2026-08-26)
- **Bei der Gegenprobe zur neuen Rolle `Gegenbuch` gefunden:** Ein Konto mit
ausschließlich `audit:read` bekam auf `GET /audit-logs/export` **200**.
@@ -50,6 +50,7 @@ const AMPEL_TITEL: Record<Ampel, string> = {
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',
@@ -125,7 +126,12 @@ export default function AuditIntegrityCard() {
<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]}</p>
<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 ·
@@ -155,9 +161,36 @@ export default function AuditIntegrityCard() {
<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.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.
+30 -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,6 +246,7 @@ function UserModal({
roleIds: [] as number[],
isActive: true,
hasDeveloperAccess: false,
hasAuditOpsAccess: false,
hasGdprAccess: false,
isServiceAccount: false,
whatsappNumber: '',
@@ -264,9 +265,10 @@ 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 || '',
@@ -283,6 +285,7 @@ function UserModal({
roleIds: [],
isActive: true,
hasDeveloperAccess: false,
hasAuditOpsAccess: false,
hasGdprAccess: false,
isServiceAccount: false,
whatsappNumber: '',
@@ -326,6 +329,7 @@ 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.
@@ -369,6 +373,7 @@ 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.
@@ -489,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"
@@ -533,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
+16 -3
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; isServiceAccount?: boolean; confirm?: string; 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[]; isServiceAccount?: boolean; confirm?: string }) => {
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;
},
@@ -1748,9 +1749,21 @@ export interface IntegrityResult {
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' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
backlogTampered: number[];
backlogMissing: number[];
backlogSealCount: number;
+10
View File
@@ -67,6 +67,15 @@ PROD_ADOPT_ACK=
# 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
@@ -83,4 +92,5 @@ STAGING_INTERVAL=3600
STAGING_GENESIS_ACK=
STAGING_ADOPT_ACK=
STAGING_SEAL_ACK=
STAGING_REHASH_ACK=
STAGING_DIR=./data/staging
+195 -40
View File
@@ -11,6 +11,37 @@ 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** |
@@ -86,57 +117,128 @@ soll nicht versehentlich passieren.
getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`.
### Wenn der erste Lauf `exit=2` meldet: den Altbestand versiegeln
### 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`, und das Gegenbuch schlägt zu Recht
Alarm. Typischerweise steht dann im Log:
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.
Hinweis: Der Altbestand ist nicht versiegelt Änderungen daran wären
nicht erkennbar.
Das Siegeln ist **einmalig** und passiert im CRM, nicht hier.
Das ist **einmalig** zu erledigen, 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
TOKEN=$(curl -s -X POST https://<crm>/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"…","password":"…"}' | jq -r '.data.token')
curl -s -X POST https://<crm>/api/audit-logs/seal-backlog \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"confirm":"SEAL"}' | jq
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
```
Dafür braucht es das Recht `audit:admin` das Gegenbuch-Dienstkonto hat es
absichtlich **nicht**. Nimm dein Administratorkonto.
Muss `audit:read` **und** `audit:admin` zeigen.
**Zuerst nachsehen, was versiegelt wird.** Das Siegel schreibt den aktuellen
Zustand fest, samt aller vorhandenen Lücken. Wer blind siegelt, beglaubigt
gegebenenfalls auch eine Lücke, die von einer Löschung stammt. Deshalb vorher:
#### 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
curl -s -X POST https://<crm>/api/audit-logs/verify \
-H "Authorization: Bearer $TOKEN" \
| jq '.data | {valid, chainGaps, unexplainedGaps, tamperedEntries}'
B="Authorization: Bearer $AT"
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
| jq '.data | {valid, checkedCount, rehashes, chainGaps, unexplainedGaps,
tamperedEntries, backlogSealStatus}'
```
Stehen dort Lücken, sieh dir die betroffenen IDs und ihre Nachbarn an
(`GET /api/audit-logs/<id>`). Liegen sie jeweils **innerhalb einer Sekunde**
zusammen mit ihren Nachbarn und fehlt kein Eintrag (kein `404`), sind es
Schreibkollisionen aus paralleln Anfragen harmlos. Fehlt dagegen ein Eintrag
oder steht etwas in `tamperedEntries`, **erst klären, dann siegeln**.
Vier Dinge müssen stimmen:
Nach dem Siegeln meldet die Prüfung wieder `valid: true`, und die bekannten
Alt-Lücken erscheinen als **beglaubigt**:
| 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) |
Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue
Lücke entstanden. 6 Lücken stammen aus der Zeit vor dem Bestandssiegel
und sind darin als Vorbefund beglaubigt (IDs …).
**Lücken einordnen.** Sieh dir die betroffenen IDs und ihre Nachbarn an:
Sie 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.
```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
@@ -177,6 +279,58 @@ 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
@@ -195,12 +349,12 @@ Im CRM, als Administrator:
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
> **Nicht den DSGVO-Haken benutzen.** Der sieht naheliegend aus („Audit-Logs,
> Datenschutz"), vergibt aber `audit:` **komplett** einschließlich
> `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.
> **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
@@ -556,6 +710,7 @@ an, bis er geklärt ist.
| 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
+3
View File
@@ -36,6 +36,8 @@ services:
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.
@@ -56,5 +58,6 @@ services:
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
+22 -2
View File
@@ -70,8 +70,16 @@ fi
# Logs zu durchsuchen.
while true; do
ZEIT=$(date -Iseconds)
node /opt/notary/notary.mjs
# 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)" ;;
@@ -82,6 +90,18 @@ while true; do
*) LAGE="unbekannter Code" ;;
esac
echo "[$INSTANZ] $ZEIT exit=$CODE ($LAGE)"
printf '%s exit=%s %s\n' "$ZEIT" "$CODE" "$LAGE" > /gegenbuch/status.txt
# 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
+95 -2
View File
@@ -153,6 +153,17 @@ const SEAL_ACK = (process.env.NOTARY_SEAL_ACK || '').trim();
const siegelBestaetigt = (wurzel) =>
SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK);
// Bestaetigung fuer eine Neuberechnung der Kette (Pentest R186, Frage (b)).
//
// Bestaetigt wird mit der ID des juengsten Rehash-Eintrags. IDs steigen
// streng, ein stehen gelassener Wert passt also beim naechsten Rehash nicht
// mehr - dasselbe selbstentwertende Prinzip wie bei NOTARY_SEAL_ACK, nur mit
// einem Wert, den man vorlesen kann.
const REHASH_ACK = (process.env.NOTARY_REHASH_ACK || '').trim();
// Wurde in diesem Lauf eine Neuberechnung ausdruecklich bestaetigt? Dann ist
// der veraenderte Kettenkopf die erwartete Folge und kein eigener Befund.
let rehashBestaetigt = false;
if (!CRM_URL) {
console.error('CRM_URL muss gesetzt sein.');
process.exit(1);
@@ -755,6 +766,17 @@ const letzter = bisher[bisher.length - 1];
if (!CRM_TOKEN) await anmelden();
const aktuell = await hole('/api/audit-logs/checkpoint');
// Die Vollpruefung wird IMMER geholt, auch beim allerersten Lauf: Aus ihr
// stammt die Zahl der protokollierten Neuberechnungen, und die gehoert schon
// in den ersten Eintrag - sonst gaebe es beim zweiten Lauf keine Grundlage,
// gegen die sich ein neuer Rehash abheben koennte.
const pruefung = await hole('/api/audit-logs/verify', 'POST');
const rehashListe = Array.isArray(pruefung?.rehashes) ? pruefung.rehashes : null;
const rehashAnzahl = rehashListe ? rehashListe.length : null;
const rehashLetzte = rehashListe && rehashListe.length
? rehashListe[rehashListe.length - 1].id
: null;
if (letzter) {
if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) {
alarm(
@@ -767,7 +789,6 @@ if (letzter) {
// Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
// ausweisen die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
// das Feld, nicht der Satz.
const pruefung = await hole('/api/audit-logs/verify', 'POST');
if (pruefung && pruefung.valid === false) {
alarm(
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
@@ -779,8 +800,77 @@ if (letzter) {
}
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
// Ein fehlender beglaubigter Eintrag ist IMMER ein Befund - auch wenn eine
// Neuberechnung bestaetigt wurde. Bestaetigt wird der Rehash, nicht das
// Verschwinden von Zeilen.
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
if (rueck.atHash !== letzter.chainHead) {
// ---------------------------------------------------------------
// Neuberechnung der Kette (Rehash) als EIGENE Alarmbedingung.
//
// Ein Rehash wurde bisher nur als Nebenwirkung gefangen: Er aendert jeden
// Hash, also stimmt der beglaubigte Kettenkopf nicht mehr und der Vergleich
// weiter oben schlaegt an. Das funktioniert - aber es ist ein Zufallstreffer
// und keine gebaute Warnung. Verschoebe sich der Anker irgendwann (anderer
// beglaubigter Wert, anderer Vergleich), waere der Melder lautlos weg.
//
// Deshalb haengt der Alarm jetzt an der Waffe selbst, nicht an ihrer Spur -
// dieselbe Lehre wie R184-01 (Gate am Ausloeser statt an der Wirkung) und
// R185-01 (Wurzelwechsel statt `valid`).
//
// Das Restrisiko, das dieser Melder abdeckt: `cleanup` + `rehash` OHNE
// erneutes Siegeln, ueber Inhalt, der gar nicht versiegelt ist. Dort greift
// weder das Bestandssiegel noch der Wurzelwechsel.
if (rehashAnzahl !== null && typeof letzter.rehashCount === 'number') {
if (rehashAnzahl > letzter.rehashCount) {
const neue = rehashAnzahl - letzter.rehashCount;
if (REHASH_ACK && String(rehashLetzte) === REHASH_ACK) {
rehashBestaetigt = true;
console.log(
`Neuberechnung bestätigt (NOTARY_REHASH_ACK=${REHASH_ACK}). ` +
'NOTARY_REHASH_ACK danach wieder leeren.',
);
} else {
const l = rehashListe[rehashListe.length - 1];
const vb = l.vorbefund;
alarm(
`Die Hash-Kette wurde neu berechnet (${neue} neue${neue === 1 ? 'r' : ''} Vorgang` +
`${neue === 1 ? '' : 'e'} seit der letzten Beglaubigung).\n` +
` zuletzt: ${l.zeitpunkt} (Eintrag ${l.id}, ${l.neuBerechnet} Zeilen)\n` +
(vb
? ` Befund unmittelbar davor: ${vb.manipuliert} beanstandet, ${vb.luecken} Lücken\n`
: ' Der Zustand davor ist nicht mehr feststellbar.\n') +
(l.signiert ? '' : ' ACHTUNG: Dieser Rehash-Eintrag trägt keine gültige Signatur.\n') +
'Ein Rehash verknüpft alle Einträge neu. Lücken, die vorher eine Löschung\n' +
'sichtbar gemacht hätten, verschwinden dabei aus der Kette die Prüfung im CRM\n' +
'meldet danach wieder „lückenlos". Genau deshalb ist das hier ein Alarm und\n' +
'kein Hinweis.\n' +
'War es geplant, bestätigen und danach wieder leeren:\n' +
` NOTARY_REHASH_ACK=${rehashLetzte}`,
);
}
}
} else if (rehashAnzahl !== null && typeof letzter.rehashCount !== 'number') {
// Gegenbuch aus der Zeit vor diesem Melder: Es gibt keine Grundlage, gegen
// die sich etwas abheben koennte. Stillschweigend zu alarmieren waere ein
// Fehlalarm bei jedem Bestandsbuch; stillschweigend zu uebernehmen waere
// eine unsichtbare Annahme. Also: uebernehmen und es sagen.
console.log(
`Grundlage für die Rehash-Überwachung wird gesetzt: ${rehashAnzahl} protokollierte ` +
'Neuberechnung(en) im CRM. Ab dem nächsten Lauf fällt jede weitere auf.',
);
}
// Der Kopf-Hash-Vergleich steht bewusst NACH der Rehash-Pruefung.
//
// Zwei Gruende: Erstens benennt der Rehash-Alarm die Ursache, waehrend
// „Eintrag wurde veraendert“ nur die Wirkung beschreibt - bei gleicher Lage
// ist die praezisere Meldung die nuetzlichere. Zweitens aendert eine
// Neuberechnung ZWANGSLAEUFIG jeden Hash; stuende dieser Vergleich davor
// oder ungeschuetzt dahinter, liesse sich eine bestaetigte Neuberechnung nie
// aufloesen und die Bestaetigung waere wertlos.
if (rueck.atHash !== letzter.chainHead && !rehashBestaetigt) {
alarm(
`Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` +
` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`,
@@ -910,6 +1000,9 @@ const eintrag = {
chainHead: aktuell.chainHead,
sealRoot: aktuell.sealRoot,
sealLeafCount: aktuell.sealLeafCount,
// Grundlage fuer die Rehash-Ueberwachung des naechsten Laufs.
rehashCount: rehashAnzahl,
rehashLast: rehashLetzte,
};
const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n';