package repository import ( "context" "errors" "fmt" "log/slog" "os" "path/filepath" "time" ) // EnforcementLevel benennt, **wer** eine Loeschung verhindern kann. // // Der Unterschied ist der ganze Sinn dieser Datei. „Unveraenderlich" ist in // Backup-Produkten ein Wort, das oft eine Datenbankspalte meint: Die Software // weigert sich zu loeschen, und jeder mit Dateizugriff loescht trotzdem. Wer // darauf eine Ransomware-Strategie baut, verliert seine Backups. // // Syncova meldet deshalb nie eine Stufe, die es nicht **gemessen** hat // (PROMPT.md §15: „Die Immutability soll nicht nur eine Software-Markierung // sein"; SYNCOVA_IMPLEMENTATION_PLAN.md §13: „Do not claim true immutability if // the underlying storage cannot enforce it"). type EnforcementLevel string const ( // EnforcementNone bedeutet: kein Schutz. EnforcementNone EnforcementLevel = "none" // EnforcementAdvisory bedeutet: nur diese Software haelt sich daran. // // Der Regelfall bei einem gewoehnlichen Verzeichnis. Der Schutz wirkt gegen // Bedienfehler und gegen jeden, der ueber Syncova geht — gegen einen // Angreifer mit Dateizugriff wirkt er nicht. EnforcementAdvisory EnforcementLevel = "advisory" // EnforcementFilesystem bedeutet: das Dateisystem verweigert die Loeschung. // // Gemessen, nicht behauptet: Eine Probedatei mit gesetztem // Unveraenderlich-Kennzeichen liess sich nachweislich nicht loeschen. Wer // Systemverwalter ist, kann das Kennzeichen weiterhin entfernen — auch das // wird gesagt statt verschwiegen. EnforcementFilesystem EnforcementLevel = "filesystem" // EnforcementStorage bedeutet: die Speicherebene selbst verhindert es. // // S3 Object Lock, WORM-Datentraeger. **Nicht umgesetzt** — diese Stufe wird // von der Messung niemals vergeben. EnforcementStorage EnforcementLevel = "storage" ) // Describe erklaert eine Durchsetzungsstufe in einem Satz. func (level EnforcementLevel) Describe() string { switch level { case EnforcementFilesystem: return "Das Dateisystem verweigert die Loeschung geschuetzter Backups. " + "Ein Systemverwalter kann den Schutz weiterhin aufheben." case EnforcementAdvisory: return "Der Schutz wird ausschliesslich von dieser Software durchgesetzt. " + "Wer Zugriff auf die Dateien hat, kann die Backups loeschen." case EnforcementStorage: return "Die Speicherebene verhindert die Loeschung." default: return "Es besteht kein Aufbewahrungsschutz." } } // IsTechnicallyEnforced meldet einen Schutz ausserhalb dieser Software. func (level EnforcementLevel) IsTechnicallyEnforced() bool { return level == EnforcementFilesystem || level == EnforcementStorage } // ProtectionObservation ist ein einzelner Versuch der Messung. // // Der Bericht nennt die Versuche einzeln, nicht nur ihr Ergebnis: Wer wissen // will, worauf sich eine Aussage stuetzt, soll es nachlesen koennen, statt // einem Wort glauben zu muessen. type ProtectionObservation struct { // Attempt beschreibt den unternommenen Versuch. Attempt string `json:"attempt"` // Prevented meldet, ob das System den Versuch verhindert hat. Prevented bool `json:"prevented"` // Detail nennt die Meldung des Betriebssystems. Detail string `json:"detail,omitempty"` } // EnforcementReport ist das Ergebnis einer Messung. type EnforcementReport struct { // Level ist die nachgewiesene Durchsetzungsstufe. Level EnforcementLevel `json:"level"` // Explanation erklaert die Stufe verstaendlich. Explanation string `json:"explanation"` // Observations sind die einzelnen Versuche. Observations []ProtectionObservation `json:"observations"` // FlagSupported meldet, ob das System ein Unveraenderlich-Kennzeichen kennt. FlagSupported bool `json:"immutable_flag_supported"` // MeasuredAt ist der Zeitpunkt der Messung in UTC. MeasuredAt time.Time `json:"measured_at"` } // probeFilePrefix ist der Namensanfang der Probedateien einer Messung. const probeFilePrefix = ".enforcement-probe-" // MeasureEnforcement stellt fest, was dieses Dateisystem tatsaechlich verhindert. // // Die Messung legt Probedateien im Metadatenverzeichnis des Repositorys an und // versucht, sie zu loeschen. Echte Daten werden dabei nicht angefasst. // // Zwei Versuche, und der erste ist der wichtigere: // // 1. Eine Datei mit den Rechten 0400 loeschen. Das **gelingt** — unter POSIX // haengt das Loeschen am Schreibrecht des Verzeichnisses, nicht an dem der // Datei. Wer „nur lesbar" fuer Loeschschutz haelt, irrt sich; genau dieser // Irrtum steckte im urspruenglichen geharteten Modus. // 2. Eine Datei mit gesetztem Unveraenderlich-Kennzeichen loeschen. Verhindert // das System es, ist die Stufe „filesystem" belegt. func MeasureEnforcement(measureContext context.Context, rootPath string, baseLogger *slog.Logger) (*EnforcementReport, error) { if contextError := measureContext.Err(); contextError != nil { return nil, contextError } probeDirectory := filepath.Join(rootPath, directoryMetadata) if _, statError := os.Stat(probeDirectory); statError != nil { return nil, fmt.Errorf("das metadatenverzeichnis ist nicht erreichbar: %w", statError) } report := &EnforcementReport{ Level: EnforcementAdvisory, FlagSupported: immutableFlagSupported(), MeasuredAt: time.Now().UTC(), Observations: make([]ProtectionObservation, 0, 2), } permissionObservation, permissionError := measureReadOnlyDeletion(probeDirectory) if permissionError != nil { return nil, permissionError } report.Observations = append(report.Observations, permissionObservation) flagObservation, flagError := measureImmutableFlagDeletion(probeDirectory) if flagError != nil { return nil, flagError } report.Observations = append(report.Observations, flagObservation) if flagObservation.Prevented { report.Level = EnforcementFilesystem } report.Explanation = report.Level.Describe() if baseLogger != nil { baseLogger.Info("die durchsetzungsstufe wurde gemessen", slog.String("stufe", string(report.Level)), slog.Bool("kennzeichen_unterstuetzt", report.FlagSupported)) } return report, nil } // measureReadOnlyDeletion prueft, ob Dateirechte vor Loeschung schuetzen. func measureReadOnlyDeletion(probeDirectory string) (ProtectionObservation, error) { probePath, createError := createProbeFile(probeDirectory) if createError != nil { return ProtectionObservation{}, createError } defer func() { _ = os.Remove(probePath) }() if chmodError := os.Chmod(probePath, 0o400); chmodError != nil { return ProtectionObservation{}, fmt.Errorf("die probedatei liess sich nicht schuetzen: %w", chmodError) } observation := ProtectionObservation{ Attempt: "Eine Datei mit den Rechten 0400 loeschen", } if removeError := os.Remove(probePath); removeError != nil { observation.Prevented = true observation.Detail = removeError.Error() return observation, nil } observation.Detail = "Die Loeschung gelang. Dateirechte schuetzen unter POSIX nicht vor " + "dem Loeschen — dafuer zaehlt das Schreibrecht des Verzeichnisses." return observation, nil } // measureImmutableFlagDeletion prueft das Unveraenderlich-Kennzeichen. func measureImmutableFlagDeletion(probeDirectory string) (ProtectionObservation, error) { observation := ProtectionObservation{ Attempt: "Eine Datei mit Unveraenderlich-Kennzeichen loeschen", } if !immutableFlagSupported() { observation.Detail = "Dieses Betriebssystem kennt kein Unveraenderlich-Kennzeichen." return observation, nil } probePath, createError := createProbeFile(probeDirectory) if createError != nil { return ProtectionObservation{}, createError } // Das Kennzeichen wird in jedem Fall wieder entfernt — auch bei einem // Fehler. Eine liegengebliebene geschuetzte Probedatei liesse sich sonst // nicht einmal von Hand aufraeumen. defer func() { _ = setImmutableFlag(probePath, false) _ = os.Remove(probePath) }() if flagError := setImmutableFlag(probePath, true); flagError != nil { observation.Detail = fmt.Sprintf( "Das Kennzeichen liess sich nicht setzen: %s. Der Schutz bleibt eine Software-Regel.", flagError) return observation, nil } if removeError := os.Remove(probePath); removeError != nil { observation.Prevented = true observation.Detail = "Das Betriebssystem verweigerte die Loeschung: " + removeError.Error() return observation, nil } observation.Detail = "Die Loeschung gelang trotz gesetztem Kennzeichen." return observation, nil } // createProbeFile legt eine Probedatei an. func createProbeFile(probeDirectory string) (string, error) { probeFile, createError := os.CreateTemp(probeDirectory, probeFilePrefix+"*") if createError != nil { return "", fmt.Errorf("die probedatei konnte nicht angelegt werden: %w", createError) } probePath := probeFile.Name() if _, writeError := probeFile.WriteString("syncova enforcement probe"); writeError != nil { _ = probeFile.Close() _ = os.Remove(probePath) return "", fmt.Errorf("die probedatei konnte nicht beschrieben werden: %w", writeError) } if closeError := probeFile.Close(); closeError != nil { _ = os.Remove(probePath) return "", fmt.Errorf("die probedatei konnte nicht geschlossen werden: %w", closeError) } return probePath, nil } // protectManifestFile setzt den Loeschschutz einer Manifestdatei. // // Der Aufruf ist absichtlich nachsichtig: Kann das Dateisystem das Kennzeichen // nicht setzen, bleibt es beim Software-Schutz. Ein Backup deshalb scheitern zu // lassen waere falsch — ein geschriebenes Backup ohne technischen Loeschschutz // ist besser als gar keines. Gesagt wird es trotzdem, ueber die gemessene Stufe. func protectManifestFile(manifestPath string) error { if !immutableFlagSupported() { return nil } return setImmutableFlag(manifestPath, true) } // releaseManifestFile hebt den Loeschschutz einer Manifestdatei auf. // // Aufgerufen wird das nur, wenn die Aufbewahrungsfrist abgelaufen ist und die // Loeschung nach den Regeln der Anwendung zulaessig war. Der Weg daran vorbei // fuehrt ueber das Betriebssystem, nicht ueber diese Funktion. func releaseManifestFile(manifestPath string) error { if !immutableFlagSupported() { return nil } if flagError := setImmutableFlag(manifestPath, false); flagError != nil { // Fehlt die Berechtigung, ist das eine Aussage und kein Betriebsfehler: // Der Schutz greift staerker als diese Software. if errors.Is(flagError, os.ErrPermission) { return fmt.Errorf("das unveraenderlich-kennzeichen liess sich nicht entfernen: %w", flagError) } return flagError } return nil } // ReleaseImmutableFlagForMaintenance hebt den Loeschschutz **einer** Datei auf. // // Der Weg ist absichtlich unbequem: eine Datei je Aufruf, ein sprechender Name, // keine rekursive Fassung. Eine Funktion, die den Schutz eines ganzen // Repositorys in einem Aufruf aufhebt, waere genau das Werkzeug, das ein // Angreifer sucht — und ein Bedienfehler haette dieselbe Wirkung. // // Gebraucht wird sie fuer den legitimen Fall, ein ausgemustertes Repository // tatsaechlich zu entfernen. Wer das tut, geht Datei fuer Datei vor und weiss // damit, was er tut. func ReleaseImmutableFlagForMaintenance(filePath string) error { return releaseManifestFile(filePath) }