syncova-backup/packages/repository/immutability_test.go
Jerrit Fritzsche 9c48cb1c1c
Some checks failed
CI / Sicherheitsprüfungen (push) Waiting to run
CI / Backend (Go) (push) Failing after 1m47s
CI / Frontend (React/TypeScript) (push) Has been cancelled
CI: Loeschschutz messen statt annehmen, Datenbanktests wirklich fahren
Der Angriffstest auf ein gehaertetes Repository scheiterte in der CI: "rm -rf
auf ein geschuetztes Repository gelang vollstaendig". Der Befund ist richtig —
und der Fehler lag im Test, nicht im Produktivcode.

immutableFlagSupported() sagt nur, ob das Betriebssystem das
Unveraenderlich-Kennzeichen *kennt*; unter Linux gibt es immer "ja" zurueck. Ob
es auch *durchgesetzt* wird, haengt am Dateisystem und an CAP_LINUX_IMMUTABLE.
In einem Container auf overlayfs ist beides nicht gegeben: Das Setzen scheitert
still, und der Angriff gelingt.

Die Anlage selbst macht es richtig — sie ist beim Setzen nachsichtig (ein Backup
ohne technischen Loeschschutz ist besser als gar keines) und sagt die Wahrheit
ueber die gemessene Stufe. Der Test tut das jetzt auch: Er misst zuerst und
prueft nur dort, wo es etwas zu pruefen gibt. Nachgewiesen in beide Richtungen —
auf macOS laeuft der Angriff wirklich, im Container wird mit Begruendung
uebersprungen.

Zwei Luecken in der CI dabei gefunden:

- SYNCOVA_TEST_DATABASE_URL fehlte. Neun Testdateien uebersprangen ihre
  Datenbanktests still, darunter der Upgrade- und der Rollback-Test. Ein
  uebersprungener Test sieht in der Zusammenfassung aus wie ein bestandener.
- make cross-build lief nicht mit. Genau daran ist in Phase 5 monatelang
  unbemerkt geblieben, dass der Agent sich fuer Windows gar nicht uebersetzen
  liess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:45:27 +02:00

487 lines
20 KiB
Go

