using System.Diagnostics.Eventing.Reader; using System.Runtime.Versioning; 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); 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." }; return new RansomwareBetaSummary { Enabled = true, State = state, Reason = reason, LookbackMinutes = lookbackMinutes, Signals = distinctSignals }; } 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; } } }