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>
186 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|