From 8407d0c5b2609f428ed74bbab16912abea0b8451 Mon Sep 17 00:00:00 2001 From: OfficeCom Codex Date: Sat, 1 Aug 2026 01:15:40 +0200 Subject: [PATCH] Add passive file churn sensor beta --- config/ocsentinel-settings.example.json | 8 + docs/beta-deployment.md | 17 +- docs/roadmap.md | 5 +- src/OCSentinelCli/AttackScanner.cs | 6 +- src/OCSentinelCli/Configuration.cs | 16 + src/OCSentinelCli/Models.cs | 34 ++ src/OCSentinelCli/OCSentinelCli.csproj | 4 +- src/OCSentinelCli/Properties/AssemblyInfo.cs | 3 + src/OCSentinelCli/RansomwareAlertPolicy.cs | 9 + src/OCSentinelCli/RansomwareBetaDetector.cs | 10 +- .../RansomwareFileChurnDetector.cs | 296 ++++++++++++++++++ .../OCSentinelCli.Tests.csproj | 19 ++ .../RansomwareBetaTests.cs | 55 ++++ 13 files changed, 468 insertions(+), 14 deletions(-) create mode 100644 src/OCSentinelCli/Properties/AssemblyInfo.cs create mode 100644 src/OCSentinelCli/RansomwareAlertPolicy.cs create mode 100644 src/OCSentinelCli/RansomwareFileChurnDetector.cs create mode 100644 tests/OCSentinelCli.Tests/OCSentinelCli.Tests.csproj create mode 100644 tests/OCSentinelCli.Tests/RansomwareBetaTests.cs diff --git a/config/ocsentinel-settings.example.json b/config/ocsentinel-settings.example.json index 97366f5..b4ca39a 100644 --- a/config/ocsentinel-settings.example.json +++ b/config/ocsentinel-settings.example.json @@ -11,10 +11,18 @@ "correlationWarningCveThreshold": 1, "correlationCriticalCveThreshold": 1, "ransomwareBetaEnabled": false, + "ransomwareBetaAlertingEnabled": false, "ransomwareLookbackMinutes": 15, "ransomwareWarningSignalCount": 2, "ransomwareCriticalSignalCount": 3, "ransomwareCaptureSmbSessions": true, + "ransomwareFileChurnEnabled": false, + "ransomwareFileChurnWindowMinutes": 15, + "ransomwareFileChurnWarningDeleteCount": 50, + "ransomwareFileChurnWarningWriteCount": 250, + "ransomwareFileChurnCriticalDeleteCount": 200, + "ransomwareFileChurnCriticalWriteCount": 1000, + "ransomwareFileChurnMaxAuditEvents": 5000, "ransomwareExcludedProcesses": [], "ftpRoots": [ "C:\\inetpub\\logs\\LogFiles", diff --git a/docs/beta-deployment.md b/docs/beta-deployment.md index a6821e5..924986a 100644 --- a/docs/beta-deployment.md +++ b/docs/beta-deployment.md @@ -21,13 +21,18 @@ Die Stable-Aufgabe verwendet keinen Kanalwert oder den Wert `stable`. Die Beta ist nach der Installation weiterhin deaktiviert. Auf einem Pilotgeraet wird in `C:\Program Files\OCSentinel\config\ocsentinel-settings.json` -der Wert `ransomwareBetaEnabled` auf `true` gesetzt. Die erste Auswertung -liest nur die letzten 15 Minuten der vorhandenen Prozess- und PowerShell- -Ereignisse; sie installiert weder Sysmon noch Windows-Dateiauditing. +der Wert `ransomwareBetaEnabled` auf `true` gesetzt. Die Auswertung bleibt +passiv, solange `ransomwareBetaAlertingEnabled` auf `false` steht: Hinweise, +Warnungen und kritische Beta-Signale erscheinen im JSON-Report und Dashboard, +veraendern aber keine NinjaOne-Alarmfelder. -Ein Hinweis wird nur im JSON-Report und Dashboard sichtbar. Warnung und -kritisch werden erst nach dem kontrollierten Alarmierungs-Pilot an NinjaOne -weitergegeben. +Der optionale Datei-Churn-Sensor wird nur mit +`ransomwareFileChurnEnabled: true` aktiviert. Er wertet ausschliesslich bereits +vorhandene Security-Ereignisse 4663 aus, setzt keine Audit-Richtlinie und +aendert keine SACLs. Es werden nur Zaehler sowie Prozessnamen gespeichert und +uebertragen, niemals Datei- oder Freigabenamen. Eine Auswertung ist auf 5.000 +Audit-Ereignisse und ein 15-Minuten-Fenster begrenzt; ein gekappter Lauf erzeugt +kein Churn-Signal. ## Rueckfall diff --git a/docs/roadmap.md b/docs/roadmap.md index 627a498..d67d438 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -120,8 +120,9 @@ Ein Pilot wird pausiert und zurueckgesetzt, wenn eines dieser Kriterien eintritt Systemereignisse sowie Aenderungszaehler seit dem letzten Pruefpunkt. - Erkennung hochrelevanter Manipulationen wie Schattenkopie-, Recovery- und Backup-Loeschbefehle sowie verdaechtiger Verschluesselungswerkzeuge. -- Lokaler Ringpuffer mit aggregierten Datei-Churn-Signalen, etwa ungewoehnliche - Umbenennungen, Loeschungen und neue Erweiterungen. +- Lokaler Ringpuffer mit aggregierten Datei-Churn-Signalen aus vorhandenen + Datei-Audit-Ereignissen. Die erste Beta wertet Loesch- und Schreibzugriffe + ohne Datei- oder Freigabenamen aus. - Snapshot der SMB-Sitzungen und des Incident-Kontexts erst bei einer Auffaelligkeit. - Verdichtetes Ransomware-Incident-Protokoll fuer n8n und die Uebersicht. diff --git a/src/OCSentinelCli/AttackScanner.cs b/src/OCSentinelCli/AttackScanner.cs index 6ebc6db..6aebfe1 100644 --- a/src/OCSentinelCli/AttackScanner.cs +++ b/src/OCSentinelCli/AttackScanner.cs @@ -55,7 +55,7 @@ internal sealed class AttackScanner .ToList(); int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count(); - AlertAssessment baseAssessment = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta); + AlertAssessment baseAssessment = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta, configuration.RansomwareBetaAlertingEnabled); string baseAlertState = baseAssessment.State; string baseAlertReason = baseAssessment.Reason; VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath) @@ -539,9 +539,9 @@ internal sealed class AttackScanner return new AlertAssessment("ok", $"Low-volume login errors observed: events={attacks.Count}, unique IPs={uniqueIpCount}; no burst or password-spraying pattern detected."); } - private static AlertAssessment MergeRansomwareAssessment(AlertAssessment loginAssessment, RansomwareBetaSummary ransomwareBeta) + private static AlertAssessment MergeRansomwareAssessment(AlertAssessment loginAssessment, RansomwareBetaSummary ransomwareBeta, bool ransomwareAlertingEnabled) { - if (ransomwareBeta.State is not ("warning" or "critical")) + if (!RansomwareAlertPolicy.CanElevate(ransomwareBeta, ransomwareAlertingEnabled)) { return loginAssessment; } diff --git a/src/OCSentinelCli/Configuration.cs b/src/OCSentinelCli/Configuration.cs index c6a2599..aeb6d19 100644 --- a/src/OCSentinelCli/Configuration.cs +++ b/src/OCSentinelCli/Configuration.cs @@ -28,6 +28,8 @@ internal sealed record ScannerConfiguration public bool RansomwareBetaEnabled { get; init; } + public bool RansomwareBetaAlertingEnabled { get; init; } + public int RansomwareLookbackMinutes { get; init; } = 15; public int RansomwareWarningSignalCount { get; init; } = 2; @@ -36,6 +38,20 @@ internal sealed record ScannerConfiguration public bool RansomwareCaptureSmbSessions { get; init; } = true; + public bool RansomwareFileChurnEnabled { get; init; } + + public int RansomwareFileChurnWindowMinutes { get; init; } = 15; + + public int RansomwareFileChurnWarningDeleteCount { get; init; } = 50; + + public int RansomwareFileChurnWarningWriteCount { get; init; } = 250; + + public int RansomwareFileChurnCriticalDeleteCount { get; init; } = 200; + + public int RansomwareFileChurnCriticalWriteCount { get; init; } = 1000; + + public int RansomwareFileChurnMaxAuditEvents { get; init; } = 5000; + public List RansomwareExcludedProcesses { get; init; } = []; public List FtpRoots { get; init; } = []; diff --git a/src/OCSentinelCli/Models.cs b/src/OCSentinelCli/Models.cs index acf717a..1d8f47e 100644 --- a/src/OCSentinelCli/Models.cs +++ b/src/OCSentinelCli/Models.cs @@ -119,6 +119,8 @@ internal sealed record RansomwareBetaSummary { public bool Enabled { get; init; } + public bool AlertingEnabled { get; init; } + public string State { get; init; } = "disabled"; public string Reason { get; init; } = "Ransomware beta is disabled."; @@ -128,6 +130,38 @@ internal sealed record RansomwareBetaSummary public List Signals { get; init; } = []; public List SmbSessions { get; init; } = []; + + public RansomwareFileChurnSummary FileChurn { get; init; } = new(); +} + +internal sealed record RansomwareFileChurnSummary +{ + public bool Enabled { get; init; } + + public bool DataAvailable { get; init; } + + public bool IsTruncated { get; init; } + + public int WindowMinutes { get; init; } + + public int FileOperationCount { get; init; } + + public int DeleteOperationCount { get; init; } + + public int WriteOperationCount { get; init; } + + public int DistinctProcessCount { get; init; } + + public List TopProcesses { get; init; } = []; +} + +internal sealed record RansomwareFileChurnProcess +{ + public string Process { get; init; } = string.Empty; + + public int DeleteOperationCount { get; init; } + + public int WriteOperationCount { get; init; } } internal sealed record RansomwareSmbSession diff --git a/src/OCSentinelCli/OCSentinelCli.csproj b/src/OCSentinelCli/OCSentinelCli.csproj index 42749bc..fec9ab3 100644 --- a/src/OCSentinelCli/OCSentinelCli.csproj +++ b/src/OCSentinelCli/OCSentinelCli.csproj @@ -9,10 +9,10 @@ OCSentinelCli OfficeCom Sentinel OfficeCom - 1.5.0-beta.1 + 1.5.0-beta.2 1.5.0.0 1.5.0.0 - 1.5.0-beta.1 + 1.5.0-beta.2 diff --git a/src/OCSentinelCli/Properties/AssemblyInfo.cs b/src/OCSentinelCli/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..a7543c8 --- /dev/null +++ b/src/OCSentinelCli/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OCSentinelCli.Tests")] diff --git a/src/OCSentinelCli/RansomwareAlertPolicy.cs b/src/OCSentinelCli/RansomwareAlertPolicy.cs new file mode 100644 index 0000000..0abd289 --- /dev/null +++ b/src/OCSentinelCli/RansomwareAlertPolicy.cs @@ -0,0 +1,9 @@ +namespace OCSentinelCli; + +internal static class RansomwareAlertPolicy +{ + public static bool CanElevate(RansomwareBetaSummary summary, bool alertingEnabled) + { + return alertingEnabled && summary.Enabled && (summary.State is "warning" or "critical"); + } +} diff --git a/src/OCSentinelCli/RansomwareBetaDetector.cs b/src/OCSentinelCli/RansomwareBetaDetector.cs index ab112f4..ccc77ba 100644 --- a/src/OCSentinelCli/RansomwareBetaDetector.cs +++ b/src/OCSentinelCli/RansomwareBetaDetector.cs @@ -22,6 +22,12 @@ internal static class RansomwareBetaDetector ScanSecurityProcesses(signals, errors, since, configuration); ScanPowerShellScriptBlocks(signals, errors, since, configuration); ScanSysmonProcesses(signals, errors, since, configuration); + RansomwareFileChurnSummary fileChurn = RansomwareFileChurnDetector.Scan(configuration, errors); + RansomwareSignal? fileChurnSignal = RansomwareFileChurnDetector.CreateSignal(fileChurn, configuration); + if (fileChurnSignal is not null) + { + signals.Add(fileChurnSignal); + } List distinctSignals = signals .OrderBy(signal => signal.Timestamp) @@ -50,11 +56,13 @@ internal static class RansomwareBetaDetector return new RansomwareBetaSummary { Enabled = true, + AlertingEnabled = configuration.RansomwareBetaAlertingEnabled, State = state, Reason = reason, LookbackMinutes = lookbackMinutes, Signals = distinctSignals, - SmbSessions = smbSessions + SmbSessions = smbSessions, + FileChurn = fileChurn }; } diff --git a/src/OCSentinelCli/RansomwareFileChurnDetector.cs b/src/OCSentinelCli/RansomwareFileChurnDetector.cs new file mode 100644 index 0000000..e4fe394 --- /dev/null +++ b/src/OCSentinelCli/RansomwareFileChurnDetector.cs @@ -0,0 +1,296 @@ +using System.Diagnostics.Eventing.Reader; +using System.Globalization; +using System.Runtime.Versioning; +using System.Text.Json; +using System.Xml.Linq; + +namespace OCSentinelCli; + +[SupportedOSPlatform("windows")] +internal static class RansomwareFileChurnDetector +{ + private const uint DeleteAccessMask = 0x00010000; + private const uint FileWriteAccessMask = 0x00000156; + private const string StateFileName = "ransomware-file-churn.json"; + + public static RansomwareFileChurnSummary Scan(ScannerConfiguration configuration, List errors) + { + if (!configuration.RansomwareFileChurnEnabled) + { + return new RansomwareFileChurnSummary(); + } + + int windowMinutes = Math.Clamp(configuration.RansomwareFileChurnWindowMinutes, 1, 60); + int maximumAuditEvents = Math.Clamp(configuration.RansomwareFileChurnMaxAuditEvents, 100, 20000); + DateTimeOffset windowStart = DateTimeOffset.UtcNow.AddMinutes(-windowMinutes); + var observed = new List(); + bool isTruncated = false; + + try + { + long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - windowStart).TotalMilliseconds); + string query = $"*[System[(EventID=4663) and TimeCreated[timediff(@SystemTime) <= {milliseconds}]]]"; + using var reader = new EventLogReader(new EventLogQuery("Security", PathType.LogName, query)); + int inspected = 0; + for (EventRecord? record = reader.ReadEvent(); record is not null; record = reader.ReadEvent()) + { + using (record) + { + if (++inspected > maximumAuditEvents) + { + isTruncated = true; + break; + } + + if (TryCreateActivity(record, configuration, out FileAuditActivity? activity) && activity is not null) + { + observed.Add(activity); + } + } + } + } + catch (UnauthorizedAccessException exception) + { + errors.Add($"Ransomware file churn cannot read the Security log: {exception.Message}"); + return Unavailable(windowMinutes); + } + catch (EventLogNotFoundException) + { + return Unavailable(windowMinutes); + } + catch (Exception exception) + { + errors.Add($"Ransomware file churn query failed: {exception.Message}"); + return Unavailable(windowMinutes); + } + + if (isTruncated) + { + return BuildSummary(observed, windowMinutes, isTruncated: true); + } + + List rollingActivities = MergeWithState(observed, windowStart, errors); + return BuildSummary(rollingActivities, windowMinutes, isTruncated: false); + } + + public static RansomwareSignal? CreateSignal(RansomwareFileChurnSummary summary, ScannerConfiguration configuration) + { + if (!summary.Enabled || !summary.DataAvailable || summary.IsTruncated) + { + return null; + } + + int warningDeletes = Math.Max(1, configuration.RansomwareFileChurnWarningDeleteCount); + int warningWrites = Math.Max(1, configuration.RansomwareFileChurnWarningWriteCount); + int criticalDeletes = Math.Max(warningDeletes, configuration.RansomwareFileChurnCriticalDeleteCount); + int criticalWrites = Math.Max(warningWrites, configuration.RansomwareFileChurnCriticalWriteCount); + bool critical = summary.DeleteOperationCount >= criticalDeletes && summary.WriteOperationCount >= criticalWrites; + bool warning = summary.DeleteOperationCount >= warningDeletes && summary.WriteOperationCount >= warningWrites; + if (!warning) + { + return null; + } + + RansomwareFileChurnProcess? topProcess = summary.TopProcesses.FirstOrDefault(); + string evidence = $"delete={summary.DeleteOperationCount}; write={summary.WriteOperationCount}; processes={summary.DistinctProcessCount}; window={summary.WindowMinutes}m"; + return new RansomwareSignal + { + Timestamp = DateTimeOffset.Now, + Category = critical ? "file-churn-critical" : "file-churn", + Confidence = critical ? "medium" : "low", + Process = topProcess?.Process ?? "[multiple]", + Source = "Security file audit", + EventId = 4663, + Evidence = evidence + }; + } + + private static RansomwareFileChurnSummary Unavailable(int windowMinutes) + { + return new RansomwareFileChurnSummary + { + Enabled = true, + WindowMinutes = windowMinutes + }; + } + + private static bool TryCreateActivity(EventRecord record, ScannerConfiguration configuration, out FileAuditActivity? activity) + { + activity = null; + if (!record.TimeCreated.HasValue) + { + return false; + } + + IReadOnlyDictionary data = ReadEventData(record); + if (!data.TryGetValue("ObjectType", out string? objectType) || !string.Equals(objectType, "File", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!data.TryGetValue("AccessMask", out string? accessMaskText) || !TryParseAccessMask(accessMaskText, out uint accessMask)) + { + return false; + } + + bool isDelete = (accessMask & DeleteAccessMask) != 0; + bool isWrite = (accessMask & FileWriteAccessMask) != 0; + if (!isDelete && !isWrite) + { + return false; + } + + string process = data.TryGetValue("ProcessName", out string? processPath) ? Path.GetFileName(processPath.Trim()) : string.Empty; + if (string.IsNullOrWhiteSpace(process)) + { + process = "[unknown]"; + } + + if (configuration.RansomwareExcludedProcesses.Any(item => string.Equals(item, process, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + activity = new FileAuditActivity + { + Timestamp = new DateTimeOffset(record.TimeCreated.Value).ToUniversalTime(), + RecordId = record.RecordId ?? 0, + Process = process, + IsDelete = isDelete, + IsWrite = isWrite + }; + return true; + } + + private static IReadOnlyDictionary ReadEventData(EventRecord record) + { + XDocument document = XDocument.Parse(record.ToXml()); + return document.Descendants().Where(element => element.Name.LocalName == "Data") + .Where(element => element.Attribute("Name") is not null) + .ToDictionary(element => element.Attribute("Name")!.Value, element => element.Value.Trim(), StringComparer.OrdinalIgnoreCase); + } + + private static bool TryParseAccessMask(string value, out uint result) + { + string normalized = value.Trim(); + NumberStyles style = NumberStyles.Integer; + if (normalized.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized[2..]; + style = NumberStyles.AllowHexSpecifier; + } + + return uint.TryParse(normalized, style, CultureInfo.InvariantCulture, out result); + } + + private static List MergeWithState(List observed, DateTimeOffset windowStart, List errors) + { + FileChurnState stored = LoadState(errors); + long highestObservedRecordId = observed.Count == 0 ? 0 : observed.Max(activity => activity.RecordId); + bool securityLogReset = stored.LastSecurityRecordId > 0 && highestObservedRecordId > 0 && highestObservedRecordId < stored.LastSecurityRecordId; + IEnumerable fresh = securityLogReset + ? observed + : observed.Where(activity => activity.RecordId == 0 || activity.RecordId > stored.LastSecurityRecordId); + List rolling = (securityLogReset ? [] : stored.Activities) + .Concat(fresh) + .Where(activity => activity.Timestamp >= windowStart) + .GroupBy(activity => activity.RecordId > 0 ? activity.RecordId.ToString(CultureInfo.InvariantCulture) : $"{activity.Timestamp:O}|{activity.Process}|{activity.IsDelete}|{activity.IsWrite}") + .Select(group => group.First()) + .OrderBy(activity => activity.Timestamp) + .ToList(); + + SaveState(new FileChurnState + { + LastSecurityRecordId = securityLogReset ? highestObservedRecordId : Math.Max(stored.LastSecurityRecordId, highestObservedRecordId), + Activities = rolling + }, errors); + return rolling; + } + + private static RansomwareFileChurnSummary BuildSummary(List activities, int windowMinutes, bool isTruncated) + { + return new RansomwareFileChurnSummary + { + Enabled = true, + DataAvailable = true, + IsTruncated = isTruncated, + WindowMinutes = windowMinutes, + FileOperationCount = activities.Count, + DeleteOperationCount = activities.Count(activity => activity.IsDelete), + WriteOperationCount = activities.Count(activity => activity.IsWrite), + DistinctProcessCount = activities.Select(activity => activity.Process).Distinct(StringComparer.OrdinalIgnoreCase).Count(), + TopProcesses = activities.GroupBy(activity => activity.Process, StringComparer.OrdinalIgnoreCase) + .Select(group => new RansomwareFileChurnProcess + { + Process = group.Key, + DeleteOperationCount = group.Count(activity => activity.IsDelete), + WriteOperationCount = group.Count(activity => activity.IsWrite) + }) + .OrderByDescending(process => process.DeleteOperationCount + process.WriteOperationCount) + .ThenBy(process => process.Process, StringComparer.OrdinalIgnoreCase) + .Take(5) + .ToList() + }; + } + + private static FileChurnState LoadState(List errors) + { + string path = GetStatePath(); + if (!File.Exists(path)) + { + return new FileChurnState(); + } + + try + { + return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions.Default) ?? new FileChurnState(); + } + catch (Exception exception) + { + errors.Add($"Ransomware file churn state could not be read: {exception.Message}"); + return new FileChurnState(); + } + } + + private static void SaveState(FileChurnState state, List errors) + { + try + { + string path = GetStatePath(); + string directory = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(directory); + string temporaryPath = path + ".tmp"; + File.WriteAllText(temporaryPath, JsonSerializer.Serialize(state, JsonOptions.Default)); + File.Move(temporaryPath, path, overwrite: true); + } + catch (Exception exception) + { + errors.Add($"Ransomware file churn state could not be saved: {exception.Message}"); + } + } + + private static string GetStatePath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OCSentinel", "state", StateFileName); + } + + private sealed record FileChurnState + { + public long LastSecurityRecordId { get; init; } + + public List Activities { get; init; } = []; + } + + private sealed record FileAuditActivity + { + public DateTimeOffset Timestamp { get; init; } + + public long RecordId { get; init; } + + public string Process { get; init; } = string.Empty; + + public bool IsDelete { get; init; } + + public bool IsWrite { get; init; } + } +} diff --git a/tests/OCSentinelCli.Tests/OCSentinelCli.Tests.csproj b/tests/OCSentinelCli.Tests/OCSentinelCli.Tests.csproj new file mode 100644 index 0000000..53c1266 --- /dev/null +++ b/tests/OCSentinelCli.Tests/OCSentinelCli.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/OCSentinelCli.Tests/RansomwareBetaTests.cs b/tests/OCSentinelCli.Tests/RansomwareBetaTests.cs new file mode 100644 index 0000000..1764a8a --- /dev/null +++ b/tests/OCSentinelCli.Tests/RansomwareBetaTests.cs @@ -0,0 +1,55 @@ +using System.Runtime.Versioning; +using Xunit; + +namespace OCSentinelCli.Tests; + +[SupportedOSPlatform("windows")] +public sealed class RansomwareBetaTests +{ + [Fact] + public void FileChurnBelowBothThresholdsDoesNotCreateSignal() + { + var summary = new RansomwareFileChurnSummary + { + Enabled = true, + DataAvailable = true, + DeleteOperationCount = 49, + WriteOperationCount = 500 + }; + + RansomwareSignal? signal = RansomwareFileChurnDetector.CreateSignal(summary, new ScannerConfiguration()); + + Assert.Null(signal); + } + + [Fact] + public void CriticalFileChurnCreatesOnlyMediumConfidenceSignal() + { + var summary = new RansomwareFileChurnSummary + { + Enabled = true, + DataAvailable = true, + WindowMinutes = 15, + DeleteOperationCount = 200, + WriteOperationCount = 1000, + DistinctProcessCount = 1, + TopProcesses = [new RansomwareFileChurnProcess { Process = "encryptor.exe", DeleteOperationCount = 200, WriteOperationCount = 1000 }] + }; + + RansomwareSignal? signal = RansomwareFileChurnDetector.CreateSignal(summary, new ScannerConfiguration()); + + Assert.NotNull(signal); + Assert.Equal("file-churn-critical", signal.Category); + Assert.Equal("medium", signal.Confidence); + Assert.Equal("encryptor.exe", signal.Process); + } + + [Fact] + public void PassiveBetaCannotElevateNinjaAlertState() + { + var summary = new RansomwareBetaSummary { Enabled = true, State = "critical" }; + + Assert.False(RansomwareAlertPolicy.CanElevate(summary, alertingEnabled: false)); + Assert.True(RansomwareAlertPolicy.CanElevate(summary, alertingEnabled: true)); + } +}