Bestandssiegel betriebstauglich gemacht (Pentest R173-01/-02/-03)

R173-01 (HIGH): Das Siegel war ueber HTTP tot. Die generische auditMiddleware
protokolliert den POST /seal-backlog-Request selbst als AuditLog/CREATE mit
demselben endpoint - exakt die Signatur, mit der der Marker gesucht wurde, nur
mit hoeherer id und ohne changesAfter. Der Selektor griff diese Zeile, root war
undefined, Ergebnis: dauerhaft "gebrochen" bei null manipulierten Zeilen.
Mein Testfehler: sealBacklog()/verifyIntegrity() direkt aufgerufen, nie ueber
HTTP - die Middleware lief nie mit. Dieselbe Fehlerklasse wie R165.
Fix: eigener Ressourcentyp AuditBacklogSeal, den die Middleware nie vergibt,
zusaetzlich muss der Marker auswertbares {toId, root} tragen.

R173-02 (MEDIUM): Fehlende Zeilen wurden uebersprungen, das Loeschen eines
gesiegelten Einbruchsbelegs erschien nur als unerklaerte Luecke, waehrend der
Indikator "intakt" meldete. Fix: fehlende gesiegelte Zeilen sind ein
Siegelbruch mit eigener Liste (backlogMissing) und werden namentlich gemeldet.

R173-03 (MEDIUM): Die R170-01-Haertung (Vorbefund im Marker) war auf
seal-backlog nie angewandt, ein Re-Seal absorbierte Manipulationen mit weniger
Spur als ein Rehash. Fix: Der Marker haelt den Befund vor dem Siegeln fest
samt Status und Wurzel des vorherigen Siegels; verify weist die Anzahl
gueltiger Siegel aus und warnt bei mehr als einem.

