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>
679 lines
23 KiB
Go
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()))
|
|
}
|
|
}
|