package alerting import ( "context" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/syncova/syncova/packages/ransomware" ) // Evaluator prueft die Regeln gegen den Zustand der Anlage. // // Jede Regel liefert die Menge der **derzeit** zutreffenden Befunde. Was darin // fehlt, aber noch als Meldung offensteht, wird aufgeloest. Diese Bauweise ist // der Grund, warum sich Meldungen von selbst erledigen: Der Auswerter beschreibt // den Ist-Zustand, statt Ereignisse zu zaehlen. type Evaluator struct { // connectionPool ist der Datenbankpool der Control Plane. connectionPool *pgxpool.Pool } // NewEvaluator erzeugt die Regelauswertung. func NewEvaluator(connectionPool *pgxpool.Pool) *Evaluator { return &Evaluator{connectionPool: connectionPool} } // RuleResult ist das Ergebnis einer ausgewerteten Regel. type RuleResult struct { // RuleName benennt die Regel. RuleName string // Findings sind die derzeit zutreffenden Befunde. Findings []Finding } // EvaluateAll prueft alle ausloesbaren Regeln. func (evaluator *Evaluator) EvaluateAll(evaluateContext context.Context) ([]RuleResult, error) { ruleEvaluators := map[string]func(context.Context) ([]Finding, error){ RuleBackupFailure: evaluator.evaluateBackupFailures, RuleRepeatedBackupFailure: evaluator.evaluateRepeatedFailures, RuleRepositoryUnavailable: evaluator.evaluateUnavailableRepositories, RuleRepositoryFilling: evaluator.evaluateFillingRepositories, RuleRepositoryFull: evaluator.evaluateFullRepositories, RuleAgentOffline: evaluator.evaluateOfflineAgents, RuleRecoveryPointViolation: evaluator.evaluateRecoveryPointViolations, RuleVerificationFailure: evaluator.evaluateVerificationFailures, RuleUnusualChangeRate: evaluator.evaluateUnusualChangeRates, RuleImmutableCopyMissing: evaluator.evaluateUnprotectedBackups, } results := make([]RuleResult, 0, len(ruleEvaluators)) for _, ruleDefinition := range Rules() { evaluateRule, isImplemented := ruleEvaluators[ruleDefinition.Name] if !isImplemented { // Regeln ohne Datengrundlage werden uebersprungen, nicht mit einer // leeren Menge ausgewertet: Eine leere Menge loeste bestehende // Meldungen auf, und die Regel behauptete damit, geprueft zu haben. continue } findings, evaluateError := evaluateRule(evaluateContext) if evaluateError != nil { return nil, fmt.Errorf("die regel %s liess sich nicht auswerten: %w", ruleDefinition.Name, evaluateError) } results = append(results, RuleResult{RuleName: ruleDefinition.Name, Findings: findings}) } return results, nil } // evaluateBackupFailures findet gescheiterte Sicherungslaeufe. // // Betrachtet wird der **letzte** Lauf je Auftrag, nicht jeder gescheiterte der // Vergangenheit: Ein vor drei Tagen gescheiterter und seither erfolgreicher // Auftrag ist kein offener Zustand. func (evaluator *Evaluator) evaluateBackupFailures(evaluateContext context.Context) ([]Finding, error) { const failureStatement = ` SELECT DISTINCT ON (j.id) j.id, j.name, r.status, r.error_code, COALESCE(r.error_message, ''), r.files_skipped, r.completed_at FROM backup_jobs j JOIN backup_job_runs r ON r.job_id = j.id WHERE j.deleted_at IS NULL AND r.status IN ('succeeded', 'partial_failure', 'failed') ORDER BY j.id, r.completed_at DESC` resultRows, queryError := evaluator.connectionPool.Query(evaluateContext, failureStatement) if queryError != nil { return nil, queryError } defer resultRows.Close() findings := make([]Finding, 0, 4) for resultRows.Next() { var ( jobIdentifier uuid.UUID jobName string runStatus string errorCode *string errorMessage string filesSkipped int64 completedAt *time.Time ) if scanError := resultRows.Scan(&jobIdentifier, &jobName, &runStatus, &errorCode, &errorMessage, &filesSkipped, &completedAt); scanError != nil { return nil, scanError } if runStatus == "succeeded" { continue } findingSeverity := SeverityHigh findingTitle := fmt.Sprintf("Sicherung gescheitert: %s", jobName) findingMessage := fmt.Sprintf("Der letzte Lauf des Auftrags %q ist gescheitert (%s). "+ "Prüfen Sie die Ursache und stossen Sie den Auftrag danach erneut an.", jobName, errorMessage) if runStatus == "partial_failure" { // Ein Teilfehler ist kein Erfolg, aber auch kein Totalausfall — und // er wird nicht wiederholt. Er verlangt genau einen Blick. findingSeverity = SeverityWarning findingTitle = fmt.Sprintf("Sicherung mit Teilfehler: %s", jobName) findingMessage = fmt.Sprintf("Der letzte Lauf des Auftrags %q hat %d Objekte übergangen. "+ "Ein Teilfehler wird nicht automatisch wiederholt; die übergangenen Objekte fehlen "+ "im Wiederherstellungspunkt.", jobName, filesSkipped) } findingDetails := map[string]any{ "status": runStatus, "files_skipped": filesSkipped, } if errorCode != nil { findingDetails["error_code"] = *errorCode } findings = append(findings, Finding{ RuleName: RuleBackupFailure, Severity: findingSeverity, Fingerprint: fmt.Sprintf("%s:%s", RuleBackupFailure, jobIdentifier), Title: findingTitle, Message: findingMessage, EntityType: "backup_job", EntityID: &jobIdentifier, EntityName: jobName, Details: findingDetails, }) } return findings, resultRows.Err() } // evaluateRepeatedFailures findet Auftraege mit mehreren Fehlschlaegen in Folge. func (evaluator *Evaluator) evaluateRepeatedFailures(evaluateContext context.Context) ([]Finding, error) { // Gezaehlt werden die letzten Laeufe je Auftrag; sind die juengsten n alle // gescheitert, liegt ein Zustand vor und kein Zwischenfall. const repeatedStatement = ` WITH letzte_laeufe AS ( SELECT j.id AS job_id, j.name, r.status, row_number() OVER (PARTITION BY j.id ORDER BY r.completed_at DESC) AS rang FROM backup_jobs j JOIN backup_job_runs r ON r.job_id = j.id WHERE j.deleted_at IS NULL AND r.status IN ('succeeded', 'partial_failure', 'failed') ) SELECT job_id, name, count(*) FILTER (WHERE status = 'failed') FROM letzte_laeufe WHERE rang <= $1 GROUP BY job_id, name HAVING count(*) FILTER (WHERE status = 'failed') >= $1` resultRows, queryError := evaluator.connectionPool.Query(evaluateContext, repeatedStatement, repeatedFailureThreshold) if queryError != nil { return nil, queryError } defer resultRows.Close() findings := make([]Finding, 0, 2) for resultRows.Next() { var ( jobIdentifier uuid.UUID jobName string failureCount int ) if scanError := resultRows.Scan(&jobIdentifier, &jobName, &failureCount); scanError != nil { return nil, scanError } findings = append(findings, Finding{ RuleName: RuleRepeatedBackupFailure, Severity: SeverityCritical, Fingerprint: fmt.Sprintf("%s:%s", RuleRepeatedBackupFailure, jobIdentifier), Title: fmt.Sprintf("Sicherung mehrfach gescheitert: %s", jobName), Message: fmt.Sprintf("Der Auftrag %q ist %d Mal in Folge gescheitert. Für diese Quelle "+ "entsteht derzeit kein Wiederherstellungspunkt.", jobName, failureCount), EntityType: "backup_job", EntityID: &jobIdentifier, EntityName: jobName, Details: map[string]any{"consecutive_failures": failureCount}, }) } return findings, resultRows.Err() } // evaluateUnavailableRepositories findet nicht erreichbare Repositories. func (evaluator *Evaluator) evaluateUnavailableRepositories(evaluateContext context.Context) ([]Finding, error) { const unavailableStatement = ` SELECT id, name, location FROM repositories WHERE status = 'unavailable'` resultRows, queryError := evaluator.connectionPool.Query(evaluateContext, unavailableStatement) if queryError != nil { return nil, queryError } defer resultRows.Close() findings := make([]Finding, 0, 2) for resultRows.Next() { var ( repositoryIdentifier uuid.UUID repositoryName string repositoryLocation string ) if scanError := resultRows.Scan(&repositoryIdentifier, &repositoryName, &repositoryLocation); scanError != nil { return nil, scanError } findings = append(findings, Finding{ RuleName: RuleRepositoryUnavailable, Severity: SeverityCritical, Fingerprint: fmt.Sprintf("%s:%s", RuleRepositoryUnavailable, repositoryIdentifier), Title: fmt.Sprintf("Repository nicht erreichbar: %s", repositoryName), Message: fmt.Sprintf("Das Repository %q unter %s ist nicht erreichbar. Solange das gilt, "+ "kann weder gesichert noch wiederhergestellt werden.", repositoryName, repositoryLocation), EntityType: "repository", EntityID: &repositoryIdentifier, EntityName: repositoryName, Details: map[string]any{"location": repositoryLocation}, }) } return findings, resultRows.Err() } // evaluateFillingRepositories findet Repositories ueber der Warnschwelle. func (evaluator *Evaluator) evaluateFillingRepositories(evaluateContext context.Context) ([]Finding, error) { // Nur zwischen Warn- und Kritischschwelle: Ein zu 95 Prozent belegtes // Repository erzeugt sonst zwei Meldungen zur selben Ursache. return evaluator.evaluateRepositoryUsage(evaluateContext, RuleRepositoryFilling, SeverityWarning, fillingThresholdPercent, fullThresholdPercent) } // evaluateFullRepositories findet Repositories ueber der kritischen Schwelle. func (evaluator *Evaluator) evaluateFullRepositories(evaluateContext context.Context) ([]Finding, error) { return evaluator.evaluateRepositoryUsage(evaluateContext, RuleRepositoryFull, SeverityCritical, fullThresholdPercent, 1000) } // evaluateRepositoryUsage findet Repositories in einem Belegungsbereich. func (evaluator *Evaluator) evaluateRepositoryUsage(evaluateContext context.Context, ruleName string, severity Severity, lowerBound float64, upperBound float64) ([]Finding, error) { // Die Belegung stammt aus der juengsten Messung (Phase 13), nicht aus der // Momentaufnahme in repositories: Diese wird nur beim Sichern // fortgeschrieben und kann Wochen alt sein. const usageStatement = ` SELECT DISTINCT ON (r.id) r.id, r.name, s.value FROM repositories r JOIN metric_samples s ON s.subject_id = r.id AND s.metric_name = 'repository_used_percent' WHERE s.measured_at > now() - interval '1 hour' ORDER BY r.id, s.measured_at DESC` resultRows, queryError := evaluator.connectionPool.Query(evaluateContext, usageStatement) if queryError != nil { return nil, queryError } defer resultRows.Close() findings := make([]Finding, 0, 2) for resultRows.Next() { var ( repositoryIdentifier uuid.UUID repositoryName string usedPercentage float64 ) if scanError := resultRows.Scan(&repositoryIdentifier, &repositoryName, &usedPercentage); scanError != nil { return nil, scanError } if usedPercentage < lowerBound || usedPercentage >= upperBound { continue } findingMessage := fmt.Sprintf("Das Repository %q ist zu %.1f Prozent belegt. "+ "Räumen Sie über eine Aufbewahrungsregel auf oder erweitern Sie den Speicher.", repositoryName, usedPercentage) if severity == SeverityCritical { findingMessage = fmt.Sprintf("Das Repository %q ist zu %.1f Prozent belegt. "+ "Eine Sicherung, die keinen Platz mehr findet, scheitert — und zwar genau dann, "+ "wenn niemand hinsieht.", repositoryName, usedPercentage) } findings = append(findings, Finding{ RuleName: ruleName, Severity: severity, Fingerprint: fmt.Sprintf("%s:%s", ruleName, repositoryIdentifier), Title: fmt.Sprintf("Repository %s zu %.0f Prozent belegt", repositoryName, usedPercentage), Message: findingMessage, EntityType: "repository", EntityID: &repositoryIdentifier, EntityName: repositoryName, Details: map[string]any{"used_percent": usedPercentage}, }) } return findings, resultRows.Err() } // evaluateOfflineAgents findet Agenten ohne Lebendmeldung. func (evaluator *Evaluator) evaluateOfflineAgents(evaluateContext context.Context) ([]Finding, error) { // Widerrufene Agenten bleiben aussen vor: Dass ein abgemeldeter Agent // schweigt, ist kein Befund, sondern die Folge. const offlineStatement = ` SELECT id, name, last_heartbeat_at FROM agents WHERE status = 'active' AND (last_heartbeat_at IS NULL OR last_heartbeat_at < now() - $1::interval)` resultRows, queryError := evaluator.connectionPool.Query(evaluateContext, offlineStatement, agentOfflineAfter.String()) if queryError != nil { return nil, queryError } defer resultRows.Close() findings := make([]Finding, 0, 2) for resultRows.Next() { var ( agentIdentifier uuid.UUID agentName string lastHeartbeatAt *time.Time ) if scanError := resultRows.Scan(&agentIdentifier, &agentName, &lastHeartbeatAt); scanError != nil { return nil, scanError } lastSeenText := "noch nie" findingDetails := map[string]any{} if lastHeartbeatAt != nil { lastSeenText = fmt.Sprintf("zuletzt am %s", lastHeartbeatAt.Format(time.RFC3339)) findingDetails["last_heartbeat_at"] = lastHeartbeatAt.Format(time.RFC3339) } findings = append(findings, Finding{ RuleName: RuleAgentOffline, Severity: SeverityHigh, Fingerprint: fmt.Sprintf("%s:%s", RuleAgentOffline, agentIdentifier), Title: fmt.Sprintf("Agent meldet sich nicht: %s", agentName), Message: fmt.Sprintf("Der Agent %q hat sich seit über einer Viertelstunde nicht gemeldet "+ "(%s). Seine Quellen werden derzeit nicht gesichert.", agentName, lastSeenText), EntityType: "agent", EntityID: &agentIdentifier, EntityName: agentName, Details: findingDetails, }) } return findings, resultRows.Err() } // evaluateRecoveryPointViolations findet Auftraege mit verletzter RPO-Vorgabe. func (evaluator *Evaluator) evaluateRecoveryPointViolations(evaluateContext context.Context) ([]Finding, error) { // Auftraege ohne RPO bleiben aussen vor: Eine Vorgabe, die niemand gemacht // hat, kann niemand verletzen. Ausgesetzte ebenso — dort ist das Ausbleiben // gewollt. const violationStatement = ` SELECT j.id, j.name, j.rpo_seconds, max(r.completed_at) FILTER (WHERE r.status IN ('succeeded', 'partial_failure')) FROM backup_jobs j LEFT JOIN backup_job_runs r ON r.job_id = j.id WHERE j.deleted_at IS NULL AND j.rpo_seconds > 0 AND j.status = 'active' GROUP BY j.id, j.name, j.rpo_seconds HAVING max(r.completed_at) FILTER (WHERE r.status IN ('succeeded', 'partial_failure')) IS NULL OR max(r.completed_at) FILTER (WHERE r.status IN ('succeeded', 'partial_failure')) <= now() - (j.rpo_seconds || ' seconds')::interval` resultRows, queryError := evaluator.connectionPool.Query(evaluateContext, violationStatement) if queryError != nil { return nil, queryError } defer resultRows.Close() findings := make([]Finding, 0, 2) for resultRows.Next() { var ( jobIdentifier uuid.UUID jobName string recoveryPointSec int64 lastSuccessAt *time.Time ) if scanError := resultRows.Scan(&jobIdentifier, &jobName, &recoveryPointSec, &lastSuccessAt); scanError != nil { return nil, scanError } lastSuccessText := "Es gibt keine erfolgreiche Sicherung." findingDetails := map[string]any{"rpo_seconds": recoveryPointSec} if lastSuccessAt != nil { lastSuccessText = fmt.Sprintf("Die letzte erfolgreiche Sicherung war am %s.", lastSuccessAt.Format(time.RFC3339)) findingDetails["last_success_at"] = lastSuccessAt.Format(time.RFC3339) findingDetails["age_seconds"] = int64(time.Since(*lastSuccessAt).Seconds()) } findings = append(findings, Finding{ RuleName: RuleRecoveryPointViolation, Severity: SeverityHigh, Fingerprint: fmt.Sprintf("%s:%s", RuleRecoveryPointViolation, jobIdentifier), Title: fmt.Sprintf("Wiederherstellungspunkt überfällig: %s", jobName), Message: fmt.Sprintf("Der Auftrag %q sagt einen Wiederherstellungspunkt alle %s zu. %s "+ "Im Ernstfall gingen mehr Daten verloren als zugesagt.", jobName, formatDuration(time.Duration(recoveryPointSec)*time.Second), lastSuccessText), EntityType: "backup_job", EntityID: &jobIdentifier, EntityName: jobName, Details: findingDetails, }) } return findings, resultRows.Err() } // evaluateVerificationFailures findet Backups mit Pruefbefund. func (evaluator *Evaluator) evaluateVerificationFailures(evaluateContext context.Context) ([]Finding, error) { // Massgeblich ist die Einstufung am Backup, nicht der einzelne Pruefauftrag: // Eine spaeter behobene Beschaedigung soll die Meldung verschwinden lassen. const failureStatement = ` SELECT b.id, b.backup_id_in_repository, r.name FROM backups b JOIN repositories r ON r.id = b.repository_id WHERE b.classification = 'corrupted' AND b.deleted_at IS NULL` resultRows, queryError := evaluator.connectionPool.Query(evaluateContext, failureStatement) if queryError != nil { return nil, queryError } defer resultRows.Close() findings := make([]Finding, 0, 2) for resultRows.Next() { var ( backupIdentifier uuid.UUID backupIDInRepository string repositoryName string ) if scanError := resultRows.Scan(&backupIdentifier, &backupIDInRepository, &repositoryName); scanError != nil { return nil, scanError } findings = append(findings, Finding{ RuleName: RuleVerificationFailure, Severity: SeverityCritical, Fingerprint: fmt.Sprintf("%s:%s", RuleVerificationFailure, backupIdentifier), Title: fmt.Sprintf("Backup beschädigt: %s", backupIDInRepository), Message: fmt.Sprintf("Eine Prüfung fand beschädigte oder fehlende Blöcke im Backup %q "+ "(Repository %q). Dieses Backup ist nicht wiederherstellbar. Der Prüfbericht nennt "+ "die betroffenen Objekte.", backupIDInRepository, repositoryName), EntityType: "backup", EntityID: &backupIdentifier, EntityName: backupIDInRepository, Details: map[string]any{"repository": repositoryName}, }) } return findings, resultRows.Err() } // evaluateUnusualChangeRates findet statistisch auffaellige Sicherungslaeufe. // // **Ein Verdacht, keine Feststellung.** Seit Phase 16 stammt die Bewertung aus // der statistischen Erkennung: Sechs Signale werden gegen den Basiswert der // jeweiligen Kette gemessen, nicht gegen eine feste Schwelle. Der Unterschied // ist erheblich — eine Quelle, bei der sich taeglich die Haelfte aendert, // erzeugte mit einer festen Schwelle jeden Tag einen Fehlalarm. // // Die Anlage handelt von sich aus **nicht** (§18: „Do not make destructive // decisions automatically. Alert first."). Sie beschreibt, was auffaellt. func (evaluator *Evaluator) evaluateUnusualChangeRates(evaluateContext context.Context) ([]Finding, error) { // Betrachtet werden die Laeufe der letzten 48 Stunden. Aeltere sind // entweder geklaert oder ihre Meldung besteht bereits. const recentStatement = ` SELECT b.id FROM backups b WHERE b.status = 'complete' AND b.deleted_at IS NULL AND b.completed_at > now() - interval '48 hours' AND b.new_chunk_count IS NOT NULL ORDER BY b.completed_at DESC LIMIT 50` resultRows, queryError := evaluator.connectionPool.Query(evaluateContext, recentStatement) if queryError != nil { return nil, queryError } recentBackupIdentifiers := make([]uuid.UUID, 0, 16) for resultRows.Next() { var backupIdentifier uuid.UUID if scanError := resultRows.Scan(&backupIdentifier); scanError != nil { resultRows.Close() return nil, scanError } recentBackupIdentifiers = append(recentBackupIdentifiers, backupIdentifier) } resultRows.Close() if rowsError := resultRows.Err(); rowsError != nil { return nil, rowsError } detector := ransomware.NewDetector(evaluator.connectionPool) findings := make([]Finding, 0, 2) for _, backupIdentifier := range recentBackupIdentifiers { assessment, assessError := detector.AssessBackup(evaluateContext, backupIdentifier) if assessError != nil { return nil, assessError } if !assessment.IsSuspicious() { continue } findingSeverity := SeverityWarning if assessment.Level == ransomware.SuspicionHigh { findingSeverity = SeverityHigh } sourceName := assessment.BackupName if assessment.SourceName != "" { sourceName = assessment.SourceName } unusualSignalNames := make([]string, 0, 3) for _, signalResult := range assessment.Signals { if signalResult.Deviation.IsUnusual { unusualSignalNames = append(unusualSignalNames, fmt.Sprintf("%s (%s)", signalResult.Label, signalResult.Deviation.Explanation)) } } findings = append(findings, Finding{ RuleName: RuleUnusualChangeRate, Severity: findingSeverity, Fingerprint: fmt.Sprintf("%s:%s", RuleUnusualChangeRate, backupIdentifier), Title: fmt.Sprintf("Auffälliger Sicherungslauf: %s", sourceName), // Der Hinweis, dass die Anlage nichts unternimmt, steht in der // Empfehlung des Assessments — hier nicht ein zweites Mal. Message: fmt.Sprintf("%s %s Auffällig: %s", assessment.Summary, assessment.Recommendation, strings.Join(unusualSignalNames, "; ")), EntityType: "backup", EntityID: &backupIdentifier, EntityName: assessment.BackupName, Details: map[string]any{ "level": string(assessment.Level), "unusual_signal_count": assessment.UnusualSignalCount, "comparable_runs": assessment.ComparableRunCount, }, }) } return findings, nil } // evaluateUnprotectedBackups findet Backups ohne Loeschschutz. func (evaluator *Evaluator) evaluateUnprotectedBackups(evaluateContext context.Context) ([]Finding, error) { // Gemeldet wird je **Repository**, nicht je Backup: Bei tausend // ungeschuetzten Wiederherstellungspunkten waeren tausend Meldungen die // sicherste Art, das Meldungswesen unbrauchbar zu machen. const unprotectedStatement = ` SELECT r.id, r.name, count(b.id) FROM repositories r JOIN backups b ON b.repository_id = r.id WHERE b.status = 'complete' AND b.deleted_at IS NULL AND b.legal_hold = false AND (b.immutable_until IS NULL OR b.immutable_until <= now()) AND r.status <> 'unavailable' GROUP BY r.id, r.name HAVING count(b.id) > 0` resultRows, queryError := evaluator.connectionPool.Query(evaluateContext, unprotectedStatement) if queryError != nil { return nil, queryError } defer resultRows.Close() findings := make([]Finding, 0, 2) for resultRows.Next() { var ( repositoryIdentifier uuid.UUID repositoryName string unprotectedCount int64 ) if scanError := resultRows.Scan(&repositoryIdentifier, &repositoryName, &unprotectedCount); scanError != nil { return nil, scanError } findings = append(findings, Finding{ RuleName: RuleImmutableCopyMissing, Severity: SeverityWarning, Fingerprint: fmt.Sprintf("%s:%s", RuleImmutableCopyMissing, repositoryIdentifier), Title: fmt.Sprintf("Backups ohne Löschschutz: %s", repositoryName), Message: fmt.Sprintf("Im Repository %q liegen %d Wiederherstellungspunkte ohne "+ "Aufbewahrungsschutz. Wer Zugriff auf die Dateien hat, kann sie entfernen — "+ "auch Schadsoftware.", repositoryName, unprotectedCount), EntityType: "repository", EntityID: &repositoryIdentifier, EntityName: repositoryName, Details: map[string]any{"unprotected_backups": unprotectedCount}, }) } return findings, resultRows.Err() } // formatDuration schreibt eine Zeitspanne lesbar. func formatDuration(duration time.Duration) string { switch { case duration >= 24*time.Hour: return fmt.Sprintf("%d Stunden", int(duration.Hours())) case duration >= time.Hour: return fmt.Sprintf("%d Stunden", int(duration.Hours())) default: return fmt.Sprintf("%d Minuten", int(duration.Minutes())) } }