Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)
R166-01 (HIGH): Der GET_LOCK-Ansatz gab die Sperre im finally INNERHALB des Transaktions-Callbacks frei, also vor dem COMMIT. Im Fenster Release-Commit las der naechste Schreiber ein noch nicht sichtbares Kettenende - zwei Zeilen hingen am selben Vorgaenger. Meine vorherige Messung war zu schwach: sie suchte Luecken zwischen Nachbarn, nicht Forks. Fix: einzeiliger Mutex AuditChainLock mit FOR UPDATE (InnoDB-Zeilensperren fallen erst beim COMMIT) plus isolationLevel ReadCommitted. Belegt im Direktvergleich mit geweitetem Fenster: Release-vor-Commit forkt, Zeilensperre nicht. R166-02 (MEDIUM): Der Hash deckte nur 7 Felder ab. changesBefore/After, success, ipAddress, resourceLabel, dataSubjectId, userId/customerId waren ungeschuetzt - ein Einzeledit dort blieb unsichtbar. Fix: hashVersion + generateHashV2 ueber alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash gueltig. Verifiziert: 5/5 zuvor ungeschuetzte Felder werden jetzt erkannt. R166-03 (LOW): "kein Cookie" (normaler Erstbesuch) wurde als HIGH/abgelehnt gefuehrt - jetzt eigener Ausgang mit LOW. Nur echte Ablehnung bleibt HIGH. R166-04 (LOW, pre-existing): GET /retention-policies wurde von GET /:id verschluckt. Konkrete Routen jetzt vor der Parameter-Route. Design-Empfehlungen: runRetentionCleanup schreibt ein Loeschungs-Manifest (ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag - Luecken ausserhalb bleiben erklaerungsbeduerftig. rehashAll schreibt einen Marker. Verifiziert: 50 parallele Schreiber -> 50/50, 0 Forks, alle V2, manipuliert 0, Luecken unveraendert 7. tsc + vite build gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
-- Einzeiliger Mutex fuer die Audit-Hash-Kette (Pentest R166-01).
|
||||||
|
--
|
||||||
|
-- Vorher wurde ueber GET_LOCK serialisiert. Dessen RELEASE_LOCK muss auf
|
||||||
|
-- derselben Verbindung laufen und stand daher im finally INNERHALB des
|
||||||
|
-- Transaktions-Callbacks - also VOR dem COMMIT. In diesem Fenster konnte der
|
||||||
|
-- naechste Schreiber die Sperre holen und das Kettenende lesen, bevor die
|
||||||
|
-- Vorgaengerzeile committed war: beide haengten sich an denselben Vorgaenger
|
||||||
|
-- (Fork). InnoDB-Zeilensperren fallen dagegen erst beim COMMIT.
|
||||||
|
CREATE TABLE IF NOT EXISTS `AuditChainLock` (
|
||||||
|
`id` INT NOT NULL,
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `AuditChainLock` (`id`, `updatedAt`) VALUES (1, NOW(3));
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Hash-Versionierung fuer Audit-Eintraege (Pentest R166-02).
|
||||||
|
--
|
||||||
|
-- Der bisherige Hash deckte nur 7 Felder ab (userEmail, action, resourceType,
|
||||||
|
-- resourceId, endpoint, createdAt, previousHash). NICHT gehasht waren u. a.
|
||||||
|
-- changesBefore/changesAfter (die eigentliche Nutzlast), success, ipAddress,
|
||||||
|
-- resourceLabel, dataSubjectId, userId/customerId - ein nachtraeglicher
|
||||||
|
-- Einzeledit an genau diesen Feldern blieb also unsichtbar.
|
||||||
|
--
|
||||||
|
-- Version 2 hasht alle Inhaltsspalten. Bestandszeilen behalten Version 1 und
|
||||||
|
-- werden weiterhin mit dem alten Verfahren geprueft - kein Rehash noetig,
|
||||||
|
-- die Beweiskraft der Vergangenheit bleibt erhalten.
|
||||||
|
ALTER TABLE `AuditLog`
|
||||||
|
ADD COLUMN IF NOT EXISTS `hashVersion` INT NOT NULL DEFAULT 1;
|
||||||
@@ -1221,6 +1221,24 @@ model CarInsuranceDetails {
|
|||||||
|
|
||||||
// ==================== AUDIT LOGGING (DSGVO) ====================
|
// ==================== AUDIT LOGGING (DSGVO) ====================
|
||||||
|
|
||||||
|
/// Einzeiliger Mutex fuer die Audit-Hash-Kette (genau eine Zeile, id = 1).
|
||||||
|
///
|
||||||
|
/// Warum eine eigene Tabelle statt GET_LOCK oder `SELECT … FOR UPDATE` auf
|
||||||
|
/// AuditLog selbst:
|
||||||
|
/// - GET_LOCK muss auf derselben Verbindung freigegeben werden. Innerhalb des
|
||||||
|
/// Prisma-Transaktions-Callbacks faellt das Release damit VOR den COMMIT –
|
||||||
|
/// der naechste Schreiber liest das Kettenende, bevor die Vorgaengerzeile
|
||||||
|
/// sichtbar ist, und haengt sich an denselben Vorgaenger (Fork, R166-01).
|
||||||
|
/// - `FOR UPDATE` auf das Kettenende von AuditLog nimmt Gap-/Next-Key-Locks,
|
||||||
|
/// die mit den gleichzeitigen INSERTs kollidieren (Deadlocks, dabei gingen
|
||||||
|
/// 38 von 40 Eintraegen verloren).
|
||||||
|
/// InnoDB-Zeilensperren werden erst beim COMMIT freigegeben – genau das
|
||||||
|
/// schliesst das Fenster.
|
||||||
|
model AuditChainLock {
|
||||||
|
id Int @id
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
enum AuditAction {
|
enum AuditAction {
|
||||||
CREATE
|
CREATE
|
||||||
READ
|
READ
|
||||||
@@ -1284,6 +1302,9 @@ model AuditLog {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
hash String? // SHA-256 Hash des Eintrags
|
hash String? // SHA-256 Hash des Eintrags
|
||||||
previousHash String? // Hash des vorherigen Eintrags
|
previousHash String? // Hash des vorherigen Eintrags
|
||||||
|
/// 1 = Alt-Hash ueber 7 Felder, 2 = Hash ueber alle Inhaltsspalten
|
||||||
|
/// (Pentest R166-02). Bestandszeilen bleiben mit Version 1 gueltig.
|
||||||
|
hashVersion Int @default(1)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([customerId])
|
@@index([customerId])
|
||||||
|
|||||||
@@ -167,6 +167,22 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
TOKEN_REFRESH: 'Sitzung verlängert',
|
TOKEN_REFRESH: 'Sitzung verlängert',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unterscheidet die drei Ausgaenge von POST /auth/refresh (Pentest R166-03).
|
||||||
|
*
|
||||||
|
* "Kein Cookie vorhanden" ist KEIN Ablehnungsfall: Der Frontend-Interceptor
|
||||||
|
* ruft /refresh beim App-Start auch dann, wenn nie ein Token gesetzt war
|
||||||
|
* (normaler Erstbesuch). Das als HIGH/"abgelehnt" zu fuehren erzeugt genau das
|
||||||
|
* Rauschen, das mit der Entrauschung beseitigt werden sollte.
|
||||||
|
*/
|
||||||
|
function refreshOutcome(responseBody: unknown, success: boolean): 'ok' | 'no-token' | 'rejected' {
|
||||||
|
if (success) return 'ok';
|
||||||
|
const err = responseBody && typeof responseBody === 'object' && 'error' in responseBody
|
||||||
|
? String((responseBody as { error?: unknown }).error ?? '')
|
||||||
|
: '';
|
||||||
|
return /kein refresh-token/i.test(err) ? 'no-token' : 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
|
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
|
||||||
*/
|
*/
|
||||||
@@ -209,8 +225,12 @@ function generateHumanLabel(
|
|||||||
}
|
}
|
||||||
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
|
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
|
||||||
if (path.includes('/auth/refresh')) {
|
if (path.includes('/auth/refresh')) {
|
||||||
const failed = responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false;
|
const success = !(responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false);
|
||||||
return failed ? 'Token-Refresh abgelehnt (ungültig/abgelaufen)' : 'Sitzung verlängert (Token erneuert)';
|
switch (refreshOutcome(responseBody, success)) {
|
||||||
|
case 'ok': return 'Sitzung verlängert (Token erneuert)';
|
||||||
|
case 'no-token': return 'Token-Refresh ohne vorliegenden Token (kein Cookie)';
|
||||||
|
default: return 'Token-Refresh abgelehnt (ungültig/abgelaufen)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kunden-Operationen
|
// Kunden-Operationen
|
||||||
@@ -460,10 +480,13 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
|||||||
isCustomerPortal: req.user?.isCustomerPortal,
|
isCustomerPortal: req.user?.isCustomerPortal,
|
||||||
action,
|
action,
|
||||||
// Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut).
|
// Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut).
|
||||||
// Fehlgeschlagener Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in
|
// Abgelehnter Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in der
|
||||||
// der Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01).
|
// Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01).
|
||||||
|
// "Kein Cookie vorhanden" ist dagegen der normale Erstbesuch → LOW (R166-03).
|
||||||
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
|
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
|
||||||
sensitivity: action === 'TOKEN_REFRESH' ? (responseSuccess ? 'LOW' : 'HIGH') : undefined,
|
sensitivity: action === 'TOKEN_REFRESH'
|
||||||
|
? (refreshOutcome(responseBody, responseSuccess) === 'rejected' ? 'HIGH' : 'LOW')
|
||||||
|
: undefined,
|
||||||
resourceType: mapping.type,
|
resourceType: mapping.type,
|
||||||
resourceId,
|
resourceId,
|
||||||
resourceLabel,
|
resourceLabel,
|
||||||
|
|||||||
@@ -7,29 +7,34 @@ const router = Router();
|
|||||||
// Alle Routen erfordern Authentifizierung
|
// Alle Routen erfordern Authentifizierung
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ACHTUNG Reihenfolge: Alle konkreten Pfade MÜSSEN vor der Parameter-Route
|
||||||
|
// '/:id' stehen, sonst schluckt diese sie und antwortet mit
|
||||||
|
// "Ungültige Audit-Log-ID". Genau so war GET /retention-policies unerreichbar
|
||||||
|
// (Pentest R166-04).
|
||||||
|
|
||||||
// Audit-Logs abrufen
|
// Audit-Logs abrufen
|
||||||
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
|
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
|
||||||
|
|
||||||
// Audit-Logs exportieren (muss VOR /:id stehen!)
|
// Audit-Logs exportieren
|
||||||
router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs);
|
router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs);
|
||||||
|
|
||||||
|
// Retention-Policies
|
||||||
|
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
|
||||||
|
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
|
||||||
|
|
||||||
// Audit-Logs für einen Kunden (DSGVO)
|
// Audit-Logs für einen Kunden (DSGVO)
|
||||||
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
|
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
|
||||||
|
|
||||||
// Einzelnes Audit-Log abrufen
|
|
||||||
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
|
|
||||||
|
|
||||||
// Hash-Ketten-Integrität prüfen
|
// Hash-Ketten-Integrität prüfen
|
||||||
router.post('/verify', requirePermission('audit:read'), auditLogController.verifyIntegrity);
|
router.post('/verify', requirePermission('audit:read'), auditLogController.verifyIntegrity);
|
||||||
|
|
||||||
// Hash-Kette reparieren
|
// Hash-Kette reparieren
|
||||||
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
|
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
|
||||||
|
|
||||||
// Retention-Policies
|
|
||||||
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
|
|
||||||
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
|
|
||||||
|
|
||||||
// Retention-Cleanup manuell ausführen
|
// Retention-Cleanup manuell ausführen
|
||||||
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
|
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
|
||||||
|
|
||||||
|
// Einzelnes Audit-Log abrufen – als LETZTE GET-Route, siehe Hinweis oben
|
||||||
|
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -161,6 +161,86 @@ function generateHashLegacy(data: {
|
|||||||
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash-Version 2 (Pentest R166-02): deckt ALLE Inhaltsspalten ab.
|
||||||
|
*
|
||||||
|
* Version 1 hashte nur 7 Felder (userEmail, action, resourceType, resourceId,
|
||||||
|
* endpoint, createdAt, previousHash). Nicht abgedeckt waren u. a.
|
||||||
|
* `changesBefore`/`changesAfter` – also die eigentliche Nutzlast –, dazu
|
||||||
|
* `success`, `ipAddress`, `resourceLabel`, `dataSubjectId`, `userId`,
|
||||||
|
* `customerId`. Ein nachtraeglicher Einzeledit an genau diesen Feldern
|
||||||
|
* (z. B. `success` false→true oder das Umschreiben von `changesAfter`) war
|
||||||
|
* damit unsichtbar – exakt der Angriff, gegen den die Kette schuetzen soll.
|
||||||
|
*
|
||||||
|
* Bestandszeilen behalten `hashVersion = 1` und werden weiter mit dem alten
|
||||||
|
* Verfahren geprueft; ein Rehash waere nicht noetig und wuerde die
|
||||||
|
* Beweiskraft der Vergangenheit zerstoeren.
|
||||||
|
*
|
||||||
|
* `undefined` wird bewusst zu `null` normalisiert, damit die Serialisierung
|
||||||
|
* deterministisch ist (die Uneindeutigkeit an dieser Stelle war die Ursache
|
||||||
|
* des Dauer-Fehlalarms bei Version 1).
|
||||||
|
*/
|
||||||
|
export interface AuditHashV2Input {
|
||||||
|
userId?: number | null;
|
||||||
|
userEmail: string;
|
||||||
|
userRole?: string | null;
|
||||||
|
customerId?: number | null;
|
||||||
|
isCustomerPortal?: boolean | null;
|
||||||
|
action: AuditAction;
|
||||||
|
sensitivity?: AuditSensitivity | null;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId?: string | null;
|
||||||
|
resourceLabel?: string | null;
|
||||||
|
endpoint: string;
|
||||||
|
httpMethod?: string | null;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
changesBefore?: string | null;
|
||||||
|
changesAfter?: string | null;
|
||||||
|
changesEncrypted?: boolean | null;
|
||||||
|
dataSubjectId?: number | null;
|
||||||
|
legalBasis?: string | null;
|
||||||
|
success?: boolean | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
durationMs?: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
previousHash?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHashV2(data: AuditHashV2Input): string {
|
||||||
|
const n = <T>(v: T | null | undefined): T | null => (v === undefined ? null : v);
|
||||||
|
// Feldreihenfolge ist Teil des Hashes und darf nicht veraendert werden.
|
||||||
|
const payload = {
|
||||||
|
v: 2,
|
||||||
|
userId: n(data.userId),
|
||||||
|
userEmail: data.userEmail,
|
||||||
|
userRole: n(data.userRole),
|
||||||
|
customerId: n(data.customerId),
|
||||||
|
isCustomerPortal: n(data.isCustomerPortal) ?? false,
|
||||||
|
action: data.action,
|
||||||
|
sensitivity: n(data.sensitivity),
|
||||||
|
resourceType: data.resourceType,
|
||||||
|
resourceId: n(data.resourceId),
|
||||||
|
resourceLabel: n(data.resourceLabel),
|
||||||
|
endpoint: data.endpoint,
|
||||||
|
httpMethod: n(data.httpMethod),
|
||||||
|
ipAddress: n(data.ipAddress),
|
||||||
|
userAgent: n(data.userAgent),
|
||||||
|
changesBefore: n(data.changesBefore),
|
||||||
|
changesAfter: n(data.changesAfter),
|
||||||
|
changesEncrypted: n(data.changesEncrypted) ?? false,
|
||||||
|
dataSubjectId: n(data.dataSubjectId),
|
||||||
|
legalBasis: n(data.legalBasis),
|
||||||
|
success: n(data.success) ?? true,
|
||||||
|
errorMessage: n(data.errorMessage),
|
||||||
|
durationMs: n(data.durationMs),
|
||||||
|
createdAt: data.createdAt.toISOString(),
|
||||||
|
previousHash: data.previousHash || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bestimmt die Sensitivität basierend auf dem Ressourcentyp
|
* Bestimmt die Sensitivität basierend auf dem Ressourcentyp
|
||||||
*/
|
*/
|
||||||
@@ -215,9 +295,6 @@ function shouldEncryptChanges(_resourceType: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* Erstellt einen neuen Audit-Log-Eintrag mit Hash-Kette
|
* Erstellt einen neuen Audit-Log-Eintrag mit Hash-Kette
|
||||||
*/
|
*/
|
||||||
// Name des DB-weiten Locks, ueber den die Hash-Kette serialisiert wird.
|
|
||||||
const AUDIT_CHAIN_LOCK = 'opencrm_audit_chain';
|
|
||||||
|
|
||||||
export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
|
export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Sensitivität bestimmen falls nicht angegeben
|
// Sensitivität bestimmen falls nicht angegeben
|
||||||
@@ -248,78 +325,99 @@ export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
|
|||||||
// haengten sich beide daran, was die Kette zerriss (echte Bruchstellen im
|
// haengten sich beide daran, was die Kette zerriss (echte Bruchstellen im
|
||||||
// Bestand, u. a. 05.05./07.05.2026).
|
// Bestand, u. a. 05.05./07.05.2026).
|
||||||
//
|
//
|
||||||
// Serialisiert wird ueber einen benannten MySQL-Lock (GET_LOCK), NICHT ueber
|
// Serialisiert wird ueber eine Zeilensperre auf dem Einzeiler-Mutex
|
||||||
// `SELECT … FOR UPDATE` am Kettenende: letzteres nimmt Gap-/Next-Key-Locks
|
// `AuditChainLock`. Zwei Alternativen wurden verworfen:
|
||||||
// am Index-Ende, die mit den gleichzeitigen INSERTs kollidieren – gemessen
|
// - `SELECT … FOR UPDATE` am Kettenende von AuditLog nimmt Gap-/Next-Key-
|
||||||
// gingen dabei 38 von 40 parallelen Eintraegen durch Deadlocks verloren.
|
// Locks, die mit den gleichzeitigen INSERTs kollidieren – gemessen gingen
|
||||||
// Ein FEHLENDER Audit-Eintrag ist unsichtbar und damit schlimmer als ein
|
// 38 von 40 parallelen Eintraegen durch Deadlocks verloren. Ein FEHLENDER
|
||||||
// sichtbarer Kettenbruch. Der benannte Lock kennt keine Gap-Locks und
|
// Audit-Eintrag ist unsichtbar und damit schlimmer als ein sichtbarer
|
||||||
// serialisiert sauber; er liegt in der DB und wirkt daher auch ueber
|
// Kettenbruch.
|
||||||
// mehrere App-Instanzen hinweg.
|
// - GET_LOCK muss auf derselben Verbindung freigegeben werden, das Release
|
||||||
|
// fiel damit VOR den COMMIT. In diesem Fenster las der naechste Schreiber
|
||||||
|
// ein noch nicht sichtbares Kettenende und hing sich an denselben
|
||||||
|
// Vorgaenger (Fork, Pentest R166-01).
|
||||||
|
// Die Zeilensperre faellt erst beim COMMIT und liegt in der DB – wirkt also
|
||||||
|
// auch ueber mehrere App-Instanzen hinweg.
|
||||||
// Alles Rechenintensive (Serialisieren/Verschluesseln) passiert bewusst
|
// Alles Rechenintensive (Serialisieren/Verschluesseln) passiert bewusst
|
||||||
// VOR der Transaktion, damit die Sperre so kurz wie moeglich gehalten wird.
|
// VOR der Transaktion, damit die Sperre so kurz wie moeglich gehalten wird.
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
// Interaktive Transaktion => alle Queries auf DERSELBEN Verbindung,
|
// Exklusive Zeilensperre auf den Einzeiler-Mutex. Sie faellt erst beim
|
||||||
// Voraussetzung dafuer, dass GET_LOCK/RELEASE_LOCK zusammengehoeren.
|
// COMMIT – dadurch sieht der naechste Schreiber die Vorgaengerzeile
|
||||||
const got = await tx.$queryRaw<Array<Record<string, number | null>>>`
|
// garantiert bereits festgeschrieben (Pentest R166-01).
|
||||||
SELECT GET_LOCK(${AUDIT_CHAIN_LOCK}, 10) AS ok
|
await tx.$queryRaw`SELECT id FROM AuditChainLock WHERE id = 1 FOR UPDATE`;
|
||||||
`;
|
|
||||||
const locked = Number(Object.values(got[0] ?? {})[0] ?? 0) === 1;
|
|
||||||
try {
|
|
||||||
const lastRows = await tx.$queryRaw<Array<{ hash: string }>>`
|
|
||||||
SELECT hash FROM AuditLog ORDER BY id DESC LIMIT 1
|
|
||||||
`;
|
|
||||||
const previousHash = lastRows[0]?.hash || null;
|
|
||||||
const createdAt = new Date();
|
|
||||||
|
|
||||||
const hash = generateHash({
|
const lastRows = await tx.$queryRaw<Array<{ hash: string }>>`
|
||||||
|
SELECT hash FROM AuditLog ORDER BY id DESC LIMIT 1
|
||||||
|
`;
|
||||||
|
const previousHash = lastRows[0]?.hash || null;
|
||||||
|
const createdAt = new Date();
|
||||||
|
|
||||||
|
// Neue Eintraege immer mit Version 2 (volle Feldabdeckung, R166-02).
|
||||||
|
const hash = generateHashV2({
|
||||||
|
userId: data.userId,
|
||||||
|
userEmail: data.userEmail,
|
||||||
|
userRole: data.userRole,
|
||||||
|
customerId: data.customerId,
|
||||||
|
isCustomerPortal: data.isCustomerPortal || false,
|
||||||
|
action: data.action,
|
||||||
|
sensitivity,
|
||||||
|
resourceType: data.resourceType,
|
||||||
|
resourceId: data.resourceId,
|
||||||
|
resourceLabel: data.resourceLabel,
|
||||||
|
endpoint: data.endpoint,
|
||||||
|
httpMethod: data.httpMethod,
|
||||||
|
ipAddress: data.ipAddress,
|
||||||
|
userAgent: data.userAgent,
|
||||||
|
changesBefore,
|
||||||
|
changesAfter,
|
||||||
|
changesEncrypted,
|
||||||
|
dataSubjectId: data.dataSubjectId,
|
||||||
|
legalBasis: data.legalBasis,
|
||||||
|
success: data.success ?? true,
|
||||||
|
errorMessage: data.errorMessage,
|
||||||
|
durationMs: data.durationMs,
|
||||||
|
createdAt,
|
||||||
|
previousHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: data.userId,
|
||||||
userEmail: data.userEmail,
|
userEmail: data.userEmail,
|
||||||
|
userRole: data.userRole,
|
||||||
|
customerId: data.customerId,
|
||||||
|
isCustomerPortal: data.isCustomerPortal || false,
|
||||||
action: data.action,
|
action: data.action,
|
||||||
|
sensitivity,
|
||||||
resourceType: data.resourceType,
|
resourceType: data.resourceType,
|
||||||
resourceId: data.resourceId,
|
resourceId: data.resourceId,
|
||||||
|
resourceLabel: data.resourceLabel,
|
||||||
endpoint: data.endpoint,
|
endpoint: data.endpoint,
|
||||||
|
httpMethod: data.httpMethod,
|
||||||
|
ipAddress: data.ipAddress,
|
||||||
|
userAgent: data.userAgent,
|
||||||
|
changesBefore,
|
||||||
|
changesAfter,
|
||||||
|
changesEncrypted,
|
||||||
|
dataSubjectId: data.dataSubjectId,
|
||||||
|
legalBasis: data.legalBasis,
|
||||||
|
success: data.success ?? true,
|
||||||
|
errorMessage: data.errorMessage,
|
||||||
|
durationMs: data.durationMs,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
hash,
|
||||||
previousHash,
|
previousHash,
|
||||||
});
|
hashVersion: 2,
|
||||||
|
},
|
||||||
await tx.auditLog.create({
|
});
|
||||||
data: {
|
}, {
|
||||||
userId: data.userId,
|
// READ COMMITTED: der Lesevorgang nach der Sperre muss den gerade
|
||||||
userEmail: data.userEmail,
|
// festgeschriebenen Stand sehen. Unter REPEATABLE READ koennte ein
|
||||||
userRole: data.userRole,
|
// Snapshot greifen, der die Vorgaengerzeile noch nicht enthaelt.
|
||||||
customerId: data.customerId,
|
isolationLevel: 'ReadCommitted',
|
||||||
isCustomerPortal: data.isCustomerPortal || false,
|
timeout: 20000,
|
||||||
action: data.action,
|
maxWait: 15000,
|
||||||
sensitivity,
|
});
|
||||||
resourceType: data.resourceType,
|
|
||||||
resourceId: data.resourceId,
|
|
||||||
resourceLabel: data.resourceLabel,
|
|
||||||
endpoint: data.endpoint,
|
|
||||||
httpMethod: data.httpMethod,
|
|
||||||
ipAddress: data.ipAddress,
|
|
||||||
userAgent: data.userAgent,
|
|
||||||
changesBefore,
|
|
||||||
changesAfter,
|
|
||||||
changesEncrypted,
|
|
||||||
dataSubjectId: data.dataSubjectId,
|
|
||||||
legalBasis: data.legalBasis,
|
|
||||||
success: data.success ?? true,
|
|
||||||
errorMessage: data.errorMessage,
|
|
||||||
durationMs: data.durationMs,
|
|
||||||
createdAt,
|
|
||||||
hash,
|
|
||||||
previousHash,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
// Benannte Locks sind NICHT transaktional – ohne explizites Release
|
|
||||||
// wandert die Sperre mit der Verbindung zurueck in den Pool und
|
|
||||||
// blockiert alle weiteren Schreiber.
|
|
||||||
if (locked) {
|
|
||||||
await tx.$queryRaw`SELECT RELEASE_LOCK(${AUDIT_CHAIN_LOCK}) AS released`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { timeout: 20000, maxWait: 15000 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Audit-Logging darf niemals die Hauptoperation blockieren
|
// Audit-Logging darf niemals die Hauptoperation blockieren
|
||||||
console.error('[AuditService] Fehler beim Erstellen des Audit-Logs:', error);
|
console.error('[AuditService] Fehler beim Erstellen des Audit-Logs:', error);
|
||||||
@@ -473,16 +571,35 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
const logs = await prisma.auditLog.findMany({
|
const logs = await prisma.auditLog.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
|
// Version 2 hasht alle Inhaltsspalten – daher vollstaendig laden.
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
userId: true,
|
||||||
userEmail: true,
|
userEmail: true,
|
||||||
|
userRole: true,
|
||||||
|
customerId: true,
|
||||||
|
isCustomerPortal: true,
|
||||||
action: true,
|
action: true,
|
||||||
|
sensitivity: true,
|
||||||
resourceType: true,
|
resourceType: true,
|
||||||
resourceId: true,
|
resourceId: true,
|
||||||
|
resourceLabel: true,
|
||||||
endpoint: true,
|
endpoint: true,
|
||||||
|
httpMethod: true,
|
||||||
|
ipAddress: true,
|
||||||
|
userAgent: true,
|
||||||
|
changesBefore: true,
|
||||||
|
changesAfter: true,
|
||||||
|
changesEncrypted: true,
|
||||||
|
dataSubjectId: true,
|
||||||
|
legalBasis: true,
|
||||||
|
success: true,
|
||||||
|
errorMessage: true,
|
||||||
|
durationMs: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
hash: true,
|
hash: true,
|
||||||
previousHash: true,
|
previousHash: true,
|
||||||
|
hashVersion: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,22 +609,39 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
for (let i = 0; i < logs.length; i++) {
|
for (let i = 0; i < logs.length; i++) {
|
||||||
const log = logs[i];
|
const log = logs[i];
|
||||||
|
|
||||||
// Hash neu berechnen
|
// Pruefverfahren richtet sich nach der Version, mit der geschrieben wurde.
|
||||||
const expectedHash = generateHash({
|
// Version 2 deckt alle Inhaltsspalten ab; Version 1 nur 7 Felder und darf
|
||||||
userEmail: log.userEmail,
|
// zusaetzlich die historische Serialisierung nutzen (generateHashLegacy).
|
||||||
action: log.action,
|
let hashOk: boolean;
|
||||||
resourceType: log.resourceType,
|
if (log.hashVersion >= 2) {
|
||||||
resourceId: log.resourceId,
|
hashOk = log.hash === generateHashV2({
|
||||||
endpoint: log.endpoint,
|
userId: log.userId,
|
||||||
createdAt: log.createdAt,
|
userEmail: log.userEmail,
|
||||||
previousHash: log.previousHash,
|
userRole: log.userRole,
|
||||||
});
|
customerId: log.customerId,
|
||||||
|
isCustomerPortal: log.isCustomerPortal,
|
||||||
// Prüfen ob Hash übereinstimmt – Altbestand darf die historische
|
action: log.action,
|
||||||
// Serialisierung nutzen (siehe generateHashLegacy). Legacy wird nur
|
sensitivity: log.sensitivity,
|
||||||
// geprüft, wenn die aktuelle Variante nicht passt.
|
resourceType: log.resourceType,
|
||||||
if (log.hash !== expectedHash) {
|
resourceId: log.resourceId,
|
||||||
const legacyHash = generateHashLegacy({
|
resourceLabel: log.resourceLabel,
|
||||||
|
endpoint: log.endpoint,
|
||||||
|
httpMethod: log.httpMethod,
|
||||||
|
ipAddress: log.ipAddress,
|
||||||
|
userAgent: log.userAgent,
|
||||||
|
changesBefore: log.changesBefore,
|
||||||
|
changesAfter: log.changesAfter,
|
||||||
|
changesEncrypted: log.changesEncrypted,
|
||||||
|
dataSubjectId: log.dataSubjectId,
|
||||||
|
legalBasis: log.legalBasis,
|
||||||
|
success: log.success,
|
||||||
|
errorMessage: log.errorMessage,
|
||||||
|
durationMs: log.durationMs,
|
||||||
|
createdAt: log.createdAt,
|
||||||
|
previousHash: log.previousHash,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const v1 = {
|
||||||
userEmail: log.userEmail,
|
userEmail: log.userEmail,
|
||||||
action: log.action,
|
action: log.action,
|
||||||
resourceType: log.resourceType,
|
resourceType: log.resourceType,
|
||||||
@@ -515,11 +649,13 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
endpoint: log.endpoint,
|
endpoint: log.endpoint,
|
||||||
createdAt: log.createdAt,
|
createdAt: log.createdAt,
|
||||||
previousHash: log.previousHash,
|
previousHash: log.previousHash,
|
||||||
});
|
};
|
||||||
if (log.hash !== legacyHash) {
|
hashOk = log.hash === generateHash(v1) || log.hash === generateHashLegacy(v1);
|
||||||
tamperedEntries.push(log.id);
|
}
|
||||||
continue;
|
|
||||||
}
|
if (!hashOk) {
|
||||||
|
tamperedEntries.push(log.id);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen ob previousHash mit dem Hash des vorherigen Eintrags übereinstimmt
|
// Prüfen ob previousHash mit dem Hash des vorherigen Eintrags übereinstimmt
|
||||||
@@ -550,11 +686,28 @@ export async function rehashAll(): Promise<{ rehashedCount: number }> {
|
|||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
userId: true,
|
||||||
userEmail: true,
|
userEmail: true,
|
||||||
|
userRole: true,
|
||||||
|
customerId: true,
|
||||||
|
isCustomerPortal: true,
|
||||||
action: true,
|
action: true,
|
||||||
|
sensitivity: true,
|
||||||
resourceType: true,
|
resourceType: true,
|
||||||
resourceId: true,
|
resourceId: true,
|
||||||
|
resourceLabel: true,
|
||||||
endpoint: true,
|
endpoint: true,
|
||||||
|
httpMethod: true,
|
||||||
|
ipAddress: true,
|
||||||
|
userAgent: true,
|
||||||
|
changesBefore: true,
|
||||||
|
changesAfter: true,
|
||||||
|
changesEncrypted: true,
|
||||||
|
dataSubjectId: true,
|
||||||
|
legalBasis: true,
|
||||||
|
success: true,
|
||||||
|
errorMessage: true,
|
||||||
|
durationMs: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -563,25 +716,37 @@ export async function rehashAll(): Promise<{ rehashedCount: number }> {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
const hash = generateHash({
|
// Rehash schreibt immer Version 2 (volle Feldabdeckung) – ein Rueckfall
|
||||||
userEmail: log.userEmail,
|
// auf Version 1 wuerde die Abdeckung nachtraeglich wieder verkleinern.
|
||||||
action: log.action,
|
const hash = generateHashV2({ ...log, previousHash });
|
||||||
resourceType: log.resourceType,
|
|
||||||
resourceId: log.resourceId,
|
|
||||||
endpoint: log.endpoint,
|
|
||||||
createdAt: log.createdAt,
|
|
||||||
previousHash,
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.auditLog.update({
|
await prisma.auditLog.update({
|
||||||
where: { id: log.id },
|
where: { id: log.id },
|
||||||
data: { hash, previousHash },
|
data: { hash, previousHash, hashVersion: 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
previousHash = hash;
|
previousHash = hash;
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Marker: Ein Rehash setzt die Beweiskraft der Vergangenheit zurueck (jede
|
||||||
|
// vorhandene Faelschung wuerde mitbesiegelt). Der Vorgang muss deshalb im
|
||||||
|
// Log selbst sichtbar sein. Der Eintrag wird NACH dem Rehash geschrieben und
|
||||||
|
// haengt sich an die neu berechnete Kette; entfernen liesse er sich nur unter
|
||||||
|
// Hinterlassung einer Luecke.
|
||||||
|
await createAuditLog({
|
||||||
|
userEmail: 'system',
|
||||||
|
userRole: 'System',
|
||||||
|
action: 'UPDATE',
|
||||||
|
resourceType: 'AuditLog',
|
||||||
|
resourceLabel: `Hash-Kette neu berechnet (${count} Einträge) – Beweiskraft der Vergangenheit zurückgesetzt`,
|
||||||
|
endpoint: '/api/audit-logs/rehash',
|
||||||
|
httpMethod: 'POST',
|
||||||
|
ipAddress: 'system',
|
||||||
|
sensitivity: 'CRITICAL',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
return { rehashedCount: count };
|
return { rehashedCount: count };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,6 +817,14 @@ export async function runRetentionCleanup(): Promise<{
|
|||||||
const results: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }> = [];
|
const results: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }> = [];
|
||||||
let totalDeleted = 0;
|
let totalDeleted = 0;
|
||||||
|
|
||||||
|
// Loeschungs-Manifest (Pentest R166, Design): Jede geloeschte Zeile reisst die
|
||||||
|
// Hash-Kette auf. Ohne Nachweis, WELCHE Bereiche legitim entfernt wurden,
|
||||||
|
// koennte sich eine boeswillige Loeschung als "harmloser Gap" tarnen. Deshalb
|
||||||
|
// halten wir Bereich + Anzahl fest und schreiben sie als eigenen, selbst
|
||||||
|
// wieder verketteten Audit-Eintrag. Luecken ausserhalb dieser Bereiche
|
||||||
|
// bleiben damit erklaerungsbeduerftig.
|
||||||
|
const manifest: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (const policy of policies) {
|
for (const policy of policies) {
|
||||||
const cutoffDate = new Date();
|
const cutoffDate = new Date();
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - policy.retentionDays);
|
cutoffDate.setDate(cutoffDate.getDate() - policy.retentionDays);
|
||||||
@@ -668,8 +841,28 @@ export async function runRetentionCleanup(): Promise<{
|
|||||||
where.sensitivity = policy.sensitivity;
|
where.sensitivity = policy.sensitivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Betroffenen ID-Bereich VOR dem Loeschen festhalten.
|
||||||
|
const range = await prisma.auditLog.aggregate({
|
||||||
|
where,
|
||||||
|
_count: true,
|
||||||
|
_min: { id: true },
|
||||||
|
_max: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
const deleted = await prisma.auditLog.deleteMany({ where });
|
const deleted = await prisma.auditLog.deleteMany({ where });
|
||||||
|
|
||||||
|
if (deleted.count > 0) {
|
||||||
|
manifest.push({
|
||||||
|
resourceType: policy.resourceType,
|
||||||
|
sensitivity: policy.sensitivity,
|
||||||
|
retentionDays: policy.retentionDays,
|
||||||
|
cutoff: cutoffDate.toISOString(),
|
||||||
|
deletedCount: deleted.count,
|
||||||
|
fromId: range._min.id,
|
||||||
|
toId: range._max.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
resourceType: policy.resourceType,
|
resourceType: policy.resourceType,
|
||||||
sensitivity: policy.sensitivity,
|
sensitivity: policy.sensitivity,
|
||||||
@@ -679,6 +872,22 @@ export async function runRetentionCleanup(): Promise<{
|
|||||||
totalDeleted += deleted.count;
|
totalDeleted += deleted.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (totalDeleted > 0) {
|
||||||
|
await createAuditLog({
|
||||||
|
userEmail: 'system',
|
||||||
|
userRole: 'System',
|
||||||
|
action: 'DELETE',
|
||||||
|
resourceType: 'AuditLog',
|
||||||
|
resourceLabel: `Retention-Cleanup: ${totalDeleted} Audit-Einträge gelöscht`,
|
||||||
|
endpoint: '/api/audit-logs/cleanup',
|
||||||
|
httpMethod: 'POST',
|
||||||
|
ipAddress: 'system',
|
||||||
|
sensitivity: 'CRITICAL',
|
||||||
|
changesAfter: { manifest },
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deletedCount: totalDeleted,
|
deletedCount: totalDeleted,
|
||||||
policies: results,
|
policies: results,
|
||||||
|
|||||||
@@ -97,6 +97,43 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🛡️ Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18)
|
||||||
|
- **R166-01 (HIGH) – Kette forkte weiter.** Mein GET_LOCK-Ansatz gab die
|
||||||
|
Sperre im `finally` INNERHALB des Transaktions-Callbacks frei, also VOR dem
|
||||||
|
COMMIT. Im Fenster Release↔Commit las der naechste Schreiber ein noch nicht
|
||||||
|
sichtbares Kettenende → zwei Zeilen am selben Vorgaenger. Meine
|
||||||
|
„100 parallel → 0 Brueche“-Messung war zu schwach: sie suchte Luecken
|
||||||
|
zwischen Nachbarn, nicht Forks, und das Fenster ist lokal sehr schmal.
|
||||||
|
Fix: einzeiliger Mutex `AuditChainLock` + `FOR UPDATE`; InnoDB-Zeilensperren
|
||||||
|
fallen erst beim COMMIT. Dazu `isolationLevel: ReadCommitted`, damit der
|
||||||
|
Lesevorgang den frisch festgeschriebenen Stand sieht.
|
||||||
|
**Belegt** im Direktvergleich mit kuenstlich geweitetem Fenster:
|
||||||
|
Release-vor-Commit → Fork, Zeilensperre → kein Fork.
|
||||||
|
- **R166-02 (MEDIUM) – Hash deckte nur 7 Felder.** `changesBefore/After`
|
||||||
|
(die eigentliche Nutzlast), `success`, `ipAddress`, `resourceLabel`,
|
||||||
|
`dataSubjectId`, `userId`/`customerId` waren NICHT gehasht – ein Einzeledit
|
||||||
|
dort blieb unsichtbar. Fix: `hashVersion` (Migration `20260818150000`) +
|
||||||
|
`generateHashV2` ueber alle Inhaltsspalten. Bestandszeilen behalten
|
||||||
|
Version 1 und bleiben ohne Rehash gueltig. `rehashAll` schreibt V2.
|
||||||
|
Verifiziert: Manipulation an success/changesAfter/ipAddress/resourceLabel/
|
||||||
|
dataSubjectId wird jetzt **5/5 erkannt**, Altbestand weiter gueltig.
|
||||||
|
- **R166-03 (LOW)** – „kein Cookie“ (normaler Erstbesuch) wurde als
|
||||||
|
`HIGH / abgelehnt` gefuehrt. Jetzt eigener Ausgang: LOW + Label
|
||||||
|
„ohne vorliegenden Token“. Nur echte Ablehnung bleibt HIGH.
|
||||||
|
- **R166-04 (LOW, pre-existing)** – `GET /retention-policies` wurde von
|
||||||
|
`GET /:id` verschluckt. Konkrete Routen jetzt konsequent vor der
|
||||||
|
Parameter-Route, mit Warnhinweis im Code.
|
||||||
|
- **Design-Empfehlungen umgesetzt:** `runRetentionCleanup` schreibt ein
|
||||||
|
Loeschungs-Manifest (Bereich `fromId`–`toId`, Anzahl, Policy, Cutoff) als
|
||||||
|
eigenen verketteten Eintrag – Luecken ausserhalb bleiben damit
|
||||||
|
erklaerungsbeduerftig; `rehashAll` schreibt einen Marker, dass die
|
||||||
|
Beweiskraft der Vergangenheit zurueckgesetzt wurde.
|
||||||
|
- Verifiziert: 50 parallele Schreiber → 50/50, 0 Forks, alle V2;
|
||||||
|
manipuliert 0, Luecken unveraendert 7. `tsc` + `vite build` gruen.
|
||||||
|
- **Offen (bewusst nicht umgesetzt):** externer Anker (HMAC mit Schluessel
|
||||||
|
ausserhalb der DB). Adressiert Full-DB-Compromise, erfordert aber
|
||||||
|
Schluesselverwaltung/Rotation im Deployment → Entscheidung des Betreibers.
|
||||||
|
|
||||||
- [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18)
|
- [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18)
|
||||||
- **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig
|
- **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig
|
||||||
unterschiedliche Befunde in einen Topf und meldete beides als
|
unterschiedliche Befunde in einen Topf und meldete beides als
|
||||||
|
|||||||
Reference in New Issue
Block a user