syncova-backup/packages/security/findings_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

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)
}
}
}