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

216 lines
8.2 KiB
Go

// Package ransomware erkennt statistische Auffaelligkeiten in Sicherungslaeufen.
//
// Die Vorgabe des Implementierungsplans (§18) steht in vier Worten:
//
// Do not make destructive decisions automatically. Alert first.
//
// Dieses Paket loescht nichts, sperrt nichts und haelt nichts an. Es beschreibt,
// was auffaellig ist, und ueberlaesst jede Entscheidung einem Menschen. Der
// Grund ist nicht Vorsicht, sondern Erfahrung: Eine Heuristik, die
// selbsttaetig handelt, macht aus jedem Fehlalarm einen Schaden — und
// Fehlalarme wird es geben, weil ein Betriebssystem-Update von aussen genauso
// aussieht wie ein Verschluesselungsangriff.
package ransomware
import (
"fmt"
"math"
"sort"
)
// minimumBaselineSamples ist die Zahl der Laeufe fuer einen belastbaren Basiswert.
//
// Fuenf, nicht drei: Mit drei Messwerten ist ein Median eine Formalitaet und die
// Streuung Zufall. Darunter gibt es **keine Aussage** — und keine geratene
// Schwelle, die so tut als gaebe es eine.
const minimumBaselineSamples = 5
// maximumBaselineSamples begrenzt die Zahl betrachteter Laeufe.
//
// Zwanzig: Genug fuer einen stabilen Wert, kurz genug, dass sich der Basiswert
// an einen veraenderten Betrieb anpasst. Ein Basiswert aus einem Jahr
// beschriebe eine Anlage, die es so nicht mehr gibt.
const maximumBaselineSamples = 20
// Baseline ist der statistische Normalwert einer Groesse.
//
// Median und mittlere absolute Abweichung statt Mittelwert und
// Standardabweichung: Bei wenigen Laeufen macht ein einziger Ausreisser den
// Mittelwert unbrauchbar — und genau ein Ausreisser ist der Fall, den wir
// erkennen wollen. Wuerde er den Basiswert mitbestimmen, hobe er die Schwelle
// an, gegen die er gemessen wird.
type Baseline struct {
// SampleCount ist die Zahl eingeflossener Laeufe.
SampleCount int `json:"sample_count"`
// Median ist der mittlere Wert.
Median float64 `json:"median"`
// AbsoluteDeviation ist die mittlere absolute Abweichung vom Median.
AbsoluteDeviation float64 `json:"absolute_deviation"`
// Minimum ist der kleinste beobachtete Wert.
Minimum float64 `json:"minimum"`
// Maximum ist der groesste beobachtete Wert.
Maximum float64 `json:"maximum"`
}
// IsUsable meldet einen belastbaren Basiswert.
func (baseline Baseline) IsUsable() bool {
return baseline.SampleCount >= minimumBaselineSamples
}
// BuildBaseline bildet den Basiswert aus beobachteten Werten.
//
// Die juengsten Werte stehen vorn; ueberzaehlige aeltere fallen weg.
func BuildBaseline(observedValues []float64) Baseline {
if len(observedValues) == 0 {
return Baseline{}
}
consideredValues := observedValues
if len(consideredValues) > maximumBaselineSamples {
consideredValues = consideredValues[:maximumBaselineSamples]
}
sortedValues := make([]float64, len(consideredValues))
copy(sortedValues, consideredValues)
sort.Float64s(sortedValues)
medianValue := medianOf(sortedValues)
deviations := make([]float64, 0, len(sortedValues))
for _, sortedValue := range sortedValues {
deviations = append(deviations, math.Abs(sortedValue-medianValue))
}
sort.Float64s(deviations)
return Baseline{
SampleCount: len(sortedValues),
Median: medianValue,
AbsoluteDeviation: medianOf(deviations),
Minimum: sortedValues[0],
Maximum: sortedValues[len(sortedValues)-1],
}
}
// medianOf liefert den Median einer sortierten Reihe.
func medianOf(sortedValues []float64) float64 {
valueCount := len(sortedValues)
if valueCount == 0 {
return 0
}
middleIndex := valueCount / 2
if valueCount%2 == 1 {
return sortedValues[middleIndex]
}
return (sortedValues[middleIndex-1] + sortedValues[middleIndex]) / 2
}
// deviationMultiplier ist die Zahl der Abweichungen bis zur Auffaelligkeit.
//
// Drei: Bei normalverteilten Werten liegen darunter rund 99 Prozent. Ein
// niedrigerer Wert erzeugte staendig Fehlalarme, ein hoeherer liesse den
// eigentlichen Fall durchgehen.
const deviationMultiplier = 3.0
// minimumRelativeChange ist die kleinste beachtete relative Veraenderung.
//
// Ohne diese Untergrenze schluege die Erkennung bei jeder winzigen Schwankung
// an, sobald die Streuung des Basiswerts nahe null liegt — etwa bei einer
// Quelle, die sich nie aendert.
const minimumRelativeChange = 0.5
// DeviationResult beschreibt die Abweichung eines Werts vom Basiswert.
type DeviationResult struct {
// ObservedValue ist der beobachtete Wert.
ObservedValue float64 `json:"observed_value"`
// Baseline ist der zugrunde liegende Basiswert.
Baseline Baseline `json:"baseline"`
// IsUnusual meldet eine Abweichung ueber der Schwelle.
IsUnusual bool `json:"is_unusual"`
// DeviationFactor ist die Abweichung in Vielfachen der mittleren Abweichung.
//
// Null, wenn sich kein Faktor bilden laesst — etwa bei einer Streuung von
// null. Das Feld traegt dann keine Aussage, und IsUnusual entscheidet.
DeviationFactor float64 `json:"deviation_factor"`
// Explanation erklaert die Einordnung in einem Satz.
Explanation string `json:"explanation"`
}
// EvaluateDeviation misst einen Wert gegen seinen Basiswert.
//
// Betrachtet wird ausschliesslich die Abweichung **nach oben**: Weniger
// geaenderte Dateien als sonst sind kein Angriffszeichen, und eine
// zweiseitige Pruefung erzeugte bei jedem ruhigen Wochenende einen Befund.
//
// minimumAbsoluteChange ist die Untergrenze, unterhalb derer nichts auffaellt.
// Sie ist je Signal verschieden und deshalb ein Parameter: Bei einer Quelle mit
// zwei geaenderten Dateien je Lauf ist „drei statt zwei" eine Steigerung um
// fuenfzig Prozent — und vollkommen belanglos. Bei einer Datenmenge waeren
// dieselben fuenfzig Prozent dagegen aussagekraeftig, sobald es um Gigabyte
// geht. Eine gemeinsame Grenze fuer beide gibt es nicht.
func EvaluateDeviation(observedValue float64, baseline Baseline, minimumAbsoluteChange float64) DeviationResult {
result := DeviationResult{ObservedValue: observedValue, Baseline: baseline}
if !baseline.IsUsable() {
result.Explanation = fmt.Sprintf("Es liegen erst %d von %d noetigen Laeufen vor. "+
"Ohne Basiswert gibt es keine Aussage — eine geratene Schwelle waere schlechter "+
"als keine.", baseline.SampleCount, minimumBaselineSamples)
return result
}
if observedValue <= baseline.Median {
result.Explanation = fmt.Sprintf("Der Wert liegt bei %.1f und damit nicht ueber dem "+
"ueblichen Stand von %.1f.", observedValue, baseline.Median)
return result
}
absoluteChange := observedValue - baseline.Median
// Unterhalb der absoluten Untergrenze faellt nichts auf, egal wie gross der
// Faktor rechnerisch waere.
if absoluteChange < minimumAbsoluteChange {
// Die Untergrenze wird ausdruecklich genannt. Ohne sie liest sich der Satz
// bei einem tausendfachen Anstieg wie ein Fehler des Werkzeugs — und wer
// dem Werkzeug einmal misstraut, liest auch den echten Befund nicht mehr.
result.Explanation = fmt.Sprintf("Der Wert stieg von %.1f auf %.1f. Der Zuwachs von %.1f "+
"bleibt unter der Untergrenze von %.1f, ab der diese Groesse etwas aussagt.",
baseline.Median, observedValue, absoluteChange, minimumAbsoluteChange)
return result
}
// Eine Streuung von null bedeutet: Die Groesse war bisher immer gleich.
// Dann zaehlt die relative Veraenderung, nicht das Vielfache einer
// Abweichung, die es nicht gibt.
if baseline.AbsoluteDeviation <= 0 {
relativeChange := absoluteChange / math.Max(baseline.Median, 1)
result.IsUnusual = relativeChange >= minimumRelativeChange
result.Explanation = fmt.Sprintf("Bisher lag der Wert stets bei %.1f, jetzt bei %.1f "+
"(%.0f Prozent mehr).", baseline.Median, observedValue, relativeChange*100)
return result
}
result.DeviationFactor = absoluteChange / baseline.AbsoluteDeviation
relativeChange := absoluteChange / math.Max(baseline.Median, 1)
// Beide Bedingungen muessen zutreffen. Der Faktor allein schluege bei sehr
// gleichmaessigen Werten schon bei geringfuegigen Aenderungen an; die
// relative Aenderung allein uebersaehe eine Quelle, die ohnehin stark
// schwankt.
result.IsUnusual = result.DeviationFactor >= deviationMultiplier &&
relativeChange >= minimumRelativeChange
result.Explanation = fmt.Sprintf("Der Wert liegt bei %.1f, ueblich sind %.1f "+
"(%.1f-fache der ueblichen Schwankung, %.0f Prozent mehr).",
observedValue, baseline.Median, result.DeviationFactor, relativeChange*100)
return result
}