Compare commits
28
Commits
773033936d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cca242119b | ||
|
|
3a50a40ad5 | ||
|
|
e76b4ace88 | ||
|
|
ed676ca4eb | ||
|
|
eb0580ac54 | ||
|
|
2ca6ed2f70 | ||
|
|
df442bb1a0 | ||
|
|
31c4c209e4 | ||
|
|
fc6f39eba0 | ||
|
|
909e523634 | ||
|
|
23505afc05 | ||
|
|
d2460fa7c0 | ||
|
|
ef2411ebe4 | ||
|
|
e81a83ae8f | ||
|
|
7af6b7591b | ||
|
|
ecaeae48d4 | ||
|
|
e504b8be96 | ||
|
|
b3a9ef6372 | ||
|
|
389fd30094 | ||
|
|
b696980793 | ||
|
|
910c94daa1 | ||
|
|
41671cbb96 | ||
|
|
1f53a3304b | ||
|
|
601fb03b22 | ||
|
|
1eb65809ec | ||
|
|
89d617ab70 | ||
|
|
cb9f1f5fce | ||
|
|
a62c51b7c7 |
+12
@@ -36,6 +36,18 @@ data/factory-defaults/*
|
||||
data/backups/*
|
||||
!data/backups/.gitkeep
|
||||
|
||||
# Gegenbuch (laeuft auf eigener Maschine): enthaelt das Buch selbst, den
|
||||
# Signaturschluessel und den Beobachtungsspeicher. Nichts davon gehoert ins
|
||||
# Repository - der Schluessel schon gar nicht.
|
||||
tools/audit-notary/data/*
|
||||
!tools/audit-notary/data/.gitkeep
|
||||
!tools/audit-notary/data/prod/
|
||||
tools/audit-notary/data/prod/*
|
||||
!tools/audit-notary/data/prod/.gitkeep
|
||||
!tools/audit-notary/data/staging/
|
||||
tools/audit-notary/data/staging/*
|
||||
!tools/audit-notary/data/staging/.gitkeep
|
||||
|
||||
# Factory-Defaults-Drop-Box (Export-ZIPs zwischen dev/prod hin und her)
|
||||
factory-exports/*
|
||||
!factory-exports/.gitkeep
|
||||
|
||||
@@ -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
|
||||
@@ -452,6 +473,50 @@ zwei Dinge:
|
||||
Notiere dir die Zahl nach dem ersten Deploy: Bleibt sie konstant, ist alles
|
||||
in Ordnung. Steigt sie, lohnt ein Blick.
|
||||
|
||||
### Gegenbuch – optionaler Zusatzschutz auf zweitem Rechner
|
||||
|
||||
Das Audit-Siegel schützt die Einträge **innerhalb** der Anwendung. Es liegt aber
|
||||
in derselben Datenbank, die es absichert: Wer vollen Zugriff auf den Server hat,
|
||||
kommt am Ende auch an das Siegel.
|
||||
|
||||
Dagegen gibt es das **Gegenbuch**. Ein zweiter Rechner holt regelmäßig einen
|
||||
kurzen Kontrollwert vom CRM und schreibt ihn mit. Wird später im CRM etwas
|
||||
nachträglich verändert oder gelöscht, widerspricht das dem Gegenbuch und fällt
|
||||
beim nächsten Durchlauf auf.
|
||||
|
||||
Die Richtung ist dabei entscheidend:
|
||||
|
||||
```
|
||||
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read)
|
||||
OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
|
||||
```
|
||||
|
||||
Wer OpenCRM übernimmt, kommt damit nicht an das Gegenbuch. Das CRM braucht
|
||||
dafür **keinerlei Konfiguration** – es liefert nur einen lesbaren Prüfwert, der
|
||||
keine Geheimnisse enthält.
|
||||
|
||||
**Einrichten** (auf einem anderen Rechner als dem CRM):
|
||||
|
||||
```bash
|
||||
git clone <dieses Repository> opencrm
|
||||
cd opencrm/tools/audit-notary
|
||||
cp .env.example .env # CRM-Adresse und Dienstkonto eintragen
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Vorher im CRM anlegen:** eine Rolle mit ausschließlich dem Recht
|
||||
`audit:read` und einen Benutzer damit. Das Gegenbuch meldet sich mit diesem
|
||||
Konto bei jedem Durchlauf selbst an – ein dauerhaftes Token gibt es nicht, weil
|
||||
Zugangstoken nach 15 Minuten ablaufen.
|
||||
|
||||
Zwei Bücher auf einer Maschine – etwa für Produktion und Test – sind
|
||||
vorgesehen. Alles Weitere in
|
||||
[tools/audit-notary/README.md](tools/audit-notary/README.md).
|
||||
|
||||
> **Optional.** Ohne Gegenbuch bleibt der Schutz innerhalb der Anwendung
|
||||
> vollständig erhalten. Es deckt zusätzlich den Fall ab, dass jemand den
|
||||
> CRM-Server selbst übernimmt.
|
||||
|
||||
<details>
|
||||
<summary><b>Technische Details</b> (für Entwickler/Admins)</summary>
|
||||
|
||||
@@ -911,6 +976,7 @@ opencrm/
|
||||
│ │ └── App.tsx # Haupt-Komponente
|
||||
│ └── package.json
|
||||
├── Caddyfile # Optionaler Reverse-Proxy (nur mit --profile caddy)
|
||||
├── tools/audit-notary/ # Gegenbuch – läuft auf einem zweiten Rechner
|
||||
│ ├── Dockerfile # Multi-Stage Build
|
||||
│ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy)
|
||||
│ ├── Caddyfile # Reverse-Proxy mit SSL
|
||||
|
||||
@@ -113,3 +113,16 @@ AUDIT_HMAC_KEY_OLD=
|
||||
# Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel
|
||||
# UND Datenbank hat, kann die Kette konsistent neu rechnen.
|
||||
# Erzeugung : openssl rand -hex 32 (256 Bit)
|
||||
|
||||
# ==================== DIENSTKONTO-WACHHUND ====================
|
||||
# Nach wie vielen Minuten Stille eines Dienstkontos (z. B. des Gegenbuchs)
|
||||
# ein Sicherheitsereignis erzeugt wird.
|
||||
#
|
||||
# Hintergrund: Die planmaessigen Anmeldungen selbst sind bewusst als Routine
|
||||
# eingestuft, damit sie die CRITICAL-Stufe nicht entwerten. Das Signal ist
|
||||
# deshalb die ABWESENHEIT: Wer das Gegenbuch stilllegt, setzt darauf, dass
|
||||
# Stille nicht auffaellt.
|
||||
#
|
||||
# Faustregel: etwa das Dreifache des Gegenbuch-Takts. Bei stuendlichem Takt
|
||||
# also 180. Ohne Dienstkonten im System passiert nichts.
|
||||
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES=180
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Dienstkonto-Kennzeichen (Pentest R182).
|
||||
--
|
||||
-- Das Gegenbuch meldet sich stuendlich an. Diese Anmeldung wurde bisher als
|
||||
-- Authentication/CRITICAL protokolliert - also das vorhersagbarste Ereignis im
|
||||
-- System auf der hoechsten Stufe. Damit trainiert die Routine den Betreiber
|
||||
-- darauf, CRITICAL wegzuklicken, und der erste ECHTE Vorfall erbt diesen
|
||||
-- Reflex. Mit dem Kennzeichen werden solche Anmeldungen als Routine gefuehrt.
|
||||
ALTER TABLE `User`
|
||||
ADD COLUMN IF NOT EXISTS `isServiceAccount` BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Neuer SecurityEventType AUDIT_SEAL_CHANGED (Pentest R185-01).
|
||||
--
|
||||
-- Das Setzen oder Ersetzen des Bestandssiegels veraendert die Grundlage, gegen
|
||||
-- die spaeter Manipulation nachgewiesen wird. Bisher landete das ausschliesslich
|
||||
-- als CRITICAL-Zeile im Audit-Log - also in einem Kanal, den ein Mensch lesen
|
||||
-- muss. Der Alarmkanal ist ein separater Store; ohne eigenen Ereignistyp gab es
|
||||
-- dort gar keinen Eintrag, und `valid` bleibt bei einem ersetzenden Siegel
|
||||
-- konstruktionsbedingt `true`.
|
||||
--
|
||||
-- MODIFY COLUMN ist idempotent (setzt die Enum-Definition, mehrfach ausfuehrbar).
|
||||
ALTER TABLE `SecurityEvent`
|
||||
MODIFY COLUMN `type` ENUM(
|
||||
'LOGIN_FAILED','LOGIN_SUCCESS','RATE_LIMIT_HIT','ACCESS_DENIED',
|
||||
'SSRF_BLOCKED','PASSWORD_RESET_REQUEST','PASSWORD_RESET_CONFIRM',
|
||||
'LOGOUT','TOKEN_REJECTED','PERMISSION_CHANGED','AUDIT_SEAL_CHANGED',
|
||||
'SUSPICIOUS'
|
||||
) NOT NULL;
|
||||
@@ -76,6 +76,11 @@ model User {
|
||||
firstName String
|
||||
lastName String
|
||||
isActive Boolean @default(true)
|
||||
/// Dienstkonto (z. B. das Gegenbuch). Meldet sich planmaessig und haeufig an.
|
||||
/// Solche Anmeldungen werden im Audit-Log als Routine gefuehrt statt als
|
||||
/// CRITICAL - sonst trainiert das vorhersagbarste Ereignis im System den
|
||||
/// Betreiber darauf, die hoechste Stufe wegzuklicken (Pentest R182).
|
||||
isServiceAccount Boolean @default(false)
|
||||
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
|
||||
|
||||
// Passwort-Reset
|
||||
@@ -1477,6 +1482,7 @@ enum SecurityEventType {
|
||||
LOGOUT // expliziter Logout
|
||||
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
|
||||
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
|
||||
AUDIT_SEAL_CHANGED // Bestandssiegel gesetzt oder ersetzt (Beweis-Grundlage)
|
||||
SUSPICIOUS // generischer Catch-All
|
||||
}
|
||||
|
||||
|
||||
+29
-5
@@ -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 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,9 +93,52 @@ async function main() {
|
||||
// Developer: alles
|
||||
const developerPermIds = allPermissions.map((p) => p.id);
|
||||
|
||||
// DSGVO: audit + gdpr komplett
|
||||
// DSGVO: Datenschutz-Verwaltung plus LESENDEN Zugriff aufs Audit-Protokoll.
|
||||
//
|
||||
// Bewusst OHNE `audit:admin`. Diese Rolle beaufsichtigt das Protokoll - sie
|
||||
// darf es nicht umschreiben koennen. Mit `audit:admin` haette ein
|
||||
// DSGVO-Beauftragter `seal-backlog`, `rehash` und `cleanup`, also die Mittel,
|
||||
// seine eigene Beweisgrundlage zu ersetzen. Wer prueft und wer eingreift,
|
||||
// sind zwei Rollen (Pentest R186, im Anschluss an R184-02: falsche Domaene,
|
||||
// zu breit gebuendelt).
|
||||
const gdprPermIds = allPermissions
|
||||
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
|
||||
.filter(
|
||||
(p) =>
|
||||
p.resource === 'gdpr' ||
|
||||
(p.resource === 'audit' && (p.action === 'read' || p.action === 'export')),
|
||||
)
|
||||
.map((p) => p.id);
|
||||
|
||||
// Audit-Betrieb: die eingreifenden Rechte am Protokoll - versiegeln,
|
||||
// neu berechnen, aufraeumen, Aufbewahrung aendern. Plus `audit:read`, denn
|
||||
// siegeln zu duerfen ohne das Ergebnis sehen zu koennen waere unbrauchbar.
|
||||
//
|
||||
// Eigene versteckte Rolle statt einem Anhaengsel an Admin: So weitet sich
|
||||
// durch die Umstellung fuer KEIN bestehendes Konto etwas aus. Wer eingreifen
|
||||
// koennen soll, bekommt es ausdruecklich - und diese Zuweisung ist selbst
|
||||
// ein sichtbarer Vorgang.
|
||||
const auditBetriebPermIds = allPermissions
|
||||
.filter(
|
||||
(p) => p.resource === 'audit' && (p.action === 'read' || p.action === 'admin'),
|
||||
)
|
||||
.map((p) => p.id);
|
||||
|
||||
// Gegenbuch: NUR audit:read.
|
||||
//
|
||||
// Der externe Notar ruft genau zwei Endpunkte auf, /audit-logs/checkpoint
|
||||
// und /audit-logs/verify, und beide verlangen audit:read. Bis hierher gab es
|
||||
// dafuer keine passende Rolle: Wer dem Dienstkonto Leserechte aufs Protokoll
|
||||
// geben wollte, musste den DSGVO-Haken setzen - und der vergibt `audit:*`
|
||||
// KOMPLETT, also auch `audit:admin` mit seal-backlog, rehash und cleanup.
|
||||
//
|
||||
// Damit haette ein Einbruch auf der Gegenbuch-Maschine nicht nur den
|
||||
// Waechter gehabt, sondern gleich die Mittel, das Bewachte umzuschreiben -
|
||||
// genau die Waesche aus R185-01, und genau die Trennung, wegen der das
|
||||
// Gegenbuch ueberhaupt auf einer eigenen Maschine laeuft. Das Kennwort des
|
||||
// Dienstkontos liegt dort im Klartext in der .env; es muss deshalb so wenig
|
||||
// wert sein wie moeglich.
|
||||
const gegenbuchPermIds = allPermissions
|
||||
.filter((p) => p.resource === 'audit' && p.action === 'read')
|
||||
.map((p) => p.id);
|
||||
|
||||
// Mitarbeiter: customers + contracts + read auf Stammdaten
|
||||
@@ -132,11 +175,13 @@ async function main() {
|
||||
.map((p) => p.id);
|
||||
|
||||
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)', permIds: adminPermIds },
|
||||
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
||||
{ name: 'Audit-Betrieb', description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern', permIds: auditBetriebPermIds },
|
||||
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
|
||||
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
|
||||
{ name: 'Gegenbuch', description: 'Darf das Audit-Protokoll nur lesen und prüfen – sonst nichts', permIds: gegenbuchPermIds },
|
||||
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
|
||||
];
|
||||
|
||||
|
||||
@@ -3,46 +3,119 @@ import { AuthRequest } from '../types/index.js';
|
||||
import * as auditService from '../services/audit.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { AuditAction, AuditSensitivity } from '@prisma/client';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
|
||||
/**
|
||||
* Filterwerte aus der Query pruefen (Pentest R185-02).
|
||||
*
|
||||
* Vorher gingen `action` und `sensitivity` als roher String an die Enum-Spalte.
|
||||
* Ein ungueltiger Wert liess Prisma auflaufen und der Handler antwortete 500.
|
||||
* Das ist zweierlei: eine fehlende Validierung – und ein Fehler-Orakel. Wer
|
||||
* 200 gegen 500 vergleicht, liest die Enum-Mitglieder aus, ohne sie zu kennen.
|
||||
* Ein ungueltiger Filter ist eine schlechte ANFRAGE, keine Server-Panne: 400.
|
||||
*
|
||||
* Dasselbe gilt fuer Datumsangaben (`new Date('foo')` ergibt Invalid Date und
|
||||
* sprengt die Query erst in der Datenbank) und fuer Zahlen (`parseInt('x')`
|
||||
* ergibt NaN).
|
||||
*/
|
||||
class FilterFehler extends Error {}
|
||||
|
||||
const AUDIT_ACTIONS = Object.values(AuditAction) as string[];
|
||||
const AUDIT_SENSITIVITIES = Object.values(AuditSensitivity) as string[];
|
||||
|
||||
function pruefeEnum<T extends string>(
|
||||
wert: unknown, erlaubt: string[], feld: string,
|
||||
): T | undefined {
|
||||
if (wert === undefined || wert === '') return undefined;
|
||||
if (typeof wert !== 'string' || !erlaubt.includes(wert)) {
|
||||
// Die erlaubten Werte stehen ohnehin in der Oberflaeche und im Schema –
|
||||
// sie zu nennen verraet nichts und erspart Rateversuche.
|
||||
throw new FilterFehler(
|
||||
`Ungültiger Wert für "${feld}". Erlaubt: ${erlaubt.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
return wert as T;
|
||||
}
|
||||
|
||||
function pruefeDatum(wert: unknown, feld: string): Date | undefined {
|
||||
if (wert === undefined || wert === '') return undefined;
|
||||
const d = new Date(wert as string);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
throw new FilterFehler(`Ungültiges Datum für "${feld}".`);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function pruefeZahl(wert: unknown, feld: string): number | undefined {
|
||||
if (wert === undefined || wert === '') return undefined;
|
||||
const n = Number(wert);
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new FilterFehler(`Ungültige Zahl für "${feld}".`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Freitextfelder begrenzen – unbegrenzte LIKE-Muster sind teuer. */
|
||||
function pruefeText(wert: unknown, feld: string, maxLaenge = 200): string | undefined {
|
||||
if (wert === undefined || wert === '') return undefined;
|
||||
if (typeof wert !== 'string' || wert.length > maxLaenge) {
|
||||
throw new FilterFehler(`Ungültiger Wert für "${feld}" (max. ${maxLaenge} Zeichen).`);
|
||||
}
|
||||
return wert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter aus der Query lesen – EINE Stelle fuer Liste und Export (R186-01).
|
||||
*
|
||||
* Vorher pflegte jeder Endpunkt seine eigene Liste, und die des Exports war
|
||||
* kuerzer: `userId`, `customerId`, `dataSubjectId`, `resourceId`, `success` und
|
||||
* `search` wurden dort stillschweigend verworfen. Ein bewusst eingegrenzter
|
||||
* Export – „nur die Spur von Benutzer X“ fuer eine DSGVO-Auskunft oder eine
|
||||
* Innentaeter-Pruefung – lieferte damit das VOLLSTAENDIGE Protokoll aller
|
||||
* Nutzer zurueck. Mit HTTP 200 und ohne jeden Hinweis: ein beruhigendes
|
||||
* Signal ueber einem Ergebnis, das genau das Gegenteil dessen ist, was
|
||||
* angefragt wurde. Auf einem datenminimierungspflichtigen Export ist das
|
||||
* nicht nur ein fehlender Filter, sondern eine Weitergabe.
|
||||
*
|
||||
* Zwei Listen, die dasselbe bedeuten sollen, laufen frueher oder spaeter
|
||||
* auseinander. Deshalb gibt es jetzt nur noch diese eine.
|
||||
*/
|
||||
function leseFilter(req: AuthRequest) {
|
||||
const q = req.query;
|
||||
return {
|
||||
userId: pruefeZahl(q.userId, 'userId'),
|
||||
customerId: pruefeZahl(q.customerId, 'customerId'),
|
||||
dataSubjectId: pruefeZahl(q.dataSubjectId, 'dataSubjectId'),
|
||||
action: pruefeEnum<AuditAction>(q.action, AUDIT_ACTIONS, 'action'),
|
||||
sensitivity: pruefeEnum<AuditSensitivity>(q.sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'),
|
||||
resourceType: pruefeText(q.resourceType, 'resourceType', 100),
|
||||
resourceId: pruefeText(q.resourceId, 'resourceId', 100),
|
||||
startDate: pruefeDatum(q.startDate, 'startDate'),
|
||||
endDate: pruefeDatum(q.endDate, 'endDate'),
|
||||
success: q.success !== undefined ? q.success === 'true' : undefined,
|
||||
search: pruefeText(q.search, 'search'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit-Logs mit Filtern abrufen
|
||||
*/
|
||||
export async function getAuditLogs(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
userId,
|
||||
customerId,
|
||||
dataSubjectId,
|
||||
action,
|
||||
sensitivity,
|
||||
resourceType,
|
||||
resourceId,
|
||||
startDate,
|
||||
endDate,
|
||||
success,
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
} = req.query;
|
||||
|
||||
const result = await auditService.searchAuditLogs({
|
||||
userId: userId ? parseInt(userId as string) : undefined,
|
||||
customerId: customerId ? parseInt(customerId as string) : undefined,
|
||||
dataSubjectId: dataSubjectId ? parseInt(dataSubjectId as string) : undefined,
|
||||
action: action as AuditAction | undefined,
|
||||
sensitivity: sensitivity as AuditSensitivity | undefined,
|
||||
resourceType: resourceType as string | undefined,
|
||||
resourceId: resourceId as string | undefined,
|
||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||
success: success !== undefined ? success === 'true' : undefined,
|
||||
search: search as string | undefined,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 50,
|
||||
...leseFilter(req),
|
||||
page: pruefeZahl(req.query.page, 'page') || 1,
|
||||
// Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
|
||||
// Zug ziehen, an der Seitenlogik vorbei.
|
||||
limit: Math.min(pruefeZahl(req.query.limit, 'limit') || 50, 200),
|
||||
});
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
if (error instanceof FilterFehler) {
|
||||
res.status(400).json({ success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Fehler beim Abrufen der Audit-Logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
|
||||
}
|
||||
@@ -96,25 +169,8 @@ export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) {
|
||||
*/
|
||||
export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const format = (req.query.format as 'json' | 'csv') || 'json';
|
||||
const {
|
||||
action,
|
||||
sensitivity,
|
||||
resourceType,
|
||||
startDate,
|
||||
endDate,
|
||||
} = req.query;
|
||||
|
||||
const content = await auditService.exportAuditLogs(
|
||||
{
|
||||
action: action as AuditAction | undefined,
|
||||
sensitivity: sensitivity as AuditSensitivity | undefined,
|
||||
resourceType: resourceType as string | undefined,
|
||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||
},
|
||||
format
|
||||
);
|
||||
const format = req.query.format === 'csv' ? 'csv' : 'json';
|
||||
const content = await auditService.exportAuditLogs(leseFilter(req), format);
|
||||
|
||||
if (format === 'csv') {
|
||||
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
@@ -126,6 +182,10 @@ export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
||||
res.json({ success: true, data: JSON.parse(content) })
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof FilterFehler) {
|
||||
res.status(400).json({ success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Fehler beim Exportieren der Audit-Logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
|
||||
}
|
||||
@@ -149,16 +209,38 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
||||
const tampered = result.tamperedEntries.length;
|
||||
const gaps = result.chainGaps.length;
|
||||
const unexplained = result.unexplainedGaps.length;
|
||||
// Beglaubigte Alt-Luecken sind kein offener Befund mehr, verschwinden aber
|
||||
// auch nicht aus dem Bericht - sie werden eigens benannt.
|
||||
const beglaubigt = result.attestedGaps.length;
|
||||
const offeneGaps = gaps - beglaubigt;
|
||||
const offeneUnexplained = result.unexplainedGaps.filter(
|
||||
(id) => !result.attestedGaps.includes(id),
|
||||
).length;
|
||||
|
||||
const luecken = gaps > 0
|
||||
? `${gaps} strukturelle Lücken` +
|
||||
(unexplained === 0
|
||||
const luecken = offeneGaps > 0
|
||||
? `${offeneGaps} strukturelle Lücke${offeneGaps === 1 ? '' : 'n'}` +
|
||||
(offeneUnexplained === 0
|
||||
? ' (alle durch protokollierte Löschungen erklärt)'
|
||||
: unexplained < gaps
|
||||
? `, davon ${unexplained} ohne dokumentierte Löschung`
|
||||
: offeneUnexplained < offeneGaps
|
||||
? `, davon ${offeneUnexplained} ohne dokumentierte Löschung`
|
||||
: ' ohne dokumentierte Löschung')
|
||||
: '';
|
||||
|
||||
// Der Satz erscheint IMMER, wenn beglaubigte Luecken existieren - auch
|
||||
// neben einem Befund. Wer den Bericht liest, soll nie den Eindruck
|
||||
// bekommen, die Kette sei lueckenlos, wenn sie es nicht ist.
|
||||
const weitere = tampered > 0 || offeneGaps > 0 ? 'weitere ' : '';
|
||||
const beglaubigtText =
|
||||
beglaubigt === 0
|
||||
? ''
|
||||
: beglaubigt === 1
|
||||
? ` Eine ${weitere}Lücke stammt aus der Zeit vor dem Bestandssiegel und ist darin als ` +
|
||||
`Vorbefund beglaubigt (ID ${result.attestedGaps[0]}); der betroffene Eintrag selbst ` +
|
||||
'ist unverändert.'
|
||||
: ` ${beglaubigt} ${weitere}Lücken stammen aus der Zeit vor dem Bestandssiegel und sind ` +
|
||||
`darin als Vorbefund beglaubigt (IDs ${result.attestedGaps.join(', ')}); ` +
|
||||
'die betroffenen Einträge selbst sind unverändert.';
|
||||
|
||||
const unverifiable = result.unverifiableEntries.length;
|
||||
const keinSchluessel = unverifiable > 0
|
||||
? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.`
|
||||
@@ -180,9 +262,59 @@ 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 === 'kein_siegel'
|
||||
? ' Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären nicht erkennbar.'
|
||||
: '';
|
||||
: result.backlogSealStatus === 'leer'
|
||||
? ' Hinweis: Es besteht ein Bestandssiegel, das aber NICHTS umschließt – ' +
|
||||
'zum Zeitpunkt des Siegelns gab es keine unsignierten Alteinträge. Es sichert ' +
|
||||
'also nichts ab. Das ist kein Fehler, aber auch keine Zusage.'
|
||||
: result.backlogSealStatus === 'nicht_noetig'
|
||||
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
|
||||
: result.backlogSealStatus === 'kein_siegel'
|
||||
? ' Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären nicht erkennbar. ' +
|
||||
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
|
||||
: '';
|
||||
|
||||
// Eine Neuberechnung der Kette gehoert IMMER erwaehnt – auch und gerade,
|
||||
// wenn sonst alles grün ist. Nach einem Rehash ist die Kette
|
||||
// zwangslaeufig stimmig, auch ueber Loeschungen hinweg. „Lueckenlos
|
||||
// verkettet“ heisst dann nur noch „seit dem Rehash“, und wer das nicht
|
||||
// mitliest, nimmt eine Entwarnung mit, die es so nicht gibt.
|
||||
const rehashText = (() => {
|
||||
if (result.rehashes.length === 0) return '';
|
||||
const letzter = result.rehashes[result.rehashes.length - 1];
|
||||
const datum = new Date(letzter.zeitpunkt).toLocaleString('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
const wieOft =
|
||||
result.rehashes.length === 1
|
||||
? 'Die Kette wurde einmal neu berechnet'
|
||||
: `Die Kette wurde ${result.rehashes.length}× neu berechnet, zuletzt`;
|
||||
// Nur benennen, was es zu benennen gibt. Bei sauberem Vorzustand ist die
|
||||
// Auskunft „nichts uebertuencht“ selbst eine nuetzliche Information –
|
||||
// ein Rehash ueber einer unbeanstandeten Kette wiegt anders als einer
|
||||
// ueber 700 Luecken.
|
||||
const vb = letzter.vorbefund;
|
||||
const uebertuencht = !vb
|
||||
? ' Der Zustand vor dieser Neuberechnung ist nicht mehr feststellbar.'
|
||||
: vb.manipuliert === 0 && vb.luecken === 0
|
||||
? ' Die Kette war unmittelbar davor unbeanstandet – es wurde nichts überdeckt.'
|
||||
: ' Unmittelbar davor: ' +
|
||||
`${vb.manipuliert} beanstandete${vb.manipuliert === 1 ? 'r Eintrag' : ' Einträge'}` +
|
||||
` und ${vb.luecken} Lücke${vb.luecken === 1 ? '' : 'n'}. ` +
|
||||
'Diese Spuren sind seitdem nicht mehr in der Kette sichtbar, sondern nur noch im ' +
|
||||
`Vorbefund des Rehash-Eintrags (id ${letzter.id}).`;
|
||||
const ohneSignatur = result.rehashes.some((r) => !r.signiert)
|
||||
? ' Achtung: Mindestens ein Rehash-Eintrag trägt keine gültige Signatur.'
|
||||
: '';
|
||||
return (
|
||||
` ⚠ ${wieOft} am ${datum}` +
|
||||
(letzter.neuBerechnet !== null ? ` (${letzter.neuBerechnet} Einträge)` : '') +
|
||||
'. Ein Rehash verknüpft alle Einträge neu – die Aussage dieser Prüfung ' +
|
||||
'reicht deshalb nur bis dorthin zurück, nicht weiter.' +
|
||||
uebertuencht +
|
||||
ohneSignatur
|
||||
);
|
||||
})();
|
||||
|
||||
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
|
||||
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
|
||||
@@ -197,13 +329,27 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
||||
const siegelProblem =
|
||||
result.backlogSealStatus === 'gebrochen' || result.backlogSealStatus === 'entfernt';
|
||||
|
||||
// „Keine Manipulation“ darf NICHT dastehen, solange `valid` falsch ist
|
||||
// (Pentest R183-02). Nach einem Cleanup mit abgesenkter Aufbewahrung waren
|
||||
// tausende Anmeldeprotokolle endgueltig geloescht – die Luecken durch
|
||||
// Tombstones „erklaert“, die Meldung las sich beruhigend, und der Befund
|
||||
// stand nur noch im Feld `valid`. Wer die Prosa liest statt des Felds,
|
||||
// klickt genau das weg, was ihn haette warnen sollen.
|
||||
const message = tampered > 0
|
||||
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
|
||||
? `${tampered} MANIPULIERTE Einträge gefunden` + (offeneGaps > 0 ? ` (zusätzlich ${luecken})` : '')
|
||||
: siegelProblem
|
||||
? 'Die Kette selbst ist rechnerisch stimmig, ABER:'
|
||||
: gaps > 0
|
||||
? `Keine Manipulation. ${luecken} – Inhalte unverändert.`
|
||||
: 'Alle Einträge sind unverändert und lückenlos verkettet';
|
||||
: offeneGaps > 0
|
||||
? `Die Kette ist nicht mehr lückenlos: ${luecken}. Die verbliebenen Inhalte sind ` +
|
||||
'unverändert – aber gelöschte Einträge lassen sich naturgemäß nicht mehr prüfen. ' +
|
||||
'Dokumentierte Löschungen sind erwartbar; unerwartete gehören nachgegangen.'
|
||||
: beglaubigt > 0
|
||||
? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' +
|
||||
'entstanden.'
|
||||
: result.rehashes.length > 0
|
||||
? 'Alle Einträge sind unverändert und lückenlos verkettet – allerdings erst ' +
|
||||
'seit der letzten Neuberechnung.'
|
||||
: 'Alle Einträge sind unverändert und lückenlos verkettet.';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -217,8 +363,12 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
||||
chainGaps: result.chainGaps,
|
||||
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
|
||||
unexplainedGaps: result.unexplainedGaps,
|
||||
// Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt.
|
||||
attestedGaps: result.attestedGaps,
|
||||
// Protokollierte Neuberechnungen – begrenzen die Reichweite der Aussage.
|
||||
rehashes: result.rehashes,
|
||||
tampered: tampered > 0,
|
||||
message: message + keinSchluessel + siegel + mehrfach,
|
||||
message: message + beglaubigtText + rehashText + keinSchluessel + siegel + mehrfach,
|
||||
unverifiableEntries: result.unverifiableEntries,
|
||||
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||||
backlogSealStatus: result.backlogSealStatus,
|
||||
@@ -300,25 +450,90 @@ export async function getCheckpoint(req: AuthRequest, res: Response) {
|
||||
*/
|
||||
export async function sealBacklog(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
if (req.body?.confirm !== 'SEAL') {
|
||||
// Erst nachsehen, ob schon ein Siegel steht (Pentest R185-01).
|
||||
//
|
||||
// Ein zweites Siegeln ist etwas grundlegend anderes als das erste: Es
|
||||
// ERSETZT die Grundlage, gegen die Manipulation nachgewiesen wird. Wer den
|
||||
// Altbestand per Datenbankzugriff beschneidet und danach neu siegelt,
|
||||
// bekommt eine passende Wurzel und eine ueber den neuen Vorbefund
|
||||
// beglaubigte Luecke - und `valid` steht wieder auf `true`. Deshalb
|
||||
// verlangt das Ersetzen ein eigenes Wort und nicht dasselbe wie das
|
||||
// Einrichten.
|
||||
const vorher = await auditService.verifyIntegrity();
|
||||
const siegelSteht =
|
||||
vorher.backlogSealStatus === 'intakt' ||
|
||||
vorher.backlogSealStatus === 'gebrochen' ||
|
||||
vorher.backlogSealStatus === 'entfernt';
|
||||
|
||||
const erwartet = siegelSteht ? 'RESEAL' : 'SEAL';
|
||||
if (req.body?.confirm !== erwartet) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'Versiegelt den aktuellen Stand des Altbestands. Zum Bestätigen ' +
|
||||
'{"confirm":"SEAL"} mitsenden.',
|
||||
error: siegelSteht
|
||||
? 'Es besteht bereits ein Bestandssiegel. Erneutes Siegeln ERSETZT die ' +
|
||||
'bisherige Beweisgrundlage: bestehende Lücken werden neu beglaubigt und ' +
|
||||
'ein Befund am Altbestand verschwindet aus der Prüfung. Das ist kein ' +
|
||||
'Wartungsschritt. Zum Bestätigen {"confirm":"RESEAL"} mitsenden – und ' +
|
||||
'vorher klären, warum das bisherige Siegel nicht mehr passt.'
|
||||
: 'Versiegelt den aktuellen Stand des Altbestands. Zum Bestätigen ' +
|
||||
'{"confirm":"SEAL"} mitsenden.',
|
||||
aktuellerSiegelzustand: vorher.backlogSealStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await auditService.sealBacklog({
|
||||
userEmail: req.user?.email,
|
||||
ipAddress: req.ip || (req.socket as any)?.remoteAddress,
|
||||
});
|
||||
|
||||
// In den ALARMKANAL, nicht nur ins Audit-Log (Pentest R185-01).
|
||||
//
|
||||
// Die CRITICAL-Zeile im Audit-Log gab es schon - aber die muss jemand
|
||||
// lesen, und genau das ist die R183-02/R184-01-Klasse. Der automatische
|
||||
// Rueckhalt des Gegenbuchs haengt an `valid`, und `valid` ueberlebt ein
|
||||
// ersetzendes Siegel per Konstruktion. Der einzige maschinell erkennbare
|
||||
// Anker ist der Wechsel der Wurzel - der gehoert dorthin, wo etwas von
|
||||
// selbst passiert.
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'AUDIT_SEAL_CHANGED',
|
||||
severity: siegelSteht ? 'CRITICAL' : 'HIGH',
|
||||
message: siegelSteht
|
||||
? `Bestandssiegel ERSETZT (${result.sealedCount} Alteinträge, id ${result.fromId}–` +
|
||||
`${result.toId}). Die bisherige Beweisgrundlage gilt nicht mehr; der Zustand ` +
|
||||
`davor war "${vorher.backlogSealStatus}". Wenn das keine geplante Maßnahme war, ` +
|
||||
'ist es ein Befund.'
|
||||
: `Bestandssiegel erstmals gesetzt (${result.sealedCount} Alteinträge, id ` +
|
||||
`${result.fromId}–${result.toId}). Ab jetzt fallen Änderungen am Altbestand auf.`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: req.user?.userId,
|
||||
userEmail: req.user?.email,
|
||||
endpoint: ctx.endpoint,
|
||||
details: {
|
||||
ersetzt: siegelSteht,
|
||||
zustandVorher: vorher.backlogSealStatus,
|
||||
wurzelVorher: vorher.backlogSealRoot,
|
||||
wurzelNachher: result.root,
|
||||
befundVorher: {
|
||||
manipuliert: vorher.tamperedEntries,
|
||||
luecken: vorher.chainGaps,
|
||||
altbestandVeraendert: vorher.backlogTampered,
|
||||
altbestandFehlend: vorher.backlogMissing,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message:
|
||||
`${result.sealedCount} Alteinträge (id ${result.fromId}–${result.toId}) versiegelt. ` +
|
||||
'Spätere Änderungen an diesen Einträgen fallen ab sofort auf.',
|
||||
'Spätere Änderungen an diesen Einträgen fallen ab sofort auf.' +
|
||||
(siegelSteht
|
||||
? ' ACHTUNG: Dies war ein ERSETZENDES Siegel – die vorherige Beweisgrundlage ' +
|
||||
'gilt nicht mehr.'
|
||||
: ''),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Versiegeln des Altbestands:', error);
|
||||
@@ -353,6 +568,48 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
||||
}
|
||||
const { retentionDays, description, legalBasis, isActive } = req.body;
|
||||
|
||||
// Die Richtlinie bestimmt, WAS der Cleanup vernichtet – sie ist die
|
||||
// geladene Waffe, der Cleanup nur der Abzug (Pentest R183-01). Bisher war
|
||||
// ausgerechnet der Abzug gegatet und die Waffe frei zugaenglich: Ein
|
||||
// `retentionDays: 0` ging ohne Bestaetigung durch und wurde nur als MEDIUM
|
||||
// protokolliert, waehrend seine Wirkung CRITICAL ist. Die ausloesende Tat
|
||||
// war damit leiser als die Folge – und lag unter der Schwelle, bei der
|
||||
// jemand hinsieht.
|
||||
const bisher = (await auditService.getRetentionPolicies()).find((p) => p.id === id);
|
||||
if (!bisher) {
|
||||
return res.status(404).json({ success: false, error: 'Aufbewahrungsrichtlinie nicht gefunden' });
|
||||
}
|
||||
|
||||
const neueTage = typeof retentionDays === 'number' ? retentionDays : bisher.retentionDays;
|
||||
|
||||
// Untergrenze: Auth-Eintraege sind der Einbruchsbeleg schlechthin. Eine
|
||||
// Aufbewahrung von faktisch null macht sie mit dem naechsten Cleanup
|
||||
// spurlos entfernbar – ueber einen sanktionierten Pfad.
|
||||
const UNTERGRENZE: Record<string, number> = { Authentication: 30, AuditLog: 30 };
|
||||
const grenze = UNTERGRENZE[bisher.resourceType];
|
||||
if (grenze !== undefined && neueTage < grenze) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
`Für ${bisher.resourceType} sind mindestens ${grenze} Tage Aufbewahrung vorgeschrieben ` +
|
||||
`(angefragt: ${neueTage}). Diese Einträge belegen Anmeldungen und Zugriffe – eine ` +
|
||||
`Aufbewahrung nahe null macht sie beim nächsten Cleanup spurlos entfernbar.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Absenken ist die gefaehrliche Richtung und braucht dieselbe ausdrueckliche
|
||||
// Bestaetigung wie der Cleanup selbst.
|
||||
const senktAb = neueTage < bisher.retentionDays;
|
||||
if (senktAb && req.body?.confirm !== 'SHORTEN') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
`Das verkürzt die Aufbewahrung von ${bisher.retentionDays} auf ${neueTage} Tage. ` +
|
||||
'Beim nächsten Cleanup werden dadurch Einträge endgültig gelöscht, die heute noch ' +
|
||||
'da sind. Zum Bestätigen {"confirm":"SHORTEN"} mitsenden.',
|
||||
});
|
||||
}
|
||||
|
||||
const policy = await auditService.updateRetentionPolicy(id, {
|
||||
retentionDays,
|
||||
description,
|
||||
@@ -363,7 +620,15 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
|
||||
resourceId: id.toString(),
|
||||
label: `Aufbewahrungsrichtlinie aktualisiert`,
|
||||
// Absenkung wird wie ihre Folge eingestuft: CRITICAL, nicht MEDIUM.
|
||||
sensitivity: senktAb ? 'CRITICAL' : undefined,
|
||||
label: senktAb
|
||||
? `Aufbewahrung VERKÜRZT für ${bisher.resourceType}` +
|
||||
(bisher.sensitivity ? `/${bisher.sensitivity}` : '') +
|
||||
`: ${bisher.retentionDays} → ${neueTage} Tage`
|
||||
: `Aufbewahrungsrichtlinie aktualisiert`,
|
||||
before: { retentionDays: bisher.retentionDays },
|
||||
details: { retentionDays: neueTage },
|
||||
});
|
||||
|
||||
res.json({ success: true, data: policy });
|
||||
|
||||
@@ -3,7 +3,9 @@ import bcrypt from 'bcryptjs';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import * as userService from '../services/user.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { AUDIT_OPS_ROLLE } from '../services/user.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
||||
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
||||
|
||||
@@ -99,7 +101,7 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
export async function updateUser(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
// `permissions` und `password` darf der generische Update nicht
|
||||
@@ -161,9 +163,89 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
...beforeUser,
|
||||
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
||||
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
||||
hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Der Audit-Betrieb-Haken vergibt die EINGREIFENDEN Rechte am Protokoll
|
||||
// (versiegeln, neu berechnen, aufraeumen, Aufbewahrung). Ihn zu setzen ist
|
||||
// kein gewoehnliches Benutzer-Update: Er entscheidet, wer die
|
||||
// Beweisgrundlage ersetzen darf.
|
||||
//
|
||||
// Eine eigene Rechte-Huerde davorzusetzen scheitert am Henne-Ei-Problem -
|
||||
// nach der Aufteilung haelt zunaechst niemand `audit:admin`, und dann
|
||||
// koennte ihn auch niemand vergeben. Stattdessen wird die Vergabe LAUT:
|
||||
// CRITICAL im Protokoll und zusaetzlich in den Alarmkanal, so wie beim
|
||||
// Dienstkonto-Kennzeichen (R184-01).
|
||||
const setztAuditBetrieb =
|
||||
data.hasAuditOpsAccess !== undefined &&
|
||||
before !== null &&
|
||||
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
|
||||
const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true;
|
||||
|
||||
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
|
||||
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
|
||||
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
|
||||
// Routine erscheinen. Es bekommt deshalb dieselbe Behandlung wie das
|
||||
// Absenken einer Aufbewahrungsfrist – Bestaetigung, laute Protokollierung,
|
||||
// und kein Selbstbedienen.
|
||||
const setztDienstkonto =
|
||||
typeof (data as any).isServiceAccount === 'boolean' &&
|
||||
(data as any).isServiceAccount !== before?.isServiceAccount;
|
||||
const aktiviertDienstkonto = setztDienstkonto && (data as any).isServiceAccount === true;
|
||||
|
||||
// R184-02: Das Kennzeichen ist ein Audit-Governance-Eingriff, kein
|
||||
// gewoehnliches Benutzerfeld – es ist Geschwister von retention-shorten,
|
||||
// seal-backlog, rehash und cleanup, die alle `audit:admin` verlangen. Es
|
||||
// haengt aber an der Route mit `users:update`. Heute deckungsgleich, weil
|
||||
// nur die Administratorrolle beides hat – aber jede kuenftige Rolle mit
|
||||
// „Benutzer bearbeiten“ wuerde still „Login-Alarme herunterstufen“ erben.
|
||||
// Deshalb hier zusaetzlich auf die Domaene pruefen, in der der Schaden
|
||||
// liegt.
|
||||
if (setztDienstkonto && !(req.user?.permissions || []).includes('audit:admin')) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error:
|
||||
'Das Dienstkonto-Kennzeichen ändert, wie Anmeldungen im Audit-Log bewertet werden. ' +
|
||||
'Dafür genügt „Benutzer bearbeiten“ nicht – es erfordert die Berechtigung ' +
|
||||
'audit:admin, wie das Verkürzen von Aufbewahrungsfristen auch.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (setztDienstkonto && req.user?.userId === userId) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error:
|
||||
'Das Dienstkonto-Kennzeichen lässt sich nicht am eigenen Konto setzen oder entfernen. ' +
|
||||
'Es stuft Anmeldungen dieses Kontos als Routine ein – wer das für sich selbst täte, ' +
|
||||
'könnte die eigenen Anmeldungen unauffällig machen. Bitte von einem anderen ' +
|
||||
'Administrator vornehmen lassen.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gate in BEIDE Richtungen (Pentest R184-01). Das Entfernen war ungegatet –
|
||||
// und es ist der gefaehrlichere Weg: Ein un-geflaggtes Konto faellt aus der
|
||||
// Heartbeat-Wache, weil diese am Live-Kennzeichen haengt. Genau das
|
||||
// „stilllegen und auf Stille setzen“, gegen das der Wachhund gebaut wurde.
|
||||
if (setztDienstkonto && req.body?.confirm !== 'SERVICE_ACCOUNT') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: aktiviertDienstkonto
|
||||
? `Damit werden künftige Anmeldungen von ${before?.email ?? 'diesem Konto'} im ` +
|
||||
'Audit-Log als Routine geführt statt als kritisches Ereignis. Das ist für ' +
|
||||
'planmäßig arbeitende Dienste gedacht (etwa das Gegenbuch) – für ein Konto, das ' +
|
||||
'ein Mensch benutzt, wäre es eine Tarnung. ' +
|
||||
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.'
|
||||
: `Damit fällt ${before?.email ?? 'dieses Konto'} aus der Überwachung heraus: Es wird ` +
|
||||
'dann nicht mehr gemeldet, wenn es sich nicht mehr anmeldet. Bei einem laufenden ' +
|
||||
'Dienst wie dem Gegenbuch hieße das, sein Ausfall bliebe unbemerkt. ' +
|
||||
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.updateUser(userId, data as any);
|
||||
if (user) {
|
||||
// Audit: Geänderte Felder ermitteln und loggen
|
||||
@@ -172,6 +254,8 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
const fieldLabels: Record<string, string> = {
|
||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
||||
hasAuditOpsAccess: 'Audit-Betrieb (versiegeln, aufräumen, Aufbewahrung)',
|
||||
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
||||
};
|
||||
for (const [key, newVal] of Object.entries(data)) {
|
||||
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
||||
@@ -191,9 +275,63 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'User',
|
||||
resourceId: user.id.toString(),
|
||||
label: changeList ? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}` : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||
label: setztDienstkonto
|
||||
? `Dienstkonto-Kennzeichen ${aktiviertDienstkonto ? 'GESETZT' : 'entfernt'} für ` +
|
||||
`${user.email} – Anmeldungen werden künftig ` +
|
||||
`${aktiviertDienstkonto ? 'als Routine' : 'wieder als kritisch'} geführt`
|
||||
: changeList
|
||||
? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}`
|
||||
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
||||
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
||||
sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined,
|
||||
details: Object.keys(changes).length > 0 ? changes : undefined,
|
||||
});
|
||||
|
||||
// Zusaetzlich in den Alarmkanal (Pentest R184-01). Eine CRITICAL-Zeile
|
||||
// im Audit-Log muss jemand LESEN – das ist die R183-02-Klasse. Das
|
||||
// Entfernen des Kennzeichens nimmt das Konto aus der Heartbeat-Wache;
|
||||
// ab dann faellt sein Ausbleiben nicht mehr auf. Diese Aenderung
|
||||
// gehoert deshalb dorthin, wo automatisch reagiert wird.
|
||||
if (setztDienstkonto) {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'PERMISSION_CHANGED',
|
||||
severity: 'CRITICAL',
|
||||
message: aktiviertDienstkonto
|
||||
? `Konto ${user.email} als Dienstkonto markiert – seine Anmeldungen gelten ab ` +
|
||||
'jetzt als Routine statt als kritisches Ereignis.'
|
||||
: `Konto ${user.email} ist KEIN Dienstkonto mehr – es fällt damit aus der ` +
|
||||
'Überwachung heraus: Ein Ausbleiben seiner Anmeldungen wird nicht mehr gemeldet.',
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: req.user?.userId,
|
||||
userEmail: req.user?.email,
|
||||
endpoint: ctx.endpoint,
|
||||
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
|
||||
});
|
||||
}
|
||||
|
||||
if (setztAuditBetrieb) {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'PERMISSION_CHANGED',
|
||||
severity: 'CRITICAL',
|
||||
message: aktiviertAuditBetrieb
|
||||
? `Konto ${user.email} darf ab jetzt am Audit-Protokoll EINGREIFEN: versiegeln, ` +
|
||||
'neu berechnen, aufräumen, Aufbewahrung ändern. Damit kann es die Grundlage ' +
|
||||
'ersetzen, gegen die Manipulation nachgewiesen wird.'
|
||||
: `Konto ${user.email} darf nicht mehr am Audit-Protokoll eingreifen.`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: req.user?.userId,
|
||||
userEmail: req.user?.email,
|
||||
endpoint: ctx.endpoint,
|
||||
details: {
|
||||
betroffenesKonto: user.email,
|
||||
aktiviert: aktiviertAuditBetrieb,
|
||||
selbstvergabe: user.id === req.user?.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'User',
|
||||
|
||||
+70
-54
@@ -1,4 +1,4 @@
|
||||
import express from 'express';
|
||||
import express, { Router } from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
@@ -69,8 +69,11 @@ import { startContractStatusScheduler } from './services/contractStatusScheduler
|
||||
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||
import { registriereIdPruefung } from './middleware/routeIds.js';
|
||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||
import { auditMiddleware } from './middleware/audit.js';
|
||||
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
||||
import { pruefePflichtrechte } from './services/pflichtrechte.service.js';
|
||||
import { authenticate } from './middleware/auth.js';
|
||||
|
||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||
@@ -325,69 +328,76 @@ app.use('/api', (_req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
||||
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
||||
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
||||
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
|
||||
// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
|
||||
// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` – also `6abc`,
|
||||
// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
|
||||
// Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die
|
||||
// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes".
|
||||
//
|
||||
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
|
||||
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` –
|
||||
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
|
||||
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
|
||||
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
|
||||
// nicht geblockt.
|
||||
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
|
||||
app.use('/api', (req, res, next) => {
|
||||
for (const seg of req.path.split('/')) {
|
||||
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
// Genau das loest `mounte()` weiter unten – die Pruefung wird an jedem Router
|
||||
// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
|
||||
// zwar in beide Richtungen:
|
||||
//
|
||||
// – Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
|
||||
// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
|
||||
// ID-Parameter und laesst nur kanonische Zahlen zu.
|
||||
// – Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
|
||||
// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
|
||||
//
|
||||
// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt – zwei Antworten
|
||||
// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
|
||||
|
||||
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
||||
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
|
||||
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
|
||||
app.use('/api', apiBackstopRateLimiter);
|
||||
|
||||
// Router einhängen – IMMER über `mounte`, nie über `app.use` direkt.
|
||||
//
|
||||
// `mounte` haengt die zentrale Pruefung numerischer Pfad-Parameter an (R188)
|
||||
// und montiert danach. Wer hier kuenftig `app.use` schreibt, umgeht sie
|
||||
// stillschweigend – deshalb steht die Pruefung im selben Handgriff wie das
|
||||
// Einhaengen und nicht in einer zweiten Liste, die man vergessen kann.
|
||||
const mounte = (pfad: string, router: Router): void => {
|
||||
app.use(pfad, registriereIdPruefung(router));
|
||||
};
|
||||
|
||||
// Öffentliche Routes (OHNE Authentifizierung)
|
||||
app.use('/api/public/consent', consentPublicRoutes);
|
||||
mounte('/api/public/consent', consentPublicRoutes);
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/customers', customerRoutes);
|
||||
app.use('/api/addresses', addressRoutes);
|
||||
app.use('/api/bank-cards', bankcardRoutes);
|
||||
app.use('/api/documents', documentRoutes);
|
||||
app.use('/api/meters', meterRoutes);
|
||||
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||
app.use('/api/contracts', contractRoutes);
|
||||
app.use('/api/credit-notes', creditNoteRoutes);
|
||||
app.use('/api/company-profile', companyProfileRoutes);
|
||||
app.use('/api/platforms', platformRoutes);
|
||||
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||
app.use('/api/contract-durations', contractDurationRoutes);
|
||||
app.use('/api/providers', providerRoutes);
|
||||
app.use('/api/tariffs', tariffRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
app.use('/api/developer', developerRoutes);
|
||||
app.use('/api/contract-categories', contractCategoryRoutes);
|
||||
app.use('/api', contractTaskRoutes);
|
||||
app.use('/api/settings', appSettingRoutes);
|
||||
app.use('/api/email-providers', emailProviderRoutes);
|
||||
app.use('/api', cachedEmailRoutes);
|
||||
app.use('/api/energy-details', invoiceRoutes);
|
||||
app.use('/api', contractHistoryRoutes);
|
||||
app.use('/api/audit-logs', auditLogRoutes);
|
||||
app.use('/api/gdpr', gdprRoutes);
|
||||
app.use('/api/email-logs', emailLogRoutes);
|
||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
||||
app.use('/api/birthdays', birthdayRoutes);
|
||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
||||
app.use('/api/monitoring', monitoringRoutes);
|
||||
mounte('/api/auth', authRoutes);
|
||||
mounte('/api/customers', customerRoutes);
|
||||
mounte('/api/addresses', addressRoutes);
|
||||
mounte('/api/bank-cards', bankcardRoutes);
|
||||
mounte('/api/documents', documentRoutes);
|
||||
mounte('/api/meters', meterRoutes);
|
||||
mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||
mounte('/api/contracts', contractRoutes);
|
||||
mounte('/api/credit-notes', creditNoteRoutes);
|
||||
mounte('/api/company-profile', companyProfileRoutes);
|
||||
mounte('/api/platforms', platformRoutes);
|
||||
mounte('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||
mounte('/api/contract-durations', contractDurationRoutes);
|
||||
mounte('/api/providers', providerRoutes);
|
||||
mounte('/api/tariffs', tariffRoutes);
|
||||
mounte('/api/users', userRoutes);
|
||||
mounte('/api/upload', uploadRoutes);
|
||||
mounte('/api/developer', developerRoutes);
|
||||
mounte('/api/contract-categories', contractCategoryRoutes);
|
||||
mounte('/api', contractTaskRoutes);
|
||||
mounte('/api/settings', appSettingRoutes);
|
||||
mounte('/api/email-providers', emailProviderRoutes);
|
||||
mounte('/api', cachedEmailRoutes);
|
||||
mounte('/api/energy-details', invoiceRoutes);
|
||||
mounte('/api', contractHistoryRoutes);
|
||||
mounte('/api/audit-logs', auditLogRoutes);
|
||||
mounte('/api/gdpr', gdprRoutes);
|
||||
mounte('/api/email-logs', emailLogRoutes);
|
||||
mounte('/api/pdf-templates', pdfTemplateRoutes);
|
||||
mounte('/api/birthdays', birthdayRoutes);
|
||||
mounte('/api/factory-defaults', factoryDefaultsRoutes);
|
||||
mounte('/api/monitoring', monitoringRoutes);
|
||||
|
||||
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
||||
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
||||
@@ -487,6 +497,12 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N
|
||||
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
||||
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
||||
|
||||
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
||||
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
||||
starteHeartbeatMonitor();
|
||||
// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01).
|
||||
void pruefePflichtrechte();
|
||||
|
||||
app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||
|
||||
@@ -167,6 +167,21 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
TOKEN_REFRESH: 'Sitzung verlängert',
|
||||
};
|
||||
|
||||
/**
|
||||
* Ist das die planmaessige Anmeldung eines Dienstkontos? (Pentest R182)
|
||||
*
|
||||
* Vorhersagbare Ereignisse sind miserable Alarme und exzellente Grundlinien.
|
||||
* Das Gegenbuch meldet sich stuendlich an – als CRITICAL gefuehrt trainiert
|
||||
* genau diese Routine den Betreiber darauf, die hoechste Stufe wegzuklicken,
|
||||
* und der erste ECHTE Vorfall erbt den Reflex. Der Eintrag bleibt (man soll
|
||||
* sehen, dass das Gegenbuch arbeitet), aber als Routine.
|
||||
*/
|
||||
function istDienstkontoAnmeldung(responseBody: unknown, success: boolean): boolean {
|
||||
if (!success || !responseBody || typeof responseBody !== 'object') return false;
|
||||
const data = (responseBody as { data?: { user?: { isServiceAccount?: boolean } } }).data;
|
||||
return data?.user?.isServiceAccount === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unterscheidet die drei Ausgaenge von POST /auth/refresh (Pentest R166-03).
|
||||
*
|
||||
@@ -219,6 +234,9 @@ function generateHumanLabel(
|
||||
// Auth
|
||||
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
|
||||
const email = req.body?.email || '';
|
||||
if (action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, true)) {
|
||||
return `Dienstkonto ${email} angemeldet (planmäßig)`;
|
||||
}
|
||||
return action === 'LOGIN'
|
||||
? `Benutzer ${email} hat sich angemeldet`
|
||||
: `Anmeldung fehlgeschlagen für ${email}`;
|
||||
@@ -486,7 +504,9 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
||||
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
|
||||
sensitivity: action === 'TOKEN_REFRESH'
|
||||
? (refreshOutcome(responseBody, responseSuccess) === 'rejected' ? 'HIGH' : 'LOW')
|
||||
: undefined,
|
||||
: action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, responseSuccess)
|
||||
? 'LOW'
|
||||
: undefined,
|
||||
resourceType: mapping.type,
|
||||
resourceId,
|
||||
resourceLabel,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -16,7 +16,17 @@ router.use(authenticate);
|
||||
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
|
||||
|
||||
// Audit-Logs exportieren
|
||||
router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs);
|
||||
//
|
||||
// Verlangt `audit:export`, nicht `audit:read`. Die Berechtigung stand im
|
||||
// Katalog und in der Rollenverwaltung, gatete aber NICHTS - jeder Leser
|
||||
// konnte das vollstaendige Protokoll in einem Zug herausziehen.
|
||||
//
|
||||
// Das ist etwas anderes als Blaettern: Der Export liefert `changesBefore` und
|
||||
// `changesAfter`, also die vollstaendigen Vorher/Nachher-Datensaetze, dazu
|
||||
// `resourceLabel` mit Klartextnamen, IP-Adressen und User-Agents. Aufgefallen
|
||||
// am Dienstkonto des Gegenbuchs: Es soll ausschliesslich Pruefwerte lesen -
|
||||
// und konnte Personendaten exportieren.
|
||||
router.get('/export', requirePermission('audit:export'), auditLogController.exportAuditLogs);
|
||||
|
||||
// Retention-Policies
|
||||
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
|
||||
|
||||
@@ -15,6 +15,12 @@ export async function logChange(opts: {
|
||||
label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen"
|
||||
details?: Record<string, unknown>; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' }
|
||||
customerId?: number;
|
||||
/// Ueberschreibt die aus dem Ressourcentyp abgeleitete Stufe. Noetig, wenn
|
||||
/// eine Aktion gefaehrlicher ist als ihr Typ vermuten laesst - etwa das
|
||||
/// Verkuerzen einer Aufbewahrungsfrist, dessen Folge CRITICAL ist
|
||||
/// (Pentest R183-01).
|
||||
sensitivity?: AuditSensitivity;
|
||||
before?: Record<string, unknown>;
|
||||
}) {
|
||||
try {
|
||||
const user = opts.req?.user;
|
||||
@@ -32,6 +38,8 @@ export async function logChange(opts: {
|
||||
httpMethod: opts.req?.method || '',
|
||||
ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown',
|
||||
dataSubjectId: opts.customerId,
|
||||
sensitivity: opts.sensitivity,
|
||||
changesBefore: opts.before,
|
||||
changesAfter: opts.details,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -865,7 +873,11 @@ export async function sealBacklog(
|
||||
export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
valid: boolean;
|
||||
checkedCount: number;
|
||||
/** Alle beanstandeten Zeilen (tampered + chainGaps) – Abwaertskompatibilitaet. */
|
||||
/**
|
||||
* Alle noch offenen Beanstandungen: veraenderte Zeilen plus Luecken, die
|
||||
* NICHT vom Bestandssiegel beglaubigt sind. Nur an dieser Liste haengt
|
||||
* `valid` – beglaubigte Alt-Luecken stehen in `attestedGaps`.
|
||||
*/
|
||||
invalidEntries: number[];
|
||||
/**
|
||||
* ERNST: Der Inhalt der Zeile passt nicht mehr zu ihrem Hash – jemand hat
|
||||
@@ -890,12 +902,37 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
* eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log.
|
||||
*/
|
||||
unverifiableEntries: number[];
|
||||
/**
|
||||
* Teilmenge von `chainGaps`, die beim Versiegeln des Altbestands bereits
|
||||
* bestand und im signierten Siegel-Marker als Vorbefund festgehalten ist.
|
||||
* Diese Luecken sind BEGLAUBIGT: sie bleiben sichtbar, kippen `valid` aber
|
||||
* nicht mehr (siehe ausfuehrliche Begruendung an der Berechnung unten).
|
||||
*/
|
||||
attestedGaps: number[];
|
||||
/**
|
||||
* Protokollierte Neuberechnungen der Kette (Rehash), aelteste zuerst.
|
||||
*
|
||||
* Ein Rehash verknuepft alle Eintraege neu. Danach ist die Kette
|
||||
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg, die vorher als
|
||||
* Luecken sichtbar waren. „Lueckenlos verkettet“ heisst nach einem Rehash
|
||||
* also nur noch: seit dem Rehash. Wer das nicht mitliest, nimmt eine
|
||||
* Entwarnung mit, die es nicht gibt.
|
||||
*/
|
||||
rehashes: Array<{
|
||||
id: number;
|
||||
zeitpunkt: string;
|
||||
neuBerechnet: number | null;
|
||||
/** Marker mit gueltiger HMAC-Signatur? Siehe Kommentar an der Auswertung. */
|
||||
signiert: boolean;
|
||||
/** Befund unmittelbar VOR dem Rehash - was also uebertuencht wurde. */
|
||||
vorbefund: { manipuliert: number; luecken: number } | null;
|
||||
}>;
|
||||
/**
|
||||
* Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||||
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
|
||||
* gueltiger Marker mehr – der Anker wurde herausgeloest (Pentest R174-01).
|
||||
*/
|
||||
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt';
|
||||
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. */
|
||||
@@ -1025,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[] = [];
|
||||
@@ -1040,7 +1139,9 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
// ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01).
|
||||
const backlogTampered: number[] = [];
|
||||
const backlogMissing: number[] = [];
|
||||
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' = 'kein_siegel';
|
||||
// Luecken, die das Siegel als bereits vorhanden beglaubigt (siehe unten).
|
||||
const beglaubigteLuecken = new Set<number>();
|
||||
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
|
||||
let backlogSealRoot: string | null = null;
|
||||
|
||||
const siegelKandidaten = await prisma.auditLog.findMany({
|
||||
@@ -1084,8 +1185,25 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
// Deshalb gilt jetzt: Blaetter vorhanden, aber kein gueltiger Marker =
|
||||
// Siegel ENTFERNT und damit ein Befund – nicht „nie versiegelt“.
|
||||
const blattAnzahl = await prisma.auditBacklogSeal.count();
|
||||
const unsignierteAnzahl = await prisma.auditLog.count({ where: { hashVersion: { lt: 3 } } });
|
||||
if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) {
|
||||
backlogSealStatus = 'entfernt';
|
||||
} else if (!siegel && blattAnzahl === 0 && unsignierteAnzahl === 0) {
|
||||
// Es gibt gar keinen Altbestand: Entweder ist alles signiert, oder das Log
|
||||
// beginnt erst mit der Signierung. Dann ist „nicht versiegelt“ kein Mangel
|
||||
// (Pentest R183-03) – die bisherige Warnung liess sich nicht aufloesen,
|
||||
// weil seal-backlog zu Recht ablehnte. Eine Warnung, die der Betreiber
|
||||
// nicht beheben kann, lernt er zu ignorieren.
|
||||
//
|
||||
// Die Bedingung haengt bewusst an der Zahl der UNSIGNIERTEN Zeilen und
|
||||
// nicht mehr an `v3FromId === null`. Letzteres bedeutet naemlich das
|
||||
// genaue Gegenteil: dass ueberhaupt nichts signiert ist – etwa weil
|
||||
// AUDIT_HMAC_KEY fehlt. Ein vollstaendig unsigniertes Log meldete damit
|
||||
// „Bestandssiegel nicht noetig“, also Entwarnung fuer genau den Zustand
|
||||
// mit der geringsten Beweiskraft. Jetzt bleibt es bei „kein_siegel“; die
|
||||
// Fehlermeldung von seal-backlog nennt den fehlenden Schluessel als
|
||||
// naechsten Schritt, die Warnung ist also aufloesbar.
|
||||
backlogSealStatus = 'nicht_noetig';
|
||||
}
|
||||
|
||||
if (siegel) {
|
||||
@@ -1125,12 +1243,88 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
backlogTampered.length === 0 && backlogMissing.length === 0 && wurzelJetzt === meta.root
|
||||
? 'intakt'
|
||||
: 'gebrochen';
|
||||
|
||||
// Ein Siegel ueber NULL Blaettern ist rechnerisch tadellos und schuetzt
|
||||
// nichts: es gab zum Zeitpunkt des Siegelns keinen Altbestand. „intakt“
|
||||
// zu melden waere formal richtig und trotzdem irrefuehrend - es liest
|
||||
// sich als Schutzzusage. Der Pentester hat Stagings Leersiegel genau so
|
||||
// missverstanden, und das ist der rote Faden im Kleinen: ein Signal, das
|
||||
// beruhigt, wo nichts abgesichert ist. Eigener Zustand, gleiche Wertung
|
||||
// wie „nicht noetig“ - kein Befund, aber auch keine Zusage.
|
||||
if (backlogSealStatus === 'intakt' && blaetter.length === 0) {
|
||||
backlogSealStatus = 'leer';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Beglaubigte Alt-Luecken
|
||||
//
|
||||
// Die Race-Luecken aus der Zeit vor dem Sperr-Fix lassen sich nicht
|
||||
// mehr heilen: die Verkettung ist gebrochen, die Inhalte sind aber
|
||||
// unversehrt. Wuerden sie `valid` dauerhaft auf false halten, meldete
|
||||
// das Gegenbuch stuendlich Alarm, ohne dass es je etwas zu tun gaebe -
|
||||
// und genau daran stirbt jede Warnung. Ein Signal, das immer schreit,
|
||||
// warnt nicht mehr.
|
||||
//
|
||||
// Deshalb gilt eine Luecke als BEGLAUBIGT, wenn beides zutrifft:
|
||||
// 1. sie liegt im versiegelten Bereich (id <= toId), und
|
||||
// 2. sie steht im Vorbefund des Siegel-Markers, also im Zustand, den
|
||||
// der Betreiber beim Siegeln ausdruecklich festgeschrieben hat.
|
||||
// Der Vorbefund liegt in `changesBefore` und ist ab Version 3 mit-
|
||||
// gehasht - die Liste laesst sich also ohne Schluessel nicht nachtraeg-
|
||||
// lich erweitern (gleiche Absicherung wie beim Manifest, R171-01).
|
||||
//
|
||||
// Beglaubigt heisst NICHT verschwunden: die Luecken bleiben in
|
||||
// `chainGaps` und werden weiter berichtet. Sie zaehlen nur nicht mehr
|
||||
// als offener Befund. Alles andere schlaegt unveraendert an - eine
|
||||
// NEUE Luecke, eine veraenderte oder entfernte Altzeile, ein gebroche-
|
||||
// nes Siegel. Bei nicht intaktem Siegel wird gar nichts beglaubigt.
|
||||
if (backlogSealStatus === 'intakt') {
|
||||
try {
|
||||
const roh = siegel.changesEncrypted
|
||||
? decrypt(siegel.changesBefore || '')
|
||||
: siegel.changesBefore || '{}';
|
||||
const vorbefund = JSON.parse(roh);
|
||||
for (const id of vorbefund?.befund?.ketten_luecken || []) {
|
||||
if (typeof id === 'number' && id <= bis) beglaubigteLuecken.add(id);
|
||||
}
|
||||
} catch {
|
||||
// Unlesbarer Vorbefund beglaubigt nichts - die sichere Richtung.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
backlogSealStatus = 'gebrochen';
|
||||
}
|
||||
}
|
||||
tamperedEntries.push(...backlogTampered, ...backlogMissing);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Fehlender ANFANG des Protokolls.
|
||||
//
|
||||
// Die Verkettung wird zeilenweise gegen die Vorgaengerin geprueft - die
|
||||
// erste Zeile hat keine, also fiel bisher NICHTS auf, wenn ein
|
||||
// zusammenhaengender Anfang entfernt wurde. Kein Kettenbruch, kein Befund,
|
||||
// `valid` blieb gruen. Das ist die stillste Loeschung von allen: Wer die
|
||||
// aeltesten Eintraege loswerden will, muss nur vorne anfangen.
|
||||
//
|
||||
// Erkennbar ist es trotzdem: Die allererste Zeile eines Protokolls wird ohne
|
||||
// Vorgaenger geschrieben und traegt deshalb einen leeren `previousHash`.
|
||||
// Traegt die erste vorhandene Zeile einen Wert, hat es eine Vorgaengerin
|
||||
// gegeben - und die ist weg.
|
||||
//
|
||||
// Nur bei Pruefung des GESAMTEN Bereichs; bei `fromId` ist ein gefuellter
|
||||
// `previousHash` selbstverstaendlich und kein Befund.
|
||||
if (fromId === undefined && logs.length > 0 && logs[0].previousHash) {
|
||||
chainGaps.push(logs[0].id);
|
||||
// Ein protokolliertes Loeschungs-Manifest erklaert auch diese Luecke; die
|
||||
// Pruefung laeuft ueber denselben Weg wie bei jeder anderen.
|
||||
if (!gapErklaert(0, logs[0].id)) {
|
||||
unexplainedGaps.push(logs[0].id);
|
||||
if (erwarteteVersion(logs[0].id) === 3) {
|
||||
tamperedEntries.push(logs[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
const log = logs[i];
|
||||
|
||||
@@ -1237,13 +1431,22 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
|
||||
// Eskalierte Luecken stehen sowohl in tamperedEntries als auch in chainGaps;
|
||||
// ohne Entdopplung zaehlte dieselbe Zeile zweimal (Pentest R171-03).
|
||||
const invalidEntries = [...new Set([...tamperedEntries, ...chainGaps])].sort((a, b) => a - b);
|
||||
//
|
||||
// Beglaubigte Alt-Luecken zaehlen NICHT als offener Befund (siehe die
|
||||
// Begruendung an `beglaubigteLuecken`). Sie bleiben in `chainGaps`
|
||||
// sichtbar und stehen zusaetzlich in `attestedGaps`.
|
||||
const attestedGaps = chainGaps.filter((id) => beglaubigteLuecken.has(id));
|
||||
const offeneLuecken = chainGaps.filter((id) => !beglaubigteLuecken.has(id));
|
||||
const invalidEntries = [...new Set([...tamperedEntries, ...offeneLuecken])].sort((a, b) => a - b);
|
||||
|
||||
// Ein gebrochenes oder entferntes Siegel muss `valid` kippen, auch wenn keine
|
||||
// einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust
|
||||
// unsichtbar (R174-01).
|
||||
const siegelInOrdnung =
|
||||
backlogSealStatus === 'intakt' || backlogSealStatus === 'kein_siegel';
|
||||
backlogSealStatus === 'intakt' ||
|
||||
backlogSealStatus === 'leer' ||
|
||||
backlogSealStatus === 'kein_siegel' ||
|
||||
backlogSealStatus === 'nicht_noetig';
|
||||
|
||||
return {
|
||||
valid: invalidEntries.length === 0 && siegelInOrdnung,
|
||||
@@ -1253,6 +1456,8 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
chainGaps,
|
||||
unexplainedGaps,
|
||||
unverifiableEntries,
|
||||
attestedGaps,
|
||||
rehashes,
|
||||
backlogSealStatus,
|
||||
backlogTampered,
|
||||
backlogMissing,
|
||||
|
||||
@@ -333,6 +333,9 @@ export async function login(email: string, password: string) {
|
||||
permissions: Array.from(permissions),
|
||||
customerId: user.customerId,
|
||||
isCustomerPortal: false,
|
||||
// Wird von der Audit-Middleware gelesen, um planmaessige
|
||||
// Dienstkonto-Anmeldungen als Routine einzustufen (R182).
|
||||
isServiceAccount: user.isServiceAccount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
|
||||
|
||||
/**
|
||||
* Wachhund auf den AUSBLEIBENDEN Heartbeat (Pentest R182/R183).
|
||||
*
|
||||
* Dienstkonten – allen voran das Gegenbuch – melden sich in festem Takt an.
|
||||
* Diese Anmeldungen sind bewusst als Routine eingestuft, damit sie die
|
||||
* CRITICAL-Stufe nicht entwerten. Damit fehlt aber die andere Haelfte: Wer das
|
||||
* Gegenbuch stilllegt, setzt genau darauf, dass STILLE nicht auffaellt. Ein
|
||||
* gestopptes Gegenbuch ist sonst nicht von „laeuft ruhig“ zu unterscheiden.
|
||||
*
|
||||
* Das Sicherheitssignal ist deshalb nicht die Anwesenheit der Anmeldung,
|
||||
* sondern ihre Abweichung: bleibt sie aus, wird es gemeldet.
|
||||
*
|
||||
* Bewusste Zurueckhaltung: Ohne jemals gesehene Anmeldung gibt es keine
|
||||
* Grundlinie – dann wird geschwiegen statt geraten. Und pro Ausfall wird nur
|
||||
* einmal gemeldet, nicht bei jedem Durchlauf.
|
||||
*/
|
||||
const PRUEFTAKT_MS = 15 * 60 * 1000;
|
||||
const MAX_ALTER_MINUTEN = Number.parseInt(process.env.SERVICE_ACCOUNT_MAX_SILENCE_MINUTES || '180', 10);
|
||||
|
||||
export async function pruefeHeartbeats(): Promise<void> {
|
||||
const konten = await prisma.user.findMany({
|
||||
where: { isServiceAccount: true, isActive: true },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
if (konten.length === 0) return;
|
||||
|
||||
const grenze = new Date(Date.now() - MAX_ALTER_MINUTEN * 60 * 1000);
|
||||
|
||||
for (const konto of konten) {
|
||||
const letzte = await prisma.auditLog.findFirst({
|
||||
where: { userId: konto.id, action: 'LOGIN' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
|
||||
// Nie angemeldet = keine Grundlinie. Schweigen statt raten.
|
||||
if (!letzte) continue;
|
||||
if (letzte.createdAt >= grenze) continue;
|
||||
|
||||
// Pro Ausfall nur einmal melden.
|
||||
const schonGemeldet = await prisma.securityEvent.findFirst({
|
||||
where: {
|
||||
type: 'SUSPICIOUS',
|
||||
userEmail: konto.email,
|
||||
createdAt: { gte: letzte.createdAt },
|
||||
message: { contains: 'Dienstkonto' },
|
||||
},
|
||||
});
|
||||
if (schonGemeldet) continue;
|
||||
|
||||
const stillSeit = Math.round((Date.now() - letzte.createdAt.getTime()) / 60000);
|
||||
await emitSecurityEvent({
|
||||
type: 'SUSPICIOUS',
|
||||
severity: 'CRITICAL',
|
||||
message:
|
||||
`Dienstkonto ${konto.email} meldet sich seit ${stillSeit} Minuten nicht mehr ` +
|
||||
`(erwartet mindestens alle ${MAX_ALTER_MINUTEN} Minuten). ` +
|
||||
'Entweder steht der zugehörige Dienst – etwa das Gegenbuch – oder er wurde stillgelegt. ' +
|
||||
'Stille ist hier kein guter Zustand: Ohne laufendes Gegenbuch fällt eine nachträgliche ' +
|
||||
'Änderung am Audit-Log nicht mehr auf.',
|
||||
userEmail: konto.email,
|
||||
userId: konto.id,
|
||||
endpoint: 'heartbeat-monitor',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function starteHeartbeatMonitor(): void {
|
||||
if (!Number.isFinite(MAX_ALTER_MINUTEN) || MAX_ALTER_MINUTEN <= 0) {
|
||||
console.warn('[Heartbeat] SERVICE_ACCOUNT_MAX_SILENCE_MINUTES ungültig – Wachhund bleibt aus.');
|
||||
return;
|
||||
}
|
||||
const lauf = () => {
|
||||
pruefeHeartbeats().catch((e) => console.error('[Heartbeat] Fehler:', e));
|
||||
};
|
||||
// Nicht sofort beim Start: Nach einem Neustart darf ein kurz zurueckliegender
|
||||
// Ausfall nicht doppelt melden, und die DB soll erst oben sein.
|
||||
setTimeout(lauf, 60_000);
|
||||
setInterval(lauf, PRUEFTAKT_MS).unref();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import prisma from '../lib/prisma.js';
|
||||
|
||||
/**
|
||||
* Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand
|
||||
* ausueben kann (Pentest R189-01).
|
||||
*
|
||||
* Hintergrund: `gdpr:export`, `gdpr:delete` und `gdpr:admin` haengen an den
|
||||
* Rollen DSGVO und Developer – nicht an der Admin-Rolle. Nach einem frischen
|
||||
* Seed war keine der beiden einem Konto zugewiesen. Ergebnis: Eine Auskunft
|
||||
* nach Art. 15 oder eine Loeschung nach Art. 17 konnte NIEMAND ausfuehren,
|
||||
* obwohl mehrere „Admin"-Konten existierten. Ein Ausfall mit Fristwirkung,
|
||||
* ausgeloest durch nichts weiter als eine Neuinstallation.
|
||||
*
|
||||
* Der Seed weist das Recht jetzt zu – aber nur bei NEUINSTALLATION. Auf einer
|
||||
* laufenden Datenbank aendert er nichts, und niemand merkt es, bis es darauf
|
||||
* ankommt. Deshalb diese Wache: Sie erkennt die ABWESENHEIT einer Faehigkeit,
|
||||
* so wie der Heartbeat das Ausbleiben eines Dienstkontos erkennt.
|
||||
*
|
||||
* Bewusst nur eine Meldung, keine automatische Vergabe: Rechte zu verteilen,
|
||||
* ohne dass ein Mensch es veranlasst hat, waere der groessere Fehler.
|
||||
*/
|
||||
|
||||
/** Rechte, deren Fehlen ein rechtliches und kein technisches Problem ist. */
|
||||
const PFLICHTRECHTE: Array<{ resource: string; action: string; wofuer: string }> = [
|
||||
{ resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' },
|
||||
{ resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' },
|
||||
{ resource: 'audit', action: 'read', wofuer: 'Prüfung des Audit-Protokolls' },
|
||||
];
|
||||
|
||||
export async function pruefePflichtrechte(): Promise<void> {
|
||||
try {
|
||||
const fehlend: string[] = [];
|
||||
|
||||
for (const recht of PFLICHTRECHTE) {
|
||||
const traeger = await prisma.user.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
roles: {
|
||||
some: {
|
||||
role: {
|
||||
permissions: {
|
||||
some: {
|
||||
permission: { resource: recht.resource, action: recht.action },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (traeger === 0) {
|
||||
fehlend.push(`${recht.resource}:${recht.action} (${recht.wofuer})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fehlend.length === 0) return;
|
||||
|
||||
console.warn(
|
||||
'\n' +
|
||||
'========================================================================\n' +
|
||||
' ACHTUNG: Für folgende Rechte gibt es KEIN aktives Konto:\n' +
|
||||
fehlend.map((f) => ` – ${f}\n`).join('') +
|
||||
'\n' +
|
||||
' Das ist kein Fehler der Anwendung, sondern eine Lücke in der\n' +
|
||||
' Rechtevergabe – und sie fällt erst auf, wenn eine Frist läuft.\n' +
|
||||
'\n' +
|
||||
' Beheben: In der Benutzerverwaltung bei einem verantwortlichen Konto\n' +
|
||||
' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' +
|
||||
' Datenschutz-Verwaltung). Für Eingriffe am Protokoll – versiegeln,\n' +
|
||||
' aufräumen, Aufbewahrung ändern – zusätzlich „Audit-Betrieb".\n' +
|
||||
'========================================================================\n',
|
||||
);
|
||||
} catch (err) {
|
||||
// Eine Wache darf den Start nicht verhindern. Aber schweigen darf sie
|
||||
// auch nicht: „nicht geprüft" ist nicht dasselbe wie „nichts gefunden".
|
||||
console.warn(
|
||||
'[Pflichtrechte] Prüfung nicht möglich – der Zustand der Rechtevergabe ist ' +
|
||||
'damit UNBEKANNT, nicht in Ordnung:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export async function getAllUsers(filters: UserFilters) {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isActive: true,
|
||||
isServiceAccount: true,
|
||||
customerId: true,
|
||||
whatsappNumber: true,
|
||||
telegramUsername: true,
|
||||
@@ -64,9 +65,10 @@ export async function getAllUsers(filters: UserFilters) {
|
||||
]);
|
||||
|
||||
// Get hidden role IDs
|
||||
const [developerRole, gdprRole] = await Promise.all([
|
||||
const [developerRole, gdprRole, auditOpsRole] = await Promise.all([
|
||||
prisma.role.findFirst({ where: { name: 'Developer' } }),
|
||||
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
||||
prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -77,11 +79,15 @@ export async function getAllUsers(filters: UserFilters) {
|
||||
const hasGdprAccess = gdprRole
|
||||
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
||||
: false;
|
||||
const hasAuditOpsAccess = auditOpsRole
|
||||
? u.roles.some((ur) => ur.roleId === auditOpsRole.id)
|
||||
: false;
|
||||
return {
|
||||
...u,
|
||||
roles: u.roles.map((r) => r.role),
|
||||
hasDeveloperAccess,
|
||||
hasGdprAccess,
|
||||
hasAuditOpsAccess,
|
||||
};
|
||||
}),
|
||||
pagination: buildPaginationResponse(page, limit, total),
|
||||
@@ -97,6 +103,7 @@ export async function getUserById(id: number) {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isActive: true,
|
||||
isServiceAccount: true,
|
||||
customerId: true,
|
||||
whatsappNumber: true,
|
||||
telegramUsername: true,
|
||||
@@ -144,6 +151,7 @@ export async function createUser(data: {
|
||||
customerId?: number;
|
||||
hasDeveloperAccess?: boolean;
|
||||
hasGdprAccess?: boolean;
|
||||
hasAuditOpsAccess?: boolean;
|
||||
whatsappNumber?: string;
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
@@ -170,6 +178,7 @@ export async function createUser(data: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isActive: true,
|
||||
isServiceAccount: true,
|
||||
customerId: true,
|
||||
roles: {
|
||||
include: { role: true },
|
||||
@@ -187,6 +196,10 @@ export async function createUser(data: {
|
||||
await setUserGdprAccess(user.id, true);
|
||||
}
|
||||
|
||||
if (data.hasAuditOpsAccess) {
|
||||
await setUserAuditOpsAccess(user.id, true);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -198,16 +211,18 @@ export async function updateUser(
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isActive?: boolean;
|
||||
isServiceAccount?: boolean;
|
||||
roleIds?: number[];
|
||||
customerId?: number;
|
||||
hasDeveloperAccess?: boolean;
|
||||
hasGdprAccess?: boolean;
|
||||
hasAuditOpsAccess?: boolean;
|
||||
whatsappNumber?: string;
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
}
|
||||
) {
|
||||
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data;
|
||||
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
|
||||
|
||||
// Check if this would remove the last admin
|
||||
const isBeingDeactivated = userData.isActive === false;
|
||||
@@ -344,6 +359,10 @@ export async function updateUser(
|
||||
await setUserGdprAccess(id, hasGdprAccess);
|
||||
}
|
||||
|
||||
if (hasAuditOpsAccess !== undefined) {
|
||||
await setUserAuditOpsAccess(id, hasAuditOpsAccess);
|
||||
}
|
||||
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
@@ -403,6 +422,16 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name der versteckten Rolle fuer eingreifende Audit-Rechte.
|
||||
*
|
||||
* Getrennt von `DSGVO`, weil Aufsicht und Eingriff nicht dieselbe Rolle sein
|
||||
* duerfen: Wer das Protokoll beaufsichtigt, darf seine Beweisgrundlage nicht
|
||||
* ersetzen koennen (Pentest R186). Die Rolle wird von `sync-roles.ts` beim
|
||||
* Containerstart angelegt.
|
||||
*/
|
||||
export const AUDIT_OPS_ROLLE = 'Audit-Betrieb';
|
||||
|
||||
// Helper to set GDPR access for a user
|
||||
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
||||
// Get or create DSGVO role
|
||||
@@ -411,17 +440,24 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
|
||||
});
|
||||
|
||||
if (!gdprRole) {
|
||||
// Create DSGVO role with all audit:* and gdpr:* permissions
|
||||
// Rechte-Satz identisch zu sync-roles.ts: gdpr komplett, vom Audit-
|
||||
// Protokoll nur LESEN und EXPORTIEREN. Ohne `audit:admin` - sonst
|
||||
// brächte dieser Notfallpfad genau die Bündelung zurück, die wir gerade
|
||||
// aufgelöst haben (zwei Listen, die dasselbe bedeuten sollen, laufen
|
||||
// auseinander).
|
||||
const gdprPermissions = await prisma.permission.findMany({
|
||||
where: {
|
||||
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
|
||||
OR: [
|
||||
{ resource: 'gdpr' },
|
||||
{ resource: 'audit', action: { in: ['read', 'export'] } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
gdprRole = await prisma.role.create({
|
||||
data: {
|
||||
name: 'DSGVO',
|
||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
||||
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
|
||||
permissions: {
|
||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
||||
},
|
||||
@@ -453,6 +489,50 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eingreifende Audit-Rechte setzen: versiegeln, neu berechnen, aufraeumen,
|
||||
* Aufbewahrung aendern. Bewusst getrennt vom DSGVO-Haken (siehe
|
||||
* `AUDIT_OPS_ROLLE`).
|
||||
*/
|
||||
async function setUserAuditOpsAccess(userId: number, enabled: boolean) {
|
||||
let rolle = await prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } });
|
||||
|
||||
if (!rolle) {
|
||||
// Rechte-Satz identisch zu sync-roles.ts.
|
||||
const rechte = await prisma.permission.findMany({
|
||||
where: { resource: 'audit', action: { in: ['read', 'admin'] } },
|
||||
});
|
||||
rolle = await prisma.role.create({
|
||||
data: {
|
||||
name: AUDIT_OPS_ROLLE,
|
||||
description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern',
|
||||
permissions: { create: rechte.map((p) => ({ permissionId: p.id })) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const hatRolle = await prisma.userRole.findFirst({
|
||||
where: { userId, roleId: rolle.id },
|
||||
});
|
||||
|
||||
if (enabled && !hatRolle) {
|
||||
await prisma.userRole.create({ data: { userId, roleId: rolle.id } });
|
||||
// Token invalidieren bei Rechteaenderung
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { tokenInvalidatedAt: new Date() },
|
||||
});
|
||||
} else if (!enabled && hatRolle) {
|
||||
await prisma.userRole.delete({
|
||||
where: { userId_roleId: { userId, roleId: rolle.id } },
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { tokenInvalidatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(id: number) {
|
||||
// Check if user is an admin
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -640,6 +640,10 @@ const USER_UPDATABLE_FIELDS = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'isActive',
|
||||
// Kennzeichnet planmaessig arbeitende Konten (z. B. das Gegenbuch). Ihre
|
||||
// Anmeldungen werden als Routine statt CRITICAL gefuehrt, dafuer wacht der
|
||||
// Heartbeat-Monitor ueber ihr AUSBLEIBEN (Pentest R182/R183).
|
||||
'isServiceAccount',
|
||||
'whatsappNumber',
|
||||
'telegramUsername',
|
||||
'signalNumber',
|
||||
@@ -650,6 +654,10 @@ const USER_UPDATABLE_FIELDS = [
|
||||
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
||||
'hasGdprAccess',
|
||||
'hasDeveloperAccess',
|
||||
// Eingreifende Audit-Rechte (versiegeln/aufraeumen/Aufbewahrung), bewusst
|
||||
// getrennt von hasGdprAccess: Aufsicht und Eingriff sind zwei Rollen
|
||||
// (Pentest R186). Mappt auf die versteckte Rolle 'Audit-Betrieb'.
|
||||
'hasAuditOpsAccess',
|
||||
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
||||
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
||||
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
||||
|
||||
+612
@@ -97,6 +97,618 @@ 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 1–5356), 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**.
|
||||
Die Berechtigung `audit:export` stand im Katalog und in der
|
||||
Rollenverwaltung – und wurde **nirgends** geprüft.
|
||||
- **Der Unterschied ist nicht kosmetisch.** Blättern zeigt 50 Zeilen; der
|
||||
Export liefert in einem Zug das gesamte Protokoll inklusive
|
||||
`changesBefore`/`changesAfter` – also der vollständigen Vorher/Nachher-
|
||||
Datensätze – dazu `resourceLabel` mit Klartextnamen, IP-Adressen und
|
||||
User-Agents. Live nachgewiesen: 43 Einträge mit gefüllter
|
||||
`resourceLabel` allein für `resourceType=Customer`.
|
||||
- Damit konnte ausgerechnet das Dienstkonto des Gegenbuchs, dessen Passwort
|
||||
im Klartext in der `.env` auf der Notar-Maschine liegt, Personendaten
|
||||
exportieren – während README und Rollenname „nur Prüfwerte lesen"
|
||||
versprachen.
|
||||
- `/audit-logs/export` verlangt jetzt `audit:export`. Betroffen ist genau
|
||||
eine Rolle: `Gegenbuch` (gewollt). Die DSGVO-Rolle hat `audit:*`
|
||||
vollständig und behält den Export.
|
||||
- Oberfläche: JSON- und CSV-Knopf werden nur noch mit `audit:export`
|
||||
angezeigt – sonst stünden dort Knöpfe, die zuverlässig 403 liefern.
|
||||
- Dateien: `backend/src/routes/auditLog.routes.ts`,
|
||||
`frontend/src/pages/settings/AuditLogs.tsx`,
|
||||
`tools/audit-notary/README.md`
|
||||
|
||||
- [x] **🔑 Rolle „Gegenbuch": Leserecht aufs Audit-Protokoll ohne `audit:admin`** (2026-08-26)
|
||||
- **Beim Selbst-Nachprüfen eines Deploys aufgefallen:** Das Gegenbuch-
|
||||
Dienstkonto auf Staging meldete beim Login
|
||||
`["audit:read","audit:export","audit:admin","gdpr:export","gdpr:delete","gdpr:admin"]`.
|
||||
Es braucht **genau eines** davon – `audit:read` – denn es ruft nur
|
||||
`/audit-logs/checkpoint` und `/audit-logs/verify` auf.
|
||||
- **Kein Bedienfehler, ein Konstruktionsfehler:** Es gab keine Rolle, die
|
||||
nur Leserecht aufs Protokoll gibt. Wer das wollte, musste den Haken
|
||||
„DSGVO-Zugriff" setzen – und der vergibt `audit:*` komplett, also auch
|
||||
`audit:admin` mit `seal-backlog`, `rehash`, `cleanup`. Das Label
|
||||
(„Audit-Logs, Datenschutz") legt Lesen nahe und liefert Vollzugriff.
|
||||
- **Warum das ernst ist:** Das Passwort des Dienstkontos liegt im Klartext
|
||||
in `tools/audit-notary/.env` auf der Gegenbuch-Maschine. Mit `audit:admin`
|
||||
hätte ein Einbruch dort nicht nur den Wächter gehabt, sondern gleich die
|
||||
Mittel zur Wäsche aus R185-01 – und damit genau die Trennung aufgehoben,
|
||||
wegen der das Gegenbuch überhaupt auf einer eigenen Maschine läuft.
|
||||
- Neue Rolle **`Gegenbuch`** in `sync-roles.ts` mit ausschließlich
|
||||
`audit:read`. Läuft beim Containerstart mit, erscheint danach in der
|
||||
Benutzerverwaltung. README des Gegenbuchs umgeschrieben: Rolle statt
|
||||
„selbst anlegen", ausdrückliche Warnung vor dem DSGVO-Haken, plus eine
|
||||
Gegenprobe (`checkpoint` → 200, `seal-backlog` → 403).
|
||||
- **Offen, bewusst nicht angefasst:** Dass die DSGVO-Rolle selbst
|
||||
`audit:admin` trägt, ist ein Gewaltenteilungs-Problem – wer das Protokoll
|
||||
beaufsichtigt, kann seine Beweisgrundlage ersetzen. Ändern hieße
|
||||
bestehenden DSGVO-Konten Rechte entziehen; gehört entschieden, nicht
|
||||
nebenbei gemacht. An den Pentester gemeldet, dessen nächstes Thema das
|
||||
Rechtemodell ist.
|
||||
- Dateien: `backend/prisma/sync-roles.ts`, `tools/audit-notary/README.md`
|
||||
|
||||
- [x] **📤 Eingegrenzter Export lieferte alles (Pentest R186-01, MEDIUM)** (2026-08-26)
|
||||
- Der Tester fand: `GET /audit-logs/export?userId=…` filterte **nicht** –
|
||||
`userId=999999` gab alle 2761 Datensätze zurück, byte-identisch zum
|
||||
ungefilterten Export. HTTP 200, sah korrekt aus.
|
||||
- **Ursache war schlimmer als der Befund.** Nicht `userId` allein fehlte:
|
||||
der Export-Controller pflegte eine **eigene, kürzere** Filterliste und
|
||||
verwarf still `userId`, `customerId`, `dataSubjectId`, `resourceId`,
|
||||
`success` **und** `search`. Der Service konnte alle sechs – sie kamen
|
||||
nie bei ihm an.
|
||||
- **Dieselbe Lücke ein drittes Mal in der Oberfläche:** Der CSV-Knopf baute
|
||||
seine Parameter nochmal von Hand, mit wieder anderen fünf Feldern. Wer im
|
||||
Suchfeld eingrenzte und dann CSV klickte, bekam das gesamte Protokoll
|
||||
statt seiner Auswahl.
|
||||
- **Warum das mehr ist als ein fehlender Filter:** Ein bewusst eingegrenzter
|
||||
Export – „nur die Spur von Benutzer X" für eine DSGVO-Auskunft oder eine
|
||||
Innentäter-Prüfung – gab das vollständige Protokoll **aller** Nutzer
|
||||
heraus, mit einem beruhigenden 200. Auf einem datenminimierungspflichtigen
|
||||
Pfad ist das eine Weitergabe, kein Schönheitsfehler.
|
||||
- **Fix ist strukturell, nicht punktuell:** ein gemeinsamer `leseFilter(req)`
|
||||
für Liste und Export; die Oberfläche schickt alle aktiven Filter statt
|
||||
einer handgepflegten Auswahl. Drei Listen, die dasselbe bedeuten sollen,
|
||||
laufen früher oder später auseinander – jetzt gibt es nur noch eine.
|
||||
- Geprüft über HTTP: Export und Liste liefern für `userId`, `action`,
|
||||
`search`, `success`, `resourceType` **identische** Treffermengen; auf dem
|
||||
Export-Pfad gilt jetzt dieselbe Validierung (`userId=abc` → 400 statt
|
||||
200). CSV-Pfad gegengeprüft.
|
||||
- Dateien: `backend/src/controllers/auditLog.controller.ts`,
|
||||
`frontend/src/pages/settings/AuditLogs.tsx`
|
||||
|
||||
- [x] **🚨 Siegelwechsel ist ein Alarm, kein Hinweis + Filter-Validierung (Pentest R185)** (2026-08-26)
|
||||
- **R185-01 (MEDIUM)** – Die Flanke, die wir dem Tester selbst gemeldet
|
||||
hatten, hat er live bestätigt: `seal-backlog` war beim **zweiten** Aufruf
|
||||
genauso gegatet wie beim ersten (`{"confirm":"SEAL"}` → 200), und das
|
||||
Ereignis landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
|
||||
automatische Rückhalt des Gegenbuchs hängt an `valid` – und `valid`
|
||||
überlebt ein ersetzendes Siegel **per Konstruktion**. Angriff: Altzeile
|
||||
per DB-Zugriff löschen → neu siegeln → Lücke ist beglaubigt, `valid`
|
||||
wieder `true`. **Live reproduziert.**
|
||||
- Zwei Schichten, in seiner Reihenfolge:
|
||||
1. **Alarmkanal.** Neuer `SecurityEventType` `AUDIT_SEAL_CHANGED` (Migration
|
||||
`20260826120000`). Erstes Siegeln → HIGH, Ersetzen → **CRITICAL** (geht
|
||||
damit über `sendPendingCriticalAlerts` sofort per Mail raus). Die
|
||||
Ereignis-Details halten Wurzel vorher/nachher und den vollständigen
|
||||
Vorbefund fest.
|
||||
2. **Gate.** Steht bereits ein Siegel, verlangt der Endpunkt
|
||||
`{"confirm":"RESEAL"}` statt `SEAL` – mit einem Text, der sagt, was
|
||||
dabei verloren geht. Ein Austausch der Beweisgrundlage soll nicht
|
||||
dasselbe Wort haben wie das Einrichten.
|
||||
- **Und im Gegenbuch selbst:** Dort stand für den Wurzelwechsel ein
|
||||
`console.warn`, während der Rückgabecode auf **0** blieb – also exakt das
|
||||
Muster, das wir dem CRM zweimal angekreidet haben (R179/R183-02), im
|
||||
Werkzeug, das dagegen gebaut wurde. Jetzt **exit 2**, mit alter und neuer
|
||||
Wurzel samt Blattzahl (`10 Blätter → 9 Blätter` zeigt die Löschung
|
||||
sofort). Auch die Erstsiegelung meldet sich, statt stillschweigend
|
||||
übernommen zu werden.
|
||||
- **Auflösbar gemacht:** Der Alarm bricht ab, *bevor* angehängt wird – ohne
|
||||
Bestätigungsweg hätte auch ein legitimes Siegeln für immer alarmiert
|
||||
(R183-03-Falle). Neu: `NOTARY_SEAL_ACK`. Bewusst **nicht** `true`, sondern
|
||||
die **Wurzel selbst** (mind. 16 Zeichen): ein stehen gelassener Wert passt
|
||||
beim nächsten Wechsel nicht mehr und kann keinen weiteren Austausch
|
||||
durchwinken – der Unterschied zu `NOTARY_GENESIS_ACK`, wo genau diese
|
||||
Falle dokumentiert werden musste.
|
||||
- **R185-02 (LOW)** – `GET /api/audit-logs?action=<x>`: ungültige Enum-Werte
|
||||
gingen roh an die Spalte → **500**. Zweifach schlecht: fehlende
|
||||
Validierung *und* Fehler-Orakel (200 vs. 500 verrät die Enum-Mitglieder).
|
||||
Jetzt 400 mit der erlaubten Menge im Klartext – die steht ohnehin in der
|
||||
Oberfläche. Gleich mitgenommen: `sensitivity`, Datumsfelder
|
||||
(`new Date('foo')` → Invalid Date → 500), Zahlenfelder (`parseInt` → NaN),
|
||||
Textlängen, und ein Deckel auf `limit` (200), über den sich sonst die
|
||||
ganze Tabelle an der Seitenlogik vorbei ziehen ließ. Beide Endpunkte
|
||||
(`/audit-logs` und `/audit-logs/export`).
|
||||
- **Getestet über HTTP gegen eine Wegwerf-DB**, inkl. echtem Gegenbuch-Lauf
|
||||
mit SSH-signiertem lokalem Repo: Erstsiegeln mit `SEAL` → 200; zweites mit
|
||||
`SEAL` → **400**; mit `RESEAL` → 200; beide Alarmkanal-Ereignisse mit
|
||||
korrekter Severity vorhanden. Wäsche (Zeile 7 gelöscht → RESEAL) → CRM
|
||||
meldet `valid:true`, **Gegenbuch exit=2**. Bestätigung: falsche Wurzel →
|
||||
weiter exit 2, zu kurzer Wert → weiter exit 2, richtige Wurzel → exit 0
|
||||
und beglaubigt, Folgelauf ruhig.
|
||||
- **Nachgeholt, was der Tester nicht herstellen konnte:** vollständig
|
||||
unsigniertes Protokoll (Platzhalter-`AUDIT_HMAC_KEY`) → `kein_siegel`
|
||||
statt der früheren falschen Entwarnung `nicht_noetig`, und
|
||||
`seal-backlog` nennt den fehlenden Schlüssel als nächsten Schritt – die
|
||||
Warnung ist also auflösbar. Gegenrichtung (Log beginnt signiert) →
|
||||
weiterhin `nicht_noetig`, R183-03 bleibt behoben.
|
||||
- Dateien: `backend/src/controllers/auditLog.controller.ts`,
|
||||
`backend/prisma/schema.prisma` + Migration, `tools/audit-notary/notary.mjs`,
|
||||
`tools/audit-notary/{docker-compose.yml,.env.example,README.md}`,
|
||||
`frontend/src/services/api.ts`, `frontend/src/pages/settings/Monitoring.tsx`
|
||||
|
||||
- [x] **👁️ Integritätsstatus in der Oberfläche (Einstellungen → Audit-Protokoll)** (2026-08-26)
|
||||
- Bisher war der Zustand der Hash-Kette nur per `POST /api/audit-logs/verify`
|
||||
einsehbar – also praktisch nur für das Gegenbuch und für jemanden mit
|
||||
`curl`. Jetzt steht er oben auf der Audit-Seite.
|
||||
- Vier Zustände statt „grün/rot": **unversehrt**, **Befund**, **unversehrt
|
||||
aber ungeschützter Altbestand** (`kein_siegel`), **nicht vollständig
|
||||
prüfbar** (signierte Zeilen ohne `AUDIT_HMAC_KEY`).
|
||||
- Die beiden mittleren Zustände sind bewusst nicht grün. Ein Protokoll mit
|
||||
unversiegeltem Altbestand ist rechnerisch stimmig, aber am Altbestand
|
||||
unbemerkt änderbar; ein Protokoll, das mangels Schlüssel nicht prüfbar ist,
|
||||
ist schlicht ungeprüft. Beides als „alles in Ordnung" zu zeigen wäre
|
||||
genau die Klasse Fehler, die diese ganze Runde behandelt hat.
|
||||
- Schlägt die Prüfung selbst fehl, steht dort ausdrücklich: *„Das ist keine
|
||||
Entwarnung – der Zustand der Kette ist damit schlicht unbekannt."*
|
||||
- Aufklappbare Einzelheiten trennen die Kategorien, die nicht gleich schwer
|
||||
wiegen: nachträglich verändert (ernst) / Verkettung unterbrochen /
|
||||
davon ohne dokumentierte Löschung / davon vom Siegel beglaubigt /
|
||||
ohne Schlüssel nicht prüfbar – mit einer Erklärung im Klartext darunter.
|
||||
- Die Prüfung liest die **gesamte** Kette. Sie läuft deshalb einmal beim
|
||||
Öffnen der Seite und wird 5 Minuten wiederverwendet; „Neu prüfen" erzwingt
|
||||
einen frischen Lauf.
|
||||
- Bewusst **read-only**: kein Siegel- oder Rehash-Knopf. Diese Eingriffe
|
||||
verlangen `audit:admin` und eine ausdrückliche Bestätigung; sie gehören
|
||||
nicht neben eine Statusanzeige, die man im Vorbeigehen anklickt.
|
||||
- Dateien: `frontend/src/pages/settings/AuditIntegrityCard.tsx` (neu),
|
||||
`frontend/src/pages/settings/AuditLogs.tsx`, `frontend/src/services/api.ts`
|
||||
(Typ `IntegrityResult` ausgelagert)
|
||||
|
||||
- [x] **🧾 Beglaubigte Alt-Lücken: Dauer-Alarm im Gegenbuch beendet** (2026-08-26)
|
||||
- **Ausgangslage.** Das Gegenbuch auf Prod meldete stündlich `exit=2`. Die
|
||||
CRM-Prüfung lieferte `valid: false` wegen **6 struktureller Lücken**
|
||||
(IDs 33, 44, 45, 922, 1434, 2583).
|
||||
- **Diagnose: harmlos.** Jede Lücke liegt innerhalb eines Schwungs von
|
||||
Einträgen mit **identischer Sekunde**, betrifft nur `/login` und
|
||||
`/refresh`, kein Eintrag fehlt (kein 404), `tamperedEntries` leer. Das ist
|
||||
die Signatur der Race-Condition, die am 19.08. mit `AuditChainLock`
|
||||
geschlossen wurde (R166-01). Bestätigt durch die Zeilen selbst: Eintrag
|
||||
2583 stammt vom 21.08., ist aber noch `hashVersion=1` – auf Prod lief zu
|
||||
dem Zeitpunkt also der alte Stand.
|
||||
- **Das eigentliche Problem war nicht die Lücke, sondern der Dauer-Alarm.**
|
||||
Diese Lücken sind nicht heilbar: die Verkettung ist gebrochen, die Inhalte
|
||||
sind unversehrt. Ohne Änderung hätte das Gegenbuch für immer Alarm gemeldet
|
||||
– und ein Signal, das immer schreit, warnt nicht mehr. Dieselbe Klasse wie
|
||||
R162, R174, R179, R182, R183-02.
|
||||
- **Lösung: Beglaubigung statt Unterdrückung.** Eine Lücke zählt nicht mehr
|
||||
als offener Befund, wenn beides gilt: sie liegt im **versiegelten Bereich**
|
||||
UND steht im **Vorbefund des Siegel-Markers**, also im Zustand, den der
|
||||
Betreiber beim Siegeln ausdrücklich festgeschrieben hat. Der Vorbefund
|
||||
liegt in `changesBefore` und ist ab Version 3 mitgehasht – die Liste lässt
|
||||
sich ohne `AUDIT_HMAC_KEY` nicht nachträglich erweitern (gleiche
|
||||
Absicherung wie beim Löschungs-Manifest, R171-01).
|
||||
- Beglaubigt heißt **nicht verschwunden**: die Lücken bleiben in `chainGaps`,
|
||||
stehen zusätzlich in neuem Feld `attestedGaps` und werden im Bericht
|
||||
ausdrücklich benannt.
|
||||
- **Nebenbefund derselben Klasse mitbehoben:** `backlogSealStatus` meldete
|
||||
`nicht_noetig` ("Es gibt keine unsignierten Alteinträge"), wenn
|
||||
`v3FromId === null` – das bedeutet aber das **Gegenteil**: dass überhaupt
|
||||
nichts signiert ist, etwa weil `AUDIT_HMAC_KEY` fehlt. Ein vollständig
|
||||
unsigniertes Log bekam damit Entwarnung für genau den Zustand mit der
|
||||
geringsten Beweiskraft. Die Bedingung hängt jetzt an der Zahl der
|
||||
unsignierten Zeilen. R183-03 (unauflösbare Warnung bei frisch signiertem
|
||||
Log) bleibt behoben – per Regressionstest geprüft.
|
||||
- **Getestet über HTTP gegen eine eigene Wegwerf-Datenbank**, nicht am
|
||||
Service vorbei: Prod-Zustand nachgebaut (10 v1-Zeilen, Bruch bei id 5) →
|
||||
`valid:false`; nach `seal-backlog` → `valid:true`, `attestedGaps:[5]`.
|
||||
Drei Gegenproben: **neue Lücke** nach dem Siegeln → `valid:false`;
|
||||
**gesiegelte Altzeile verändert** → Siegel `gebrochen`, nichts mehr
|
||||
beglaubigt; **Beglaubigungsliste im Marker gefälscht** (DB-Schreibrecht,
|
||||
kein Schlüssel) → Marker ungültig, Status `entfernt`, `valid:false`.
|
||||
- Doku: Abschnitt „Wenn der erste Lauf `exit=2` meldet" in
|
||||
`tools/audit-notary/README.md` – inklusive der Warnung, **vor** dem
|
||||
Siegeln zu prüfen, was man da festschreibt.
|
||||
- Dateien: `backend/src/services/audit.service.ts`,
|
||||
`backend/src/controllers/auditLog.controller.ts`,
|
||||
`frontend/src/services/api.ts`, `tools/audit-notary/README.md`
|
||||
|
||||
- [x] **🔓 Dienstkonto-Flag: Gate in beide Richtungen, richtige Rechte-Domaene (Pentest R184-01/-02)** (2026-08-24)
|
||||
- **R184-01** – Setzen war gegatet, **Entfernen nicht**. Und das Entfernen ist
|
||||
der gefaehrlichere Weg: Der Heartbeat-Wachhund fragt `isServiceAccount:
|
||||
true` ab, haengt also am **Live-Kennzeichen**. Seine Hypothese war damit
|
||||
richtig – Un-Flaggen kappt die Wache und macht genau das moeglich, wogegen
|
||||
der Tripwire gebaut wurde: stilllegen und auf Stille setzen.
|
||||
Fix: Gate in beide Richtungen, mit unterschiedlichem Wortlaut (beim
|
||||
Entfernen: „faellt aus der Ueberwachung heraus“).
|
||||
- **Wichtiger noch:** Die Aenderung geht jetzt zusaetzlich in den
|
||||
**Alarmkanal** (`PERMISSION_CHANGED`/CRITICAL), nicht nur ins Audit-Log.
|
||||
Eine CRITICAL-Zeile muss jemand LESEN – das war die R183-02-Klasse. Jetzt
|
||||
reagiert die Ueberwachung automatisch.
|
||||
- **R184-02** – Das Kennzeichen hing an `users:update`, einer
|
||||
Anwendungs-Berechtigung, obwohl es ein Audit-Governance-Eingriff ist –
|
||||
Geschwister von retention-shorten, seal-backlog, rehash und cleanup, die
|
||||
alle `audit:admin` verlangen. Heute deckungsgleich, aber jede kuenftige
|
||||
Rolle mit „Benutzer bearbeiten“ haette still „Login-Alarme herunterstufen“
|
||||
geerbt. Fix: zusaetzliche Pruefung auf `audit:admin` im Controller.
|
||||
- Verifiziert: Setzen mit `audit:admin` 200 · Entfernen ohne Bestaetigung
|
||||
**400** (vorher 200) · mit Bestaetigung 200 · ohne `audit:admin` **403** ·
|
||||
zwei CRITICAL-Meldungen im Alarmkanal mit sprechendem Text. Frontend sendet
|
||||
die Bestaetigung in beide Richtungen. `npm run build` und Backend-`tsc`
|
||||
gruen.
|
||||
- Seine Non-Findings uebernommen: Selbst-Block nicht ueber Methode, Pfad oder
|
||||
ID-Aliasing umgehbar; Create streift das Feld ab; Confirm-Gate strikt.
|
||||
|
||||
- [x] **🔒 Dienstkonto-Kennzeichen gegatet, laut protokolliert, kein Selbstbedienen (Pentest R184)** (2026-08-24)
|
||||
- Der Pentester hat sofort erkannt, was das Scharfschalten des Feldes
|
||||
bedeutet: Ein Attribut, das die **Alarmstufe senkt**, war ueber den
|
||||
normalen Benutzer-Update-Pfad setzbar – dieselbe Klasse wie R183-01,
|
||||
nur neu gebaut. Wer sein eigenes Konto so markiert, laesst die eigenen
|
||||
auffaelligen Anmeldungen als Routine erscheinen. Das ist die Waesche.
|
||||
- Ist-Zustand vor dem Fix, alle drei seiner Sorgen bestaetigt: kein Gate,
|
||||
Protokollierung nur als MEDIUM (Standardstufe fuer `User`), und jeder mit
|
||||
`users:update` konnte es auf jedes Konto setzen – auch auf das eigene.
|
||||
- Fix, analog zur Retention-Absenkung:
|
||||
* **Bestaetigung** `{"confirm":"SERVICE_ACCOUNT"}` beim Aktivieren, mit
|
||||
Klartext, was das bedeutet.
|
||||
* **CRITICAL** statt MEDIUM, mit eigenem Label („Dienstkonto-Kennzeichen
|
||||
GESETZT fuer … – Anmeldungen werden kuenftig als Routine gefuehrt“) sowie
|
||||
Wer/Vorher/Nachher.
|
||||
* **Kein Selbstbedienen**: am eigenen Konto ist das Kennzeichen weder
|
||||
setzbar noch entfernbar (403 mit Begruendung) – muss ein anderer
|
||||
Administrator vornehmen.
|
||||
- Frontend sendet die Bestaetigung mit; der Haken im Formular IST die
|
||||
Bestaetigung, der Betreiber merkt nichts davon.
|
||||
- Verifiziert ueber den echten Controller: ohne Bestaetigung 400, mit 200,
|
||||
am eigenen Konto 403, Protokolleintrag CRITICAL mit sprechendem Label.
|
||||
`npm run build` (inkl. `tsc`) und Backend-`tsc` gruen.
|
||||
|
||||
- [x] **🖱️ Dienstkonto-Kennzeichen in der Benutzerverwaltung** (2026-08-24)
|
||||
- Nachgezogen: Das Feld `isServiceAccount` lag zwar in der Datenbank, war
|
||||
aber **nirgends setzbar** – weder im Formular noch ueber die API. Der
|
||||
Betreiber haette es nicht aktivieren koennen, damit waere die
|
||||
R182-Einstufung wirkungslos geblieben.
|
||||
- Jetzt: Ankreuzfeld „Dienstkonto“ im Benutzerformular unter den
|
||||
zusaetzlichen Berechtigungen, mit Erklaerung im Klartext (planmaessige
|
||||
Anmeldungen als Routine, dafuer Meldung bei AUSBLEIBEN). Feld in der
|
||||
Whitelist (`USER_UPDATABLE_FIELDS`, damit auch beim Anlegen), im
|
||||
Service-Typ und in allen drei `select`-Bloecken, damit es beim Bearbeiten
|
||||
vorbelegt wird.
|
||||
- Verifiziert: Whitelist laesst das Feld durch und blockt Fremdfelder
|
||||
weiterhin; Wert ueber Prisma lesbar; `tsc` und `vite build` gruen.
|
||||
|
||||
- [x] **🚨 Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)** (2026-08-24)
|
||||
- **R183-01 (HIGH)** – Die geladene Waffe war ungegatet, der Abzug gegatet:
|
||||
`PUT /retention-policies/{id}` nahm `retentionDays: 0` **ohne
|
||||
Bestaetigung** an und protokollierte es als MEDIUM – waehrend cleanup,
|
||||
seal und rehash alle ein Confirm-Gate haben und die FOLGE CRITICAL ist.
|
||||
Die ausloesende Tat war leiser als ihre Wirkung.
|
||||
Fix: Absenken verlangt `{"confirm":"SHORTEN"}`, wird als **CRITICAL** mit
|
||||
Vorher/Nachher protokolliert („Aufbewahrung VERKUERZT … 730 → 0 Tage“), und
|
||||
fuer `Authentication`/`AuditLog` gilt eine **Untergrenze von 30 Tagen**.
|
||||
`logChange` nimmt dafuer jetzt Sensitivitaet und Vorzustand entgegen.
|
||||
- **R183-02 (HIGH)** – Nach dem Cleanup meldete verify
|
||||
„**Keine Manipulation.** 656 strukturelle Luecken (alle durch protokollierte
|
||||
Loeschungen erklaert)“ – bei `valid: false` und 3010 endgueltig geloeschten
|
||||
Anmeldeprotokollen. Der Befund stand nur im Feld, die Prosa beruhigte.
|
||||
Dieselbe Alarm-Muedigkeit wie R162/R182, diesmal im Verifizierer selbst.
|
||||
Fix: Bei Luecken heisst es jetzt „Die Kette ist nicht mehr lueckenlos …
|
||||
geloeschte Eintraege lassen sich naturgemaess nicht mehr pruefen“.
|
||||
Zusaetzlich wertet das **Gegenbuch `valid:false` hart** – es ruft
|
||||
`/verify` mit und schlaegt Alarm, egal wie der Text klingt.
|
||||
- **R183-03 (MEDIUM)** – verify warnte dauerhaft „Altbestand nicht
|
||||
versiegelt“, waehrend seal-backlog zu Recht ablehnte („kein Altbestand“).
|
||||
Eine Warnung, die niemand aufloesen kann, lernt man zu ignorieren.
|
||||
Fix: eigener Zustand `nicht_noetig` mit Klartext; die echte Warnung nennt
|
||||
jetzt den Befehl zum Beheben.
|
||||
- **Heartbeat-Wachhund gebaut** (seine wichtigere Haelfte): Bleibt ein
|
||||
Dienstkonto laenger still als `SERVICE_ACCOUNT_MAX_SILENCE_MINUTES`
|
||||
(Standard 180), gibt es `SUSPICIOUS/CRITICAL`. Ohne je gesehene Anmeldung
|
||||
wird geschwiegen statt geraten; pro Ausfall wird genau einmal gemeldet.
|
||||
Verifiziert: keine Grundlinie → still; 500 Minuten Stille → Meldung;
|
||||
Wiederholung → keine Dublette.
|
||||
- Seine Non-Findings bestaetigt uebernommen: Confirm-Gates gegen neun
|
||||
Umgehungsvarianten dicht, rehash-Protokoll ehrlich, Tombstone-Mechanik und
|
||||
Anker-Backstop intakt.
|
||||
|
||||
- [x] **🔧 Gegenbuch-Container: Rechte am Bind-Mount selbst geraderuecken** (2026-08-22)
|
||||
- Fehlerbild aus dem echten Betrieb: `mkdir: cannot create directory
|
||||
'/gegenbuch/schluessel': Permission denied`, Container in der
|
||||
Neustart-Schleife. Ursache: Das Datenverzeichnis kommt als Bind-Mount vom
|
||||
Host; der Betreiber hatte das Projekt als **root** geklont, der Container
|
||||
lief aber direkt als UID 1000 und durfte dort nichts anlegen.
|
||||
Mein `.gitkeep`-Ansatz hatte stillschweigend angenommen, dass als normaler
|
||||
Benutzer geklont wird – auf einem Server ist root der Normalfall.
|
||||
- Fix: Der Container startet als root, setzt `/gegenbuch` per `chown` auf den
|
||||
Arbeitsbenutzer (`PUID`/`PGID`, Standard 1000) und startet sich per
|
||||
`setpriv` als dieser neu. Die eigentliche Arbeit laeuft damit weiterhin
|
||||
unprivilegiert. Schlaegt das `chown` fehl (z. B. rootless Docker), gibt es
|
||||
einen Hinweis mit dem passenden Host-Befehl statt eines stummen Abbruchs.
|
||||
- Verifiziert mit exakt der Ausgangslage: Datenverzeichnis auf `root:root`
|
||||
gesetzt, Container gestartet → laeuft durch, Schluessel und Buch werden
|
||||
angelegt, erzeugte Dateien gehoeren 1000:1000, der private Schluessel liegt
|
||||
mit `0600`.
|
||||
|
||||
- [x] **🔑 Gegenbuch: Dienstkonto statt Token, .env mit gueltigen Werten** (2026-08-22)
|
||||
- **Blocker gefunden und behoben:** Ich hatte ein dauerhaftes API-Token
|
||||
vorausgesetzt – das gibt es in OpenCRM gar nicht. Zugangstoken leben
|
||||
**15 Minuten**; der Gegenbuch-Container waere nach dem ersten Durchlauf
|
||||
gestorben. Aufgefallen erst, als der Betreiber fragte, woher er den Token
|
||||
nimmt.
|
||||
Loesung: Das Gegenbuch meldet sich bei **jedem Lauf selbst an** – mit einem
|
||||
eigenen Benutzerkonto, dessen Rolle ausschliesslich `audit:read` traegt.
|
||||
Damit kann es nur Pruefwerte lesen: keine Kundendaten, keine Aenderungen.
|
||||
`CRM_TOKEN` bleibt fuer Tests moeglich, ist aber nicht mehr der Normalweg.
|
||||
- Fehlerfaelle sauber gemeldet: falsches Passwort → Klartext statt HTTP-Code,
|
||||
Anmelde-Bremse (429) benannt, CRM nicht erreichbar unterschieden.
|
||||
- **`.env.example` nennt jetzt die gueltigen Werte** – bisher liess sich nur
|
||||
raten, ob es `prod` oder `production` heisst. Fuer jeden Schalter steht
|
||||
dabei, was erlaubt ist, inkl. des Falls „erst nur Staging testen, Prod
|
||||
spaeter dazunehmen“ (`COMPOSE_PROFILES=staging` → `prod,staging`).
|
||||
- Beide READMEs um „Zugang einrichten“ ergaenzt: Rolle mit nur `audit:read`,
|
||||
Benutzer damit, Zugangsdaten in die `.env`. Mit dem Hinweis, dass jede
|
||||
Anmeldung im Audit-Log erscheint – gewollt, denn so sieht man auch, wenn
|
||||
das Gegenbuch aufhoert zu arbeiten.
|
||||
- Verifiziert gegen eine Attrappe, die wie das echte CRM eine Anmeldung
|
||||
verlangt: Anmeldung + Lauf erfolgreich, falsches Passwort → verstaendliche
|
||||
Meldung, exit 1.
|
||||
|
||||
- [x] **🐳 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine** (2026-08-22)
|
||||
- Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer **eigenen Maschine**
|
||||
fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch
|
||||
liegt lokal auf dieser Maschine. Die Trennung, auf die es ankommt, ist
|
||||
damit gegeben – wer OpenCRM uebernimmt, kommt nicht ans Buch.
|
||||
- Richtung bewusst so herum: Das Gegenbuch **holt** ueber HTTPS mit einem
|
||||
Token, das nur `audit:read` kann. OpenCRM kennt weder Adresse noch
|
||||
Schluessel des Gegenbuchs. Kein SSH-Zugang zum CRM noetig – das Token kann
|
||||
ausschliesslich Pruefwerte lesen.
|
||||
- Unterverzeichnis `tools/audit-notary/`: `Dockerfile`, `entrypoint.sh`,
|
||||
`docker-compose.yml`, `.env.example`. Zwei Dienste (`prod`, `staging`) mit
|
||||
getrennten Verzeichnissen und Schluesseln, gesteuert ueber
|
||||
`COMPOSE_PROFILES` – dasselbe Muster wie beim Caddy-Profil im Hauptstack.
|
||||
Der Signaturschluessel wird beim ersten Start auf der Gegenbuch-Maschine
|
||||
erzeugt und verlaesst sie nie.
|
||||
- **Lokaler Betrieb ist jetzt ein vollwertiger Modus**, kein Testschalter
|
||||
mehr. Die Erfolgsmeldung benennt bei jedem Lauf, was abgedeckt ist
|
||||
(„jemand veraendert Daten im CRM“) und was nicht („jemand uebernimmt diesen
|
||||
Rechner hier“) – statt der frueheren pauschalen Formulierung „kein
|
||||
Manipulationsschutz“, die im Einsatz auf eigener Maschine schlicht falsch
|
||||
war.
|
||||
- Jeder Durchlauf schreibt `status.txt` fuer die Ueberwachung; alles ausser
|
||||
`exit=0` gehoert angesehen.
|
||||
- Verifiziert mit echtem Docker-Build gegen eine CRM-Attrappe: Genesis ohne
|
||||
Bestaetigung → Code 4; mit Bestaetigung → Buch angelegt, Normalbetrieb
|
||||
exit 0; **Eintrag nachtraeglich veraendert → Alarm exit 2**; **Eintraege
|
||||
geloescht (maxId geschrumpft) → Alarm exit 2**; Siegel verschwunden →
|
||||
Alarm. Testartefakte und Image geloescht.
|
||||
- **Daten im Projektverzeichnis, keine Docker-Volumes** (Projektkonvention):
|
||||
Bind-Mount auf `tools/audit-notary/data/<instanz>/`. Der Inhalt ist per
|
||||
`.gitignore` ausgenommen – dort liegt der Signaturschluessel –, die
|
||||
Verzeichnisse selbst bestehen ueber `.gitkeep`. Ohne das haette Docker sie
|
||||
beim ersten Start als `root` angelegt und der Container (UID 1000) haette
|
||||
nicht schreiben koennen.
|
||||
- Behoben beim Bauen: `useradd -u 1000 || true` verschluckte, dass UID 1000
|
||||
im Node-Image bereits vergeben ist – der Container startete gar nicht.
|
||||
Jetzt laeuft er als der vorhandene Benutzer `node`.
|
||||
|
||||
- [x] **🧮 Gegenbuch: Pruefmodus schreibt nicht mehr, Widerspruch aufgeloest (Pentest R181)** (2026-08-18)
|
||||
- **R181-01 (LOW→MED)** – Der Pruefmodus sagte zu, nichts zu veraendern und
|
||||
kein Schreibrecht zu brauchen – und pushte trotzdem: `ankerNachziehen()`
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { auditLogApi, IntegrityResult } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import {
|
||||
ShieldCheck, ShieldAlert, ShieldQuestion, RefreshCw, Loader2, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Statuskarte fuer die Unversehrtheit des Audit-Protokolls.
|
||||
*
|
||||
* Die Prueflast steigt mit der Groesse des Protokolls (die Pruefung liest die
|
||||
* gesamte Kette). Deshalb laeuft sie beim Oeffnen der Seite EINMAL und wird
|
||||
* fuenf Minuten lang wiederverwendet; wer sofort neu pruefen will, nutzt den
|
||||
* Knopf.
|
||||
*/
|
||||
|
||||
type Ampel = 'ok' | 'befund' | 'hinweis' | 'unbekannt';
|
||||
|
||||
function ampelFuer(r: IntegrityResult): Ampel {
|
||||
if (!r.valid) return 'befund';
|
||||
// Gueltig, aber nicht vollstaendig pruefbar: kein gruenes Licht vortaeuschen.
|
||||
if (r.unverifiableEntries.length > 0) return 'unbekannt';
|
||||
if (r.backlogSealStatus === 'kein_siegel') return 'hinweis';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
const AMPEL_STIL: Record<Ampel, { box: string; titel: string; text: string }> = {
|
||||
ok: { box: 'bg-green-50 border-green-200', titel: 'text-green-900', text: 'text-green-800' },
|
||||
befund: { box: 'bg-red-50 border-red-200', titel: 'text-red-900', text: 'text-red-800' },
|
||||
hinweis: { box: 'bg-yellow-50 border-yellow-200',titel: 'text-yellow-900', text: 'text-yellow-800' },
|
||||
unbekannt: { box: 'bg-gray-50 border-gray-200', titel: 'text-gray-900', text: 'text-gray-700' },
|
||||
};
|
||||
|
||||
function AmpelIcon({ ampel }: { ampel: Ampel }) {
|
||||
const c = 'w-7 h-7 shrink-0';
|
||||
if (ampel === 'ok') return <ShieldCheck className={`${c} text-green-600`} />;
|
||||
if (ampel === 'befund') return <ShieldAlert className={`${c} text-red-600`} />;
|
||||
if (ampel === 'hinweis') return <ShieldAlert className={`${c} text-yellow-600`} />;
|
||||
return <ShieldQuestion className={`${c} text-gray-500`} />;
|
||||
}
|
||||
|
||||
const AMPEL_TITEL: Record<Ampel, string> = {
|
||||
ok: 'Protokoll unversehrt',
|
||||
befund: 'Befund – das gehört angesehen',
|
||||
hinweis: 'Unversehrt, aber ungeschützter Altbestand',
|
||||
unbekannt: 'Nicht vollständig prüfbar',
|
||||
};
|
||||
|
||||
const SIEGEL_TEXT: Record<IntegrityResult['backlogSealStatus'], string> = {
|
||||
intakt: 'versiegelt und intakt',
|
||||
leer: 'Siegel vorhanden, aber ohne Inhalt',
|
||||
kein_siegel: 'nicht versiegelt',
|
||||
nicht_noetig: 'nicht nötig (alles signiert)',
|
||||
gebrochen: 'GEBROCHEN',
|
||||
entfernt: 'ENTFERNT',
|
||||
};
|
||||
|
||||
/** Zeigt eine Zahl nur, wenn sie ungleich null ist – sonst bleibt es ruhig. */
|
||||
function Zahl({ label, ids, ton }: { label: string; ids: number[]; ton: string }) {
|
||||
if (ids.length === 0) return null;
|
||||
const gekuerzt = ids.length > 12 ? `${ids.slice(0, 12).join(', ')} … (+${ids.length - 12})` : ids.join(', ');
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className={`font-medium ${ton}`}>{label}:</span>
|
||||
<span className="text-gray-600">{ids.length}</span>
|
||||
<span className="text-gray-400 font-mono text-xs break-all">({gekuerzt})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuditIntegrityCard() {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const { data, isFetching, isError, error, refetch } = useQuery({
|
||||
queryKey: ['audit-integrity'],
|
||||
queryFn: () => auditLogApi.verifyIntegrity(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const ergebnis = data?.data;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="mb-6"
|
||||
title="Unversehrtheit des Protokolls"
|
||||
actions={
|
||||
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
{isFetching
|
||||
? <Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
: <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
Neu prüfen
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{isFetching && !ergebnis && (
|
||||
<div className="flex items-center gap-3 text-gray-500">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>Die Kette wird geprüft …</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="flex items-start gap-4 p-4 rounded-lg border bg-gray-50 border-gray-200">
|
||||
<ShieldQuestion className="w-7 h-7 shrink-0 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Prüfung nicht möglich</p>
|
||||
<p className="text-sm text-gray-700 mt-1">
|
||||
{error instanceof Error ? error.message : 'Unbekannter Fehler'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Das ist <strong>keine</strong> Entwarnung – der Zustand der Kette ist damit
|
||||
schlicht unbekannt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ergebnis && (() => {
|
||||
const ampel = ampelFuer(ergebnis);
|
||||
const stil = AMPEL_STIL[ampel];
|
||||
return (
|
||||
<>
|
||||
<div className={`flex items-start gap-4 p-4 rounded-lg border ${stil.box}`}>
|
||||
<AmpelIcon ampel={ampel} />
|
||||
<div className="min-w-0">
|
||||
<p className={`font-medium ${stil.titel}`}>
|
||||
{AMPEL_TITEL[ampel]}
|
||||
{ampel === 'ok' && ergebnis.rehashes.length > 0 && (
|
||||
<span className="font-normal"> – seit der letzten Neuberechnung</span>
|
||||
)}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${stil.text}`}>{ergebnis.message}</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft ·
|
||||
{' '}Altbestand: {SIEGEL_TEXT[ergebnis.backlogSealStatus]}
|
||||
{ergebnis.backlogSealCount > 1 && ` · ${ergebnis.backlogSealCount}× versiegelt`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(o => !o)}
|
||||
className="mt-3 flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
{offen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
Einzelheiten
|
||||
</button>
|
||||
|
||||
{offen && (
|
||||
<div className="mt-3 space-y-2 border-t pt-3">
|
||||
<Zahl label="Nachträglich verändert" ids={ergebnis.tamperedEntries} ton="text-red-700" />
|
||||
<Zahl label="Gesiegelte Alteinträge verändert" ids={ergebnis.backlogTampered} ton="text-red-700" />
|
||||
<Zahl label="Gesiegelte Alteinträge entfernt" ids={ergebnis.backlogMissing} ton="text-red-700" />
|
||||
<Zahl label="Verkettung unterbrochen" ids={ergebnis.chainGaps} ton="text-yellow-700" />
|
||||
<Zahl label="davon ohne dokumentierte Löschung" ids={ergebnis.unexplainedGaps} ton="text-yellow-700" />
|
||||
<Zahl label="davon vom Siegel beglaubigt" ids={ergebnis.attestedGaps} ton="text-gray-700" />
|
||||
<Zahl label="Ohne Schlüssel nicht prüfbar" ids={ergebnis.unverifiableEntries} ton="text-gray-700" />
|
||||
{ergebnis.tamperedEntries.length === 0 &&
|
||||
ergebnis.chainGaps.length === 0 &&
|
||||
ergebnis.unverifiableEntries.length === 0 &&
|
||||
ergebnis.rehashes.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic">Nichts zu berichten.</p>
|
||||
)}
|
||||
{ergebnis.rehashes.length > 0 && (
|
||||
<div className="pt-2 border-t mt-2">
|
||||
<p className="text-sm font-medium text-gray-700">Neuberechnungen der Kette</p>
|
||||
{ergebnis.rehashes.map((r) => (
|
||||
<p key={r.id} className="text-sm text-gray-600 mt-1">
|
||||
{new Date(r.zeitpunkt).toLocaleString('de-DE')} · Eintrag {r.id}
|
||||
{r.neuBerechnet !== null && ` · ${r.neuBerechnet} Einträge neu verkettet`}
|
||||
{r.vorbefund && (
|
||||
<span className="text-gray-500">
|
||||
{' '}· davor: {r.vorbefund.manipuliert} beanstandet,{' '}
|
||||
{r.vorbefund.luecken} Lücken
|
||||
</span>
|
||||
)}
|
||||
{!r.signiert && (
|
||||
<span className="text-red-700"> · ohne gültige Signatur</span>
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Ein Rehash verknüpft alle Einträge neu. Was davor als Lücke oder
|
||||
Beanstandung sichtbar war, ist danach nicht mehr in der Kette zu sehen –
|
||||
nur noch im Vorbefund des jeweiligen Eintrags. Die Aussage dieser Prüfung
|
||||
reicht deshalb bis zur letzten Neuberechnung zurück, nicht weiter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 pt-2">
|
||||
<strong>Verändert</strong> heißt: der Inhalt einer bestehenden Zeile passt nicht
|
||||
mehr zu ihrer Prüfsumme – das ist ernst.
|
||||
{' '}<strong>Verkettung unterbrochen</strong> heißt: die Zeilen selbst sind
|
||||
unversehrt, aber ein Glied fehlt oder wurde parallel geschrieben.
|
||||
{' '}<strong>Beglaubigt</strong> sind Lücken, die beim Versiegeln des Altbestands
|
||||
bereits bestanden und im signierten Siegel festgehalten sind.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { auditLogApi, AuditLogSearchParams, authApi } from '../../services/api';
|
||||
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import AuditIntegrityCard from './AuditIntegrityCard';
|
||||
import { ArrowLeft, Download, Eye, Shield, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
@@ -278,6 +280,10 @@ function DetailModal({ log, onClose }: DetailModalProps) {
|
||||
|
||||
export default function AuditLogs() {
|
||||
const navigate = useNavigate();
|
||||
// Export haengt an `audit:export`, Lesen an `audit:read` – ein Konto darf
|
||||
// blaettern duerfen, ohne das gesamte Protokoll herausziehen zu koennen.
|
||||
// Ohne diese Abfrage stuenden hier Knoepfe, die zuverlaessig 403 liefern.
|
||||
const { hasPermission } = useAuth();
|
||||
const [page, setPage] = useState(1);
|
||||
const [filters, setFilters] = useState<AuditLogSearchParams>({
|
||||
page: 1,
|
||||
@@ -307,11 +313,16 @@ export default function AuditLogs() {
|
||||
const downloadToken = await authApi.getDownloadToken();
|
||||
const params = new URLSearchParams();
|
||||
params.set('format', 'csv');
|
||||
if (filters.action) params.set('action', filters.action);
|
||||
if (filters.sensitivity) params.set('sensitivity', filters.sensitivity);
|
||||
if (filters.resourceType) params.set('resourceType', filters.resourceType);
|
||||
if (filters.startDate) params.set('startDate', filters.startDate);
|
||||
if (filters.endDate) params.set('endDate', filters.endDate);
|
||||
// ALLE aktiven Filter mitgeben (Pentest R186-01). Vorher standen hier
|
||||
// nur fuenf – wer im Suchfeld eingrenzte und dann CSV klickte, bekam
|
||||
// stillschweigend das gesamte Protokoll statt seiner Auswahl.
|
||||
// `page`/`limit` gehoeren nicht dazu: der Export ist bewusst
|
||||
// vollstaendig ueber die gefilterte Menge.
|
||||
for (const [schluessel, wert] of Object.entries(filters)) {
|
||||
if (schluessel === 'page' || schluessel === 'limit') continue;
|
||||
if (wert === undefined || wert === null || wert === '') continue;
|
||||
params.set(schluessel, String(wert));
|
||||
}
|
||||
window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank');
|
||||
} else {
|
||||
const result = await auditLogApi.export({ ...filters, format });
|
||||
@@ -341,6 +352,8 @@ export default function AuditLogs() {
|
||||
<h1 className="text-2xl font-bold">Audit-Protokoll</h1>
|
||||
</div>
|
||||
|
||||
<AuditIntegrityCard />
|
||||
|
||||
{/* Filter */}
|
||||
<Card className="mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
@@ -385,14 +398,18 @@ export default function AuditLogs() {
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Aktualisieren
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => handleExport('json')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
JSON
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => handleExport('csv')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
CSV
|
||||
</Button>
|
||||
{hasPermission('audit:export') && (
|
||||
<Button variant="secondary" onClick={() => handleExport('json')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
JSON
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('audit:export') && (
|
||||
<Button variant="secondary" onClick={() => handleExport('csv')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
CSV
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -37,6 +37,7 @@ const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
|
||||
{ value: 'LOGOUT', label: 'Logout' },
|
||||
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
|
||||
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
|
||||
{ value: 'AUDIT_SEAL_CHANGED', label: 'Bestandssiegel gesetzt/ersetzt' },
|
||||
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
|
||||
];
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function UserList() {
|
||||
<td className="py-3 px-4">{user.email}</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role: any) => (
|
||||
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role: any) => (
|
||||
<Badge key={role.id || role.name} variant="info">
|
||||
{role.name}
|
||||
</Badge>
|
||||
@@ -246,7 +246,9 @@ function UserModal({
|
||||
roleIds: [] as number[],
|
||||
isActive: true,
|
||||
hasDeveloperAccess: false,
|
||||
hasAuditOpsAccess: false,
|
||||
hasGdprAccess: false,
|
||||
isServiceAccount: false,
|
||||
whatsappNumber: '',
|
||||
telegramUsername: '',
|
||||
signalNumber: '',
|
||||
@@ -263,10 +265,12 @@ function UserModal({
|
||||
currentPassword: '',
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
|
||||
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(r.name)).map((r: any) => r.id) || [],
|
||||
isActive: (user as any).isActive ?? true,
|
||||
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
|
||||
hasAuditOpsAccess: (user as any).hasAuditOpsAccess ?? false,
|
||||
hasGdprAccess: (user as any).hasGdprAccess ?? false,
|
||||
isServiceAccount: (user as any).isServiceAccount ?? false,
|
||||
whatsappNumber: (user as any).whatsappNumber || '',
|
||||
telegramUsername: (user as any).telegramUsername || '',
|
||||
signalNumber: (user as any).signalNumber || '',
|
||||
@@ -281,7 +285,9 @@ function UserModal({
|
||||
roleIds: [],
|
||||
isActive: true,
|
||||
hasDeveloperAccess: false,
|
||||
hasAuditOpsAccess: false,
|
||||
hasGdprAccess: false,
|
||||
isServiceAccount: false,
|
||||
whatsappNumber: '',
|
||||
telegramUsername: '',
|
||||
signalNumber: '',
|
||||
@@ -323,7 +329,16 @@ function UserModal({
|
||||
roleIds: formData.roleIds,
|
||||
isActive: formData.isActive,
|
||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||
hasAuditOpsAccess: formData.hasAuditOpsAccess,
|
||||
hasGdprAccess: formData.hasGdprAccess,
|
||||
isServiceAccount: formData.isServiceAccount,
|
||||
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||
// Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken
|
||||
// im Formular IST diese Bestätigung.
|
||||
// Das Kennzeichen senkt bzw. hebt die Bewertung der Anmeldungen dieses
|
||||
// Kontos. Der Server verlangt in BEIDE Richtungen eine ausdrückliche
|
||||
// Bestätigung; der Haken im Formular ist diese Bestätigung.
|
||||
confirm: 'SERVICE_ACCOUNT',
|
||||
whatsappNumber: formData.whatsappNumber || undefined,
|
||||
telegramUsername: formData.telegramUsername || undefined,
|
||||
signalNumber: formData.signalNumber || undefined,
|
||||
@@ -358,7 +373,16 @@ function UserModal({
|
||||
lastName: formData.lastName,
|
||||
roleIds: formData.roleIds,
|
||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||
hasAuditOpsAccess: formData.hasAuditOpsAccess,
|
||||
hasGdprAccess: formData.hasGdprAccess,
|
||||
isServiceAccount: formData.isServiceAccount,
|
||||
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||
// Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken
|
||||
// im Formular IST diese Bestätigung.
|
||||
// Das Kennzeichen senkt bzw. hebt die Bewertung der Anmeldungen dieses
|
||||
// Kontos. Der Server verlangt in BEIDE Richtungen eine ausdrückliche
|
||||
// Bestätigung; der Haken im Formular ist diese Bestätigung.
|
||||
confirm: 'SERVICE_ACCOUNT',
|
||||
whatsappNumber: formData.whatsappNumber || undefined,
|
||||
telegramUsername: formData.telegramUsername || undefined,
|
||||
signalNumber: formData.signalNumber || undefined,
|
||||
@@ -470,7 +494,7 @@ function UserModal({
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
|
||||
<div className="space-y-2">
|
||||
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role) => (
|
||||
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role) => (
|
||||
<label key={role.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -487,6 +511,22 @@ function UserModal({
|
||||
</div>
|
||||
<label className="block text-sm font-medium text-gray-700 mt-4 mb-2">Zusätzliche Berechtigungen</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isServiceAccount}
|
||||
onChange={(e) => setFormData({ ...formData, isServiceAccount: e.target.checked })}
|
||||
className="mt-1 rounded border-blue-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Dienstkonto</span>
|
||||
<span className="block text-sm text-gray-500">
|
||||
Für Konten, die sich planmäßig und regelmäßig anmelden – etwa das Gegenbuch.
|
||||
Ihre Anmeldungen erscheinen im Audit-Log als Routine statt als kritisches
|
||||
Ereignis. Dafür wird gemeldet, wenn ein solches Konto <em>ausbleibt</em>.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -498,7 +538,28 @@ function UserModal({
|
||||
<Shield className="w-4 h-4 text-blue-600" />
|
||||
DSGVO-Zugriff
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">(Audit-Logs, Datenschutz)</span>
|
||||
<span className="text-sm text-gray-500">(Audit-Protokoll lesen, Datenschutz)</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasAuditOpsAccess}
|
||||
onChange={(e) => setFormData({ ...formData, hasAuditOpsAccess: e.target.checked })}
|
||||
className="mt-1 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Shield className="w-4 h-4 text-amber-600" />
|
||||
Audit-Betrieb
|
||||
<span className="text-sm text-gray-500">(versiegeln, aufräumen, Aufbewahrung)</span>
|
||||
</span>
|
||||
<span className="block text-sm text-gray-500 mt-0.5">
|
||||
Erlaubt Eingriffe am Audit-Protokoll: Bestandssiegel setzen oder
|
||||
ersetzen, aufräumen, Aufbewahrungsfristen ändern. Bewusst getrennt
|
||||
vom DSGVO-Zugriff – wer das Protokoll beaufsichtigt, soll seine
|
||||
eigene Beweisgrundlage nicht ersetzen können.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
|
||||
@@ -1548,11 +1548,12 @@ export const userApi = {
|
||||
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
||||
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean;
|
||||
hasAuditOpsAccess?: boolean; isServiceAccount?: boolean; confirm?: string; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
||||
const res = await api.post<ApiResponse<User>>('/users', data);
|
||||
return res.data;
|
||||
},
|
||||
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[] }) => {
|
||||
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[]; isServiceAccount?: boolean; hasGdprAccess?: boolean; hasDeveloperAccess?: boolean; hasAuditOpsAccess?: boolean; confirm?: string }) => {
|
||||
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
@@ -1734,6 +1735,42 @@ export interface AuditLogSearchParams {
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/** Ergebnis der Integritaetspruefung des Audit-Protokolls. */
|
||||
export interface IntegrityResult {
|
||||
valid: boolean;
|
||||
checkedCount: number;
|
||||
/** Noch offene Beanstandungen – nur hieran haengt `valid`. */
|
||||
invalidEntries: number[];
|
||||
/** ERNST: Inhalt einer bestehenden Zeile wurde nachtraeglich veraendert. */
|
||||
tamperedEntries: number[];
|
||||
/** Verkettung unterbrochen; die Zeilen selbst koennen unversehrt sein. */
|
||||
chainGaps: number[];
|
||||
/** Luecken ohne protokolliertes Loeschungs-Manifest. */
|
||||
unexplainedGaps: number[];
|
||||
/** Alt-Luecken, die das Bestandssiegel als bereits vorhanden beglaubigt. */
|
||||
attestedGaps: number[];
|
||||
/**
|
||||
* Protokollierte Neuberechnungen der Kette. Nach einem Rehash ist die Kette
|
||||
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg. Die Aussage der
|
||||
* Pruefung reicht dann nur bis zur letzten Neuberechnung zurueck.
|
||||
*/
|
||||
rehashes: Array<{
|
||||
id: number;
|
||||
zeitpunkt: string;
|
||||
neuBerechnet: number | null;
|
||||
signiert: boolean;
|
||||
vorbefund: { manipuliert: number; luecken: number } | null;
|
||||
}>;
|
||||
/** Signierte Zeilen, die ohne AUDIT_HMAC_KEY nicht pruefbar sind. */
|
||||
unverifiableEntries: number[];
|
||||
backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
||||
backlogTampered: number[];
|
||||
backlogMissing: number[];
|
||||
backlogSealCount: number;
|
||||
tampered: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const auditLogApi = {
|
||||
search: async (params?: AuditLogSearchParams) => {
|
||||
const res = await api.get<ApiResponse<AuditLog[]>>('/audit-logs', { params });
|
||||
@@ -1752,7 +1789,7 @@ export const auditLogApi = {
|
||||
return res.data;
|
||||
},
|
||||
verifyIntegrity: async () => {
|
||||
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; unexplainedGaps: number[]; unverifiableEntries: number[]; tampered: boolean; message: string }>>('/audit-logs/verify');
|
||||
const res = await api.post<ApiResponse<IntegrityResult>>('/audit-logs/verify');
|
||||
return res.data;
|
||||
},
|
||||
rehash: async () => {
|
||||
@@ -1848,7 +1885,7 @@ export interface EmailLog {
|
||||
export type SecurityEventType =
|
||||
| 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED'
|
||||
| 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM'
|
||||
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS';
|
||||
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'AUDIT_SEAL_CHANGED' | 'SUSPICIOUS';
|
||||
|
||||
export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# ============================================================
|
||||
# Gegenbuch für OpenCRM
|
||||
# ============================================================
|
||||
# Diese Datei gehört auf den Gegenbuch-Rechner – NICHT auf den CRM-Server.
|
||||
#
|
||||
# Was hier passiert: Der Rechner holt regelmäßig einen kurzen Kontrollwert von
|
||||
# OpenCRM ab und schreibt ihn in ein Buch, das nur hier liegt. Wird später im
|
||||
# CRM etwas nachträglich verändert, widerspricht das dem Buch.
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Welche Bücher sollen laufen?
|
||||
# ============================================================
|
||||
# Gültige Werte: prod | staging | prod,staging | (leer = keins)
|
||||
# Genau so geschrieben – nicht "production" oder "test".
|
||||
#
|
||||
# Nur Staging testen: COMPOSE_PROFILES=staging
|
||||
# Später Prod dazunehmen: COMPOSE_PROFILES=prod,staging
|
||||
# Danach: docker compose up -d (der laufende Dienst bleibt unberührt)
|
||||
COMPOSE_PROFILES=staging
|
||||
|
||||
# Steht in den Einträgen des Buchs. Beliebiger Text, nur Kosmetik.
|
||||
NOTAR_EMAIL=gegenbuch@example.de
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Produktion
|
||||
# ============================================================
|
||||
# Adresse der OpenCRM-Instanz, von der geholt wird.
|
||||
# Gültig: vollständige URL mit https:// und OHNE Schrägstrich am Ende.
|
||||
PROD_CRM_URL=https://crm.example.de
|
||||
|
||||
# Zugang: ein eigenes Benutzerkonto im CRM, das NUR das Recht "audit:read" hat.
|
||||
# Wie man es anlegt, steht in der README unter "Zugang einrichten".
|
||||
#
|
||||
# Das Gegenbuch meldet sich damit bei jedem Durchlauf selbst an. Ein fest
|
||||
# hinterlegtes Token gibt es bewusst nicht – Zugangstoken laufen nach
|
||||
# 15 Minuten ab und wären beim nächsten Durchlauf längst ungültig.
|
||||
PROD_CRM_EMAIL=gegenbuch@deine-domain.de
|
||||
PROD_CRM_PASSWORD=
|
||||
|
||||
# Wie oft geprüft wird, in Sekunden.
|
||||
# Gültig: ganze Zahl > 0. Üblich: 3600 (stündlich), 900 (viertelstündlich)
|
||||
# Kürzer heißt: kleineres Zeitfenster, in dem eine Änderung unbemerkt bliebe.
|
||||
PROD_INTERVAL=3600
|
||||
|
||||
# Beim ALLERERSTEN Start einmalig setzen, danach wieder leeren.
|
||||
# Gültige Werte: true | (leer)
|
||||
# Grund: Die erste Eintragung legt fest, was als Ausgangszustand gilt – das
|
||||
# soll nicht versehentlich passieren.
|
||||
PROD_GENESIS_ACK=
|
||||
|
||||
# Normalerweise leer lassen.
|
||||
# Gültige Werte: true | (leer)
|
||||
# Nur nötig, wenn das Datenverzeichnis verlorenging (z. B. gelöscht) UND du
|
||||
# geklärt hast, warum. Siehe README, Abschnitt "Wenn das Gedächtnis fehlt".
|
||||
PROD_ADOPT_ACK=
|
||||
|
||||
# Normalerweise leer lassen.
|
||||
# Gültige Werte: die neue Siegelwurzel (mind. 16 Zeichen) | (leer)
|
||||
# Das Gegenbuch schlägt Alarm, wenn sich die Wurzel des Bestandssiegels
|
||||
# ändert – denn ein erneutes Siegeln ersetzt die Grundlage, gegen die
|
||||
# Manipulation nachgewiesen wird. War der Wechsel gewollt, hier die Wurzel
|
||||
# eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
|
||||
# Bewusst KEIN "true": ein stehen gelassener Wert passt beim nächsten
|
||||
# Wechsel nicht mehr und kann darum keinen weiteren stillschweigend
|
||||
# durchwinken.
|
||||
PROD_SEAL_ACK=
|
||||
|
||||
# Normalerweise leer lassen.
|
||||
# Gültige Werte: die ID des Rehash-Eintrags | (leer)
|
||||
# Das Gegenbuch schlägt Alarm, wenn die Hash-Kette neu berechnet wurde. Ein
|
||||
# Rehash verknüpft alle Einträge neu – Lücken, die eine Löschung sichtbar
|
||||
# gemacht hätten, verschwinden dabei aus der Kette, und die Prüfung im CRM
|
||||
# meldet danach wieder „lückenlos". War die Neuberechnung geplant, hier die
|
||||
# ID eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
|
||||
PROD_REHASH_ACK=
|
||||
|
||||
# Wo das Buch liegt – relativ zu diesem Verzeichnis.
|
||||
# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel).
|
||||
PROD_DIR=./data/prod
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Test / Staging
|
||||
# ============================================================
|
||||
# Gleiche Regeln wie oben, eigenes Konto und eigenes Verzeichnis.
|
||||
STAGING_CRM_URL=https://staging.example.de
|
||||
STAGING_CRM_EMAIL=gegenbuch@deine-domain.de
|
||||
STAGING_CRM_PASSWORD=
|
||||
STAGING_INTERVAL=3600
|
||||
STAGING_GENESIS_ACK=
|
||||
STAGING_ADOPT_ACK=
|
||||
STAGING_SEAL_ACK=
|
||||
STAGING_REHASH_ACK=
|
||||
STAGING_DIR=./data/staging
|
||||
@@ -0,0 +1,27 @@
|
||||
# Gegenbuch – läuft auf einem EIGENEN Rechner, nicht dort, wo OpenCRM läuft.
|
||||
#
|
||||
# Der Container holt lesend einen Kontrollwert vom CRM (HTTPS + Token mit
|
||||
# audit:read) und legt seine Einträge in einem Git-Repository auf DIESER
|
||||
# Maschine ab. Er braucht keinen SSH-Zugang zum CRM, keine Datenbank, keine
|
||||
# Shell dort. Umgekehrt kennt OpenCRM dieses Gegenbuch überhaupt nicht.
|
||||
FROM node:20-slim
|
||||
|
||||
# git für das Buch, openssh-client für die Signaturen (ssh-keygen).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git openssh-client ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY notary.mjs /opt/notary/notary.mjs
|
||||
COPY entrypoint.sh /opt/notary/entrypoint.sh
|
||||
RUN chmod +x /opt/notary/entrypoint.sh
|
||||
|
||||
# Der Container STARTET als root – aber nur, um die Rechte auf dem
|
||||
# Datenverzeichnis geradezuziehen. Danach gibt der entrypoint die Privilegien
|
||||
# ab und arbeitet als unprivilegierter Benutzer weiter.
|
||||
#
|
||||
# Grund: Das Datenverzeichnis kommt als Bind-Mount vom Host. Wer das Projekt
|
||||
# als root geklont hat, hat dort root-eigene Verzeichnisse – ein Container, der
|
||||
# direkt als UID 1000 startet, kann darin nichts anlegen.
|
||||
WORKDIR /gegenbuch
|
||||
|
||||
ENTRYPOINT ["/opt/notary/entrypoint.sh"]
|
||||
@@ -6,9 +6,59 @@ absichern sollen. Wer dort schreiben kann, sitzt am Ende immer schon auf der
|
||||
Ebene, die den Beweis führt. Genau das hat der Pentest über mehrere Runden
|
||||
Schicht für Schicht gezeigt.
|
||||
|
||||
Das Gegenbuch durchbricht das: Ein zweiter Rechner holt regelmäßig einen kurzen
|
||||
Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und hängt ihn
|
||||
signiert an ein privates Repository an.
|
||||
Das Gegenbuch durchbricht das: Ein **zweiter Rechner** holt regelmäßig einen
|
||||
kurzen Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und schreibt
|
||||
ihn signiert fort. Wird später im CRM etwas nachträglich verändert,
|
||||
widerspricht das dem Gegenbuch.
|
||||
|
||||
## Was du hier suchst
|
||||
|
||||
**Einrichten**
|
||||
[Betriebsart wählen](#zwei-betriebsarten--erst-hier-entscheiden) ·
|
||||
[mit Docker](#einrichten-mit-docker-empfohlen) ·
|
||||
[erster Start](#erster-start--drei-schritte) ·
|
||||
[Zugangskonto im CRM](#zugang-einrichten-das-brauchst-du-vorher) ·
|
||||
[ohne Docker](#einrichten-ohne-docker)
|
||||
|
||||
**Im Betrieb**
|
||||
[**Altbestand versiegeln**](#den-altbestand-versiegeln-einmalig-im-crm) ·
|
||||
[Rückgabecodes](#rückgabecodes) ·
|
||||
[Überwachung](#überwachung) ·
|
||||
[wo die Daten liegen](#wo-die-daten-liegen)
|
||||
|
||||
**Wenn Alarm kommt**
|
||||
[Siegelwurzel hat gewechselt](#wenn-sich-die-siegelwurzel-ändert-alarm-und-warum) ·
|
||||
[Kette wurde neu berechnet](#wenn-die-kette-neu-berechnet-wurde-ebenfalls-alarm) ·
|
||||
[Zurückspulen erkannt](#der-rewind-wächter) ·
|
||||
[Gedächtnis fehlt](#wenn-das-gedächtnis-trotzdem-fehlt)
|
||||
|
||||
**Zum Nachlesen**
|
||||
[was erkannt wird](#was-das-skript-erkennt) ·
|
||||
[was nicht – ehrlich](#was-es-nicht-leistet--ehrlich) ·
|
||||
[Prüfmodus für Auditoren](#prüfmodus-für-auditoren)
|
||||
|
||||
> **Nur schnell versiegeln?** Der Abschnitt
|
||||
> [Altbestand versiegeln](#den-altbestand-versiegeln-einmalig-im-crm) ist
|
||||
> eigenständig und gilt **auch ohne Gegenbuch** – es ist ein Vorgang im CRM,
|
||||
> nicht hier.
|
||||
|
||||
## Zwei Betriebsarten – erst hier entscheiden
|
||||
|
||||
| | **Lokal** (Normalfall) | **Mit externem Repository** |
|
||||
|---|---|---|
|
||||
| Das Buch liegt | auf dem Gegenbuch-Rechner | zusätzlich auf einem dritten Server |
|
||||
| Aufwand | Docker starten, fertig | privates Git-Repo, Schlüssel, Server-Regeln |
|
||||
| Schützt gegen | jemand verändert Daten **im CRM** | zusätzlich: jemand übernimmt den **Gegenbuch-Rechner** |
|
||||
|
||||
**Für die allermeisten Installationen ist „lokal" die richtige Wahl.** Der
|
||||
Schutz, um den es geht – nachträgliche Änderungen im CRM auffliegen zu lassen –
|
||||
steht damit vollständig. Die zweite Variante deckt einen Angreifer ab, der
|
||||
zusätzlich den Gegenbuch-Rechner übernimmt; sie kostet spürbar mehr Einrichtung
|
||||
und laufende Aufmerksamkeit.
|
||||
|
||||
Diese Anleitung beschreibt zuerst den lokalen Betrieb. Alles zur zweiten
|
||||
Variante steht gesammelt unter **„Zusätzliche Härtung"** weiter unten – wer
|
||||
lokal betreibt, kann diesen ganzen Teil überspringen.
|
||||
|
||||
## Die eine nicht verhandelbare Bedingung
|
||||
|
||||
@@ -18,7 +68,406 @@ der erste – es sieht nach doppeltem Boden aus und ist keiner. Das CRM braucht
|
||||
für dieses Verfahren **gar nichts** zu wissen: Es liefert nur einen lesbaren
|
||||
Kontrollwert, der keine Geheimnisse enthält.
|
||||
|
||||
## Einrichten
|
||||
## Einrichten mit Docker (empfohlen)
|
||||
|
||||
Auf dem **Gegenbuch-Rechner** – nicht auf dem CRM-Server:
|
||||
|
||||
```bash
|
||||
git clone <dieses Repository> opencrm
|
||||
cd opencrm/tools/audit-notary
|
||||
cp .env.example .env
|
||||
# .env ausfüllen: CRM-Adresse und Token eintragen
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Erster Start – drei Schritte
|
||||
|
||||
Beim allerersten Lauf meldet der Container `exit=4` und verlangt eine
|
||||
Bestätigung. Das ist Absicht: Der erste Eintrag legt fest, was als
|
||||
Ausgangszustand gilt.
|
||||
|
||||
```bash
|
||||
# 1. In der .env freigeben
|
||||
STAGING_GENESIS_ACK=true
|
||||
|
||||
# 2. Neu starten (kein --build nötig, nur die Umgebung ändert sich)
|
||||
docker compose up -d && docker compose logs -f
|
||||
# Erwartet: "OK: Checkpoint 1 erstellt" und exit=3
|
||||
# exit=3 ist hier richtig – beim ersten Mal gibt es nichts zu vergleichen.
|
||||
|
||||
# 3. Wieder leeren und erneut starten
|
||||
STAGING_GENESIS_ACK=
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Ab dem nächsten Durchlauf steht dort `exit=0 (in Ordnung)`.
|
||||
|
||||
**Warum Schritt 3 wichtig ist:** Bleibt die Zeile auf `true`, würde der
|
||||
Container nach einem Verlust des Datenverzeichnisses stillschweigend eine neue
|
||||
Grundlage setzen, statt zu fragen – genau davor schützt die Abfrage.
|
||||
|
||||
Für Produktion später dasselbe mit `PROD_GENESIS_ACK`. Schnell nachsehen ohne
|
||||
Logs: `cat data/staging/status.txt`.
|
||||
|
||||
Beim ersten Start einmalig `PROD_GENESIS_ACK=true` setzen (und danach wieder
|
||||
leeren) – die erste Eintragung legt fest, was als Ausgangszustand gilt, und das
|
||||
soll nicht versehentlich passieren.
|
||||
|
||||
**Zwei Bücher auf einer Maschine** sind vorgesehen: `prod` und `staging` sind
|
||||
getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
|
||||
Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`.
|
||||
|
||||
### Den Altbestand versiegeln (einmalig, im CRM)
|
||||
|
||||
Ein CRM, das schon länger läuft, hat fast immer einen **Altbestand** – Einträge
|
||||
aus der Zeit, bevor das Protokoll signiert wurde. Solange der nicht versiegelt
|
||||
ist, meldet die Prüfung `valid: false`, das Gegenbuch schlägt zu Recht Alarm –
|
||||
und, wichtig: **es beglaubigt so lange gar nichts.** Es bricht vor dem Anhängen
|
||||
ab, weil ein Checkpoint über einen ungeklärten Zustand diesen mitbeglaubigen
|
||||
würde. Ein unversiegeltes CRM ist also nicht „bewacht mit Warnung", sondern
|
||||
unbewacht.
|
||||
|
||||
Das Siegeln ist **einmalig** und passiert im CRM, nicht hier.
|
||||
|
||||
#### Wer darf das
|
||||
|
||||
Das Recht `audit:admin`. Das bekommt man über den Haken **„Audit-Betrieb"** in
|
||||
der Benutzerverwaltung – **nicht** über die Admin-Rolle (die hat bewusst keine
|
||||
Audit-Rechte) und **nicht** über den DSGVO-Haken (der darf lesen, nicht
|
||||
eingreifen). Das Gegenbuch-Dienstkonto hat es erst recht nicht.
|
||||
|
||||
```bash
|
||||
CRM=https://<crm>
|
||||
read -s -p "Passwort: " PASS; echo
|
||||
AT=$(curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.token')
|
||||
curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.user.permissions[]' | grep audit
|
||||
```
|
||||
|
||||
Muss `audit:read` **und** `audit:admin` zeigen.
|
||||
|
||||
#### Erst nachsehen, was du festschreibst
|
||||
|
||||
Das Siegel hält den **aktuellen** Zustand fest, samt aller vorhandenen Lücken.
|
||||
Wer blind siegelt, beglaubigt womöglich eine Lücke, die von einer Löschung
|
||||
stammt. Der Schritt ist praktisch einwegs: Danach ist die Beglaubigung tragend,
|
||||
und ein Siegel wieder zu entfernen erzeugt den Zustand `entfernt` – also selbst
|
||||
einen Befund.
|
||||
|
||||
```bash
|
||||
B="Authorization: Bearer $AT"
|
||||
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
|
||||
| jq '.data | {valid, checkedCount, rehashes, chainGaps, unexplainedGaps,
|
||||
tamperedEntries, backlogSealStatus}'
|
||||
```
|
||||
|
||||
Vier Dinge müssen stimmen:
|
||||
|
||||
| Feld | Erwartung | Warum |
|
||||
|---|---|---|
|
||||
| `rehashes` | `[]` | Eine Neuberechnung verknüpft alles neu – danach ist die Kette *zwangsläufig* stimmig, auch über Löschungen hinweg. Steht hier etwas, sagt `chainGaps` nichts über die Zeit davor aus. |
|
||||
| `tamperedEntries` | `[]` | Veränderter Inhalt gehört geklärt, nicht beglaubigt. |
|
||||
| `chainGaps` | erklärbar | siehe unten |
|
||||
| `backlogSealStatus` | `kein_siegel` | sonst ist es kein Erst-Siegeln (siehe nächster Abschnitt) |
|
||||
|
||||
**Lücken einordnen.** Sieh dir die betroffenen IDs und ihre Nachbarn an:
|
||||
|
||||
```bash
|
||||
for id in <lücke-1> <nachbar> …; do
|
||||
printf '%-6s ' "$id"
|
||||
curl -s -o /tmp/r.json -w 'HTTP %{http_code} ' "$CRM/api/audit-logs/$id" -H "$B"
|
||||
jq -r 'if .success then (.data|"\(.createdAt[0:19]) v\(.hashVersion) \(.action)/\(.resourceType)") else .error end' /tmp/r.json
|
||||
done
|
||||
```
|
||||
|
||||
> **Den HTTP-Code mit ausgeben, immer.** Zugangstoken laufen nach 15 Minuten ab.
|
||||
> Ohne den Code liest sich eine abgelehnte Anfrage (401) wie ein fehlender
|
||||
> Eintrag – ein Prüfwerkzeug, das ein verweigertes Lesen als Löschung meldet.
|
||||
> Uns ist genau das passiert, mitten in der Vorbereitung eines Prod-Siegels.
|
||||
|
||||
Liegen die Lücken jeweils **innerhalb einer Sekunde** zusammen mit ihren
|
||||
Nachbarn, betreffen nur `Authentication` und fehlt kein Eintrag, sind es
|
||||
Schreibkollisionen aus parallelen Anfragen – harmlos, historisch, nicht mehr
|
||||
reproduzierbar. Fehlt dagegen ein Eintrag oder passt eine Lücke nicht in dieses
|
||||
Bild: **erst klären, dann siegeln.**
|
||||
|
||||
*(Ein abgeschnittener **Anfang** des Protokolls wird seit dem Anfangs-Detektor
|
||||
mitgeprüft: Die erste Zeile eines Protokolls trägt einen leeren `previousHash`;
|
||||
trägt die erste vorhandene Zeile einen Wert, fehlt eine Vorgängerin. Das
|
||||
erscheint als ganz niedrige Lücke und kippt `valid` – du musst es nicht selbst
|
||||
suchen.)*
|
||||
|
||||
#### Siegeln
|
||||
|
||||
```bash
|
||||
curl -s -X POST $CRM/api/audit-logs/seal-backlog -H "$B" \
|
||||
-H 'Content-Type: application/json' -d '{"confirm":"SEAL"}' | jq
|
||||
```
|
||||
|
||||
`SEAL` ist das Erst-Siegeln. Besteht bereits ein Siegel, verlangt der Endpunkt
|
||||
stattdessen `RESEAL` – siehe nächster Abschnitt. Notiere `root` aus der Antwort.
|
||||
|
||||
#### Danach: drei Punkte gegenprüfen
|
||||
|
||||
```bash
|
||||
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
|
||||
| jq '.data | {valid, chainGaps, attestedGaps, backlogSealStatus, backlogSealCount}'
|
||||
|
||||
SID=$(curl -s "$CRM/api/audit-logs?resourceType=AuditBacklogSeal&limit=1" -H "$B" | jq -r '.data[0].id')
|
||||
curl -s "$CRM/api/audit-logs/$SID" -H "$B" \
|
||||
| jq '{vorbefund:.data.changesBefore.befund.ketten_luecken, siegel:.data.changesAfter}'
|
||||
```
|
||||
|
||||
1. `valid: true`, und `chainGaps` = `attestedGaps` = die bekannten Lücken
|
||||
2. `backlogSealStatus: "intakt"` – **nicht** `leer`. `leer` heißt: Das Siegel
|
||||
umschließt nichts, es gab keinen Altbestand. Kein Fehler, aber auch keine
|
||||
Zusage.
|
||||
3. Die Lücken stehen im `changesBefore.befund.ketten_luecken` des Markers –
|
||||
genau daran hängt die Beglaubigung.
|
||||
|
||||
Die Lücken verschwinden also **nicht** aus dem Bericht. Sie zählen nur nicht
|
||||
mehr als offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte
|
||||
Altzeile und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus.
|
||||
|
||||
#### Zuletzt: das Gegenbuch quittieren
|
||||
|
||||
Der nächste Lauf meldet **einmal** `exit=2` („Erstmals ein Bestandssiegel
|
||||
gesetzt") – erwartet, kein Befund. Wurzel aus `data/<instanz>/status.txt` in
|
||||
`PROD_SEAL_ACK` bzw. `STAGING_SEAL_ACK` eintragen, `docker compose up -d`, nach
|
||||
dem Lauf mit `exit=0` wieder leeren. Details im nächsten Abschnitt.
|
||||
|
||||
Ab da schreibt das Gegenbuch wieder Checkpoints fort – und setzt im selben Lauf
|
||||
die Grundlage für die Rehash-Überwachung.
|
||||
|
||||
### Wenn sich die Siegelwurzel ändert: Alarm, und warum
|
||||
|
||||
Erneutes Siegeln **ersetzt** die Grundlage, gegen die Manipulation nachgewiesen
|
||||
wird. Wer eine Altzeile per Datenbankzugriff entfernt und danach neu siegelt,
|
||||
bekommt eine passende Wurzel und eine beglaubigte Lücke — und die Prüfung im
|
||||
CRM meldet wieder `valid: true`. Der Wechsel der Wurzel ist die einzige Spur
|
||||
davon, die eine Maschine sehen kann.
|
||||
|
||||
Deshalb ist er ein **Alarm** (exit 2), kein Hinweis. Vorher stand hier eine
|
||||
Zeile Prosa, während der Rückgabecode auf 0 blieb — also genau das Muster, das
|
||||
wir dem CRM selbst zweimal angekreidet haben.
|
||||
|
||||
Der Alarm nennt die alte und die neue Wurzel samt Blattzahl. Ein Sprung von
|
||||
`10 Blätter` auf `9 Blätter` sagt sofort, was passiert ist.
|
||||
|
||||
**War der Wechsel gewollt** (typisch: dein einmaliges Erstsiegeln), bestätigst
|
||||
du ihn mit der Wurzel, die der Alarm ausgibt:
|
||||
|
||||
```bash
|
||||
# in der .env
|
||||
PROD_SEAL_ACK=72062c88a8b6e2b53b30496b483885cd
|
||||
|
||||
docker compose up -d # ein Lauf – die neue Wurzel wird beglaubigt
|
||||
PROD_SEAL_ACK= # danach wieder leeren
|
||||
```
|
||||
|
||||
Bestätigt wird bewusst **nicht** mit `true`, sondern mit der Wurzel selbst.
|
||||
Ein versehentlich stehen gelassener Wert passt beim nächsten Wechsel nicht mehr
|
||||
und kann deshalb keinen weiteren Austausch stillschweigend durchwinken.
|
||||
|
||||
**War er nicht gewollt**, sieh im CRM nach: das Sicherheits-Ereignis
|
||||
`AUDIT_SEAL_CHANGED` (Einstellungen → Monitoring) und die CRITICAL-Zeile zu
|
||||
`/api/audit-logs/seal-backlog` im Audit-Protokoll nennen Konto, Zeitpunkt und
|
||||
den Befund, der vor dem Siegeln galt.
|
||||
|
||||
Im CRM selbst ist erneutes Siegeln zusätzlich gegatet: es verlangt
|
||||
`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der
|
||||
Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten.
|
||||
|
||||
### Wenn die Kette neu berechnet wurde: ebenfalls Alarm
|
||||
|
||||
Ein **Rehash** verknüpft alle Einträge neu. Danach ist die Kette
|
||||
zwangsläufig stimmig — auch über Löschungen hinweg, die vorher als Lücken
|
||||
sichtbar gewesen wären. Die Reihenfolge `cleanup` → `rehash` macht aus einem
|
||||
beschnittenen Protokoll ein scheinbar makelloses, und die Prüfung im CRM meldet
|
||||
danach wieder „lückenlos verkettet". Beides braucht nur `audit:admin`, keinen
|
||||
Datenbankzugriff.
|
||||
|
||||
Genau das ist im Betrieb vorgekommen: Auf einer Testinstanz wurden 3.155
|
||||
Einträge gelöscht und anschließend neu berechnet. Die Prüfung war danach grün,
|
||||
die 656 Kettenlücken standen nur noch im Vorbefund des Rehash-Eintrags — den
|
||||
niemand liest. Das Gegenbuch war der einzige Zeuge.
|
||||
|
||||
Deshalb ist eine neue Neuberechnung seit der letzten Beglaubigung ein **Alarm**
|
||||
(exit 2). Er nennt Zeitpunkt, Zahl der betroffenen Zeilen und den Befund, der
|
||||
unmittelbar davor galt:
|
||||
|
||||
```
|
||||
ALARM: Die Hash-Kette wurde neu berechnet (1 neuer Vorgang seit der letzten Beglaubigung).
|
||||
zuletzt: 2026-08-26T16:28:25.467Z (Eintrag 13, 9 Zeilen)
|
||||
Befund unmittelbar davor: 1 beanstandet, 1 Lücken
|
||||
…
|
||||
NOTARY_REHASH_ACK=13
|
||||
```
|
||||
|
||||
**War die Neuberechnung gewollt**, bestätigst du sie mit der genannten ID:
|
||||
|
||||
```bash
|
||||
# in der .env
|
||||
PROD_REHASH_ACK=13
|
||||
docker compose up -d
|
||||
PROD_REHASH_ACK= # danach wieder leeren
|
||||
```
|
||||
|
||||
Auch hier wird nicht mit `true` bestätigt, sondern mit einem Wert, der zum
|
||||
Vorgang gehört. IDs steigen streng — ein stehen gelassener Wert passt bei der
|
||||
nächsten Neuberechnung nicht mehr.
|
||||
|
||||
**Warum ein eigener Melder, wo doch schon der Kettenkopf verglichen wird?**
|
||||
Eine Neuberechnung ändert jeden Hash, der beglaubigte Kopf stimmt also
|
||||
ohnehin nicht mehr — der Alarm käme auch so. Aber als *Nebenwirkung*, nicht als
|
||||
gebaute Warnung: Verschöbe sich der Anker irgendwann, wäre der Melder lautlos
|
||||
weg. Und die Meldung hieße „Eintrag wurde verändert" statt „die Kette wurde neu
|
||||
berechnet" — die Wirkung statt der Ursache. Deshalb hängt der Alarm an der
|
||||
Sache selbst und steht **vor** dem Kopf-Vergleich; nach einer bestätigten
|
||||
Neuberechnung wird dieser übersprungen, weil der veränderte Kopf dann die
|
||||
erwartete Folge ist.
|
||||
|
||||
Ein fehlender beglaubigter Eintrag bleibt davon unberührt und alarmiert immer:
|
||||
Bestätigt wird die Neuberechnung, nicht das Verschwinden von Zeilen.
|
||||
|
||||
### Zugang einrichten (das brauchst du vorher)
|
||||
|
||||
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM – kein Token. Der
|
||||
Grund: Zugangstoken laufen nach 15 Minuten ab und wären beim nächsten
|
||||
stündlichen Durchlauf längst ungültig. Das Gegenbuch meldet sich deshalb bei
|
||||
jedem Lauf selbst an.
|
||||
|
||||
Im CRM, als Administrator:
|
||||
|
||||
1. **Benutzer anlegen**, z. B. `gegenbuch@deine-domain.de`, mit einem langen,
|
||||
zufälligen Passwort.
|
||||
2. Ihm die Rolle **`Gegenbuch`** geben – **nur diese**. Sie bringt genau ein
|
||||
Recht mit: `audit:read`.
|
||||
3. Zusätzlich **„Dienstkonto"** ankreuzen. Dann gelten seine Anmeldungen als
|
||||
Routine statt als kritisches Ereignis – und sein *Ausbleiben* wird gemeldet.
|
||||
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
|
||||
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
|
||||
|
||||
> **Weder den DSGVO- noch den Audit-Betrieb-Haken setzen.** Der DSGVO-Haken
|
||||
> gibt zusätzlich Leserechte auf personenbezogene Daten und den Export; der
|
||||
> Haken „Audit-Betrieb" gibt `audit:admin` mit `seal-backlog`, `rehash` und
|
||||
> `cleanup`. Ein Einbruch auf dieser Maschine hätte damit nicht nur den
|
||||
> Wächter, sondern gleich die Mittel, das Bewachte umzuschreiben. Das Passwort
|
||||
> steht hier im Klartext in der `.env`; es muss so wenig wert sein wie möglich.
|
||||
|
||||
Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** – keine
|
||||
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
|
||||
die Zugangsdaten abhandenkommen, ist damit nichts anzufangen.
|
||||
|
||||
Gegenprobe nach dem Einrichten – **200, dann dreimal 403**:
|
||||
|
||||
```bash
|
||||
B="Authorization: Bearer $TOKEN"
|
||||
curl -s -o /dev/null -w 'checkpoint %{http_code}\n' https://<crm>/api/audit-logs/checkpoint -H "$B"
|
||||
curl -s -o /dev/null -w 'export %{http_code}\n' https://<crm>/api/audit-logs/export -H "$B"
|
||||
curl -s -o /dev/null -w 'seal-backlog %{http_code}\n' -X POST https://<crm>/api/audit-logs/seal-backlog -H "$B" -H 'Content-Type: application/json' -d '{}'
|
||||
curl -s -o /dev/null -w 'kunden %{http_code}\n' https://<crm>/api/customers -H "$B"
|
||||
```
|
||||
|
||||
Der Export gehört ausdrücklich dazu: Er liefert `changesBefore`/`changesAfter`,
|
||||
also die vollständigen Vorher/Nachher-Datensätze samt Klartextnamen. Prüfwerte
|
||||
lesen und das Protokoll herausziehen sind zwei verschiedene Dinge – deshalb
|
||||
hängt der Export an `audit:export`, das die Rolle `Gegenbuch` nicht hat.
|
||||
|
||||
Für Produktion und Test jeweils ein eigenes Konto in der jeweiligen Instanz.
|
||||
|
||||
> Jede Anmeldung erscheint im Audit-Log der jeweiligen Instanz. Das ist so
|
||||
> gewollt: Man sieht, dass das Gegenbuch arbeitet – und wenn es aufhört, fällt
|
||||
> auch das auf.
|
||||
|
||||
### Wer redet mit wem
|
||||
|
||||
```
|
||||
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read)
|
||||
OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
|
||||
```
|
||||
|
||||
Das ist der eigentliche Schutz. Das Gegenbuch **holt** – es lässt sich nichts
|
||||
schicken. OpenCRM kennt weder Adresse noch Schlüssel des Gegenbuchs. Wer
|
||||
OpenCRM übernimmt, kommt hier nicht heran.
|
||||
|
||||
Das Token kann ausschließlich Prüfwerte lesen: keine Kundendaten, keine
|
||||
Änderungen. Selbst wenn es abhandenkommt, ist damit nichts anzufangen.
|
||||
|
||||
Der Signaturschlüssel wird beim ersten Start **auf dem Gegenbuch-Rechner
|
||||
erzeugt** und verlässt ihn nie.
|
||||
|
||||
### Wo die Daten liegen
|
||||
|
||||
Im Projektverzeichnis, nicht in Docker-Volumes:
|
||||
|
||||
```
|
||||
tools/audit-notary/data/prod/
|
||||
buch/ das Gegenbuch selbst (ein Git-Repository)
|
||||
schluessel/ Signaturschlüssel – wird beim ersten Start erzeugt
|
||||
beobachtungen.jsonl was zuletzt gesehen wurde
|
||||
status.txt Ergebnis des letzten Durchlaufs
|
||||
```
|
||||
|
||||
Der Inhalt ist vom Repository ausgenommen – der Signaturschlüssel gehört dort
|
||||
nicht hinein.
|
||||
|
||||
**Zu Dateirechten:** Der Container startet kurz als `root`, setzt das
|
||||
Datenverzeichnis auf den Arbeitsbenutzer um und gibt die Privilegien dann ab.
|
||||
Das ist nötig, weil das Verzeichnis vom Host kommt – wer das Projekt als `root`
|
||||
geklont hat, hätte sonst ein Verzeichnis, in das der Container nicht schreiben
|
||||
darf. Passiert automatisch, du musst nichts tun. Ein anderer Zielbenutzer geht
|
||||
über `PUID`/`PGID`. Die Verzeichnisse selbst sind über `.gitkeep` vorhanden, damit
|
||||
sie nach einem `git clone` schon existieren und Docker sie nicht als `root`
|
||||
anlegt.
|
||||
|
||||
**Ins Backup gehört genau dieses Verzeichnis.** Geht es verloren, beginnt die
|
||||
Beobachtung von vorn – und der nächste Lauf sagt das ausdrücklich, statt „alles
|
||||
gut" zu melden.
|
||||
|
||||
### Überwachung
|
||||
|
||||
Jeder Durchlauf schreibt seinen Stand nach `data/<instanz>/status.txt`:
|
||||
|
||||
```
|
||||
2026-08-22T16:49:50+00:00 exit=0 in Ordnung
|
||||
```
|
||||
|
||||
**Alles außer `exit=0` gehört angesehen.** Wer eine Überwachung hat, greift
|
||||
diese Datei ab; wer keine hat, schaut regelmäßig mit `docker compose logs`
|
||||
hinein. Ein Alarm, den niemand liest, ist keiner.
|
||||
|
||||
### Was dieser Betrieb abdeckt – und was nicht
|
||||
|
||||
**Abgedeckt:** Jemand verändert oder löscht nachträglich Einträge im CRM –
|
||||
auch mit direktem Datenbankzugriff. Das widerspricht dem Gegenbuch und fällt
|
||||
beim nächsten Durchlauf auf.
|
||||
|
||||
**Nicht abgedeckt:** Jemand übernimmt den Gegenbuch-Rechner selbst. Dagegen
|
||||
hülfe nur eine zusätzliche Ablage außerhalb (z. B. ein privates Git-Repository
|
||||
auf einem dritten Server) – das ist vorbereitet, aber für die meisten
|
||||
Installationen mehr Aufwand als Nutzen. Die Erfolgsmeldung benennt diese Grenze
|
||||
bei jedem Lauf, damit sie nicht in Vergessenheit gerät.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# Zusätzliche Härtung: externes Repository
|
||||
|
||||
> **Alles ab hier gilt nur für die zweite Betriebsart.** Wer das Gegenbuch
|
||||
> lokal auf einer eigenen Maschine betreibt – der Normalfall, siehe oben – kann
|
||||
> diesen gesamten Abschnitt überspringen. Die Anforderungen darin (Git-Server,
|
||||
> Rewind-Sperre, geschützte Refs) beziehen sich auf ein zusätzliches Repository
|
||||
> auf einem dritten Server und existieren im lokalen Betrieb nicht.
|
||||
|
||||
Sinn der Variante: Beim lokalen Betrieb liegt das Buch auf demselben Rechner
|
||||
wie der Signaturschlüssel. Wer diesen Rechner übernimmt, kann beides
|
||||
manipulieren. Ein zusätzliches Repository auf einem dritten Server, das nur
|
||||
Anhängen erlaubt, schließt auch das – vorausgesetzt, dessen Regeln stimmen.
|
||||
|
||||
## Einrichten ohne Docker
|
||||
|
||||
Auf einem **anderen** Rechner als dem CRM-Server:
|
||||
|
||||
@@ -210,7 +659,7 @@ Deshalb gilt jetzt:
|
||||
|---|---|
|
||||
| 0 | alles in Ordnung, Checkpoint angehängt (bzw. Prüfung bestanden) |
|
||||
| 1 | Betriebsfehler (Konfiguration, Commit oder Push fehlgeschlagen) |
|
||||
| 2 | **Befund** – Widerspruch zwischen CRM und Gegenbuch, oder ungültige Signatur |
|
||||
| 2 | **Befund** – Widerspruch zwischen CRM und Gegenbuch, ungültige Signatur, oder die Wurzel des Bestandssiegels hat sich geändert (`NOTARY_SEAL_ACK`) |
|
||||
| 5 | **Anker unvollständig** – die Kette ist gültig, aber `refs/notary/seq-N` fehlt. Reparierbar durch einen Notar-Schreiblauf |
|
||||
| 4 | **Wächter-Gedächtnis fehlt** – Erstinbetriebnahme unbestätigt, oder Speicher nach der Etablierung verloren |
|
||||
| 3 | beglaubigter Stand nicht abschließend feststellbar – Remote fehlt/unerreichbar, erste Beobachtung, Zurückspulen nicht ausschließbar, **oder** Checkpoint erstellt aber nicht verankert |
|
||||
@@ -259,6 +708,9 @@ an, bis er geklärt ist.
|
||||
| Untergeschobener Commit | Commit ohne gültige Signatur in der Historie |
|
||||
| Nie gepushte lokale Commits | Abgleich gegen den Remote-Kopf |
|
||||
| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen |
|
||||
| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt – `valid` allein bleibt dabei `true`** |
|
||||
| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine |
|
||||
| **`cleanup` + `rehash` ohne erneutes Siegeln** (Wäsche ohne Datenbankzugriff) | **neue Neuberechnung seit der letzten Beglaubigung – `valid` und Kette sind danach makellos** |
|
||||
|
||||
Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt
|
||||
nichts an** – der manipulierte Zustand wird also nicht als neue Wahrheit
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Hier liegen die Gegenbuch-Daten (je Instanz ein Unterverzeichnis):
|
||||
|
||||
data/prod/ Buch, Signaturschlüssel, Beobachtungsspeicher, status.txt
|
||||
data/staging/ dasselbe für die Testinstanz
|
||||
|
||||
Der Inhalt wird bewusst NICHT im Repository geführt – er enthält den
|
||||
Signaturschlüssel. Dieses Verzeichnis gehört ins Backup.
|
||||
@@ -0,0 +1,3 @@
|
||||
Gegenbuch-Daten der Produktivinstanz.
|
||||
Inhalt wird nicht im Repository gefuehrt (enthaelt den Signaturschluessel).
|
||||
Dieses Verzeichnis gehoert ins Backup.
|
||||
@@ -0,0 +1,2 @@
|
||||
Gegenbuch-Daten der Testinstanz.
|
||||
Inhalt wird nicht im Repository gefuehrt.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Gegenbuch für OpenCRM
|
||||
# =====================
|
||||
# Gehört auf einen EIGENEN Rechner – nicht dorthin, wo OpenCRM läuft.
|
||||
#
|
||||
# Richtung der Verbindung (das ist der eigentliche Schutz):
|
||||
# Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Token nur mit audit:read)
|
||||
# OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
|
||||
#
|
||||
# Wer OpenCRM übernimmt, kommt damit nicht an dieses Buch heran. Deshalb holt
|
||||
# das Gegenbuch selbst, statt sich etwas schicken zu lassen.
|
||||
#
|
||||
# Start:
|
||||
# cp .env.example .env # Werte eintragen
|
||||
# docker compose up -d
|
||||
#
|
||||
# Zwei Bücher auf einer Maschine sind vorgesehen (Produktion und Test): zwei
|
||||
# getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
|
||||
# Welche laufen, steuert COMPOSE_PROFILES in der .env.
|
||||
|
||||
services:
|
||||
prod:
|
||||
build: .
|
||||
container_name: gegenbuch-prod
|
||||
restart: unless-stopped
|
||||
profiles: ["prod"]
|
||||
environment:
|
||||
INSTANZ: prod
|
||||
CRM_URL: ${PROD_CRM_URL}
|
||||
CRM_EMAIL: ${PROD_CRM_EMAIL}
|
||||
CRM_PASSWORD: ${PROD_CRM_PASSWORD}
|
||||
NOTARY_INTERVAL: ${PROD_INTERVAL:-3600}
|
||||
NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost}
|
||||
# Nur beim allerersten Lauf einmalig auf true, danach wieder leeren:
|
||||
NOTARY_GENESIS_ACK: ${PROD_GENESIS_ACK:-}
|
||||
# Nur nach geklärtem Verlust des Beobachtungsspeichers, siehe README:
|
||||
NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-}
|
||||
# Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel:
|
||||
NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-}
|
||||
# Nur nach einer GEWOLLTEN Neuberechnung, mit der ID des Rehash-Eintrags:
|
||||
NOTARY_REHASH_ACK: ${PROD_REHASH_ACK:-}
|
||||
volumes:
|
||||
# Buch, Schlüssel, Beobachtungsspeicher und Statusdatei.
|
||||
# Dieses Verzeichnis ist das Gegenbuch – es gehört ins Backup.
|
||||
- ${PROD_DIR:-./data/prod}:/gegenbuch
|
||||
|
||||
staging:
|
||||
build: .
|
||||
container_name: gegenbuch-staging
|
||||
restart: unless-stopped
|
||||
profiles: ["staging"]
|
||||
environment:
|
||||
INSTANZ: staging
|
||||
CRM_URL: ${STAGING_CRM_URL}
|
||||
CRM_EMAIL: ${STAGING_CRM_EMAIL}
|
||||
CRM_PASSWORD: ${STAGING_CRM_PASSWORD}
|
||||
NOTARY_INTERVAL: ${STAGING_INTERVAL:-3600}
|
||||
NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost}
|
||||
NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-}
|
||||
NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-}
|
||||
NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-}
|
||||
NOTARY_REHASH_ACK: ${STAGING_REHASH_ACK:-}
|
||||
volumes:
|
||||
- ${STAGING_DIR:-./data/staging}:/gegenbuch
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
# Gegenbuch-Schleife: einrichten, dann in festem Takt beglaubigen.
|
||||
set -uo pipefail
|
||||
|
||||
: "${INSTANZ:?INSTANZ fehlt (z. B. prod oder staging)}"
|
||||
: "${CRM_URL:?CRM_URL fehlt – von welcher OpenCRM-Instanz soll geholt werden?}"
|
||||
: "${CRM_EMAIL:?CRM_EMAIL fehlt – Dienstkonto im CRM mit dem Recht audit:read}"
|
||||
: "${CRM_PASSWORD:?CRM_PASSWORD fehlt – Passwort dieses Dienstkontos}"
|
||||
|
||||
INTERVALL="${NOTARY_INTERVAL:-3600}"
|
||||
BUCH=/gegenbuch/buch
|
||||
SCHLUESSEL=/gegenbuch/schluessel
|
||||
export NOTARY_STATE_FILE=/gegenbuch/beobachtungen.jsonl
|
||||
export NOTARY_ALLOW_LOCAL=true # Buch liegt auf dieser Maschine
|
||||
export NOTARY_PUSH=false # es gibt kein Ziel zum Pushen
|
||||
|
||||
# --- Rechte geraderuecken, dann Privilegien abgeben ------------------------
|
||||
# Laeuft der Container als root, gehoert das Bind-Mount-Verzeichnis vermutlich
|
||||
# ebenfalls root (typisch, wenn das Projekt als root geklont wurde). Wir setzen
|
||||
# es auf den Arbeitsbenutzer um und starten uns selbst als dieser neu.
|
||||
BENUTZER_UID="${PUID:-1000}"
|
||||
BENUTZER_GID="${PGID:-1000}"
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
if ! chown -R "$BENUTZER_UID:$BENUTZER_GID" /gegenbuch 2>/dev/null; then
|
||||
echo "[$INSTANZ] HINWEIS: Rechte auf /gegenbuch liessen sich nicht setzen." >&2
|
||||
echo "[$INSTANZ] Falls es gleich an fehlenden Schreibrechten scheitert, auf dem Host:" >&2
|
||||
echo "[$INSTANZ] chown -R $BENUTZER_UID:$BENUTZER_GID <datenverzeichnis>" >&2
|
||||
fi
|
||||
exec setpriv --reuid="$BENUTZER_UID" --regid="$BENUTZER_GID" --init-groups \
|
||||
--inh-caps=-all "$0" "$@"
|
||||
fi
|
||||
|
||||
echo "[$INSTANZ] Gegenbuch startet – Quelle: $CRM_URL, Takt: ${INTERVALL}s"
|
||||
|
||||
# --- Signaturschlüssel: einmalig auf DIESER Maschine erzeugen ---------------
|
||||
# Er verlässt den Rechner nie. OpenCRM kennt ihn nicht.
|
||||
mkdir -p "$SCHLUESSEL"
|
||||
if [ ! -f "$SCHLUESSEL/id_ed25519" ]; then
|
||||
echo "[$INSTANZ] Erzeuge Signaturschlüssel..."
|
||||
ssh-keygen -t ed25519 -f "$SCHLUESSEL/id_ed25519" -N "" -C "gegenbuch-$INSTANZ" -q
|
||||
fi
|
||||
chmod 600 "$SCHLUESSEL/id_ed25519"
|
||||
if [ ! -f "$SCHLUESSEL/allowed_signers" ]; then
|
||||
echo "gegenbuch-$INSTANZ namespaces=\"git\" $(cat "$SCHLUESSEL/id_ed25519.pub")" \
|
||||
> "$SCHLUESSEL/allowed_signers"
|
||||
fi
|
||||
|
||||
# --- Buch anlegen, falls noch nicht vorhanden ------------------------------
|
||||
if [ ! -d "$BUCH/.git" ]; then
|
||||
echo "[$INSTANZ] Lege Gegenbuch an..."
|
||||
mkdir -p "$BUCH"
|
||||
git -C "$BUCH" init -q -b main
|
||||
fi
|
||||
cd "$BUCH" || exit 1
|
||||
|
||||
git config user.email "${NOTAR_EMAIL:-gegenbuch@localhost}"
|
||||
git config user.name "Gegenbuch $INSTANZ"
|
||||
git config gpg.format ssh
|
||||
git config user.signingkey "$SCHLUESSEL/id_ed25519.pub"
|
||||
git config gpg.ssh.allowedSignersFile "$SCHLUESSEL/allowed_signers"
|
||||
|
||||
# Ein erster Commit, damit es eine Historie gibt, an die angehängt werden kann.
|
||||
if ! git rev-parse HEAD >/dev/null 2>&1; then
|
||||
git commit -q -S --allow-empty -m "Gegenbuch $INSTANZ angelegt"
|
||||
fi
|
||||
|
||||
# --- Schleife ---------------------------------------------------------------
|
||||
# Jeder Rückgabecode außer 0 gehört angesehen. Der letzte Stand steht
|
||||
# zusätzlich in status.txt, damit eine Überwachung ihn abgreifen kann, ohne
|
||||
# Logs zu durchsuchen.
|
||||
while true; do
|
||||
ZEIT=$(date -Iseconds)
|
||||
# Ausgabe mitschneiden, um den GRUND in die Statusdatei zu bekommen.
|
||||
#
|
||||
# Vorher stand dort nur das Etikett ("BEFUND"), und das ist fuer jeden
|
||||
# Exit-2 dasselbe - ein echter Widerspruch sieht darin aus wie eine
|
||||
# quittierpflichtige Erstsiegelung. Wer nur die Statusdatei liest, kann
|
||||
# nicht entscheiden, ob er handeln muss. Genau die Sorte Signal, gegen die
|
||||
# dieses Werkzeug gebaut ist.
|
||||
node /opt/notary/notary.mjs > /tmp/lauf.log 2>&1
|
||||
CODE=$?
|
||||
cat /tmp/lauf.log
|
||||
case $CODE in
|
||||
0) LAGE="in Ordnung" ;;
|
||||
1) LAGE="Betriebsfehler (Konfiguration, CRM nicht erreichbar, Commit)" ;;
|
||||
2) LAGE="BEFUND – Widerspruch zwischen CRM und Gegenbuch" ;;
|
||||
3) LAGE="nicht abschliessend feststellbar (u. a. erster Lauf)" ;;
|
||||
4) LAGE="Gedaechtnis fehlt – Bestaetigung noetig, siehe README" ;;
|
||||
5) LAGE="Anker unvollstaendig" ;;
|
||||
*) LAGE="unbekannter Code" ;;
|
||||
esac
|
||||
echo "[$INSTANZ] $ZEIT exit=$CODE ($LAGE)"
|
||||
# Die aussagekraeftigste Zeile mitnehmen, nicht einfach die erste.
|
||||
#
|
||||
# Bei Alarm ist das die ALARM-Zeile. Sonst die Zusammenfassung ("OK: …") -
|
||||
# denn ein Lauf kann davor beilaeufige Hinweise ausgeben (etwa das einmalige
|
||||
# Setzen einer Ueberwachungs-Grundlage), und die verdeckten sonst das
|
||||
# Ergebnis. Erst wenn beides fehlt, die erste Zeile ueberhaupt.
|
||||
GRUND=$(grep -m1 '^ALARM: ' /tmp/lauf.log | sed 's/^ALARM: //')
|
||||
[ -z "$GRUND" ] && GRUND=$(grep -m1 '^OK: ' /tmp/lauf.log)
|
||||
[ -z "$GRUND" ] && GRUND=$(grep -m1 -v '^[[:space:]]*$' /tmp/lauf.log)
|
||||
{
|
||||
printf '%s exit=%s %s\n' "$ZEIT" "$CODE" "$LAGE"
|
||||
[ -n "$GRUND" ] && printf 'Grund: %s\n' "$GRUND"
|
||||
} > /gegenbuch/status.txt
|
||||
sleep "$INTERVALL"
|
||||
done
|
||||
+265
-12
@@ -132,8 +132,49 @@ if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschu
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!CRM_URL || !CRM_TOKEN) {
|
||||
console.error('CRM_URL und CRM_TOKEN müssen gesetzt sein.');
|
||||
const CRM_EMAIL = process.env.CRM_EMAIL;
|
||||
const CRM_PASSWORD = process.env.CRM_PASSWORD;
|
||||
|
||||
// Bestaetigung fuer einen Wechsel der Siegelwurzel (Pentest R185-01).
|
||||
//
|
||||
// Ein Wechsel loest Alarm aus – und der Alarm bricht ab, BEVOR der neue Stand
|
||||
// ins Gegenbuch kommt. Ohne Bestaetigungsweg wuerde deshalb auch ein voellig
|
||||
// legitimes Siegeln von da an bei jedem Lauf erneut alarmieren, ohne dass der
|
||||
// Betreiber es je aufloesen koennte. Genau daran stirbt eine Warnung
|
||||
// (dieselbe Lehre wie R183-03).
|
||||
//
|
||||
// Bestaetigt wird deshalb nicht pauschal mit „true“, sondern mit der WURZEL,
|
||||
// die man akzeptiert – mindestens 16 Zeichen. Ein versehentlich stehen
|
||||
// gelassener Wert passt beim naechsten Wechsel nicht mehr und kann darum
|
||||
// keinen weiteren Austausch stillschweigend durchwinken. Das ist der
|
||||
// Unterschied zu NOTARY_GENESIS_ACK, wo genau diese Falle dokumentiert
|
||||
// werden musste.
|
||||
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);
|
||||
}
|
||||
if (!CRM_TOKEN && !(CRM_EMAIL && CRM_PASSWORD)) {
|
||||
console.error(
|
||||
'Es fehlt der Zugang zum CRM.\n' +
|
||||
'Entweder CRM_EMAIL und CRM_PASSWORD eines Dienstkontos setzen (empfohlen –\n' +
|
||||
'das Gegenbuch meldet sich dann bei jedem Lauf selbst an), oder ein bereits\n' +
|
||||
'vorhandenes CRM_TOKEN mitgeben (läuft nach 15 Minuten ab, nur für Tests).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -180,13 +221,63 @@ if (SIGN && !PIN) {
|
||||
}
|
||||
}
|
||||
|
||||
async function hole(pfad) {
|
||||
// Access-Tokens leben nur 15 Minuten – ein dauerhaft hinterlegtes Token waere
|
||||
// beim naechsten stuendlichen Lauf laengst abgelaufen. Deshalb meldet sich das
|
||||
// Gegenbuch mit einem eigenen Dienstkonto an und holt sich pro Lauf ein
|
||||
// frisches. Das Konto braucht ausschliesslich die Berechtigung `audit:read`.
|
||||
let zugangsToken = CRM_TOKEN || null;
|
||||
|
||||
async function anmelden() {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`${CRM_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: CRM_EMAIL, password: CRM_PASSWORD }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
|
||||
` Grund: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (r.status === 401) {
|
||||
console.error(
|
||||
'Anmeldung am CRM abgelehnt – E-Mail oder Passwort des Dienstkontos stimmen nicht.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (r.status === 429) {
|
||||
console.error('Das CRM hat die Anmeldung wegen zu vieler Versuche gebremst. Später erneut.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!r.ok) {
|
||||
console.error(`Anmeldung am CRM fehlgeschlagen: HTTP ${r.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const j = await r.json();
|
||||
if (!j.success || !j.data?.token) {
|
||||
console.error(`Anmeldung am CRM fehlgeschlagen: ${j.error || 'kein Token in der Antwort'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
zugangsToken = j.data.token;
|
||||
}
|
||||
|
||||
async function hole(pfad, methode = 'GET') {
|
||||
// Fehler werden hier zu einer erklaerenden Zeile – frueher flog ein
|
||||
// Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer
|
||||
// git-Meldungen schon abgestellt war.
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${CRM_TOKEN}` } });
|
||||
r = await fetch(`${CRM_URL}${pfad}`, {
|
||||
method: methode,
|
||||
headers: {
|
||||
Authorization: `Bearer ${zugangsToken}`,
|
||||
...(methode === 'POST' ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
...(methode === 'POST' ? { body: '{}' } : {}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
|
||||
@@ -221,14 +312,22 @@ async function hole(pfad) {
|
||||
return j.data;
|
||||
}
|
||||
|
||||
// Lokaler Betrieb ist ein vollwertiger Modus, wenn das Gegenbuch auf einem
|
||||
// EIGENEN Rechner laeuft: Wer das CRM uebernimmt, kommt dann nicht an dieses
|
||||
// Buch. Was fehlt, ist der Schutz gegen jemanden, der DIESEN Rechner
|
||||
// uebernimmt – das gehoert benannt, aber es ist kein „kein Schutz“.
|
||||
const OHNE_ANKER = () =>
|
||||
NUR_LOKAL ? ' – ACHTUNG: nur lokal, kein externer Anker, also kein Manipulationsschutz.' : '';
|
||||
NUR_LOKAL
|
||||
? '\n Betriebsart: lokal. Geschützt ist damit der Fall „jemand verändert Daten im ' +
|
||||
'CRM“.\n NICHT geschützt ist der Fall „jemand übernimmt diesen Rechner hier“ – ' +
|
||||
'dagegen hülfe\n nur eine zusätzliche Ablage außerhalb.'
|
||||
: '';
|
||||
|
||||
// Ohne bekannte Mindesthoehe bleibt ein Rewind auf einen aelteren, echt
|
||||
// signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung,
|
||||
// sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist.
|
||||
const REWIND_VORBEHALT = () =>
|
||||
REWIND_GESCHUETZT || ffBelegt
|
||||
REWIND_GESCHUETZT || ffBelegt || NUR_LOKAL
|
||||
? ''
|
||||
: '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' +
|
||||
'lässt sich hier nicht ausschließen.' +
|
||||
@@ -251,6 +350,11 @@ const REWIND_VORBEHALT = () =>
|
||||
// bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen.
|
||||
const abschliessendSicher = (anzahl) =>
|
||||
REWIND_GESCHUETZT ||
|
||||
// Im lokalen Betrieb gibt es keinen Remote, den jemand zurueckspulen koennte.
|
||||
// Der Vergleich gegen den eigenen Beobachtungsspeicher ist hier der
|
||||
// vollstaendige Nachweis – die Grenze liegt bei der Integritaet dieses
|
||||
// Rechners und ist als solche benannt, nicht ungeklaert.
|
||||
(NUR_LOKAL && ffBelegt) ||
|
||||
// BEWUSST NICHT `ankerBelegt` (Pentest R180-01): Die Anker-Refs zeigen nur
|
||||
// den HOECHSTEN NOCH VORHANDENEN Stand – nicht den hoechsten je erreichten.
|
||||
// Wer die obersten Refs loescht, senkt damit den Vergleichswert selbst; ein
|
||||
@@ -305,8 +409,9 @@ if (hatRemote) {
|
||||
process.exit(3);
|
||||
}
|
||||
} else if (process.env.NOTARY_ALLOW_LOCAL === 'true') {
|
||||
// Der Schalter darf nicht zu einem glatten „OK“ fuehren: Leckt er je ins
|
||||
// Produktivumfeld, gaebe es gruenes Licht auf nicht notarisiertem Stand.
|
||||
// Ausdruecklich gewaehlter lokaler Betrieb (Gegenbuch auf eigenem Rechner,
|
||||
// ohne zusaetzliche Ablage ausserhalb). Die Erfolgsmeldung benennt, was das
|
||||
// abdeckt und was nicht – ein glattes „OK“ ohne Einordnung gibt es nicht.
|
||||
NUR_LOKAL = true;
|
||||
} else {
|
||||
console.error(
|
||||
@@ -658,8 +763,20 @@ if (Number.isFinite(MIN_SEQ) && bisher.length < MIN_SEQ) {
|
||||
// 4) Abgleich mit dem CRM, VOR dem Anhaengen.
|
||||
// ---------------------------------------------------------------------------
|
||||
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(
|
||||
@@ -667,9 +784,93 @@ if (letzter) {
|
||||
`jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`,
|
||||
);
|
||||
}
|
||||
// `valid: false` wird HART gewertet, unabhaengig von der erklaerenden Prosa
|
||||
// (Pentest R183-02). Ein Cleanup mit abgesenkter Aufbewahrung kann tausende
|
||||
// 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.
|
||||
if (pruefung && pruefung.valid === false) {
|
||||
alarm(
|
||||
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
|
||||
` CRM-Text: ${pruefung.message}\n` +
|
||||
'Auch wenn dieser Text harmlos klingt: Es wird nichts beglaubigt, solange die\n' +
|
||||
'Prüfung nicht sauber ist. Häufigste Ursache ist ein Cleanup mit verkürzter\n' +
|
||||
'Aufbewahrung – dann wurden Einträge endgültig gelöscht.',
|
||||
);
|
||||
}
|
||||
|
||||
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}`,
|
||||
@@ -681,10 +882,59 @@ if (letzter) {
|
||||
if (letzter.sealLeafCount > 0 && aktuell.sealLeafCount === 0) {
|
||||
alarm('Die Blattwerte des Bestandssiegels wurden entfernt.');
|
||||
}
|
||||
// Wechsel der Siegelwurzel ist ein ALARM, kein Hinweis (Pentest R185-01).
|
||||
//
|
||||
// Vorher stand hier ein console.warn und der Rueckgabecode blieb 0 – also
|
||||
// genau das Muster, das wir dem CRM zweimal angekreidet haben: die Warnung
|
||||
// steht in der Prosa, die Maschine meldet „in Ordnung“. Das ist hier
|
||||
// besonders teuer, weil `valid` ein ERSETZENDES Siegel per Konstruktion
|
||||
// ueberlebt: Wer eine Altzeile per Datenbankzugriff entfernt und danach neu
|
||||
// siegelt, bekommt eine passende Wurzel und eine beglaubigte Luecke. Die
|
||||
// Vollpruefung sagt dann `true`. Der Wurzelwechsel ist der EINZIGE
|
||||
// maschinell erkennbare Anker dagegen – und der gehoert in den Alarm.
|
||||
//
|
||||
// Ein legitimes Neu-Siegeln loest hier einmal aus. Das ist gewollt: ein
|
||||
// Austausch der Beweisgrundlage soll einmal wehtun und bestaetigt werden,
|
||||
// statt lautlos durchzulaufen.
|
||||
if (letzter.sealRoot && aktuell.sealRoot && letzter.sealRoot !== aktuell.sealRoot) {
|
||||
console.warn(
|
||||
`HINWEIS: Das Bestandssiegel wurde erneuert (${letzter.sealRoot.slice(0, 12)}… → ` +
|
||||
`${aktuell.sealRoot.slice(0, 12)}…). Legitim nach einem Retention-Lauf – sonst prüfen.`,
|
||||
if (siegelBestaetigt(aktuell.sealRoot)) {
|
||||
console.log(
|
||||
`Siegelwechsel bestätigt (NOTARY_SEAL_ACK): ${letzter.sealRoot.slice(0, 16)}… → ` +
|
||||
`${aktuell.sealRoot.slice(0, 16)}…. Die neue Wurzel wird beglaubigt.\n` +
|
||||
' NOTARY_SEAL_ACK danach wieder leeren.',
|
||||
);
|
||||
} else alarm(
|
||||
'Das Bestandssiegel wurde ERSETZT – die beglaubigte Grundlage ist eine andere.\n' +
|
||||
` beglaubigt: ${letzter.sealRoot.slice(0, 16)}… (${letzter.sealLeafCount} Blätter)\n` +
|
||||
` jetzt : ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` +
|
||||
'Ein erneutes Siegeln schreibt den AKTUELLEN Stand des Altbestands fest. Wurden\n' +
|
||||
'vorher Einträge entfernt, sind deren Lücken danach beglaubigt und die Prüfung im\n' +
|
||||
'CRM meldet wieder valid:true – dieser Wurzelwechsel ist die einzige Spur davon.\n' +
|
||||
'Wenn das keine geplante Maßnahme war: Im CRM das Ereignis AUDIT_SEAL_CHANGED und\n' +
|
||||
'die CRITICAL-Zeile zu /api/audit-logs/seal-backlog ansehen (wer, wann, Vorbefund).\n' +
|
||||
'War es geplant, den Wechsel bestätigen und danach wieder leeren:\n' +
|
||||
` NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`,
|
||||
);
|
||||
}
|
||||
// Erstsiegelung: vorher nichts beglaubigt, jetzt eine Wurzel. Das ist der
|
||||
// eine legitime Einrichtungsschritt – aber auch das Fenster, in dem ein
|
||||
// beschnittener Altbestand einmalig festgeschrieben werden koennte. Deshalb
|
||||
// ebenfalls melden, mit eigenem Text statt stillschweigend zu uebernehmen.
|
||||
if (!letzter.sealRoot && aktuell.sealRoot) {
|
||||
if (siegelBestaetigt(aktuell.sealRoot)) {
|
||||
console.log(
|
||||
`Erstsiegelung bestätigt (NOTARY_SEAL_ACK): ${aktuell.sealRoot.slice(0, 16)}…. ` +
|
||||
'Die Wurzel wird beglaubigt.\n NOTARY_SEAL_ACK danach wieder leeren.',
|
||||
);
|
||||
} else alarm(
|
||||
'Erstmals ein Bestandssiegel gesetzt – bisher war keines beglaubigt.\n' +
|
||||
` Wurzel: ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` +
|
||||
'War das dein Einrichtungsschritt, ist alles in Ordnung: Der nächste Lauf läuft\n' +
|
||||
'wieder auf 0, sobald diese Wurzel im Gegenbuch steht.\n' +
|
||||
'War es das NICHT, dann hat jemand den Stand des Altbestands festgeschrieben –\n' +
|
||||
'samt aller Lücken, die zu diesem Zeitpunkt bestanden.\n' +
|
||||
'Zum Bestätigen setzen und danach wieder leeren:\n' +
|
||||
` NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -750,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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user