syncova-backup/packages/ransomware/detector.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

526 lines
19 KiB
Go

package ransomware
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// SignalName benennt eines der sechs Signale (SYNCOVA_IMPLEMENTATION_PLAN.md §18).
type SignalName string
const (
// SignalChangedBytes ist die Menge neu abgelegter Daten.
SignalChangedBytes SignalName = "changed_bytes"
// SignalChangedFiles ist die Zahl geaenderter Objekte.
SignalChangedFiles SignalName = "changed_files"
// SignalDeletedFiles ist die Zahl verschwundener Objekte.
SignalDeletedFiles SignalName = "deleted_files"
// SignalIncompressibleShare ist der Anteil nicht verkleinerbarer Bloecke.
SignalIncompressibleShare SignalName = "incompressible_share"
// SignalNewExtensions ist die Zahl neu aufgetauchter Dateiendungen.
SignalNewExtensions SignalName = "new_extensions"
// SignalBackupSize ist die Groesse des Laufs.
SignalBackupSize SignalName = "backup_size"
)
// SignalResult ist die Bewertung eines einzelnen Signals.
type SignalResult struct {
// Name ist der Bezeichner des Signals.
Name SignalName `json:"name"`
// Label ist die Beschriftung.
Label string `json:"label"`
// Deviation ist die gemessene Abweichung.
Deviation DeviationResult `json:"deviation"`
// Detail nennt zusaetzliche Beobachtungen.
Detail string `json:"detail,omitempty"`
}
// SuspicionLevel ist die Einstufung eines Laufs.
type SuspicionLevel string
const (
// SuspicionNone bedeutet: nichts Auffaelliges.
SuspicionNone SuspicionLevel = "none"
// SuspicionUnknown bedeutet: zu wenig Vergleichslaeufe fuer eine Aussage.
//
// Ausdruecklich getrennt von „nichts Auffaelliges": Wer nicht messen kann,
// hat nichts gemessen — und darf nicht so tun, als sei alles in Ordnung.
SuspicionUnknown SuspicionLevel = "unknown"
// SuspicionElevated bedeutet: ein Signal ist auffaellig.
SuspicionElevated SuspicionLevel = "elevated"
// SuspicionHigh bedeutet: mehrere Signale zugleich sind auffaellig.
SuspicionHigh SuspicionLevel = "high"
)
// Assessment ist die Bewertung eines Sicherungslaufs.
type Assessment struct {
// BackupID ist das bewertete Backup.
BackupID uuid.UUID `json:"backup_id"`
// BackupName ist seine Kennung im Repository.
BackupName string `json:"backup_name"`
// SourceName ist der Name der Quelle.
SourceName string `json:"source_name,omitempty"`
// CompletedAt ist der Abschluss des Laufs in UTC.
CompletedAt time.Time `json:"completed_at"`
// Level ist die Einstufung.
Level SuspicionLevel `json:"level"`
// Signals sind die Bewertungen der einzelnen Signale.
Signals []SignalResult `json:"signals"`
// UnusualSignalCount ist die Zahl auffaelliger Signale.
UnusualSignalCount int `json:"unusual_signal_count"`
// ComparableRunCount ist die Zahl der Vergleichslaeufe.
ComparableRunCount int `json:"comparable_run_count"`
// Summary fasst die Bewertung in einem Satz zusammen.
Summary string `json:"summary"`
// Recommendation nennt die naechste Handlung.
//
// Immer gefuellt, auch bei unauffaelligen Laeufen: Eine Bewertung ohne
// Handlungsanweisung ist eine Beunruhigung (dieselbe Regel wie im Security
// Center, Phase 15).
Recommendation string `json:"recommendation"`
}
// IsSuspicious meldet einen auffaelligen Lauf.
func (assessment *Assessment) IsSuspicious() bool {
return assessment.Level == SuspicionElevated || assessment.Level == SuspicionHigh
}
// highSuspicionSignalCount ist die Zahl auffaelliger Signale fuer die hoehere Stufe.
//
// Zwei: Ein einzelnes auffaelliges Signal hat viele harmlose Ursachen — ein
// grosses Update, ein Umzug, ein neuer Datenbestand. Zwei zugleich sind das
// Muster, das massenhafte Verschluesselung hinterlaesst: viele geaenderte
// Dateien **und** kaum noch komprimierbare Daten.
const highSuspicionSignalCount = 2
// Detector bewertet Sicherungslaeufe gegen ihren Basiswert.
type Detector struct {
// connectionPool ist der Datenbankpool der Control Plane.
connectionPool *pgxpool.Pool
}
// NewDetector erzeugt die Erkennung.
func NewDetector(connectionPool *pgxpool.Pool) *Detector {
return &Detector{connectionPool: connectionPool}
}
// backupSignals sind die Rohwerte eines Laufs.
type backupSignals struct {
// BackupID ist das Backup.
BackupID uuid.UUID
// BackupName ist seine Kennung im Repository.
BackupName string
// SourceName ist der Name der Quelle.
SourceName string
// CompletedAt ist der Abschluss in UTC.
CompletedAt time.Time
// UniqueBytes ist die Menge neu abgelegter Daten.
UniqueBytes int64
// LogicalBytes ist die Menge der Ursprungsdaten.
LogicalBytes int64
// ChangedFileCount ist die Zahl geaenderter Objekte.
ChangedFileCount int64
// DeletedFileCount ist die Zahl verschwundener Objekte.
DeletedFileCount int64
// IncompressibleChunks ist die Zahl nicht verkleinerbarer Bloecke.
IncompressibleChunks int64
// NewChunkCount ist die Zahl neuer Bloecke.
NewChunkCount int64
// ExtensionDistribution zaehlt die Dateiendungen.
ExtensionDistribution map[string]int64
// HasSignals meldet, ob die Signale ueberhaupt erhoben wurden.
//
// Backups aus der Zeit vor Phase 16 tragen sie nicht. Sie als Nullwerte zu
// behandeln verfaelschte jeden Basiswert nach unten — und liesse damit
// jeden neuen Lauf auffaellig erscheinen.
HasSignals bool
}
// incompressibleShare liefert den Anteil nicht verkleinerbarer Bloecke.
func (signals backupSignals) incompressibleShare() float64 {
if signals.NewChunkCount <= 0 {
return 0
}
return float64(signals.IncompressibleChunks) * 100 / float64(signals.NewChunkCount)
}
// AssessBackup bewertet ein Backup gegen die vorherigen Laeufe seiner Kette.
func (detector *Detector) AssessBackup(assessContext context.Context, backupIdentifier uuid.UUID) (*Assessment, error) {
currentSignals, chainIdentifier, loadError := detector.loadBackupSignals(assessContext, backupIdentifier)
if loadError != nil {
return nil, loadError
}
previousSignals, historyError := detector.loadChainHistory(assessContext, chainIdentifier,
currentSignals.CompletedAt)
if historyError != nil {
return nil, historyError
}
return buildAssessment(currentSignals, previousSignals), nil
}
// loadBackupSignals liest die Signale eines Backups.
func (detector *Detector) loadBackupSignals(loadContext context.Context, backupIdentifier uuid.UUID) (backupSignals, uuid.UUID, error) {
const selectStatement = `
SELECT b.id, b.backup_id_in_repository, COALESCE(j.name, ''), b.completed_at,
COALESCE(b.unique_bytes, 0), COALESCE(b.logical_bytes, 0),
b.changed_file_count, b.deleted_file_count,
b.incompressible_chunks, b.new_chunk_count, b.extension_distribution,
b.chain_id
FROM backups b
LEFT JOIN backup_job_runs r ON r.id = b.job_run_id
LEFT JOIN backup_jobs j ON j.id = r.job_id
WHERE b.id = $1`
var (
signals backupSignals
chainIdentifier *uuid.UUID
completedAt *time.Time
changedFileCount *int64
deletedFileCount *int64
incompressibleChunks *int64
newChunkCount *int64
distributionJSON []byte
)
scanError := detector.connectionPool.QueryRow(loadContext, selectStatement, backupIdentifier).Scan(
&signals.BackupID, &signals.BackupName, &signals.SourceName, &completedAt,
&signals.UniqueBytes, &signals.LogicalBytes,
&changedFileCount, &deletedFileCount,
&incompressibleChunks, &newChunkCount, &distributionJSON, &chainIdentifier)
if scanError != nil {
return backupSignals{}, uuid.Nil, fmt.Errorf("die signale konnten nicht gelesen werden: %w", scanError)
}
if completedAt != nil {
signals.CompletedAt = *completedAt
}
// Ein Backup ohne erhobene Signale stammt aus der Zeit vor dieser Phase.
signals.HasSignals = newChunkCount != nil
for target, source := range map[*int64]*int64{
&signals.ChangedFileCount: changedFileCount,
&signals.DeletedFileCount: deletedFileCount,
&signals.IncompressibleChunks: incompressibleChunks,
&signals.NewChunkCount: newChunkCount,
} {
if source != nil {
*target = *source
}
}
if len(distributionJSON) > 0 {
_ = json.Unmarshal(distributionJSON, &signals.ExtensionDistribution)
}
if chainIdentifier == nil {
return signals, uuid.Nil, nil
}
return signals, *chainIdentifier, nil
}
// loadChainHistory liest die vorherigen Laeufe einer Kette.
func (detector *Detector) loadChainHistory(loadContext context.Context, chainIdentifier uuid.UUID, beforeTime time.Time) ([]backupSignals, error) {
if chainIdentifier == uuid.Nil {
return nil, nil
}
const selectStatement = `
SELECT b.id, b.backup_id_in_repository, b.completed_at,
COALESCE(b.unique_bytes, 0), COALESCE(b.logical_bytes, 0),
b.changed_file_count, b.deleted_file_count,
b.incompressible_chunks, b.new_chunk_count, b.extension_distribution
FROM backups b
WHERE b.chain_id = $1
AND b.status = 'complete' AND b.deleted_at IS NULL
AND b.completed_at < $2
ORDER BY b.completed_at DESC
LIMIT $3`
historyRows, queryError := detector.connectionPool.Query(loadContext, selectStatement,
chainIdentifier, beforeTime, maximumBaselineSamples)
if queryError != nil {
return nil, fmt.Errorf("die vergleichslaeufe konnten nicht gelesen werden: %w", queryError)
}
defer historyRows.Close()
previousSignals := make([]backupSignals, 0, maximumBaselineSamples)
for historyRows.Next() {
var (
signals backupSignals
completedAt *time.Time
changedFileCount *int64
deletedFileCount *int64
incompressibleChunks *int64
newChunkCount *int64
distributionJSON []byte
)
if scanError := historyRows.Scan(&signals.BackupID, &signals.BackupName, &completedAt,
&signals.UniqueBytes, &signals.LogicalBytes,
&changedFileCount, &deletedFileCount,
&incompressibleChunks, &newChunkCount, &distributionJSON); scanError != nil {
return nil, fmt.Errorf("ein vergleichslauf konnte nicht gelesen werden: %w", scanError)
}
if completedAt != nil {
signals.CompletedAt = *completedAt
}
signals.HasSignals = newChunkCount != nil
for target, source := range map[*int64]*int64{
&signals.ChangedFileCount: changedFileCount,
&signals.DeletedFileCount: deletedFileCount,
&signals.IncompressibleChunks: incompressibleChunks,
&signals.NewChunkCount: newChunkCount,
} {
if source != nil {
*target = *source
}
}
if len(distributionJSON) > 0 {
_ = json.Unmarshal(distributionJSON, &signals.ExtensionDistribution)
}
previousSignals = append(previousSignals, signals)
}
return previousSignals, historyRows.Err()
}
// buildAssessment bewertet einen Lauf gegen seine Vorgaenger.
func buildAssessment(currentSignals backupSignals, previousSignals []backupSignals) *Assessment {
assessment := &Assessment{
BackupID: currentSignals.BackupID,
BackupName: currentSignals.BackupName,
SourceName: currentSignals.SourceName,
CompletedAt: currentSignals.CompletedAt,
Signals: make([]SignalResult, 0, 6),
Level: SuspicionNone,
}
// Nur Laeufe mit erhobenen Signalen taugen zum Vergleich. Aeltere als
// Nullwerte zu behandeln zoege jeden Basiswert nach unten und liesse jeden
// neuen Lauf auffaellig erscheinen.
comparableSignals := make([]backupSignals, 0, len(previousSignals))
for _, previousSignal := range previousSignals {
if previousSignal.HasSignals {
comparableSignals = append(comparableSignals, previousSignal)
}
}
assessment.ComparableRunCount = len(comparableSignals)
assessment.Signals = append(assessment.Signals,
evaluateSignal(SignalChangedBytes, "Neu abgelegte Datenmenge",
float64(currentSignals.UniqueBytes), collectValues(comparableSignals,
func(signals backupSignals) float64 { return float64(signals.UniqueBytes) }),
minimumByteChange),
evaluateSignal(SignalChangedFiles, "Geaenderte Objekte",
float64(currentSignals.ChangedFileCount), collectValues(comparableSignals,
func(signals backupSignals) float64 { return float64(signals.ChangedFileCount) }),
minimumFileCountChange),
evaluateSignal(SignalDeletedFiles, "Verschwundene Objekte",
float64(currentSignals.DeletedFileCount), collectValues(comparableSignals,
func(signals backupSignals) float64 { return float64(signals.DeletedFileCount) }),
minimumFileCountChange),
evaluateSignal(SignalIncompressibleShare, "Anteil nicht verkleinerbarer Bloecke",
currentSignals.incompressibleShare(), collectValues(comparableSignals,
func(signals backupSignals) float64 { return signals.incompressibleShare() }),
minimumSharePointChange),
evaluateSignal(SignalBackupSize, "Groesse des Laufs",
float64(currentSignals.LogicalBytes), collectValues(comparableSignals,
func(signals backupSignals) float64 { return float64(signals.LogicalBytes) }),
minimumByteChange),
evaluateExtensionSignal(currentSignals, comparableSignals),
)
for _, signalResult := range assessment.Signals {
if signalResult.Deviation.IsUnusual {
assessment.UnusualSignalCount++
}
}
finalizeLevel(assessment, currentSignals)
return assessment
}
// collectValues liest eine Groesse aus den Vergleichslaeufen.
func collectValues(signalHistory []backupSignals, readValue func(backupSignals) float64) []float64 {
collectedValues := make([]float64, 0, len(signalHistory))
for _, signals := range signalHistory {
collectedValues = append(collectedValues, readValue(signals))
}
return collectedValues
}
// Untergrenzen je Signal.
//
// Unterhalb dieser Groessenordnungen bedeutet eine Abweichung nichts. Die Werte
// sind bewusst verschieden: Zwanzig Dateien mehr sind ein Ereignis, zwanzig
// Bytes mehr sind Rauschen.
const (
// minimumByteChange ist die Untergrenze fuer Datenmengen.
//
// Hundert Megabyte: Darunter ist eine Verschluesselungswelle weder
// wirtschaftlich noch bemerkbar.
minimumByteChange = 100 * 1024 * 1024
// minimumFileCountChange ist die Untergrenze fuer Objektzahlen.
minimumFileCountChange = 20
// minimumSharePointChange ist die Untergrenze fuer Anteile in Prozentpunkten.
//
// Zehn Punkte: Der Anteil unkomprimierbarer Bloecke schwankt allein durch
// die Zusammensetzung der geaenderten Dateien um einige Punkte.
minimumSharePointChange = 10
)
// evaluateSignal bewertet ein Signal gegen seinen Basiswert.
func evaluateSignal(signalName SignalName, label string, observedValue float64, historyValues []float64, minimumChange float64) SignalResult {
return SignalResult{
Name: signalName,
Label: label,
Deviation: EvaluateDeviation(observedValue, BuildBaseline(historyValues), minimumChange),
}
}
// evaluateExtensionSignal prueft neu aufgetauchte Dateiendungen.
//
// Das aussagekraeftigste Einzelsignal: Schadsoftware haengt den verschluesselten
// Dateien eine eigene Endung an. Eine Endung, die in keinem der Vergleichslaeufe
// vorkam und jetzt haeufig ist, hat wenige harmlose Erklaerungen.
func evaluateExtensionSignal(currentSignals backupSignals, comparableSignals []backupSignals) SignalResult {
signalResult := SignalResult{
Name: SignalNewExtensions,
Label: "Neue Dateiendungen",
}
knownExtensions := make(map[string]bool, 32)
for _, previousSignal := range comparableSignals {
for extension := range previousSignal.ExtensionDistribution {
knownExtensions[extension] = true
}
}
// Ohne Vergleichslaeufe ist jede Endung neu. Das als Befund zu werten waere
// ein Fehlalarm mit Ansage.
if len(comparableSignals) < minimumBaselineSamples {
signalResult.Deviation = DeviationResult{
Explanation: fmt.Sprintf("Es liegen erst %d von %d noetigen Laeufen vor. "+
"Ohne Vergleich waere jede Endung neu.",
len(comparableSignals), minimumBaselineSamples),
}
return signalResult
}
newExtensionCount := 0
newFileCount := int64(0)
newExtensionNames := make([]string, 0, 4)
for extension, fileCount := range currentSignals.ExtensionDistribution {
if knownExtensions[extension] || extension == "(weitere)" {
continue
}
newExtensionCount++
newFileCount += fileCount
if len(newExtensionNames) < 5 {
newExtensionNames = append(newExtensionNames, extension)
}
}
signalResult.Deviation.ObservedValue = float64(newExtensionCount)
signalResult.Deviation.Baseline = Baseline{SampleCount: len(comparableSignals)}
if newExtensionCount == 0 {
signalResult.Deviation.Explanation = "Es sind keine neuen Dateiendungen aufgetaucht."
return signalResult
}
// Eine einzelne neue Endung mit wenigen Dateien ist Alltag. Auffaellig wird
// es, wenn viele Dateien betroffen sind.
totalFileCount := int64(0)
for _, fileCount := range currentSignals.ExtensionDistribution {
totalFileCount += fileCount
}
newFileShare := float64(0)
if totalFileCount > 0 {
newFileShare = float64(newFileCount) * 100 / float64(totalFileCount)
}
signalResult.Deviation.IsUnusual = newFileShare >= 25
signalResult.Deviation.Explanation = fmt.Sprintf("%d neue Dateiendungen (%v) betreffen "+
"%.0f Prozent der Objekte.", newExtensionCount, newExtensionNames, newFileShare)
signalResult.Detail = fmt.Sprintf("%d Objekte mit bisher unbekannter Endung", newFileCount)
return signalResult
}
// finalizeLevel bestimmt die Einstufung und die Handlungsanweisung.
func finalizeLevel(assessment *Assessment, currentSignals backupSignals) {
if !currentSignals.HasSignals {
assessment.Level = SuspicionUnknown
assessment.Summary = "Fuer diesen Lauf wurden keine Signale erhoben."
assessment.Recommendation = "Backups aus der Zeit vor der Einfuehrung der Erkennung " +
"tragen die noetigen Kennzahlen nicht. Ab dem naechsten Lauf steht die Bewertung zur " +
"Verfuegung."
return
}
if assessment.ComparableRunCount < minimumBaselineSamples {
assessment.Level = SuspicionUnknown
assessment.Summary = fmt.Sprintf("Es liegen erst %d von %d noetigen Vergleichslaeufen vor.",
assessment.ComparableRunCount, minimumBaselineSamples)
assessment.Recommendation = "Die Erkennung braucht einen Basiswert. Nach weiteren Laeufen " +
"steht er zur Verfuegung; bis dahin gibt es keine Aussage — und keine geratene Schwelle."
return
}
switch {
case assessment.UnusualSignalCount >= highSuspicionSignalCount:
assessment.Level = SuspicionHigh
assessment.Summary = fmt.Sprintf("%d von %d Signalen sind auffaellig. Dieses Muster "+
"hinterlaesst massenhafte Verschluesselung — ein grosses Update oder ein Umzug des "+
"Datenbestands aber auch.", assessment.UnusualSignalCount, len(assessment.Signals))
assessment.Recommendation = "Pruefen Sie die Quelle, **bevor** Sie aeltere " +
"Wiederherstellungspunkte loeschen oder eine Aufbewahrungsregel anwenden. Die Anlage " +
"unternimmt von sich aus nichts."
case assessment.UnusualSignalCount > 0:
assessment.Level = SuspicionElevated
assessment.Summary = fmt.Sprintf("%d Signal weicht vom ueblichen Verlauf ab.",
assessment.UnusualSignalCount)
assessment.Recommendation = "Ein einzelnes auffaelliges Signal hat viele harmlose " +
"Ursachen. Sehen Sie nach, ob die Aenderung erklaerbar ist. Die Anlage unternimmt " +
"von sich aus nichts."
default:
assessment.Level = SuspicionNone
assessment.Summary = "Der Lauf entspricht dem ueblichen Verlauf."
assessment.Recommendation = "Keine Handlung noetig."
}
}