Implement reversible Sentinel beta foundation
This commit is contained in:
@@ -34,6 +34,7 @@ internal sealed class AttackScanner
|
||||
ScanExchangeLogons(attacks, errors, since);
|
||||
ScanIisFtpLogs(attacks, errors, since, configuration);
|
||||
ScanFileZillaLogs(attacks, errors, since, configuration);
|
||||
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
|
||||
|
||||
if (configuration.ExcludedIps.Count > 0)
|
||||
{
|
||||
@@ -53,7 +54,7 @@ internal sealed class AttackScanner
|
||||
.ToList();
|
||||
|
||||
int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||
AlertAssessment baseAssessment = AssessAttackActivity(attacks, uniqueIpCount, configuration);
|
||||
AlertAssessment baseAssessment = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta);
|
||||
string baseAlertState = baseAssessment.State;
|
||||
string baseAlertReason = baseAssessment.Reason;
|
||||
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
|
||||
@@ -79,6 +80,7 @@ internal sealed class AttackScanner
|
||||
BaseAlertState = baseAlertState,
|
||||
BaseAlertReason = baseAlertReason,
|
||||
VulnerabilityCorrelation = vulnerabilityCorrelation,
|
||||
RansomwareBeta = ransomwareBeta,
|
||||
Runtime = new ScanRuntimeMetadata
|
||||
{
|
||||
StartedAtUtc = startedAtUtc,
|
||||
@@ -517,6 +519,27 @@ 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.");
|
||||
}
|
||||
|
||||
private static AlertAssessment MergeRansomwareAssessment(AlertAssessment loginAssessment, RansomwareBetaSummary ransomwareBeta)
|
||||
{
|
||||
if (ransomwareBeta.State is not ("warning" or "critical"))
|
||||
{
|
||||
return loginAssessment;
|
||||
}
|
||||
|
||||
int loginPriority = AlertPriority(loginAssessment.State);
|
||||
int ransomwarePriority = AlertPriority(ransomwareBeta.State);
|
||||
string state = ransomwarePriority > loginPriority ? ransomwareBeta.State : loginAssessment.State;
|
||||
string reason = $"{loginAssessment.Reason} {ransomwareBeta.Reason}";
|
||||
return new AlertAssessment(state, reason);
|
||||
}
|
||||
|
||||
private static int AlertPriority(string state) => state switch
|
||||
{
|
||||
"critical" => 2,
|
||||
"warning" => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
private static int GetPeakEventCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
|
||||
{
|
||||
int start = 0;
|
||||
|
||||
@@ -73,6 +73,8 @@ internal static class ScanCommand
|
||||
Console.WriteLine($"Status: {result.AlertState}");
|
||||
Console.WriteLine($"Reason: {result.AlertReason}");
|
||||
Console.WriteLine($"Base status: {result.BaseAlertState}");
|
||||
Console.WriteLine($"Ransomware beta: {result.RansomwareBeta.State}");
|
||||
Console.WriteLine($"Ransomware beta signals: {result.RansomwareBeta.Signals.Count}");
|
||||
Console.WriteLine($"Scan errors: {result.Errors.Count}");
|
||||
Console.WriteLine();
|
||||
|
||||
|
||||
@@ -26,6 +26,16 @@ internal sealed record ScannerConfiguration
|
||||
|
||||
public int CorrelationCriticalCveThreshold { get; init; } = 1;
|
||||
|
||||
public bool RansomwareBetaEnabled { get; init; }
|
||||
|
||||
public int RansomwareLookbackMinutes { get; init; } = 15;
|
||||
|
||||
public int RansomwareWarningSignalCount { get; init; } = 2;
|
||||
|
||||
public int RansomwareCriticalSignalCount { get; init; } = 3;
|
||||
|
||||
public List<string> RansomwareExcludedProcesses { get; init; } = [];
|
||||
|
||||
public List<string> FtpRoots { get; init; } = [];
|
||||
|
||||
public List<string> FileZillaRoots { get; init; } = [];
|
||||
|
||||
@@ -104,6 +104,8 @@ internal sealed record ScanResult
|
||||
|
||||
public VulnerabilityCorrelationSummary VulnerabilityCorrelation { get; init; } = new();
|
||||
|
||||
public RansomwareBetaSummary RansomwareBeta { get; init; } = new();
|
||||
|
||||
public ScanRuntimeMetadata Runtime { get; init; } = new();
|
||||
|
||||
public List<AttackEvent> Events { get; init; } = [];
|
||||
@@ -113,6 +115,36 @@ internal sealed record ScanResult
|
||||
public List<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record RansomwareBetaSummary
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string State { get; init; } = "disabled";
|
||||
|
||||
public string Reason { get; init; } = "Ransomware beta is disabled.";
|
||||
|
||||
public int LookbackMinutes { get; init; }
|
||||
|
||||
public List<RansomwareSignal> Signals { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record RansomwareSignal
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
public string Category { get; init; } = string.Empty;
|
||||
|
||||
public string Confidence { get; init; } = string.Empty;
|
||||
|
||||
public string Process { get; init; } = string.Empty;
|
||||
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
public long EventId { get; init; }
|
||||
|
||||
public string Evidence { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record NinjaOneContext
|
||||
{
|
||||
public string OrganizationId { get; init; } = string.Empty;
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<RootNamespace>OCSentinelCli</RootNamespace>
|
||||
<Product>OfficeCom Sentinel</Product>
|
||||
<Company>OfficeCom</Company>
|
||||
<Version>1.4.0</Version>
|
||||
<AssemblyVersion>1.4.0.0</AssemblyVersion>
|
||||
<FileVersion>1.4.0.0</FileVersion>
|
||||
<InformationalVersion>1.4.0</InformationalVersion>
|
||||
<Version>1.5.0-beta.1</Version>
|
||||
<AssemblyVersion>1.5.0.0</AssemblyVersion>
|
||||
<FileVersion>1.5.0.0</FileVersion>
|
||||
<InformationalVersion>1.5.0-beta.1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
172
src/OCSentinelCli/RansomwareBetaDetector.cs
Normal file
172
src/OCSentinelCli/RansomwareBetaDetector.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace OCSentinelCli;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class RansomwareBetaDetector
|
||||
{
|
||||
public static RansomwareBetaSummary Scan(ScannerConfiguration configuration, List<string> 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<RansomwareSignal>();
|
||||
|
||||
ScanSecurityProcesses(signals, errors, since, configuration);
|
||||
ScanPowerShellScriptBlocks(signals, errors, since, configuration);
|
||||
ScanSysmonProcesses(signals, errors, since, configuration);
|
||||
|
||||
List<RansomwareSignal> 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<RansomwareSignal> signals, List<string> 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<RansomwareSignal> signals, List<string> 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<RansomwareSignal> signals, List<string> 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<RansomwareSignal> 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<string> errors, Action<EventRecord> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user