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

186 lines
6.7 KiB
Go

package ransomware
import (
"strings"
"testing"
)
// TestBaselineNeedsEnoughSamples prueft die Mindestzahl an Laeufen.
//
// 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.
func TestBaselineNeedsEnoughSamples(testInstance *testing.T) {
thinBaseline := BuildBaseline([]float64{100, 105, 98})
if thinBaseline.IsUsable() {
testInstance.Error("ein Basiswert aus drei Laeufen gilt als belastbar")
}
deviation := EvaluateDeviation(10000, thinBaseline, 20)
if deviation.IsUnusual {
testInstance.Error("ohne Basiswert wurde ein Wert als auffaellig gemeldet")
}
// Die Erklaerung muss sagen, warum es keine Aussage gibt.
if !strings.Contains(deviation.Explanation, "Basiswert") {
testInstance.Errorf("die Erklaerung nennt den fehlenden Basiswert nicht: %q",
deviation.Explanation)
}
solidBaseline := BuildBaseline([]float64{100, 105, 98, 102, 99, 101})
if !solidBaseline.IsUsable() {
testInstance.Error("ein Basiswert aus sechs Laeufen gilt nicht als belastbar")
}
}
// TestMedianResistsSingleOutlier ist der Kerntest der robusten Statistik.
//
// Ein einziger Ausreisser ist genau der Fall, den wir erkennen wollen. Wuerde er
// den Basiswert mitbestimmen, hobe er die Schwelle an, gegen die er gemessen
// wird — und der naechste Angriff derselben Groesse faellt nicht mehr auf.
func TestMedianResistsSingleOutlier(testInstance *testing.T) {
// Fuenf normale Laeufe und ein Ausreisser, wie ihn ein Angriff erzeugt.
withOutlier := BuildBaseline([]float64{100, 102, 98, 101, 99, 50000})
withoutOutlier := BuildBaseline([]float64{100, 102, 98, 101, 99})
// Der Median darf sich kaum bewegen. Ein Mittelwert laege bei ueber 8000.
medianShift := withOutlier.Median - withoutOutlier.Median
if medianShift > 5 || medianShift < -5 {
testInstance.Errorf("der Median verschob sich um %.1f; ein einzelner Ausreisser "+
"bestimmt den Basiswert mit", medianShift)
}
if withOutlier.Median > 1000 {
testInstance.Errorf("der Basiswert liegt bei %.1f — das ist ein Mittelwert, kein Median",
withOutlier.Median)
}
}
// TestUnusualValueIsDetected prueft die Erkennung einer echten Abweichung.
func TestUnusualValueIsDetected(testInstance *testing.T) {
// Ein Bestand, der sich taeglich um wenige Prozent aendert.
normalBaseline := BuildBaseline([]float64{100, 105, 98, 102, 99, 101, 103})
// Ein Lauf, bei dem ploetzlich das Hundertfache neu ist.
deviation := EvaluateDeviation(10000, normalBaseline, 20)
if !deviation.IsUnusual {
testInstance.Fatalf("eine hundertfache Abweichung wurde nicht erkannt: %q",
deviation.Explanation)
}
if deviation.DeviationFactor < deviationMultiplier {
testInstance.Errorf("der Abweichungsfaktor ist %.1f, erwartet wurde mindestens %.1f",
deviation.DeviationFactor, deviationMultiplier)
}
}
// TestSmallFluctuationIsNotUnusual prueft die Vermeidung von Fehlalarmen.
//
// **Der wichtigste Test des Pakets.** Falsch-Positive sind der Grund, warum
// solche Systeme abgeschaltet werden — und ein abgeschaltetes System erkennt
// gar nichts.
func TestSmallFluctuationIsNotUnusual(testInstance *testing.T) {
normalBaseline := BuildBaseline([]float64{100, 105, 98, 102, 99, 101, 103})
// Ein Lauf mit zwanzig Prozent mehr — ein groesserer Arbeitstag.
for _, ordinaryValue := range []float64{110, 120, 125} {
deviation := EvaluateDeviation(ordinaryValue, normalBaseline, 20)
if deviation.IsUnusual {
testInstance.Errorf("ein Wert von %.0f bei einem ueblichen Stand von %.0f wurde als "+
"auffaellig gemeldet: %q", ordinaryValue, normalBaseline.Median, deviation.Explanation)
}
}
}
// TestLowerValuesAreNeverUnusual prueft die Einseitigkeit der Pruefung.
//
// Weniger geaenderte Dateien als sonst sind kein Angriffszeichen. Eine
// zweiseitige Pruefung erzeugte bei jedem ruhigen Wochenende einen Befund.
func TestLowerValuesAreNeverUnusual(testInstance *testing.T) {
normalBaseline := BuildBaseline([]float64{100, 105, 98, 102, 99, 101, 103})
for _, quietValue := range []float64{0, 1, 50} {
deviation := EvaluateDeviation(quietValue, normalBaseline, 20)
if deviation.IsUnusual {
testInstance.Errorf("ein Wert von %.0f unterhalb des ueblichen Stands wurde als "+
"auffaellig gemeldet", quietValue)
}
}
}
// TestConstantBaselineUsesRelativeChange prueft den Fall ohne Streuung.
//
// Eine Quelle, die sich nie aendert, hat eine Streuung von null. Ohne
// Sonderbehandlung waere dort jede noch so kleine Aenderung unendlich viele
// Abweichungen entfernt — aus „von 2 auf 3 Dateien" wuerde ein Alarm.
func TestConstantBaselineUsesRelativeChange(testInstance *testing.T) {
constantBaseline := BuildBaseline([]float64{100, 100, 100, 100, 100, 100})
if constantBaseline.AbsoluteDeviation != 0 {
testInstance.Fatalf("die Streuung ist %.1f, erwartet wurde null",
constantBaseline.AbsoluteDeviation)
}
// Zehn Prozent mehr: kein Befund.
if EvaluateDeviation(110, constantBaseline, 5).IsUnusual {
testInstance.Error("eine Aenderung um zehn Prozent wurde als auffaellig gemeldet")
}
// Verdopplung: Befund.
if !EvaluateDeviation(200, constantBaseline, 5).IsUnusual {
testInstance.Error("eine Verdopplung wurde nicht erkannt")
}
}
// TestTinyNumbersDoNotTriggerAlarms prueft die absolute Untergrenze.
//
// Bei einer Quelle mit zwei geaenderten Dateien je Lauf ist „drei statt zwei"
// eine Verfuenfzigprozentung — und vollkommen belanglos.
func TestTinyNumbersDoNotTriggerAlarms(testInstance *testing.T) {
tinyBaseline := BuildBaseline([]float64{2, 2, 3, 2, 2, 3})
// Von zwei auf drei: Der Median liegt bei 2, die Streuung bei 0.
deviation := EvaluateDeviation(3, tinyBaseline, 20)
if deviation.IsUnusual {
testInstance.Errorf("'drei statt zwei Dateien' wurde als auffaellig gemeldet: %q",
deviation.Explanation)
}
}
// TestBaselineIsLimitedToRecentRuns prueft die Begrenzung der Historie.
//
// Ein Basiswert aus einem Jahr beschriebe eine Anlage, die es so nicht mehr gibt.
func TestBaselineIsLimitedToRecentRuns(testInstance *testing.T) {
manyValues := make([]float64, 0, 100)
// Die juengsten Werte stehen vorn.
for valueIndex := 0; valueIndex < maximumBaselineSamples; valueIndex++ {
manyValues = append(manyValues, 1000)
}
for valueIndex := 0; valueIndex < 80; valueIndex++ {
manyValues = append(manyValues, 1)
}
limitedBaseline := BuildBaseline(manyValues)
if limitedBaseline.SampleCount != maximumBaselineSamples {
testInstance.Errorf("%d Werte flossen ein, erlaubt sind %d",
limitedBaseline.SampleCount, maximumBaselineSamples)
}
// Die alten Werte duerfen den Basiswert nicht bestimmen.
if limitedBaseline.Median != 1000 {
testInstance.Errorf("der Basiswert ist %.1f; alte Laeufe wirken noch nach",
limitedBaseline.Median)
}
}