Verifiziert ueber den echten HTTP-Pfad inkl. Middleware (separate Wegwerf-DB,
Mischbestand 6xV1/4xV2/4xV3): Protokollzeile vorhanden, Status trotzdem
intakt; Loeschen einer gesiegelten Zeile -> gebrochen mit backlogMissing[2];
erneutes Siegeln -> Anzahl 2 gemeldet, Vorbefund im Marker. tsc + vite build
gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 14:20:32 +02:00
co-authored by Claude Opus 5
parent 2932598c98
commit ac62198a01
3 changed files with 143 additions and 18 deletions
+21 -2
View File
@@ -166,11 +166,28 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
const siegel =
result.backlogSealStatus === 'gebrochen'
? ` ⚠ Bestandssiegel GEBROCHEN: ${result.backlogTampered.length} Alteinträge wurden nachträglich verändert.`
? ' ⚠ Bestandssiegel GEBROCHEN:' +
(result.backlogTampered.length
? ` ${result.backlogTampered.length} Alteinträge nachträglich verändert.`
: '') +
(result.backlogMissing.length
? ` ${result.backlogMissing.length} gesiegelte Einträge wurden ENTFERNT (${result.backlogMissing.join(', ')}).`
: '') +
(!result.backlogTampered.length && !result.backlogMissing.length
? ' Der Blattbestand des Siegels stimmt nicht mehr mit der signierten Wurzel überein.'
: '')
: result.backlogSealStatus === 'kein_siegel'
? ' Hinweis: Der Altbestand ist nicht versiegelt Änderungen daran wären nicht erkennbar.'
: '';
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
const mehrfach =
result.backlogSealCount > 1
? ` Hinweis: Der Altbestand wurde ${result.backlogSealCount}× versiegelt der jüngste Marker gilt. ` +
'Jeder Marker enthält den Befund vor dem Siegeln; bei Zweifeln dort vergleichen.'
: '';
const message = tampered > 0
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
: gaps > 0
@@ -190,11 +207,13 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
unexplainedGaps: result.unexplainedGaps,
tampered: tampered > 0,
message: message + keinSchluessel + siegel,
message: message + keinSchluessel + siegel + mehrfach,
unverifiableEntries: result.unverifiableEntries,
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
backlogSealStatus: result.backlogSealStatus,
backlogTampered: result.backlogTampered,
backlogMissing: result.backlogMissing,
backlogSealCount: result.backlogSealCount,
},
});
} catch (error) {
+86 -16
View File
@@ -650,9 +650,23 @@ function backlogRoot(leaves: Array<{ auditLogId: number; leafHash: string }>): s
.digest('hex');
}
/** Endpoint-Kennung des Siegel-Markers (dient zugleich als Suchschluessel). */
/** Endpoint-Kennung des Siegel-Markers. */
const BACKLOG_SEAL_ENDPOINT = '/api/audit-logs/seal-backlog';
/**
* Eigener Ressourcentyp fuer den Siegel-Marker (Pentest R173-01).
*
* Der Marker wurde urspruenglich als `AuditLog` / `CREATE` /
* `endpoint=/api/audit-logs/seal-backlog` gesucht. Genau diese Signatur
* erzeugt aber die generische Audit-Middleware, wenn jemand den Endpunkt
* aufruft als Protokollzeile des Requests selbst, mit hoeherer id und ohne
* `changesAfter`. Der Selektor griff dann diese Zeile statt des echten
* Markers, `root` war `undefined`, und die Pruefung meldete dauerhaft
* „gebrochen“. Ein Ressourcentyp, den die Middleware nie vergibt (sie leitet
* ihn aus dem Pfad ab → `AuditLog`), macht den Marker eindeutig.
*/
const BACKLOG_SEAL_RESOURCE = 'AuditBacklogSeal';
/**
* Versiegelt den Altbestand einmalig.
*
@@ -685,6 +699,21 @@ export async function sealBacklog(
throw new Error('Kein Altbestand vorhanden es gibt nichts zu versiegeln.');
}
// Zustand VOR dem Siegeln festhalten (R173-03).
const vorher = await verifyIntegrity();
const vorbefund = {
vorherigesSiegel:
vorher.backlogSealStatus === 'kein_siegel'
? null
: { status: vorher.backlogSealStatus, root: vorher.backlogSealRoot },
befund: {
manipuliert: vorher.tamperedEntries,
altbestand_veraendert: vorher.backlogTampered,
altbestand_fehlend: vorher.backlogMissing,
ketten_luecken: vorher.chainGaps,
},
};
const rows = await prisma.auditLog.findMany({
where: { id: { lte: toId } },
orderBy: { id: 'asc' },
@@ -704,16 +733,27 @@ export async function sealBacklog(
// Marker ist selbst HMAC-signiert (Version 3) damit ist die Wurzel
// signiert, ohne dass es einen zweiten Signaturpfad braucht.
//
// Vorzustand mitschreiben (Pentest R173-03): Ein erneutes Siegeln wuerde
// sonst eine zwischenzeitliche Manipulation absorbieren und die gefaelschte
// Zeile als echt zertifizieren mit noch weniger Spur als ein Rehash. Der
// Marker haelt deshalb fest, was VOR dem Siegeln galt: der Befund und die
// Wurzel des vorherigen Siegels.
await createAuditLog({
userEmail: ausgeloestVon?.userEmail || 'system',
userRole: 'System',
action: 'CREATE',
resourceType: 'AuditLog',
resourceLabel: `Bestandssiegel erstellt: ${leaves.length} Alteinträge (id 1${toId})`,
resourceType: BACKLOG_SEAL_RESOURCE,
resourceLabel:
`Bestandssiegel erstellt: ${leaves.length} Alteinträge (id 1${toId})` +
(vorbefund.vorherigesSiegel
? ' ERNEUTES Siegeln, vorheriges Siegel wird ersetzt'
: ''),
endpoint: BACKLOG_SEAL_ENDPOINT,
httpMethod: 'POST',
ipAddress: ausgeloestVon?.ipAddress || 'system',
sensitivity: 'CRITICAL',
changesBefore: vorbefund,
changesAfter: { fromId: 1, toId, count: leaves.length, root },
success: true,
});
@@ -756,6 +796,12 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen';
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
backlogTampered: number[];
/** Gesiegelte Zeilen, die nicht mehr existieren Beweismaterial entfernt. */
backlogMissing: number[];
/** Anzahl gueltiger Siegel-Marker; >1 bedeutet erneutes Siegeln. */
backlogSealCount: number;
/** Wurzel des aktuell gueltigen Siegels. */
backlogSealRoot: string | null;
}> {
const where: Prisma.AuditLogWhereInput = {};
@@ -891,23 +937,39 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
// Der Marker muss selbst signiert und gueltig sein - sonst waere das Siegel
// ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01).
const backlogTampered: number[] = [];
const backlogMissing: number[] = [];
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' = 'kein_siegel';
let backlogSealRoot: string | null = null;
const siegelKandidaten = await prisma.auditLog.findMany({
where: { resourceType: 'AuditLog', action: 'CREATE', endpoint: BACKLOG_SEAL_ENDPOINT },
where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT },
orderBy: { id: 'desc' },
});
const siegelSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
(k): k is string => !!k,
);
const siegel = siegelSchluessel.length
? siegelKandidaten.find(
// Nur signierte Marker MIT auswertbarem Inhalt zaehlen. Der zweite Teil ist
// die Absicherung gegen Zeilen, die zufaellig dieselbe Signatur tragen, aber
// gar kein Siegel beschreiben (Pentest R173-01).
const hatSiegelInhalt = (r: { changesAfter: string | null }) => {
try {
const m = JSON.parse(r.changesAfter || '{}');
return typeof m.toId === 'number' && typeof m.root === 'string' && m.root.length > 0;
} catch {
return false;
}
};
const gueltigeSiegel = siegelSchluessel.length
? siegelKandidaten.filter(
(r) =>
erwarteteVersion(r.id) === 3 &&
r.hashVersion >= 3 &&
hatSiegelInhalt(r) &&
siegelSchluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k)),
)
: undefined;
: [];
const siegel = gueltigeSiegel[0];
const backlogSealCount = gueltigeSiegel.length;
if (siegel) {
try {
@@ -924,9 +986,15 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
for (const [id, blatt] of erwartet) {
const zeile = vorhanden.get(id);
// Fehlende Zeile schlaegt bereits als Ketten-Luecke auf; hier zaehlt
// die inhaltliche Veraenderung.
if (!zeile) continue;
if (!zeile) {
// FEHLENDE gesiegelte Zeile ist ein Siegelbruch, keine blosse Luecke
// (Pentest R173-02). Vorher wurde hier uebersprungen: das Loeschen
// eines Einbruchsbelegs erschien nur als "unerklaerte Luecke"
// ununterscheidbar von den als meist harmlos dokumentierten
// Race-Luecken , waehrend der dedizierte Indikator "intakt" meldete.
backlogMissing.push(id);
continue;
}
if (zeile.hashVersion >= 3 || backlogLeaf(zeile as unknown as Record<string, unknown>) !== blatt) {
backlogTampered.push(id);
}
@@ -935,17 +1003,16 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
// Wurzel gegenrechnen: faengt auch Zeilen ab, die dem Siegel
// nachtraeglich hinzugefuegt oder daraus entfernt wurden.
const wurzelJetzt = backlogRoot(blaetter);
backlogSealRoot = meta.root;
backlogSealStatus =
backlogTampered.length === 0 && wurzelJetzt === meta.root ? 'intakt' : 'gebrochen';
if (wurzelJetzt !== meta.root && backlogTampered.length === 0) {
// Blattbestand selbst veraendert, ohne dass eine Zeile abweicht.
backlogSealStatus = 'gebrochen';
}
backlogTampered.length === 0 && backlogMissing.length === 0 && wurzelJetzt === meta.root
? 'intakt'
: 'gebrochen';
} catch {
backlogSealStatus = 'gebrochen';
}
}
tamperedEntries.push(...backlogTampered);
tamperedEntries.push(...backlogTampered, ...backlogMissing);
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
@@ -1065,6 +1132,9 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
unverifiableEntries,
backlogSealStatus,
backlogTampered,
backlogMissing,
backlogSealCount,
backlogSealRoot,
};
}
+36
View File
@@ -97,6 +97,42 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔧 Bestandssiegel betriebstauglich gemacht (Pentest R173-01 HIGH, R173-02/-03 MEDIUM)** (2026-08-18)
- **R173-01 (HIGH) Siegel war ueber HTTP tot.** Die generische
`auditMiddleware` protokolliert den `POST /seal-backlog`-Request SELBST als
`AuditLog / CREATE / endpoint=/api/audit-logs/seal-backlog` exakt die
Signatur, mit der ich den Marker suchte, nur mit hoeherer id und ohne
`changesAfter`. Der Selektor griff diese Zeile, `root` war `undefined`,
Ergebnis: **dauerhaft „gebrochen“ bei null manipulierten Zeilen.** Das
komplette R171-02-Deliverable war damit im Betrieb wertlos.
**Mein Testfehler:** Ich hatte `sealBacklog()`/`verifyIntegrity()` direkt
aufgerufen, nie ueber HTTP die Middleware lief nie mit. Dieselbe
Fehlerklasse wie R165 (Code korrekt, im Betrieb nicht erreichbar).
Fix: eigener Ressourcentyp `AuditBacklogSeal`, den die Middleware nie
vergibt (sie leitet ihn aus dem Pfad ab → `AuditLog`), zusaetzlich muss der
Marker auswertbares `{toId, root}` tragen.
- **R173-02 (MEDIUM) Siegel war blind fuers Loeschen.** `if (!zeile)
continue` uebersprang fehlende Zeilen; das Loeschen eines gesiegelten
Einbruchsbelegs erschien nur als „unerklaerte Luecke“, waehrend der
dedizierte Indikator „intakt“ meldete Entwarnung, waehrend Beweismaterial
verschwindet. Fix: fehlende gesiegelte Zeilen sind ein Siegelbruch mit
eigener Liste (`backlogMissing`) und werden namentlich gemeldet. Deckt
zugleich seine Frage (a) ab: geprueft wird jetzt die gesiegelte Menge
gegen die Live-Population, nicht nur der Inhalt vorhandener Zeilen.
- **R173-03 (MEDIUM) Re-Seal wusch mit weniger Spur als ein Rehash.** Die
R170-01-Haertung (Vorbefund im Marker) war auf `seal-backlog` nie
angewandt. Fix: Der Siegel-Marker haelt jetzt den Befund VOR dem Siegeln
fest (manipulierte/fehlende Zeilen, Ketten-Luecken) samt Status und Wurzel
des vorherigen Siegels; `/verify` weist die Anzahl gueltiger Siegel aus und
weist bei >1 ausdruecklich darauf hin.
- Verifiziert **ueber den echten HTTP-Pfad inkl. Middleware** (separate
Wegwerf-DB, Mischbestand 6×V1/4×V2/4×V3): Middleware-Protokollzeile ist
vorhanden, Status trotzdem `intakt`; Loeschen einer gesiegelten Zeile →
`gebrochen`, `backlogMissing:[2]`, namentlich in der Meldung; erneutes
Siegeln → Anzahl 2 gemeldet, Marker enthaelt „vorheriges Siegel: gebrochen,
Befund fehlend [2]“. Wegwerf-DB geloescht, Dev unberuehrt.
`tsc` + `vite build` gruen.
- [x] **🧿 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02, HIGH)** (2026-08-18)
- Der Pentester hat den Fund live reproduziert: Bei Hash-Version 1 sind nur
7 von 24 Spalten gehasht. Ein DB-Schreibzugriff konnte eine