package repository
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// newHardenedTestRepository legt ein geharztetes Repository an.
func newHardenedTestRepository(testInstance *testing.T, createOptions CreateOptions) *LocalRepository {
testInstance.Helper()
createOptions.Kind = KindLocal
createOptions.CreatedByVersion = "test"
createdRepository, createError := Create(context.Background(), testInstance.TempDir(),
createOptions, newTestLogger())
if createError != nil {
testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError)
}
testInstance.Cleanup(func() { _ = createdRepository.Close() })
// Ohne dieses Aufraeumen scheitert t.TempDir: Geschuetzte Dateien lassen
// sich nicht loeschen — was genau der Zweck der Uebung ist. Der Testlauf
// hinterliesse sonst Verzeichnisse, die auch von Hand kaum wegzubekommen
// sind. Dieselbe Beobachtung gilt im Betrieb: Ein geharztetes Repository
// laesst sich nicht mit rm -rf entfernen.
testInstance.Cleanup(func() { releaseAllImmutableFlagsForTest(testInstance, createdRepository.RootPath()) })
return createdRepository
}
// releaseAllImmutableFlagsForTest hebt jeden Loeschschutz unterhalb eines Pfads auf.
//
// Bewusst nur im Test: Eine Funktion, die jeden Aufbewahrungsschutz eines
// Repositorys in einem Aufruf aufhebt, waere im Produktivcode genau das
// Werkzeug, das ein Angreifer sucht.
func releaseAllImmutableFlagsForTest(testInstance *testing.T, rootPath string) {
testInstance.Helper()
if !immutableFlagSupported() {
return
}
_ = filepath.WalkDir(rootPath, func(entryPath string, directoryEntry fs.DirEntry, walkError error) error {
if walkError != nil || directoryEntry.IsDir() {
return nil
}
_ = setImmutableFlag(entryPath, false)
return nil
})
}
// writeProtectedTestBackup schreibt ein Backup und liefert seine Kennung.
func writeProtectedTestBackup(testInstance *testing.T, testRepository *LocalRepository, backupID string) string {
testInstance.Helper()
writeTestBackup(testInstance, testRepository, backupID, [][]byte{[]byte("geschuetzte nutzdaten")})
return backupID
}
// TestReadOnlyPermissionsDoNotPreventDeletion haelt den zentralen Irrtum fest.
//
// Der urspruengliche geharrtete Modus legte Manifeste mit den Rechten 0400 ab
// und nannte das Loeschschutz. Unter POSIX haengt das Entfernen einer Datei aber
// am Schreibrecht ihres **Verzeichnisses**. Dieser Test steht hier, damit die
// Annahme nie wieder unbemerkt einzieht.
func TestReadOnlyPermissionsDoNotPreventDeletion(testInstance *testing.T) {
temporaryDirectory := testInstance.TempDir()
probePath := filepath.Join(temporaryDirectory, "geschuetzt.json")
if writeError := os.WriteFile(probePath, []byte("inhalt"), 0o400); writeError != nil {
testInstance.Fatalf("die Probedatei liess sich nicht anlegen: %v", writeError)
}
if removeError := os.Remove(probePath); removeError != nil {
testInstance.Fatalf("unerwartet: 0400 verhinderte die Loeschung (%v). "+
"Falls das auf einer Plattform zutrifft, muss die Messung das erkennen — "+
"behaupten darf sie es weiterhin nicht.", removeError)
}
}
// TestMeasureEnforcementReportsWhatItObserved prueft die gemessene Stufe.
func TestMeasureEnforcementReportsWhatItObserved(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "messung", Immutable: true,
})
enforcementReport, measureError := MeasureEnforcement(context.Background(),
createdRepository.RootPath(), newTestLogger())
if measureError != nil {
testInstance.Fatalf("die Messung schlug fehl: %v", measureError)
}
if len(enforcementReport.Observations) != 2 {
testInstance.Fatalf("es wurden %d Beobachtungen gemeldet, erwartet wurden 2",
len(enforcementReport.Observations))
}
// Die erste Beobachtung darf niemals „verhindert" melden — sonst stimmt die
// Messung nicht mit dem ueberein, was das Betriebssystem tut.
if enforcementReport.Observations[0].Prevented {
testInstance.Error("die Messung meldete Loeschschutz durch blosse Dateirechte")
}
// Die Stufe muss zur Beobachtung passen. Behauptet werden darf nur, was
// gemessen wurde.
expectedLevel := EnforcementAdvisory
if enforcementReport.Observations[1].Prevented {
expectedLevel = EnforcementFilesystem
}
if enforcementReport.Level != expectedLevel {
testInstance.Errorf("Stufe war %q, aus der Beobachtung folgt %q",
enforcementReport.Level, expectedLevel)
}
// Die Speicherebene wird nie gemeldet: Sie ist nicht umgesetzt.
if enforcementReport.Level == EnforcementStorage {
testInstance.Error("die Messung meldete eine Durchsetzung durch die Speicherebene, die es nicht gibt")
}
if enforcementReport.Explanation == "" {
testInstance.Error("die Stufe wurde nicht erklaert")
}
}
// TestMeasureEnforcementLeavesNoProbeFiles prueft das Aufraeumen.
//
// Eine liegengebliebene Probedatei mit gesetztem Unveraenderlich-Kennzeichen
// liesse sich von Hand kaum mehr entfernen.
func TestMeasureEnforcementLeavesNoProbeFiles(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "aufraeumen", Immutable: true,
})
if _, measureError := MeasureEnforcement(context.Background(),
createdRepository.RootPath(), newTestLogger()); measureError != nil {
testInstance.Fatalf("die Messung schlug fehl: %v", measureError)
}
metadataEntries, readError := os.ReadDir(filepath.Join(createdRepository.RootPath(), directoryMetadata))
if readError != nil {
testInstance.Fatalf("das Metadatenverzeichnis liess sich nicht lesen: %v", readError)
}
for _, metadataEntry := range metadataEntries {
if strings.HasPrefix(metadataEntry.Name(), probeFilePrefix) {
testInstance.Errorf("die Messung liess %q zurueck", metadataEntry.Name())
}
}
}
// TestHardenedRepositoryUsesConfiguredRetention prueft die konfigurierbare Frist.
func TestHardenedRepositoryUsesConfiguredRetention(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "frist",
Immutable: true,
Retention: 90 * 24 * time.Hour,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "frist-backup")
protectionStatus, statusError := createdRepository.ProtectionStatusOf(context.Background(), backupIdentifier)
if statusError != nil {
testInstance.Fatalf("die Schutzlage liess sich nicht lesen: %v", statusError)
}
if protectionStatus.ImmutableUntil == nil {
testInstance.Fatal("das Backup steht ohne Aufbewahrungsschutz")
}
protectionDuration := time.Until(*protectionStatus.ImmutableUntil)
// Erwartet werden 90 Tage, nicht die fruehere feste Vorgabe von 30.
if protectionDuration < 89*24*time.Hour || protectionDuration > 91*24*time.Hour {
testInstance.Errorf("der Schutz laeuft %s, erwartet wurden rund 90 Tage", protectionDuration)
}
}
// TestRetentionCannotBeShortened prueft die Sperre gegen Verkuerzung.
func TestRetentionCannotBeShortened(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "verkuerzung", Immutable: true, Retention: 30 * 24 * time.Hour,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "verkuerzung-backup")
shorterEnd := time.Now().Add(time.Hour)
if _, extendError := createdRepository.ExtendRetention(context.Background(), backupIdentifier,
shorterEnd, "admin", "brauche platz"); !errors.Is(extendError, ErrRetentionCannotBeShortened) {
testInstance.Fatalf("die Verkuerzung haette abgelehnt werden muessen, Fehler war: %v", extendError)
}
// Verlaengern muss dagegen gelingen.
longerEnd := time.Now().Add(365 * 24 * time.Hour)
extendedStatus, extendError := createdRepository.ExtendRetention(context.Background(), backupIdentifier,
longerEnd, "admin", "aufbewahrungspflicht")
if extendError != nil {
testInstance.Fatalf("die Verlaengerung schlug fehl: %v", extendError)
}
if !extendedStatus.ExtendedBeyondManifest {
testInstance.Error("die Verlaengerung wurde nicht als solche ausgewiesen")
}
if extendedStatus.ImmutableUntil == nil || extendedStatus.ImmutableUntil.Before(longerEnd.Add(-time.Minute)) {
testInstance.Error("die verlaengerte Frist wurde nicht uebernommen")
}
}
// TestLegalHoldOutlivesRetentionPeriod prueft den unbefristeten Schutz.
//
// Der Fall ist der eigentliche Zweck eines Legal Holds: Die Frist ist abgelaufen,
// das Backup wird trotzdem gebraucht.
func TestLegalHoldOutlivesRetentionPeriod(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "beweis", Immutable: true, Retention: time.Hour,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "beweis-backup")
if _, holdError := createdRepository.PlaceLegalHold(context.Background(), backupIdentifier,
"auditor", "Rechtsstreit 2026/117"); holdError != nil {
testInstance.Fatalf("der Legal Hold liess sich nicht setzen: %v", holdError)
}
// Die Zeit wird ueber die Frist hinaus vorgestellt.
createdRepository.timeSource = func() time.Time { return time.Now().Add(48 * time.Hour) }
deleteError := createdRepository.DeleteBackup(context.Background(), backupIdentifier)
if !errors.Is(deleteError, ErrLegalHold) {
testInstance.Fatalf("die Loeschung haette am Legal Hold scheitern muessen, Fehler war: %v", deleteError)
}
// Nach der Aufhebung — und erst nach Fristablauf — gelingt sie.
if _, releaseError := createdRepository.ReleaseLegalHold(context.Background(), backupIdentifier,
"auditor", "Verfahren abgeschlossen"); releaseError != nil {
testInstance.Fatalf("der Legal Hold liess sich nicht aufheben: %v", releaseError)
}
if deleteError := createdRepository.DeleteBackup(context.Background(), backupIdentifier); deleteError != nil {
testInstance.Fatalf("nach Aufhebung und Fristablauf schlug die Loeschung fehl: %v", deleteError)
}
}
// TestLegalHoldRequiresReason prueft die Begruendungspflicht.
func TestLegalHoldRequiresReason(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "begruendung", Immutable: true,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "begruendung-backup")
if _, holdError := createdRepository.PlaceLegalHold(context.Background(), backupIdentifier,
"admin", ""); holdError == nil {
testInstance.Fatal("ein Legal Hold ohne Begruendung wurde angenommen")
}
}
// TestRetentionHoldKeepsHistory prueft die Nachvollziehbarkeit im Repository.
//
// Die Historie liegt im Repository und nicht nur in der Datenbank: Wer das
// Repository an einen fremden Server anhaengt, muss erkennen koennen, warum ein
// Backup gehalten wird.
func TestRetentionHoldKeepsHistory(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "historie", Immutable: true, Retention: 24 * time.Hour,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "historie-backup")
if _, extendError := createdRepository.ExtendRetention(context.Background(), backupIdentifier,
time.Now().Add(72*time.Hour), "operator", "Quartalsabschluss"); extendError != nil {
testInstance.Fatalf("die Verlaengerung schlug fehl: %v", extendError)
}
if _, holdError := createdRepository.PlaceLegalHold(context.Background(), backupIdentifier,
"auditor", "Pruefung"); holdError != nil {
testInstance.Fatalf("der Legal Hold schlug fehl: %v", holdError)
}
retentionHold, readError := createdRepository.ReadRetentionHold(backupIdentifier)
if readError != nil {
testInstance.Fatalf("der Schutzvermerk liess sich nicht lesen: %v", readError)
}
if len(retentionHold.History) != 2 {
testInstance.Fatalf("die Historie hat %d Einträge, erwartet wurden 2", len(retentionHold.History))
}
if retentionHold.History[0].Actor != "operator" || retentionHold.History[0].Reason != "Quartalsabschluss" {
testInstance.Error("die Verlaengerung wurde nicht mit Verursacher und Grund festgehalten")
}
}
// TestUnreadableHoldKeepsBackupProtected prueft den gefaehrlichsten Fehlerfall.
//
// Ein beschaedigter Schutzvermerk darf niemals als „kein Schutz" gedeutet
// werden. Diese eine Zeile entscheidet, ob eine kaputte Datei ein geschuetztes
// Backup zum Loeschen freigibt.
func TestUnreadableHoldKeepsBackupProtected(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "kaputt", Immutable: true, Retention: time.Minute,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "kaputt-backup")
if _, holdError := createdRepository.PlaceLegalHold(context.Background(), backupIdentifier,
"auditor", "Pruefung"); holdError != nil {
testInstance.Fatalf("der Legal Hold schlug fehl: %v", holdError)
}
holdFilePath := createdRepository.retentionHoldPath(backupIdentifier)
_ = releaseManifestFile(holdFilePath)
if writeError := os.WriteFile(holdFilePath, []byte("{ kein gueltiges json"), 0o600); writeError != nil {
testInstance.Fatalf("der Schutzvermerk liess sich nicht beschaedigen: %v", writeError)
}
createdRepository.timeSource = func() time.Time { return time.Now().Add(24 * time.Hour) }
if deleteError := createdRepository.DeleteBackup(context.Background(), backupIdentifier); deleteError == nil {
testInstance.Fatal("ein Backup mit unlesbarem Schutzvermerk wurde geloescht")
}
}
// TestHardenedRepositorySurvivesRecursiveDelete ist der Angriffstest der Phase.
//
// Er stellt den Fall nach, gegen den Unveraenderlichkeit ueberhaupt existiert:
// Jemand mit Dateizugriff — Ransomware, ein Bedienfehler, ein kompromittierter
// Control Server — versucht, das Repository zu loeschen.
//
// Zwei Funde stammen aus genau diesem Versuch, und beide waren zunaechst
// erfolgreich:
//
// 1. Das Manifest ueberlebte, seine Bloecke nicht. Zurueck blieb ein Backup,
// das sich fuer vollstaendig ausgibt und leer ist.
// 2. Manifest und Bloecke ueberlebten, der Descriptor und der Datenschluessel
// nicht. Die Daten waren da und weder als Repository erkennbar noch
// entschluesselbar.
func TestHardenedRepositorySurvivesRecursiveDelete(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "angriff", Immutable: true, Retention: 30 * 24 * time.Hour,
})
requireMeasuredFilesystemEnforcement(testInstance, createdRepository.RootPath())
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "angriff-backup")
repositoryRoot := createdRepository.RootPath()
manifestBeforeAttack, manifestError := createdRepository.ReadManifest(context.Background(), backupIdentifier)
if manifestError != nil {
testInstance.Fatalf("das Manifest liess sich vor dem Angriff nicht lesen: %v", manifestError)
}
chunkIdentifiersBeforeAttack := manifestBeforeAttack.UniqueChunkIdentifiers()
if closeError := createdRepository.Close(); closeError != nil {
testInstance.Fatalf("das Repository liess sich nicht schliessen: %v", closeError)
}
// Der Angriff. Dass er einen Fehler meldet, ist genau der Zweck.
if removeError := os.RemoveAll(repositoryRoot); removeError == nil {
testInstance.Fatal("rm -rf auf ein geschuetztes Repository gelang vollstaendig")
}
// Der Descriptor: ohne ihn waeren die Daten nicht mehr als Repository
// erkennbar.
if _, statError := os.Stat(filepath.Join(repositoryRoot, directoryFormat, fileDescriptor)); statError != nil {
testInstance.Error("der Descriptor hat den Angriff nicht ueberlebt")
}
reopenedRepository, openError := Open(context.Background(), repositoryRoot,
OpenOptions{ReadOnly: true}, newTestLogger())
if openError != nil {
testInstance.Fatalf("das Repository liess sich nach dem Angriff nicht oeffnen: %v", openError)
}
testInstance.Cleanup(func() {
_ = reopenedRepository.Close()
releaseAllImmutableFlagsForTest(testInstance, repositoryRoot)
})
manifestAfterAttack, readError := reopenedRepository.ReadManifest(context.Background(), backupIdentifier)
if readError != nil {
testInstance.Fatalf("das Manifest hat den Angriff nicht ueberlebt: %v", readError)
}
if manifestAfterAttack.ContentHash != manifestBeforeAttack.ContentHash {
testInstance.Error("das Manifest wurde veraendert")
}
// Der entscheidende Teil: Ein Manifest ohne seine Bloecke waere ein Backup,
// das sich fuer vollstaendig ausgibt und keines ist.
for chunkIdentifier := range chunkIdentifiersBeforeAttack {
if _, chunkError := reopenedRepository.ReadChunk(context.Background(), chunkIdentifier); chunkError != nil {
testInstance.Errorf("der Block %s hat den Angriff nicht ueberlebt: %v", chunkIdentifier, chunkError)
}
}
}
// TestPruneReleasesProtectedOrphanedChunks prueft die Bereinigung im gehaerteten Betrieb.
//
// Ohne Freigabe des Kennzeichens liefe die Bereinigung in einem gehaerteten
// Repository in einen Fehler — und ein Repository, das nie aufraeumen kann,
// laeuft irgendwann voll.
func TestPruneReleasesProtectedOrphanedChunks(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "bereinigung", Immutable: true, Retention: time.Minute,
})
requireMeasuredFilesystemEnforcement(testInstance, createdRepository.RootPath())
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "bereinigung-backup")
// Nach Fristablauf wird das Backup geloescht; seine Bloecke sind danach
// verwaist, tragen aber weiterhin das Kennzeichen.
createdRepository.timeSource = func() time.Time { return time.Now().Add(2 * time.Hour) }
if deleteError := createdRepository.DeleteBackup(context.Background(), backupIdentifier); deleteError != nil {
testInstance.Fatalf("die Loeschung nach Fristablauf schlug fehl: %v", deleteError)
}
removedCount, freedBytes, pruneError := createdRepository.PruneOrphanedChunks(context.Background(), false)
if pruneError != nil {
testInstance.Fatalf("die Bereinigung schlug fehl: %v", pruneError)
}
if removedCount == 0 {
testInstance.Error("die Bereinigung entfernte keinen einzigen verwaisten Block")
}
if freedBytes == 0 {
testInstance.Error("die Bereinigung meldete keinen freigegebenen Speicher")
}
}
// requireMeasuredFilesystemEnforcement ueberspringt einen Test ohne echten Schutz.
//
// **Der Unterschied zu immutableFlagSupported() ist der Kern der Phase 11.**
// Jene Funktion sagt nur, ob das Betriebssystem das Kennzeichen *kennt* — unter
// Linux gibt sie immer "ja" zurueck. Ob es auch *durchgesetzt* wird, haengt am
// Dateisystem und an der Berechtigung CAP_LINUX_IMMUTABLE. In einem Container
// auf overlayfs ist beides typischerweise nicht gegeben: Das Setzen scheitert
// still, und ein rm -rf gelingt vollstaendig.
//
// Genau daran ist dieser Test in der CI gescheitert — zu Recht. Er behauptete
// Schutz in einer Umgebung, die keinen leisten kann. Die Anlage selbst macht es
// richtig: Sie ist beim Setzen nachsichtig (ein Backup ohne technischen
// Loeschschutz ist besser als gar keines) und sagt die Wahrheit ueber die
// **gemessene** Stufe.
//
// Der Test tut jetzt dasselbe: messen, und nur dort pruefen, wo es etwas zu
// pruefen gibt. Ein uebersprungener Test ist ehrlich; ein gruener, der nichts
// geprueft hat, waere es nicht.
func requireMeasuredFilesystemEnforcement(testInstance *testing.T, repositoryRoot string) {
testInstance.Helper()
enforcementReport, measureError := MeasureEnforcement(context.Background(), repositoryRoot, newTestLogger())
if measureError != nil {
testInstance.Fatalf("die Durchsetzungsstufe liess sich nicht messen: %v", measureError)
}
if enforcementReport.Level == EnforcementFilesystem {
return
}
testInstance.Skipf("dieses Dateisystem setzt den Loeschschutz nicht durch (gemessen: %s).\n"+
" Das ist keine Panne, sondern die Auskunft: In einem Container auf overlayfs "+
"oder ohne CAP_LINUX_IMMUTABLE gibt es keinen technischen Loeschschutz.\n"+
" Der Angriffsversuch braucht ein Dateisystem, das ihn abwehren kann.",
enforcementReport.Level)
}