syncova-backup/packages/alerting/evaluator.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

679 lines
23 KiB
Go

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()))
}
}