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>
487 lines
20 KiB
Go
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)
|
|
}
|