Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
526 lines
19 KiB
Go
526 lines
19 KiB
Go
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."
|
|
}
|
|
}
|