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>
299 lines
10 KiB
Go
299 lines
10 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// TestFindingRequiresAllFourFields prueft die Pflichtfelder eines Befunds.
|
|
//
|
|
// Der Plan (§17) verlangt Schweregrad, Erklaerung, betroffenes Objekt und
|
|
// Empfehlung. Der letzte Punkt ist der wichtigste: Ein Befund ohne Empfehlung
|
|
// ist eine Beunruhigung — er sagt, dass etwas nicht stimmt, und laesst den
|
|
// Betreiber damit allein.
|
|
func TestFindingRequiresAllFourFields(testInstance *testing.T) {
|
|
completeFinding := Finding{
|
|
Area: AreaMultiFactor,
|
|
Severity: SeverityHigh,
|
|
Title: "Konten ohne zweiten Faktor",
|
|
Explanation: "Ein Passwort allein haelt niemanden auf, der es kennt.",
|
|
AffectedObject: "2 von 3 Konten",
|
|
Recommendation: "Richten Sie den zweiten Faktor ein.",
|
|
}
|
|
|
|
if validationError := completeFinding.Validate(); validationError != nil {
|
|
testInstance.Fatalf("ein vollstaendiger Befund wurde abgelehnt: %v", validationError)
|
|
}
|
|
|
|
incompleteVariants := map[string]Finding{
|
|
"ohne Ueberschrift": {Area: "x", Explanation: "e", AffectedObject: "o", Recommendation: "r"},
|
|
"ohne Erklaerung": {Area: "x", Title: "t", AffectedObject: "o", Recommendation: "r"},
|
|
"ohne Objekt": {Area: "x", Title: "t", Explanation: "e", Recommendation: "r"},
|
|
"ohne Empfehlung": {Area: "x", Title: "t", Explanation: "e", AffectedObject: "o"},
|
|
}
|
|
|
|
for variantName, incompleteFinding := range incompleteVariants {
|
|
if validationError := incompleteFinding.Validate(); validationError == nil {
|
|
testInstance.Errorf("ein Befund %s wurde angenommen", variantName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUncheckedAreasDoNotCountAsPassed ist der Kerntest der Bewertung.
|
|
//
|
|
// Ein Bereich, der nicht geprueft werden konnte, geht **nicht** in die Rechnung
|
|
// ein — weder positiv noch negativ. Ihn als bestanden zu werten waere
|
|
// Schoenfaerberei, ihn als durchgefallen zu werten eine Behauptung.
|
|
func TestUncheckedAreasDoNotCountAsPassed(testInstance *testing.T) {
|
|
assessment := &Assessment{
|
|
Areas: []AreaResult{
|
|
{Area: "a", Title: "Geprueft", Available: true, Weight: 20, EarnedPoints: 20},
|
|
{
|
|
Area: "b", Title: "Nicht pruefbar", Available: false, Weight: 30,
|
|
UnavailableReason: "Die Funktion gibt es nicht.",
|
|
},
|
|
},
|
|
UncheckedAreas: make([]string, 0, 1),
|
|
}
|
|
|
|
finalizeAssessment(assessment)
|
|
|
|
// 20 von 20 möglichen Punkten der **geprueften** Bereiche: 100 Prozent.
|
|
// Waere der ungeprueft Bereich als durchgefallen gezaehlt worden, staende
|
|
// hier 40 Prozent — eine Zahl, die eine Aussage ueber etwas macht, das
|
|
// niemand geprueft hat.
|
|
if assessment.Score != 100 {
|
|
testInstance.Errorf("die Bewertung ist %d Prozent, erwartet wurden 100", assessment.Score)
|
|
}
|
|
|
|
if assessment.MaximumScore != 20 {
|
|
testInstance.Errorf("die erreichbaren Punkte sind %d, erwartet wurden 20",
|
|
assessment.MaximumScore)
|
|
}
|
|
|
|
if assessment.UncheckedAreaCount != 1 {
|
|
testInstance.Errorf("%d Bereiche gelten als ungeprueft, erwartet wurde einer",
|
|
assessment.UncheckedAreaCount)
|
|
}
|
|
|
|
// Der ungeprueft Bereich muss namentlich erscheinen — er ist die
|
|
// Handlungsanweisung.
|
|
if len(assessment.UncheckedAreas) != 1 || assessment.UncheckedAreas[0] != "Nicht pruefbar" {
|
|
testInstance.Errorf("die ungeprueften Bereiche wurden nicht benannt: %v",
|
|
assessment.UncheckedAreas)
|
|
}
|
|
}
|
|
|
|
// TestCriticalFindingCapsGrade prueft die Deckelung bei einem kritischen Befund.
|
|
//
|
|
// Dieselbe Regel wie bei der Recovery Assurance (Phase 10): Ein beschaedigtes
|
|
// Backup ist nicht zu 70 Prozent wiederherstellbar. Eine Anlage, bei der ein
|
|
// gestohlenes Konto alle Backups vernichten kann, ist nicht „gut abgesichert
|
|
// mit kleinem Mangel".
|
|
func TestCriticalFindingCapsGrade(testInstance *testing.T) {
|
|
assessment := &Assessment{
|
|
Areas: []AreaResult{
|
|
{Area: "a", Title: "Fast alles gut", Available: true, Weight: 90, EarnedPoints: 88},
|
|
{
|
|
Area: "b", Title: "Ein kritischer Punkt", Available: true, Weight: 10, EarnedPoints: 0,
|
|
Findings: []Finding{{
|
|
Area: "b",
|
|
Severity: SeverityCritical,
|
|
Title: "Kein Loeschschutz",
|
|
Explanation: "Alles loeschbar.",
|
|
AffectedObject: "alle Repositories",
|
|
Recommendation: "Gehaertetes Repository anlegen.",
|
|
}},
|
|
},
|
|
},
|
|
UncheckedAreas: make([]string, 0),
|
|
}
|
|
|
|
finalizeAssessment(assessment)
|
|
|
|
if assessment.Score < 80 {
|
|
testInstance.Fatalf("die Bewertung ist %d Prozent; der Test prueft damit nichts",
|
|
assessment.Score)
|
|
}
|
|
|
|
if assessment.Grade != "unzureichend" {
|
|
testInstance.Errorf("die Einstufung ist %q trotz kritischem Befund", assessment.Grade)
|
|
}
|
|
|
|
if assessment.IsTrustworthy() {
|
|
testInstance.Error("eine Lage mit kritischem Befund gilt als vertrauenswuerdig")
|
|
}
|
|
|
|
// Die Zusammenfassung muss sagen, dass die Prozentzahl nichts daran aendert.
|
|
if !strings.Contains(assessment.Summary, "kritische") {
|
|
testInstance.Errorf("die Zusammenfassung nennt den kritischen Befund nicht: %q",
|
|
assessment.Summary)
|
|
}
|
|
}
|
|
|
|
// TestManyUncheckedAreasMakeScoreAGuess prueft den Hinweis bei duenner Grundlage.
|
|
func TestManyUncheckedAreasMakeScoreAGuess(testInstance *testing.T) {
|
|
assessment := &Assessment{
|
|
Areas: []AreaResult{
|
|
{Area: "a", Title: "Geprueft", Available: true, Weight: 10, EarnedPoints: 10},
|
|
{Area: "b", Title: "Ungeprueft 1", Available: false, Weight: 10},
|
|
{Area: "c", Title: "Ungeprueft 2", Available: false, Weight: 10},
|
|
{Area: "d", Title: "Ungeprueft 3", Available: false, Weight: 10},
|
|
},
|
|
UncheckedAreas: make([]string, 0, 3),
|
|
}
|
|
|
|
finalizeAssessment(assessment)
|
|
|
|
if assessment.IsTrustworthy() {
|
|
testInstance.Error("eine Bewertung aus einem von vier Bereichen gilt als belastbar")
|
|
}
|
|
|
|
if !strings.Contains(assessment.Summary, "Vermutung") {
|
|
testInstance.Errorf("die Zusammenfassung ordnet die duenne Grundlage nicht ein: %q",
|
|
assessment.Summary)
|
|
}
|
|
}
|
|
|
|
// TestFindingsAreOrderedBySeverity prueft die Reihenfolge der Befunde.
|
|
//
|
|
// Wer die Liste oeffnet, soll oben lesen, was zuerst zu tun ist.
|
|
func TestFindingsAreOrderedBySeverity(testInstance *testing.T) {
|
|
assessment := &Assessment{
|
|
Areas: []AreaResult{
|
|
{
|
|
Area: "a", Available: true,
|
|
Findings: []Finding{
|
|
{Severity: SeverityWarning, Title: "Warnung"},
|
|
{Severity: SeverityCritical, Title: "Kritisch"},
|
|
},
|
|
},
|
|
{
|
|
Area: "b", Available: true,
|
|
Findings: []Finding{{Severity: SeverityHigh, Title: "Hoch"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
orderedFindings := assessment.AllFindings()
|
|
|
|
if len(orderedFindings) != 3 {
|
|
testInstance.Fatalf("%d Befunde geliefert, erwartet wurden 3", len(orderedFindings))
|
|
}
|
|
|
|
expectedOrder := []string{"Kritisch", "Hoch", "Warnung"}
|
|
|
|
for findingIndex, expectedTitle := range expectedOrder {
|
|
if orderedFindings[findingIndex].Title != expectedTitle {
|
|
testInstance.Errorf("an Stelle %d steht %q, erwartet wurde %q",
|
|
findingIndex, orderedFindings[findingIndex].Title, expectedTitle)
|
|
}
|
|
}
|
|
}
|
|
|
|
// connectTestDatabase oeffnet die Testdatenbank.
|
|
func connectTestDatabase(testInstance *testing.T) *pgxpool.Pool {
|
|
testInstance.Helper()
|
|
|
|
connectionString := os.Getenv("SYNCOVA_TEST_DATABASE_URL")
|
|
if connectionString == "" {
|
|
testInstance.Skip("SYNCOVA_TEST_DATABASE_URL ist nicht gesetzt; die Datenbanktests werden uebersprungen")
|
|
}
|
|
|
|
connectContext, cancelConnect := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancelConnect()
|
|
|
|
connectionPool, poolError := pgxpool.New(connectContext, connectionString)
|
|
if poolError != nil {
|
|
testInstance.Skipf("die Testdatenbank war nicht erreichbar: %v", poolError)
|
|
}
|
|
|
|
if pingError := connectionPool.Ping(connectContext); pingError != nil {
|
|
connectionPool.Close()
|
|
testInstance.Skipf("die Testdatenbank antwortete nicht: %v", pingError)
|
|
}
|
|
|
|
testInstance.Cleanup(connectionPool.Close)
|
|
|
|
return connectionPool
|
|
}
|
|
|
|
// TestInspectionCoversEveryPlannedArea prueft die Vollstaendigkeit gegen die Datenbank.
|
|
//
|
|
// Der Plan (§17) nennt zehn Bereiche. Fehlt einer, ist er nicht „noch nicht
|
|
// gebaut", sondern vergessen — und niemand sieht es.
|
|
func TestInspectionCoversEveryPlannedArea(testInstance *testing.T) {
|
|
connectionPool := connectTestDatabase(testInstance)
|
|
inspector := NewInspector(connectionPool, "0.1.0-test")
|
|
|
|
assessment, inspectError := inspector.Inspect(context.Background())
|
|
if inspectError != nil {
|
|
testInstance.Fatalf("die Pruefung schlug fehl: %v", inspectError)
|
|
}
|
|
|
|
if len(assessment.Areas) != 10 {
|
|
testInstance.Errorf("%d Bereiche geprueft, der Plan nennt zehn", len(assessment.Areas))
|
|
}
|
|
|
|
expectedAreas := []string{
|
|
AreaMultiFactor, AreaEncryption, AreaImmutability, AreaOffsite, AreaAudit,
|
|
AreaCertificates, AreaAgentSecurity, AreaUpdates, AreaRansomwareRisk, AreaAccessControl,
|
|
}
|
|
|
|
presentAreas := make(map[string]bool, len(assessment.Areas))
|
|
|
|
for _, areaResult := range assessment.Areas {
|
|
presentAreas[areaResult.Area] = true
|
|
|
|
if areaResult.Title == "" || areaResult.Summary == "" {
|
|
testInstance.Errorf("der Bereich %s ist nicht beschrieben", areaResult.Area)
|
|
}
|
|
|
|
// Ein nicht pruefbarer Bereich muss sagen, warum.
|
|
if !areaResult.Available && areaResult.UnavailableReason == "" {
|
|
testInstance.Errorf("der Bereich %s ist nicht pruefbar und erklaert sich nicht",
|
|
areaResult.Area)
|
|
}
|
|
}
|
|
|
|
for _, expectedArea := range expectedAreas {
|
|
if !presentAreas[expectedArea] {
|
|
testInstance.Errorf("der Bereich %s fehlt in der Pruefung", expectedArea)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEveryRealFindingIsComplete prueft die Befunde gegen die echte Datenbank.
|
|
//
|
|
// Die Pruefung selbst lehnt unvollstaendige Befunde ab; dieser Test stellt
|
|
// sicher, dass sie es auch mit echten Daten tut — dort entstehen die Befunde
|
|
// aus Zeichenkettenformatierung, und ein leeres Feld faellt sonst nicht auf.
|
|
func TestEveryRealFindingIsComplete(testInstance *testing.T) {
|
|
connectionPool := connectTestDatabase(testInstance)
|
|
inspector := NewInspector(connectionPool, "0.1.0-test")
|
|
|
|
assessment, inspectError := inspector.Inspect(context.Background())
|
|
if inspectError != nil {
|
|
testInstance.Fatalf("die Pruefung schlug fehl: %v", inspectError)
|
|
}
|
|
|
|
allFindings := assessment.AllFindings()
|
|
|
|
for _, finding := range allFindings {
|
|
if validationError := finding.Validate(); validationError != nil {
|
|
testInstance.Errorf("ein echter Befund ist unvollstaendig: %v", validationError)
|
|
}
|
|
|
|
// Eine Empfehlung, die nur den Befund wiederholt, hilft niemandem.
|
|
if len(finding.Recommendation) < 20 {
|
|
testInstance.Errorf("die Empfehlung zu %q ist zu knapp: %q",
|
|
finding.Title, finding.Recommendation)
|
|
}
|
|
}
|
|
}
|