Siegelwechsel ist ein Alarm, kein Hinweis (Pentest R185-01/-02)

R185-01 (MEDIUM): Die Flanke, die wir selbst gemeldet hatten, hat der
Tester live bestaetigt. seal-backlog war beim ZWEITEN Aufruf genauso
gegatet wie beim ersten ({"confirm":"SEAL"} -> 200), und das Ereignis
landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
automatische Rueckhalt des Gegenbuchs haengt an `valid` - und `valid`
ueberlebt ein ersetzendes Siegel per Konstruktion. Angriff: Altzeile per
DB-Zugriff loeschen, neu siegeln, Luecke ist beglaubigt, valid wieder
true. Live reproduziert.

Zwei Schichten, in seiner Reihenfolge:

1. Alarmkanal. Neuer SecurityEventType AUDIT_SEAL_CHANGED (Migration
   20260826120000). Erstes Siegeln HIGH, Ersetzen CRITICAL - geht damit
   ueber sendPendingCriticalAlerts sofort per Mail raus. Die Details
   halten Wurzel vorher/nachher und den vollstaendigen Vorbefund fest.
2. Gate. Steht bereits ein Siegel, verlangt der Endpunkt
   {"confirm":"RESEAL"} statt SEAL, mit einem Text, der sagt, was dabei
   verloren geht. Ein Austausch der Beweisgrundlage soll nicht dasselbe
   Wort haben wie das Einrichten.

Und im Gegenbuch selbst: dort stand fuer den Wurzelwechsel ein
console.warn, waehrend der Rueckgabecode auf 0 blieb - also exakt das
Muster, das wir dem CRM zweimal angekreidet haben (R179, R183-02), im
Werkzeug, das dagegen gebaut wurde. Jetzt exit 2, mit alter und neuer
Wurzel samt Blattzahl; "10 Blaetter -> 9 Blaetter" zeigt die Loeschung
sofort. Auch die Erstsiegelung meldet sich, statt stillschweigend
uebernommen zu werden.

Aufloesbar gemacht: der Alarm bricht ab, BEVOR angehaengt wird - ohne
Bestaetigungsweg haette auch ein legitimes Siegeln fuer immer alarmiert
(R183-03-Falle). Neu ist NOTARY_SEAL_ACK, bewusst nicht "true", sondern
die Wurzel selbst (mind. 16 Zeichen): ein stehen gelassener Wert passt
beim naechsten Wechsel nicht mehr und kann keinen weiteren Austausch
durchwinken.

R185-02 (LOW): GET /api/audit-logs?action=<x> gab ungueltige Enum-Werte
roh an die Spalte -> 500. Zweifach schlecht: fehlende Validierung und
Fehler-Orakel (200 vs 500 verraet die Enum-Mitglieder). Jetzt 400 mit
der erlaubten Menge im Klartext. Mitgenommen: sensitivity, Datumsfelder,
Zahlenfelder, Textlaengen und ein Deckel auf limit (200), ueber den sich
sonst die ganze Tabelle an der Seitenlogik vorbei ziehen liess. Beide
Endpunkte.

Getestet ueber HTTP gegen eine Wegwerf-DB, inkl. echtem Gegenbuch-Lauf
mit SSH-signiertem lokalem Repo. Zusaetzlich nachgeholt, was der Tester
nicht herstellen konnte: vollstaendig unsigniertes Protokoll ->
kein_siegel statt der frueheren falschen Entwarnung nicht_noetig, und
seal-backlog nennt den fehlenden Schluessel als naechsten Schritt.
Gegenrichtung geprueft, R183-03 bleibt behoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 09:41:11 +02:00
co-authored by Claude Opus 5
parent 7af6b7591b
commit e81a83ae8f
10 changed files with 365 additions and 28 deletions
@@ -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;
+1
View File
@@ -1482,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
}
+158 -23
View File
@@ -3,6 +3,66 @@ 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;
}
/**
* Audit-Logs mit Filtern abrufen
@@ -26,23 +86,29 @@ export async function getAuditLogs(req: AuthRequest, res: Response) {
} = 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,
userId: pruefeZahl(userId, 'userId'),
customerId: pruefeZahl(customerId, 'customerId'),
dataSubjectId: pruefeZahl(dataSubjectId, 'dataSubjectId'),
action: pruefeEnum<AuditAction>(action, AUDIT_ACTIONS, 'action'),
sensitivity: pruefeEnum<AuditSensitivity>(sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'),
resourceType: pruefeText(resourceType, 'resourceType', 100),
resourceId: pruefeText(resourceId, 'resourceId', 100),
startDate: pruefeDatum(startDate, 'startDate'),
endDate: pruefeDatum(endDate, 'endDate'),
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,
search: pruefeText(search, 'search'),
page: pruefeZahl(page, 'page') || 1,
// Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
// Zug ziehen, an der Seitenlogik vorbei.
limit: Math.min(pruefeZahl(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,7 +162,7 @@ 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 format = req.query.format === 'csv' ? 'csv' : 'json';
const {
action,
sensitivity,
@@ -107,11 +173,11 @@ export async function exportAuditLogs(req: AuthRequest, res: Response) {
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,
action: pruefeEnum<AuditAction>(action, AUDIT_ACTIONS, 'action'),
sensitivity: pruefeEnum<AuditSensitivity>(sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'),
resourceType: pruefeText(resourceType, 'resourceType', 100),
startDate: pruefeDatum(startDate, 'startDate'),
endDate: pruefeDatum(endDate, 'endDate'),
},
format
);
@@ -126,6 +192,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' });
}
@@ -338,25 +408,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);