Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)
R183-01: PUT /retention-policies/{id} nahm retentionDays 0 ohne Bestaetigung an
und protokollierte es als MEDIUM - waehrend cleanup, seal und rehash alle ein
Confirm-Gate haben und die Folge CRITICAL ist. Die geladene Waffe war ungegatet,
der Abzug gegatet. Fix: Absenken verlangt confirm SHORTEN, wird als CRITICAL mit
Vorher/Nachher protokolliert, Untergrenze 30 Tage fuer Authentication/AuditLog.
R183-02: Nach dem Cleanup meldete verify "Keine Manipulation" bei valid:false
und 3010 endgueltig geloeschten Anmeldeprotokollen - der Befund stand nur im
Feld, die Prosa beruhigte. Fix: ehrliche Formulierung bei Luecken, und das
Gegenbuch ruft /verify mit und wertet valid:false hart, egal wie der Text
klingt.
R183-03: verify warnte dauerhaft "Altbestand nicht versiegelt", waehrend
seal-backlog zu Recht ablehnte. Eine unaufloesbare Warnung lernt man zu
ignorieren. Fix: eigener Zustand nicht_noetig, echte Warnung nennt den Befehl.
Heartbeat-Wachhund: Bleibt ein Dienstkonto laenger still als
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES (Standard 180), gibt es SUSPICIOUS/CRITICAL.
Ohne je gesehene Anmeldung wird geschwiegen statt geraten, pro Ausfall genau
eine Meldung. Verifiziert in allen drei Faellen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -113,3 +113,16 @@ AUDIT_HMAC_KEY_OLD=
|
|||||||
# Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel
|
# Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel
|
||||||
# UND Datenbank hat, kann die Kette konsistent neu rechnen.
|
# UND Datenbank hat, kann die Kette konsistent neu rechnen.
|
||||||
# Erzeugung : openssl rand -hex 32 (256 Bit)
|
# Erzeugung : openssl rand -hex 32 (256 Bit)
|
||||||
|
|
||||||
|
# ==================== DIENSTKONTO-WACHHUND ====================
|
||||||
|
# Nach wie vielen Minuten Stille eines Dienstkontos (z. B. des Gegenbuchs)
|
||||||
|
# ein Sicherheitsereignis erzeugt wird.
|
||||||
|
#
|
||||||
|
# Hintergrund: Die planmaessigen Anmeldungen selbst sind bewusst als Routine
|
||||||
|
# eingestuft, damit sie die CRITICAL-Stufe nicht entwerten. Das Signal ist
|
||||||
|
# deshalb die ABWESENHEIT: Wer das Gegenbuch stilllegt, setzt darauf, dass
|
||||||
|
# Stille nicht auffaellt.
|
||||||
|
#
|
||||||
|
# Faustregel: etwa das Dreifache des Gegenbuch-Takts. Bei stuendlichem Takt
|
||||||
|
# also 180. Ohne Dienstkonten im System passiert nichts.
|
||||||
|
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES=180
|
||||||
|
|||||||
@@ -180,9 +180,12 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
|
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
|
||||||
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht – Änderungen am ' +
|
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht – Änderungen am ' +
|
||||||
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
|
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
|
||||||
: result.backlogSealStatus === 'kein_siegel'
|
: result.backlogSealStatus === 'nicht_noetig'
|
||||||
? ' Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären nicht erkennbar.'
|
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
|
||||||
: '';
|
: result.backlogSealStatus === 'kein_siegel'
|
||||||
|
? ' Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären nicht erkennbar. ' +
|
||||||
|
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
|
||||||
|
: '';
|
||||||
|
|
||||||
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
|
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
|
||||||
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
|
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
|
||||||
@@ -197,12 +200,20 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
const siegelProblem =
|
const siegelProblem =
|
||||||
result.backlogSealStatus === 'gebrochen' || result.backlogSealStatus === 'entfernt';
|
result.backlogSealStatus === 'gebrochen' || result.backlogSealStatus === 'entfernt';
|
||||||
|
|
||||||
|
// „Keine Manipulation“ darf NICHT dastehen, solange `valid` falsch ist
|
||||||
|
// (Pentest R183-02). Nach einem Cleanup mit abgesenkter Aufbewahrung waren
|
||||||
|
// tausende Anmeldeprotokolle endgueltig geloescht – die Luecken durch
|
||||||
|
// Tombstones „erklaert“, die Meldung las sich beruhigend, und der Befund
|
||||||
|
// stand nur noch im Feld `valid`. Wer die Prosa liest statt des Felds,
|
||||||
|
// klickt genau das weg, was ihn haette warnen sollen.
|
||||||
const message = tampered > 0
|
const message = tampered > 0
|
||||||
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
|
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
|
||||||
: siegelProblem
|
: siegelProblem
|
||||||
? 'Die Kette selbst ist rechnerisch stimmig, ABER:'
|
? 'Die Kette selbst ist rechnerisch stimmig, ABER:'
|
||||||
: gaps > 0
|
: gaps > 0
|
||||||
? `Keine Manipulation. ${luecken} – Inhalte unverändert.`
|
? `Die Kette ist nicht mehr lückenlos: ${luecken}. Die verbliebenen Inhalte sind ` +
|
||||||
|
'unverändert – aber gelöschte Einträge lassen sich naturgemäß nicht mehr prüfen. ' +
|
||||||
|
'Dokumentierte Löschungen sind erwartbar; unerwartete gehören nachgegangen.'
|
||||||
: 'Alle Einträge sind unverändert und lückenlos verkettet';
|
: 'Alle Einträge sind unverändert und lückenlos verkettet';
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -353,6 +364,48 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
|||||||
}
|
}
|
||||||
const { retentionDays, description, legalBasis, isActive } = req.body;
|
const { retentionDays, description, legalBasis, isActive } = req.body;
|
||||||
|
|
||||||
|
// Die Richtlinie bestimmt, WAS der Cleanup vernichtet – sie ist die
|
||||||
|
// geladene Waffe, der Cleanup nur der Abzug (Pentest R183-01). Bisher war
|
||||||
|
// ausgerechnet der Abzug gegatet und die Waffe frei zugaenglich: Ein
|
||||||
|
// `retentionDays: 0` ging ohne Bestaetigung durch und wurde nur als MEDIUM
|
||||||
|
// protokolliert, waehrend seine Wirkung CRITICAL ist. Die ausloesende Tat
|
||||||
|
// war damit leiser als die Folge – und lag unter der Schwelle, bei der
|
||||||
|
// jemand hinsieht.
|
||||||
|
const bisher = (await auditService.getRetentionPolicies()).find((p) => p.id === id);
|
||||||
|
if (!bisher) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Aufbewahrungsrichtlinie nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const neueTage = typeof retentionDays === 'number' ? retentionDays : bisher.retentionDays;
|
||||||
|
|
||||||
|
// Untergrenze: Auth-Eintraege sind der Einbruchsbeleg schlechthin. Eine
|
||||||
|
// Aufbewahrung von faktisch null macht sie mit dem naechsten Cleanup
|
||||||
|
// spurlos entfernbar – ueber einen sanktionierten Pfad.
|
||||||
|
const UNTERGRENZE: Record<string, number> = { Authentication: 30, AuditLog: 30 };
|
||||||
|
const grenze = UNTERGRENZE[bisher.resourceType];
|
||||||
|
if (grenze !== undefined && neueTage < grenze) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`Für ${bisher.resourceType} sind mindestens ${grenze} Tage Aufbewahrung vorgeschrieben ` +
|
||||||
|
`(angefragt: ${neueTage}). Diese Einträge belegen Anmeldungen und Zugriffe – eine ` +
|
||||||
|
`Aufbewahrung nahe null macht sie beim nächsten Cleanup spurlos entfernbar.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absenken ist die gefaehrliche Richtung und braucht dieselbe ausdrueckliche
|
||||||
|
// Bestaetigung wie der Cleanup selbst.
|
||||||
|
const senktAb = neueTage < bisher.retentionDays;
|
||||||
|
if (senktAb && req.body?.confirm !== 'SHORTEN') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`Das verkürzt die Aufbewahrung von ${bisher.retentionDays} auf ${neueTage} Tage. ` +
|
||||||
|
'Beim nächsten Cleanup werden dadurch Einträge endgültig gelöscht, die heute noch ' +
|
||||||
|
'da sind. Zum Bestätigen {"confirm":"SHORTEN"} mitsenden.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const policy = await auditService.updateRetentionPolicy(id, {
|
const policy = await auditService.updateRetentionPolicy(id, {
|
||||||
retentionDays,
|
retentionDays,
|
||||||
description,
|
description,
|
||||||
@@ -363,7 +416,15 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
|||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
|
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
|
||||||
resourceId: id.toString(),
|
resourceId: id.toString(),
|
||||||
label: `Aufbewahrungsrichtlinie aktualisiert`,
|
// Absenkung wird wie ihre Folge eingestuft: CRITICAL, nicht MEDIUM.
|
||||||
|
sensitivity: senktAb ? 'CRITICAL' : undefined,
|
||||||
|
label: senktAb
|
||||||
|
? `Aufbewahrung VERKÜRZT für ${bisher.resourceType}` +
|
||||||
|
(bisher.sensitivity ? `/${bisher.sensitivity}` : '') +
|
||||||
|
`: ${bisher.retentionDays} → ${neueTage} Tage`
|
||||||
|
: `Aufbewahrungsrichtlinie aktualisiert`,
|
||||||
|
before: { retentionDays: bisher.retentionDays },
|
||||||
|
details: { retentionDays: neueTage },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: policy });
|
res.json({ success: true, data: policy });
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import { startSecurityMonitorScheduler } from './services/securityAlert.service.
|
|||||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
|
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
||||||
import { authenticate } from './middleware/auth.js';
|
import { authenticate } from './middleware/auth.js';
|
||||||
|
|
||||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||||
@@ -487,6 +488,10 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N
|
|||||||
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
||||||
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
||||||
|
|
||||||
|
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
||||||
|
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
||||||
|
starteHeartbeatMonitor();
|
||||||
|
|
||||||
app.listen(PORT as number, LISTEN_ADDR, () => {
|
app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||||
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
||||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export async function logChange(opts: {
|
|||||||
label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen"
|
label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen"
|
||||||
details?: Record<string, unknown>; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' }
|
details?: Record<string, unknown>; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' }
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
|
/// Ueberschreibt die aus dem Ressourcentyp abgeleitete Stufe. Noetig, wenn
|
||||||
|
/// eine Aktion gefaehrlicher ist als ihr Typ vermuten laesst - etwa das
|
||||||
|
/// Verkuerzen einer Aufbewahrungsfrist, dessen Folge CRITICAL ist
|
||||||
|
/// (Pentest R183-01).
|
||||||
|
sensitivity?: AuditSensitivity;
|
||||||
|
before?: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const user = opts.req?.user;
|
const user = opts.req?.user;
|
||||||
@@ -32,6 +38,8 @@ export async function logChange(opts: {
|
|||||||
httpMethod: opts.req?.method || '',
|
httpMethod: opts.req?.method || '',
|
||||||
ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown',
|
ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown',
|
||||||
dataSubjectId: opts.customerId,
|
dataSubjectId: opts.customerId,
|
||||||
|
sensitivity: opts.sensitivity,
|
||||||
|
changesBefore: opts.before,
|
||||||
changesAfter: opts.details,
|
changesAfter: opts.details,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -895,7 +903,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
|
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
|
||||||
* gueltiger Marker mehr – der Anker wurde herausgeloest (Pentest R174-01).
|
* gueltiger Marker mehr – der Anker wurde herausgeloest (Pentest R174-01).
|
||||||
*/
|
*/
|
||||||
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt';
|
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
||||||
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
|
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
|
||||||
backlogTampered: number[];
|
backlogTampered: number[];
|
||||||
/** Gesiegelte Zeilen, die nicht mehr existieren – Beweismaterial entfernt. */
|
/** Gesiegelte Zeilen, die nicht mehr existieren – Beweismaterial entfernt. */
|
||||||
@@ -1040,7 +1048,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
// ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01).
|
// ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01).
|
||||||
const backlogTampered: number[] = [];
|
const backlogTampered: number[] = [];
|
||||||
const backlogMissing: number[] = [];
|
const backlogMissing: number[] = [];
|
||||||
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' = 'kein_siegel';
|
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
|
||||||
let backlogSealRoot: string | null = null;
|
let backlogSealRoot: string | null = null;
|
||||||
|
|
||||||
const siegelKandidaten = await prisma.auditLog.findMany({
|
const siegelKandidaten = await prisma.auditLog.findMany({
|
||||||
@@ -1086,6 +1094,13 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
const blattAnzahl = await prisma.auditBacklogSeal.count();
|
const blattAnzahl = await prisma.auditBacklogSeal.count();
|
||||||
if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) {
|
if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) {
|
||||||
backlogSealStatus = 'entfernt';
|
backlogSealStatus = 'entfernt';
|
||||||
|
} else if (!siegel && blattAnzahl === 0 && (v3FromId === null || v3FromId <= 1)) {
|
||||||
|
// Es gibt gar keinen Altbestand: Entweder ist alles signiert, oder das Log
|
||||||
|
// beginnt erst mit der Signierung. Dann ist „nicht versiegelt“ kein Mangel
|
||||||
|
// (Pentest R183-03) – die bisherige Warnung liess sich nicht aufloesen,
|
||||||
|
// weil seal-backlog zu Recht ablehnte. Eine Warnung, die der Betreiber
|
||||||
|
// nicht beheben kann, lernt er zu ignorieren.
|
||||||
|
backlogSealStatus = 'nicht_noetig';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (siegel) {
|
if (siegel) {
|
||||||
@@ -1243,7 +1258,9 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
|||||||
// einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust
|
// einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust
|
||||||
// unsichtbar (R174-01).
|
// unsichtbar (R174-01).
|
||||||
const siegelInOrdnung =
|
const siegelInOrdnung =
|
||||||
backlogSealStatus === 'intakt' || backlogSealStatus === 'kein_siegel';
|
backlogSealStatus === 'intakt' ||
|
||||||
|
backlogSealStatus === 'kein_siegel' ||
|
||||||
|
backlogSealStatus === 'nicht_noetig';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: invalidEntries.length === 0 && siegelInOrdnung,
|
valid: invalidEntries.length === 0 && siegelInOrdnung,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wachhund auf den AUSBLEIBENDEN Heartbeat (Pentest R182/R183).
|
||||||
|
*
|
||||||
|
* Dienstkonten – allen voran das Gegenbuch – melden sich in festem Takt an.
|
||||||
|
* Diese Anmeldungen sind bewusst als Routine eingestuft, damit sie die
|
||||||
|
* CRITICAL-Stufe nicht entwerten. Damit fehlt aber die andere Haelfte: Wer das
|
||||||
|
* Gegenbuch stilllegt, setzt genau darauf, dass STILLE nicht auffaellt. Ein
|
||||||
|
* gestopptes Gegenbuch ist sonst nicht von „laeuft ruhig“ zu unterscheiden.
|
||||||
|
*
|
||||||
|
* Das Sicherheitssignal ist deshalb nicht die Anwesenheit der Anmeldung,
|
||||||
|
* sondern ihre Abweichung: bleibt sie aus, wird es gemeldet.
|
||||||
|
*
|
||||||
|
* Bewusste Zurueckhaltung: Ohne jemals gesehene Anmeldung gibt es keine
|
||||||
|
* Grundlinie – dann wird geschwiegen statt geraten. Und pro Ausfall wird nur
|
||||||
|
* einmal gemeldet, nicht bei jedem Durchlauf.
|
||||||
|
*/
|
||||||
|
const PRUEFTAKT_MS = 15 * 60 * 1000;
|
||||||
|
const MAX_ALTER_MINUTEN = Number.parseInt(process.env.SERVICE_ACCOUNT_MAX_SILENCE_MINUTES || '180', 10);
|
||||||
|
|
||||||
|
export async function pruefeHeartbeats(): Promise<void> {
|
||||||
|
const konten = await prisma.user.findMany({
|
||||||
|
where: { isServiceAccount: true, isActive: true },
|
||||||
|
select: { id: true, email: true },
|
||||||
|
});
|
||||||
|
if (konten.length === 0) return;
|
||||||
|
|
||||||
|
const grenze = new Date(Date.now() - MAX_ALTER_MINUTEN * 60 * 1000);
|
||||||
|
|
||||||
|
for (const konto of konten) {
|
||||||
|
const letzte = await prisma.auditLog.findFirst({
|
||||||
|
where: { userId: konto.id, action: 'LOGIN' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nie angemeldet = keine Grundlinie. Schweigen statt raten.
|
||||||
|
if (!letzte) continue;
|
||||||
|
if (letzte.createdAt >= grenze) continue;
|
||||||
|
|
||||||
|
// Pro Ausfall nur einmal melden.
|
||||||
|
const schonGemeldet = await prisma.securityEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
type: 'SUSPICIOUS',
|
||||||
|
userEmail: konto.email,
|
||||||
|
createdAt: { gte: letzte.createdAt },
|
||||||
|
message: { contains: 'Dienstkonto' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (schonGemeldet) continue;
|
||||||
|
|
||||||
|
const stillSeit = Math.round((Date.now() - letzte.createdAt.getTime()) / 60000);
|
||||||
|
await emitSecurityEvent({
|
||||||
|
type: 'SUSPICIOUS',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message:
|
||||||
|
`Dienstkonto ${konto.email} meldet sich seit ${stillSeit} Minuten nicht mehr ` +
|
||||||
|
`(erwartet mindestens alle ${MAX_ALTER_MINUTEN} Minuten). ` +
|
||||||
|
'Entweder steht der zugehörige Dienst – etwa das Gegenbuch – oder er wurde stillgelegt. ' +
|
||||||
|
'Stille ist hier kein guter Zustand: Ohne laufendes Gegenbuch fällt eine nachträgliche ' +
|
||||||
|
'Änderung am Audit-Log nicht mehr auf.',
|
||||||
|
userEmail: konto.email,
|
||||||
|
userId: konto.id,
|
||||||
|
endpoint: 'heartbeat-monitor',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function starteHeartbeatMonitor(): void {
|
||||||
|
if (!Number.isFinite(MAX_ALTER_MINUTEN) || MAX_ALTER_MINUTEN <= 0) {
|
||||||
|
console.warn('[Heartbeat] SERVICE_ACCOUNT_MAX_SILENCE_MINUTES ungültig – Wachhund bleibt aus.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lauf = () => {
|
||||||
|
pruefeHeartbeats().catch((e) => console.error('[Heartbeat] Fehler:', e));
|
||||||
|
};
|
||||||
|
// Nicht sofort beim Start: Nach einem Neustart darf ein kurz zurueckliegender
|
||||||
|
// Ausfall nicht doppelt melden, und die DB soll erst oben sein.
|
||||||
|
setTimeout(lauf, 60_000);
|
||||||
|
setInterval(lauf, PRUEFTAKT_MS).unref();
|
||||||
|
}
|
||||||
@@ -97,6 +97,40 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🚨 Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)** (2026-08-24)
|
||||||
|
- **R183-01 (HIGH)** – Die geladene Waffe war ungegatet, der Abzug gegatet:
|
||||||
|
`PUT /retention-policies/{id}` nahm `retentionDays: 0` **ohne
|
||||||
|
Bestaetigung** an und protokollierte es als MEDIUM – waehrend cleanup,
|
||||||
|
seal und rehash alle ein Confirm-Gate haben und die FOLGE CRITICAL ist.
|
||||||
|
Die ausloesende Tat war leiser als ihre Wirkung.
|
||||||
|
Fix: Absenken verlangt `{"confirm":"SHORTEN"}`, wird als **CRITICAL** mit
|
||||||
|
Vorher/Nachher protokolliert („Aufbewahrung VERKUERZT … 730 → 0 Tage“), und
|
||||||
|
fuer `Authentication`/`AuditLog` gilt eine **Untergrenze von 30 Tagen**.
|
||||||
|
`logChange` nimmt dafuer jetzt Sensitivitaet und Vorzustand entgegen.
|
||||||
|
- **R183-02 (HIGH)** – Nach dem Cleanup meldete verify
|
||||||
|
„**Keine Manipulation.** 656 strukturelle Luecken (alle durch protokollierte
|
||||||
|
Loeschungen erklaert)“ – bei `valid: false` und 3010 endgueltig geloeschten
|
||||||
|
Anmeldeprotokollen. Der Befund stand nur im Feld, die Prosa beruhigte.
|
||||||
|
Dieselbe Alarm-Muedigkeit wie R162/R182, diesmal im Verifizierer selbst.
|
||||||
|
Fix: Bei Luecken heisst es jetzt „Die Kette ist nicht mehr lueckenlos …
|
||||||
|
geloeschte Eintraege lassen sich naturgemaess nicht mehr pruefen“.
|
||||||
|
Zusaetzlich wertet das **Gegenbuch `valid:false` hart** – es ruft
|
||||||
|
`/verify` mit und schlaegt Alarm, egal wie der Text klingt.
|
||||||
|
- **R183-03 (MEDIUM)** – verify warnte dauerhaft „Altbestand nicht
|
||||||
|
versiegelt“, waehrend seal-backlog zu Recht ablehnte („kein Altbestand“).
|
||||||
|
Eine Warnung, die niemand aufloesen kann, lernt man zu ignorieren.
|
||||||
|
Fix: eigener Zustand `nicht_noetig` mit Klartext; die echte Warnung nennt
|
||||||
|
jetzt den Befehl zum Beheben.
|
||||||
|
- **Heartbeat-Wachhund gebaut** (seine wichtigere Haelfte): Bleibt ein
|
||||||
|
Dienstkonto laenger still als `SERVICE_ACCOUNT_MAX_SILENCE_MINUTES`
|
||||||
|
(Standard 180), gibt es `SUSPICIOUS/CRITICAL`. Ohne je gesehene Anmeldung
|
||||||
|
wird geschwiegen statt geraten; pro Ausfall wird genau einmal gemeldet.
|
||||||
|
Verifiziert: keine Grundlinie → still; 500 Minuten Stille → Meldung;
|
||||||
|
Wiederholung → keine Dublette.
|
||||||
|
- Seine Non-Findings bestaetigt uebernommen: Confirm-Gates gegen neun
|
||||||
|
Umgehungsvarianten dicht, rehash-Protokoll ehrlich, Tombstone-Mechanik und
|
||||||
|
Anker-Backstop intakt.
|
||||||
|
|
||||||
- [x] **🔧 Gegenbuch-Container: Rechte am Bind-Mount selbst geraderuecken** (2026-08-22)
|
- [x] **🔧 Gegenbuch-Container: Rechte am Bind-Mount selbst geraderuecken** (2026-08-22)
|
||||||
- Fehlerbild aus dem echten Betrieb: `mkdir: cannot create directory
|
- Fehlerbild aus dem echten Betrieb: `mkdir: cannot create directory
|
||||||
'/gegenbuch/schluessel': Permission denied`, Container in der
|
'/gegenbuch/schluessel': Permission denied`, Container in der
|
||||||
|
|||||||
@@ -235,13 +235,20 @@ async function anmelden() {
|
|||||||
zugangsToken = j.data.token;
|
zugangsToken = j.data.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hole(pfad) {
|
async function hole(pfad, methode = 'GET') {
|
||||||
// Fehler werden hier zu einer erklaerenden Zeile – frueher flog ein
|
// Fehler werden hier zu einer erklaerenden Zeile – frueher flog ein
|
||||||
// Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer
|
// Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer
|
||||||
// git-Meldungen schon abgestellt war.
|
// git-Meldungen schon abgestellt war.
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${zugangsToken}` } });
|
r = await fetch(`${CRM_URL}${pfad}`, {
|
||||||
|
method: methode,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${zugangsToken}`,
|
||||||
|
...(methode === 'POST' ? { 'content-type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
...(methode === 'POST' ? { body: '{}' } : {}),
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
|
`Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
|
||||||
@@ -737,6 +744,22 @@ if (letzter) {
|
|||||||
`jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`,
|
`jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// `valid: false` wird HART gewertet, unabhaengig von der erklaerenden Prosa
|
||||||
|
// (Pentest R183-02). Ein Cleanup mit abgesenkter Aufbewahrung kann tausende
|
||||||
|
// Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
|
||||||
|
// ausweisen – die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
|
||||||
|
// das Feld, nicht der Satz.
|
||||||
|
const pruefung = await hole('/api/audit-logs/verify', 'POST');
|
||||||
|
if (pruefung && pruefung.valid === false) {
|
||||||
|
alarm(
|
||||||
|
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
|
||||||
|
` CRM-Text: ${pruefung.message}\n` +
|
||||||
|
'Auch wenn dieser Text harmlos klingt: Es wird nichts beglaubigt, solange die\n' +
|
||||||
|
'Prüfung nicht sauber ist. Häufigste Ursache ist ein Cleanup mit verkürzter\n' +
|
||||||
|
'Aufbewahrung – dann wurden Einträge endgültig gelöscht.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
|
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
|
||||||
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
|
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
|
||||||
if (rueck.atHash !== letzter.chainHead) {
|
if (rueck.atHash !== letzter.chainHead) {
|
||||||
|
|||||||
Reference in New Issue
Block a user