using System.Diagnostics; using System.Diagnostics.Eventing.Reader; using System.Runtime.Versioning; using System.Text.Json; namespace OCSentinelCli; [SupportedOSPlatform("windows")] internal static class RansomwareBetaDetector { public static RansomwareBetaSummary Scan(ScannerConfiguration configuration, List errors) { if (!configuration.RansomwareBetaEnabled) { return new RansomwareBetaSummary(); } int lookbackMinutes = Math.Clamp(configuration.RansomwareLookbackMinutes, 1, 60); DateTimeOffset since = DateTimeOffset.UtcNow.AddMinutes(-lookbackMinutes); var signals = new List(); 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) .GroupBy(signal => $"{signal.Category}\u001f{signal.Process}", StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .Take(20) .ToList(); int strongSignals = distinctSignals.Count(signal => signal.Confidence == "high"); string state = distinctSignals.Count >= Math.Max(2, configuration.RansomwareCriticalSignalCount) ? "critical" : strongSignals > 0 || distinctSignals.Count >= Math.Max(2, configuration.RansomwareWarningSignalCount) ? "warning" : distinctSignals.Count > 0 ? "hint" : "ok"; string reason = state switch { "critical" => $"Ransomware beta detected {distinctSignals.Count} independent high-risk signals within {lookbackMinutes} minutes.", "warning" => $"Ransomware beta detected {strongSignals} high-confidence and {distinctSignals.Count - strongSignals} low-confidence signals within {lookbackMinutes} minutes.", "hint" => $"Ransomware beta observed an isolated low-confidence signal within {lookbackMinutes} minutes.", _ => $"Ransomware beta found no suspicious process activity in the last {lookbackMinutes} minutes." }; List smbSessions = state is "warning" or "critical" && configuration.RansomwareCaptureSmbSessions ? CaptureSmbSessions(errors) : []; return new RansomwareBetaSummary { Enabled = true, AlertingEnabled = configuration.RansomwareBetaAlertingEnabled, State = state, Reason = reason, LookbackMinutes = lookbackMinutes, Signals = distinctSignals, SmbSessions = smbSessions, FileChurn = fileChurn }; } private static void ScanSecurityProcesses(List signals, List errors, DateTimeOffset since, ScannerConfiguration configuration) { TryScan("Security", 4688, since, errors, record => AddSignal(signals, record, ReadProperty(record, 5), ReadProperty(record, 8), "Security", configuration)); } private static void ScanPowerShellScriptBlocks(List signals, List errors, DateTimeOffset since, ScannerConfiguration configuration) { TryScan("Microsoft-Windows-PowerShell/Operational", 4104, since, errors, record => AddSignal(signals, record, "powershell", FormatDescription(record), "PowerShell", configuration)); } private static void ScanSysmonProcesses(List signals, List errors, DateTimeOffset since, ScannerConfiguration configuration) { TryScan("Microsoft-Windows-Sysmon/Operational", 1, since, errors, record => AddSignal(signals, record, "sysmon-process", FormatDescription(record), "Sysmon", configuration)); } private static void AddSignal(List signals, EventRecord record, string process, string commandLine, string source, ScannerConfiguration configuration) { if (!record.TimeCreated.HasValue || string.IsNullOrWhiteSpace(commandLine)) { return; } string processName = Path.GetFileName(process.Trim()); if (configuration.RansomwareExcludedProcesses.Any(item => string.Equals(item, processName, StringComparison.OrdinalIgnoreCase))) { return; } RansomwareSignal? signal = Classify(record.TimeCreated.Value, record.Id, processName, commandLine, source); if (signal is not null) { signals.Add(signal); } } private static RansomwareSignal? Classify(DateTime timestamp, int eventId, string process, string commandLine, string source) { string value = commandLine.ToLowerInvariant(); string evidence = commandLine.Length > 512 ? commandLine[..512] : commandLine; if (ContainsAll(value, "vssadmin", "delete", "shadow") || ContainsAll(value, "wmic", "shadowcopy", "delete") || ContainsAll(value, "win32_shadowcopy", "delete")) { return CreateSignal(timestamp, eventId, "shadow-copy-deletion", "high", process, source, evidence); } if (ContainsAll(value, "wbadmin", "delete") || ContainsAll(value, "catalog", "delete")) { return CreateSignal(timestamp, eventId, "backup-catalog-deletion", "high", process, source, evidence); } if (ContainsAll(value, "bcdedit", "recoveryenabled", "no") || ContainsAll(value, "bcdedit", "bootstatuspolicy", "ignoreallfailures")) { return CreateSignal(timestamp, eventId, "recovery-disable", "high", process, source, evidence); } if (ContainsAll(value, "wevtutil", " cl ")) { return CreateSignal(timestamp, eventId, "event-log-clearing", "high", process, source, evidence); } return value.Contains("win32_shadowcopy", StringComparison.Ordinal) ? CreateSignal(timestamp, eventId, "shadow-copy-access", "low", process, source, evidence) : null; } private static RansomwareSignal CreateSignal(DateTime timestamp, int eventId, string category, string confidence, string process, string source, string evidence) { return new RansomwareSignal { Timestamp = new DateTimeOffset(timestamp).ToLocalTime(), Category = category, Confidence = confidence, Process = string.IsNullOrWhiteSpace(process) ? "[unknown]" : process, Source = source, EventId = eventId, Evidence = evidence }; } private static bool ContainsAll(string value, params string[] needles) => needles.All(needle => value.Contains(needle, StringComparison.Ordinal)); private static void TryScan(string logName, int eventId, DateTimeOffset since, List errors, Action processRecord) { try { long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - since).TotalMilliseconds); string query = $"*[System[(EventID={eventId}) and TimeCreated[timediff(@SystemTime) <= {milliseconds}]]]"; using var reader = new EventLogReader(new EventLogQuery(logName, PathType.LogName, query)); for (EventRecord? record = reader.ReadEvent(); record is not null; record = reader.ReadEvent()) { using (record) { processRecord(record); } } } catch (EventLogNotFoundException) { } catch (Exception exception) { errors.Add($"Ransomware beta query failed for {logName}: {exception.Message}"); } } private static string ReadProperty(EventRecord record, int index) => index >= 0 && index < record.Properties.Count ? record.Properties[index].Value?.ToString()?.Trim() ?? string.Empty : string.Empty; private static string FormatDescription(EventRecord record) { try { return record.FormatDescription() ?? string.Empty; } catch (EventLogException) { return string.Empty; } } private static List CaptureSmbSessions(List errors) { try { using var process = Process.Start(new ProcessStartInfo { FileName = "powershell.exe", Arguments = "-NoProfile -NonInteractive -Command \"Get-SmbSession | Select-Object ClientComputerName,ClientUserName,SessionId,NumOpens | ConvertTo-Json -Compress\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }); if (process is null || !process.WaitForExit(5000) || process.ExitCode != 0) { return []; } string json = process.StandardOutput.ReadToEnd(); if (string.IsNullOrWhiteSpace(json)) { return []; } JsonElement root = JsonSerializer.Deserialize(json, JsonOptions.Default); IEnumerable rows = root.ValueKind == JsonValueKind.Array ? root.EnumerateArray().ToArray() : [root]; return rows.Take(100).Select(row => new RansomwareSmbSession { ClientComputerName = GetJsonString(row, "ClientComputerName"), ClientUserName = GetJsonString(row, "ClientUserName"), SessionId = GetJsonLong(row, "SessionId"), OpenFileCount = GetJsonLong(row, "NumOpens") }).ToList(); } catch (Exception exception) { errors.Add($"Ransomware beta SMB snapshot failed: {exception.Message}"); return []; } } private static string GetJsonString(JsonElement value, string name) => value.TryGetProperty(name, out JsonElement property) ? property.ToString() : string.Empty; private static long GetJsonLong(JsonElement value, string name) => value.TryGetProperty(name, out JsonElement property) && property.TryGetInt64(out long result) ? result : 0; }