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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user