Add passive file churn sensor beta
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s

This commit is contained in:
OfficeCom Codex
2026-08-01 01:15:40 +02:00
parent 22be9689e2
commit 8407d0c5b2
13 changed files with 468 additions and 14 deletions

View File

@@ -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<string> 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<FileAuditActivity>();
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<FileAuditActivity> 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<string, string> 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<string, string> 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<FileAuditActivity> MergeWithState(List<FileAuditActivity> observed, DateTimeOffset windowStart, List<string> 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<FileAuditActivity> fresh = securityLogReset
? observed
: observed.Where(activity => activity.RecordId == 0 || activity.RecordId > stored.LastSecurityRecordId);
List<FileAuditActivity> 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<FileAuditActivity> 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<string> errors)
{
string path = GetStatePath();
if (!File.Exists(path))
{
return new FileChurnState();
}
try
{
return JsonSerializer.Deserialize<FileChurnState>(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<string> 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<FileAuditActivity> 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; }
}
}