package ransomware import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // SignalName benennt eines der sechs Signale (SYNCOVA_IMPLEMENTATION_PLAN.md §18). type SignalName string const ( // SignalChangedBytes ist die Menge neu abgelegter Daten. SignalChangedBytes SignalName = "changed_bytes" // SignalChangedFiles ist die Zahl geaenderter Objekte. SignalChangedFiles SignalName = "changed_files" // SignalDeletedFiles ist die Zahl verschwundener Objekte. SignalDeletedFiles SignalName = "deleted_files" // SignalIncompressibleShare ist der Anteil nicht verkleinerbarer Bloecke. SignalIncompressibleShare SignalName = "incompressible_share" // SignalNewExtensions ist die Zahl neu aufgetauchter Dateiendungen. SignalNewExtensions SignalName = "new_extensions" // SignalBackupSize ist die Groesse des Laufs. SignalBackupSize SignalName = "backup_size" ) // SignalResult ist die Bewertung eines einzelnen Signals. type SignalResult struct { // Name ist der Bezeichner des Signals. Name SignalName `json:"name"` // Label ist die Beschriftung. Label string `json:"label"` // Deviation ist die gemessene Abweichung. Deviation DeviationResult `json:"deviation"` // Detail nennt zusaetzliche Beobachtungen. Detail string `json:"detail,omitempty"` } // SuspicionLevel ist die Einstufung eines Laufs. type SuspicionLevel string const ( // SuspicionNone bedeutet: nichts Auffaelliges. SuspicionNone SuspicionLevel = "none" // SuspicionUnknown bedeutet: zu wenig Vergleichslaeufe fuer eine Aussage. // // Ausdruecklich getrennt von „nichts Auffaelliges": Wer nicht messen kann, // hat nichts gemessen — und darf nicht so tun, als sei alles in Ordnung. SuspicionUnknown SuspicionLevel = "unknown" // SuspicionElevated bedeutet: ein Signal ist auffaellig. SuspicionElevated SuspicionLevel = "elevated" // SuspicionHigh bedeutet: mehrere Signale zugleich sind auffaellig. SuspicionHigh SuspicionLevel = "high" ) // Assessment ist die Bewertung eines Sicherungslaufs. type Assessment struct { // BackupID ist das bewertete Backup. BackupID uuid.UUID `json:"backup_id"` // BackupName ist seine Kennung im Repository. BackupName string `json:"backup_name"` // SourceName ist der Name der Quelle. SourceName string `json:"source_name,omitempty"` // CompletedAt ist der Abschluss des Laufs in UTC. CompletedAt time.Time `json:"completed_at"` // Level ist die Einstufung. Level SuspicionLevel `json:"level"` // Signals sind die Bewertungen der einzelnen Signale. Signals []SignalResult `json:"signals"` // UnusualSignalCount ist die Zahl auffaelliger Signale. UnusualSignalCount int `json:"unusual_signal_count"` // ComparableRunCount ist die Zahl der Vergleichslaeufe. ComparableRunCount int `json:"comparable_run_count"` // Summary fasst die Bewertung in einem Satz zusammen. Summary string `json:"summary"` // Recommendation nennt die naechste Handlung. // // Immer gefuellt, auch bei unauffaelligen Laeufen: Eine Bewertung ohne // Handlungsanweisung ist eine Beunruhigung (dieselbe Regel wie im Security // Center, Phase 15). Recommendation string `json:"recommendation"` } // IsSuspicious meldet einen auffaelligen Lauf. func (assessment *Assessment) IsSuspicious() bool { return assessment.Level == SuspicionElevated || assessment.Level == SuspicionHigh } // highSuspicionSignalCount ist die Zahl auffaelliger Signale fuer die hoehere Stufe. // // Zwei: Ein einzelnes auffaelliges Signal hat viele harmlose Ursachen — ein // grosses Update, ein Umzug, ein neuer Datenbestand. Zwei zugleich sind das // Muster, das massenhafte Verschluesselung hinterlaesst: viele geaenderte // Dateien **und** kaum noch komprimierbare Daten. const highSuspicionSignalCount = 2 // Detector bewertet Sicherungslaeufe gegen ihren Basiswert. type Detector struct { // connectionPool ist der Datenbankpool der Control Plane. connectionPool *pgxpool.Pool } // NewDetector erzeugt die Erkennung. func NewDetector(connectionPool *pgxpool.Pool) *Detector { return &Detector{connectionPool: connectionPool} } // backupSignals sind die Rohwerte eines Laufs. type backupSignals struct { // BackupID ist das Backup. BackupID uuid.UUID // BackupName ist seine Kennung im Repository. BackupName string // SourceName ist der Name der Quelle. SourceName string // CompletedAt ist der Abschluss in UTC. CompletedAt time.Time // UniqueBytes ist die Menge neu abgelegter Daten. UniqueBytes int64 // LogicalBytes ist die Menge der Ursprungsdaten. LogicalBytes int64 // ChangedFileCount ist die Zahl geaenderter Objekte. ChangedFileCount int64 // DeletedFileCount ist die Zahl verschwundener Objekte. DeletedFileCount int64 // IncompressibleChunks ist die Zahl nicht verkleinerbarer Bloecke. IncompressibleChunks int64 // NewChunkCount ist die Zahl neuer Bloecke. NewChunkCount int64 // ExtensionDistribution zaehlt die Dateiendungen. ExtensionDistribution map[string]int64 // HasSignals meldet, ob die Signale ueberhaupt erhoben wurden. // // Backups aus der Zeit vor Phase 16 tragen sie nicht. Sie als Nullwerte zu // behandeln verfaelschte jeden Basiswert nach unten — und liesse damit // jeden neuen Lauf auffaellig erscheinen. HasSignals bool } // incompressibleShare liefert den Anteil nicht verkleinerbarer Bloecke. func (signals backupSignals) incompressibleShare() float64 { if signals.NewChunkCount <= 0 { return 0 } return float64(signals.IncompressibleChunks) * 100 / float64(signals.NewChunkCount) } // AssessBackup bewertet ein Backup gegen die vorherigen Laeufe seiner Kette. func (detector *Detector) AssessBackup(assessContext context.Context, backupIdentifier uuid.UUID) (*Assessment, error) { currentSignals, chainIdentifier, loadError := detector.loadBackupSignals(assessContext, backupIdentifier) if loadError != nil { return nil, loadError } previousSignals, historyError := detector.loadChainHistory(assessContext, chainIdentifier, currentSignals.CompletedAt) if historyError != nil { return nil, historyError } return buildAssessment(currentSignals, previousSignals), nil } // loadBackupSignals liest die Signale eines Backups. func (detector *Detector) loadBackupSignals(loadContext context.Context, backupIdentifier uuid.UUID) (backupSignals, uuid.UUID, error) { const selectStatement = ` SELECT b.id, b.backup_id_in_repository, COALESCE(j.name, ''), b.completed_at, COALESCE(b.unique_bytes, 0), COALESCE(b.logical_bytes, 0), b.changed_file_count, b.deleted_file_count, b.incompressible_chunks, b.new_chunk_count, b.extension_distribution, b.chain_id FROM backups b LEFT JOIN backup_job_runs r ON r.id = b.job_run_id LEFT JOIN backup_jobs j ON j.id = r.job_id WHERE b.id = $1` var ( signals backupSignals chainIdentifier *uuid.UUID completedAt *time.Time changedFileCount *int64 deletedFileCount *int64 incompressibleChunks *int64 newChunkCount *int64 distributionJSON []byte ) scanError := detector.connectionPool.QueryRow(loadContext, selectStatement, backupIdentifier).Scan( &signals.BackupID, &signals.BackupName, &signals.SourceName, &completedAt, &signals.UniqueBytes, &signals.LogicalBytes, &changedFileCount, &deletedFileCount, &incompressibleChunks, &newChunkCount, &distributionJSON, &chainIdentifier) if scanError != nil { return backupSignals{}, uuid.Nil, fmt.Errorf("die signale konnten nicht gelesen werden: %w", scanError) } if completedAt != nil { signals.CompletedAt = *completedAt } // Ein Backup ohne erhobene Signale stammt aus der Zeit vor dieser Phase. signals.HasSignals = newChunkCount != nil for target, source := range map[*int64]*int64{ &signals.ChangedFileCount: changedFileCount, &signals.DeletedFileCount: deletedFileCount, &signals.IncompressibleChunks: incompressibleChunks, &signals.NewChunkCount: newChunkCount, } { if source != nil { *target = *source } } if len(distributionJSON) > 0 { _ = json.Unmarshal(distributionJSON, &signals.ExtensionDistribution) } if chainIdentifier == nil { return signals, uuid.Nil, nil } return signals, *chainIdentifier, nil } // loadChainHistory liest die vorherigen Laeufe einer Kette. func (detector *Detector) loadChainHistory(loadContext context.Context, chainIdentifier uuid.UUID, beforeTime time.Time) ([]backupSignals, error) { if chainIdentifier == uuid.Nil { return nil, nil } const selectStatement = ` SELECT b.id, b.backup_id_in_repository, b.completed_at, COALESCE(b.unique_bytes, 0), COALESCE(b.logical_bytes, 0), b.changed_file_count, b.deleted_file_count, b.incompressible_chunks, b.new_chunk_count, b.extension_distribution FROM backups b WHERE b.chain_id = $1 AND b.status = 'complete' AND b.deleted_at IS NULL AND b.completed_at < $2 ORDER BY b.completed_at DESC LIMIT $3` historyRows, queryError := detector.connectionPool.Query(loadContext, selectStatement, chainIdentifier, beforeTime, maximumBaselineSamples) if queryError != nil { return nil, fmt.Errorf("die vergleichslaeufe konnten nicht gelesen werden: %w", queryError) } defer historyRows.Close() previousSignals := make([]backupSignals, 0, maximumBaselineSamples) for historyRows.Next() { var ( signals backupSignals completedAt *time.Time changedFileCount *int64 deletedFileCount *int64 incompressibleChunks *int64 newChunkCount *int64 distributionJSON []byte ) if scanError := historyRows.Scan(&signals.BackupID, &signals.BackupName, &completedAt, &signals.UniqueBytes, &signals.LogicalBytes, &changedFileCount, &deletedFileCount, &incompressibleChunks, &newChunkCount, &distributionJSON); scanError != nil { return nil, fmt.Errorf("ein vergleichslauf konnte nicht gelesen werden: %w", scanError) } if completedAt != nil { signals.CompletedAt = *completedAt } signals.HasSignals = newChunkCount != nil for target, source := range map[*int64]*int64{ &signals.ChangedFileCount: changedFileCount, &signals.DeletedFileCount: deletedFileCount, &signals.IncompressibleChunks: incompressibleChunks, &signals.NewChunkCount: newChunkCount, } { if source != nil { *target = *source } } if len(distributionJSON) > 0 { _ = json.Unmarshal(distributionJSON, &signals.ExtensionDistribution) } previousSignals = append(previousSignals, signals) } return previousSignals, historyRows.Err() } // buildAssessment bewertet einen Lauf gegen seine Vorgaenger. func buildAssessment(currentSignals backupSignals, previousSignals []backupSignals) *Assessment { assessment := &Assessment{ BackupID: currentSignals.BackupID, BackupName: currentSignals.BackupName, SourceName: currentSignals.SourceName, CompletedAt: currentSignals.CompletedAt, Signals: make([]SignalResult, 0, 6), Level: SuspicionNone, } // Nur Laeufe mit erhobenen Signalen taugen zum Vergleich. Aeltere als // Nullwerte zu behandeln zoege jeden Basiswert nach unten und liesse jeden // neuen Lauf auffaellig erscheinen. comparableSignals := make([]backupSignals, 0, len(previousSignals)) for _, previousSignal := range previousSignals { if previousSignal.HasSignals { comparableSignals = append(comparableSignals, previousSignal) } } assessment.ComparableRunCount = len(comparableSignals) assessment.Signals = append(assessment.Signals, evaluateSignal(SignalChangedBytes, "Neu abgelegte Datenmenge", float64(currentSignals.UniqueBytes), collectValues(comparableSignals, func(signals backupSignals) float64 { return float64(signals.UniqueBytes) }), minimumByteChange), evaluateSignal(SignalChangedFiles, "Geaenderte Objekte", float64(currentSignals.ChangedFileCount), collectValues(comparableSignals, func(signals backupSignals) float64 { return float64(signals.ChangedFileCount) }), minimumFileCountChange), evaluateSignal(SignalDeletedFiles, "Verschwundene Objekte", float64(currentSignals.DeletedFileCount), collectValues(comparableSignals, func(signals backupSignals) float64 { return float64(signals.DeletedFileCount) }), minimumFileCountChange), evaluateSignal(SignalIncompressibleShare, "Anteil nicht verkleinerbarer Bloecke", currentSignals.incompressibleShare(), collectValues(comparableSignals, func(signals backupSignals) float64 { return signals.incompressibleShare() }), minimumSharePointChange), evaluateSignal(SignalBackupSize, "Groesse des Laufs", float64(currentSignals.LogicalBytes), collectValues(comparableSignals, func(signals backupSignals) float64 { return float64(signals.LogicalBytes) }), minimumByteChange), evaluateExtensionSignal(currentSignals, comparableSignals), ) for _, signalResult := range assessment.Signals { if signalResult.Deviation.IsUnusual { assessment.UnusualSignalCount++ } } finalizeLevel(assessment, currentSignals) return assessment } // collectValues liest eine Groesse aus den Vergleichslaeufen. func collectValues(signalHistory []backupSignals, readValue func(backupSignals) float64) []float64 { collectedValues := make([]float64, 0, len(signalHistory)) for _, signals := range signalHistory { collectedValues = append(collectedValues, readValue(signals)) } return collectedValues } // Untergrenzen je Signal. // // Unterhalb dieser Groessenordnungen bedeutet eine Abweichung nichts. Die Werte // sind bewusst verschieden: Zwanzig Dateien mehr sind ein Ereignis, zwanzig // Bytes mehr sind Rauschen. const ( // minimumByteChange ist die Untergrenze fuer Datenmengen. // // Hundert Megabyte: Darunter ist eine Verschluesselungswelle weder // wirtschaftlich noch bemerkbar. minimumByteChange = 100 * 1024 * 1024 // minimumFileCountChange ist die Untergrenze fuer Objektzahlen. minimumFileCountChange = 20 // minimumSharePointChange ist die Untergrenze fuer Anteile in Prozentpunkten. // // Zehn Punkte: Der Anteil unkomprimierbarer Bloecke schwankt allein durch // die Zusammensetzung der geaenderten Dateien um einige Punkte. minimumSharePointChange = 10 ) // evaluateSignal bewertet ein Signal gegen seinen Basiswert. func evaluateSignal(signalName SignalName, label string, observedValue float64, historyValues []float64, minimumChange float64) SignalResult { return SignalResult{ Name: signalName, Label: label, Deviation: EvaluateDeviation(observedValue, BuildBaseline(historyValues), minimumChange), } } // evaluateExtensionSignal prueft neu aufgetauchte Dateiendungen. // // Das aussagekraeftigste Einzelsignal: Schadsoftware haengt den verschluesselten // Dateien eine eigene Endung an. Eine Endung, die in keinem der Vergleichslaeufe // vorkam und jetzt haeufig ist, hat wenige harmlose Erklaerungen. func evaluateExtensionSignal(currentSignals backupSignals, comparableSignals []backupSignals) SignalResult { signalResult := SignalResult{ Name: SignalNewExtensions, Label: "Neue Dateiendungen", } knownExtensions := make(map[string]bool, 32) for _, previousSignal := range comparableSignals { for extension := range previousSignal.ExtensionDistribution { knownExtensions[extension] = true } } // Ohne Vergleichslaeufe ist jede Endung neu. Das als Befund zu werten waere // ein Fehlalarm mit Ansage. if len(comparableSignals) < minimumBaselineSamples { signalResult.Deviation = DeviationResult{ Explanation: fmt.Sprintf("Es liegen erst %d von %d noetigen Laeufen vor. "+ "Ohne Vergleich waere jede Endung neu.", len(comparableSignals), minimumBaselineSamples), } return signalResult } newExtensionCount := 0 newFileCount := int64(0) newExtensionNames := make([]string, 0, 4) for extension, fileCount := range currentSignals.ExtensionDistribution { if knownExtensions[extension] || extension == "(weitere)" { continue } newExtensionCount++ newFileCount += fileCount if len(newExtensionNames) < 5 { newExtensionNames = append(newExtensionNames, extension) } } signalResult.Deviation.ObservedValue = float64(newExtensionCount) signalResult.Deviation.Baseline = Baseline{SampleCount: len(comparableSignals)} if newExtensionCount == 0 { signalResult.Deviation.Explanation = "Es sind keine neuen Dateiendungen aufgetaucht." return signalResult } // Eine einzelne neue Endung mit wenigen Dateien ist Alltag. Auffaellig wird // es, wenn viele Dateien betroffen sind. totalFileCount := int64(0) for _, fileCount := range currentSignals.ExtensionDistribution { totalFileCount += fileCount } newFileShare := float64(0) if totalFileCount > 0 { newFileShare = float64(newFileCount) * 100 / float64(totalFileCount) } signalResult.Deviation.IsUnusual = newFileShare >= 25 signalResult.Deviation.Explanation = fmt.Sprintf("%d neue Dateiendungen (%v) betreffen "+ "%.0f Prozent der Objekte.", newExtensionCount, newExtensionNames, newFileShare) signalResult.Detail = fmt.Sprintf("%d Objekte mit bisher unbekannter Endung", newFileCount) return signalResult } // finalizeLevel bestimmt die Einstufung und die Handlungsanweisung. func finalizeLevel(assessment *Assessment, currentSignals backupSignals) { if !currentSignals.HasSignals { assessment.Level = SuspicionUnknown assessment.Summary = "Fuer diesen Lauf wurden keine Signale erhoben." assessment.Recommendation = "Backups aus der Zeit vor der Einfuehrung der Erkennung " + "tragen die noetigen Kennzahlen nicht. Ab dem naechsten Lauf steht die Bewertung zur " + "Verfuegung." return } if assessment.ComparableRunCount < minimumBaselineSamples { assessment.Level = SuspicionUnknown assessment.Summary = fmt.Sprintf("Es liegen erst %d von %d noetigen Vergleichslaeufen vor.", assessment.ComparableRunCount, minimumBaselineSamples) assessment.Recommendation = "Die Erkennung braucht einen Basiswert. Nach weiteren Laeufen " + "steht er zur Verfuegung; bis dahin gibt es keine Aussage — und keine geratene Schwelle." return } switch { case assessment.UnusualSignalCount >= highSuspicionSignalCount: assessment.Level = SuspicionHigh assessment.Summary = fmt.Sprintf("%d von %d Signalen sind auffaellig. Dieses Muster "+ "hinterlaesst massenhafte Verschluesselung — ein grosses Update oder ein Umzug des "+ "Datenbestands aber auch.", assessment.UnusualSignalCount, len(assessment.Signals)) assessment.Recommendation = "Pruefen Sie die Quelle, **bevor** Sie aeltere " + "Wiederherstellungspunkte loeschen oder eine Aufbewahrungsregel anwenden. Die Anlage " + "unternimmt von sich aus nichts." case assessment.UnusualSignalCount > 0: assessment.Level = SuspicionElevated assessment.Summary = fmt.Sprintf("%d Signal weicht vom ueblichen Verlauf ab.", assessment.UnusualSignalCount) assessment.Recommendation = "Ein einzelnes auffaelliges Signal hat viele harmlose " + "Ursachen. Sehen Sie nach, ob die Aenderung erklaerbar ist. Die Anlage unternimmt " + "von sich aus nichts." default: assessment.Level = SuspicionNone assessment.Summary = "Der Lauf entspricht dem ueblichen Verlauf." assessment.Recommendation = "Keine Handlung noetig." } }