From 9c48cb1c1c4f98be52e631be1b6181ba9b940455 Mon Sep 17 00:00:00 2001 From: Jerrit Fritzsche Date: Mon, 17 Aug 2026 09:45:27 +0200 Subject: [PATCH] CI: Loeschschutz messen statt annehmen, Datenbanktests wirklich fahren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 10 +++++ packages/repository/immutability_test.go | 49 ++++++++++++++++++++---- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 589a680..78c8027 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,10 @@ jobs: SYNCOVA_DB_SSLMODE: disable # Nur für diesen CI-Lauf; schützt keine echten Daten. SYNCOVA_ENCRYPTION_KEYS: 'v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' + # Ohne diese Variable überspringen neun Testdateien ihre Datenbanktests + # still — darunter der Upgrade- und der Rollback-Test. Ein übersprungener + # Test sieht in der Zusammenfassung aus wie ein bestandener. + SYNCOVA_TEST_DATABASE_URL: 'postgres://syncova_test:ci-only-ephemeral-password@127.0.0.1:5432/syncova_test?sslmode=disable' run: go test -race -coverprofile=coverage.out $(go list ./... | grep -v '/node_modules/') - name: Migrationen gegen echte Datenbank prüfen @@ -107,6 +111,12 @@ jobs: - name: Binaries bauen run: go build $(go list ./... | grep -v '/node_modules/') + - name: Übersetzbarkeit aller Zielplattformen + # Der Agent liess sich in Phase 5 monatelang gar nicht für Windows + # übersetzen, ohne dass es jemandem auffiel: unix.Statfs gibt es dort + # nicht. Der Bau auf der eigenen Plattform bemerkt das nie. + run: make cross-build + frontend: name: Frontend (React/TypeScript) runs-on: ubuntu-latest diff --git a/packages/repository/immutability_test.go b/packages/repository/immutability_test.go index 0dd4d95..2c79be4 100644 --- a/packages/repository/immutability_test.go +++ b/packages/repository/immutability_test.go @@ -352,14 +352,12 @@ func TestUnreadableHoldKeepsBackupProtected(testInstance *testing.T) { // nicht. Die Daten waren da und weder als Repository erkennbar noch // entschluesselbar. func TestHardenedRepositorySurvivesRecursiveDelete(testInstance *testing.T) { - if !immutableFlagSupported() { - testInstance.Skip("dieses Betriebssystem kennt kein Unveraenderlich-Kennzeichen") - } - 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() @@ -420,14 +418,12 @@ func TestHardenedRepositorySurvivesRecursiveDelete(testInstance *testing.T) { // Repository in einen Fehler — und ein Repository, das nie aufraeumen kann, // laeuft irgendwann voll. func TestPruneReleasesProtectedOrphanedChunks(testInstance *testing.T) { - if !immutableFlagSupported() { - testInstance.Skip("dieses Betriebssystem kennt kein Unveraenderlich-Kennzeichen") - } - 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 @@ -451,3 +447,40 @@ func TestPruneReleasesProtectedOrphanedChunks(testInstance *testing.T) { 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) +}