Add passive file churn sensor beta
This commit is contained in:
@@ -11,10 +11,18 @@
|
|||||||
"correlationWarningCveThreshold": 1,
|
"correlationWarningCveThreshold": 1,
|
||||||
"correlationCriticalCveThreshold": 1,
|
"correlationCriticalCveThreshold": 1,
|
||||||
"ransomwareBetaEnabled": false,
|
"ransomwareBetaEnabled": false,
|
||||||
|
"ransomwareBetaAlertingEnabled": false,
|
||||||
"ransomwareLookbackMinutes": 15,
|
"ransomwareLookbackMinutes": 15,
|
||||||
"ransomwareWarningSignalCount": 2,
|
"ransomwareWarningSignalCount": 2,
|
||||||
"ransomwareCriticalSignalCount": 3,
|
"ransomwareCriticalSignalCount": 3,
|
||||||
"ransomwareCaptureSmbSessions": true,
|
"ransomwareCaptureSmbSessions": true,
|
||||||
|
"ransomwareFileChurnEnabled": false,
|
||||||
|
"ransomwareFileChurnWindowMinutes": 15,
|
||||||
|
"ransomwareFileChurnWarningDeleteCount": 50,
|
||||||
|
"ransomwareFileChurnWarningWriteCount": 250,
|
||||||
|
"ransomwareFileChurnCriticalDeleteCount": 200,
|
||||||
|
"ransomwareFileChurnCriticalWriteCount": 1000,
|
||||||
|
"ransomwareFileChurnMaxAuditEvents": 5000,
|
||||||
"ransomwareExcludedProcesses": [],
|
"ransomwareExcludedProcesses": [],
|
||||||
"ftpRoots": [
|
"ftpRoots": [
|
||||||
"C:\\inetpub\\logs\\LogFiles",
|
"C:\\inetpub\\logs\\LogFiles",
|
||||||
|
|||||||
@@ -21,13 +21,18 @@ Die Stable-Aufgabe verwendet keinen Kanalwert oder den Wert `stable`.
|
|||||||
|
|
||||||
Die Beta ist nach der Installation weiterhin deaktiviert. Auf einem
|
Die Beta ist nach der Installation weiterhin deaktiviert. Auf einem
|
||||||
Pilotgeraet wird in `C:\Program Files\OCSentinel\config\ocsentinel-settings.json`
|
Pilotgeraet wird in `C:\Program Files\OCSentinel\config\ocsentinel-settings.json`
|
||||||
der Wert `ransomwareBetaEnabled` auf `true` gesetzt. Die erste Auswertung
|
der Wert `ransomwareBetaEnabled` auf `true` gesetzt. Die Auswertung bleibt
|
||||||
liest nur die letzten 15 Minuten der vorhandenen Prozess- und PowerShell-
|
passiv, solange `ransomwareBetaAlertingEnabled` auf `false` steht: Hinweise,
|
||||||
Ereignisse; sie installiert weder Sysmon noch Windows-Dateiauditing.
|
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
|
Der optionale Datei-Churn-Sensor wird nur mit
|
||||||
kritisch werden erst nach dem kontrollierten Alarmierungs-Pilot an NinjaOne
|
`ransomwareFileChurnEnabled: true` aktiviert. Er wertet ausschliesslich bereits
|
||||||
weitergegeben.
|
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
|
## Rueckfall
|
||||||
|
|
||||||
|
|||||||
@@ -120,8 +120,9 @@ Ein Pilot wird pausiert und zurueckgesetzt, wenn eines dieser Kriterien eintritt
|
|||||||
Systemereignisse sowie Aenderungszaehler seit dem letzten Pruefpunkt.
|
Systemereignisse sowie Aenderungszaehler seit dem letzten Pruefpunkt.
|
||||||
- Erkennung hochrelevanter Manipulationen wie Schattenkopie-, Recovery- und
|
- Erkennung hochrelevanter Manipulationen wie Schattenkopie-, Recovery- und
|
||||||
Backup-Loeschbefehle sowie verdaechtiger Verschluesselungswerkzeuge.
|
Backup-Loeschbefehle sowie verdaechtiger Verschluesselungswerkzeuge.
|
||||||
- Lokaler Ringpuffer mit aggregierten Datei-Churn-Signalen, etwa ungewoehnliche
|
- Lokaler Ringpuffer mit aggregierten Datei-Churn-Signalen aus vorhandenen
|
||||||
Umbenennungen, Loeschungen und neue Erweiterungen.
|
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
|
- Snapshot der SMB-Sitzungen und des Incident-Kontexts erst bei einer
|
||||||
Auffaelligkeit.
|
Auffaelligkeit.
|
||||||
- Verdichtetes Ransomware-Incident-Protokoll fuer n8n und die Uebersicht.
|
- Verdichtetes Ransomware-Incident-Protokoll fuer n8n und die Uebersicht.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ internal sealed class AttackScanner
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
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 baseAlertState = baseAssessment.State;
|
||||||
string baseAlertReason = baseAssessment.Reason;
|
string baseAlertReason = baseAssessment.Reason;
|
||||||
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
|
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.");
|
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;
|
return loginAssessment;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ internal sealed record ScannerConfiguration
|
|||||||
|
|
||||||
public bool RansomwareBetaEnabled { get; init; }
|
public bool RansomwareBetaEnabled { get; init; }
|
||||||
|
|
||||||
|
public bool RansomwareBetaAlertingEnabled { get; init; }
|
||||||
|
|
||||||
public int RansomwareLookbackMinutes { get; init; } = 15;
|
public int RansomwareLookbackMinutes { get; init; } = 15;
|
||||||
|
|
||||||
public int RansomwareWarningSignalCount { get; init; } = 2;
|
public int RansomwareWarningSignalCount { get; init; } = 2;
|
||||||
@@ -36,6 +38,20 @@ internal sealed record ScannerConfiguration
|
|||||||
|
|
||||||
public bool RansomwareCaptureSmbSessions { get; init; } = true;
|
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<string> RansomwareExcludedProcesses { get; init; } = [];
|
public List<string> RansomwareExcludedProcesses { get; init; } = [];
|
||||||
|
|
||||||
public List<string> FtpRoots { get; init; } = [];
|
public List<string> FtpRoots { get; init; } = [];
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ internal sealed record RansomwareBetaSummary
|
|||||||
{
|
{
|
||||||
public bool Enabled { get; init; }
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public bool AlertingEnabled { get; init; }
|
||||||
|
|
||||||
public string State { get; init; } = "disabled";
|
public string State { get; init; } = "disabled";
|
||||||
|
|
||||||
public string Reason { get; init; } = "Ransomware beta is disabled.";
|
public string Reason { get; init; } = "Ransomware beta is disabled.";
|
||||||
@@ -128,6 +130,38 @@ internal sealed record RansomwareBetaSummary
|
|||||||
public List<RansomwareSignal> Signals { get; init; } = [];
|
public List<RansomwareSignal> Signals { get; init; } = [];
|
||||||
|
|
||||||
public List<RansomwareSmbSession> SmbSessions { get; init; } = [];
|
public List<RansomwareSmbSession> 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<RansomwareFileChurnProcess> 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
|
internal sealed record RansomwareSmbSession
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
<RootNamespace>OCSentinelCli</RootNamespace>
|
<RootNamespace>OCSentinelCli</RootNamespace>
|
||||||
<Product>OfficeCom Sentinel</Product>
|
<Product>OfficeCom Sentinel</Product>
|
||||||
<Company>OfficeCom</Company>
|
<Company>OfficeCom</Company>
|
||||||
<Version>1.5.0-beta.1</Version>
|
<Version>1.5.0-beta.2</Version>
|
||||||
<AssemblyVersion>1.5.0.0</AssemblyVersion>
|
<AssemblyVersion>1.5.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.5.0.0</FileVersion>
|
<FileVersion>1.5.0.0</FileVersion>
|
||||||
<InformationalVersion>1.5.0-beta.1</InformationalVersion>
|
<InformationalVersion>1.5.0-beta.2</InformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
3
src/OCSentinelCli/Properties/AssemblyInfo.cs
Normal file
3
src/OCSentinelCli/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("OCSentinelCli.Tests")]
|
||||||
9
src/OCSentinelCli/RansomwareAlertPolicy.cs
Normal file
9
src/OCSentinelCli/RansomwareAlertPolicy.cs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@ internal static class RansomwareBetaDetector
|
|||||||
ScanSecurityProcesses(signals, errors, since, configuration);
|
ScanSecurityProcesses(signals, errors, since, configuration);
|
||||||
ScanPowerShellScriptBlocks(signals, errors, since, configuration);
|
ScanPowerShellScriptBlocks(signals, errors, since, configuration);
|
||||||
ScanSysmonProcesses(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<RansomwareSignal> distinctSignals = signals
|
List<RansomwareSignal> distinctSignals = signals
|
||||||
.OrderBy(signal => signal.Timestamp)
|
.OrderBy(signal => signal.Timestamp)
|
||||||
@@ -50,11 +56,13 @@ internal static class RansomwareBetaDetector
|
|||||||
return new RansomwareBetaSummary
|
return new RansomwareBetaSummary
|
||||||
{
|
{
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
|
AlertingEnabled = configuration.RansomwareBetaAlertingEnabled,
|
||||||
State = state,
|
State = state,
|
||||||
Reason = reason,
|
Reason = reason,
|
||||||
LookbackMinutes = lookbackMinutes,
|
LookbackMinutes = lookbackMinutes,
|
||||||
Signals = distinctSignals,
|
Signals = distinctSignals,
|
||||||
SmbSessions = smbSessions
|
SmbSessions = smbSessions,
|
||||||
|
FileChurn = fileChurn
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
296
src/OCSentinelCli/RansomwareFileChurnDetector.cs
Normal file
296
src/OCSentinelCli/RansomwareFileChurnDetector.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tests/OCSentinelCli.Tests/OCSentinelCli.Tests.csproj
Normal file
19
tests/OCSentinelCli.Tests/OCSentinelCli.Tests.csproj
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\OCSentinelCli\OCSentinelCli.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
55
tests/OCSentinelCli.Tests/RansomwareBetaTests.cs
Normal file
55
tests/OCSentinelCli.Tests/RansomwareBetaTests.cs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user