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; } } }