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
+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,
};
}