Implement reversible Sentinel beta foundation
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s

This commit is contained in:
OfficeCom Codex
2026-07-30 01:05:30 +02:00
parent 58ad77242f
commit c3ca95dfa5
21 changed files with 464 additions and 18 deletions

View File

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

View File

@@ -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();

View File

@@ -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; } = [];

View File

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

View File

@@ -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>

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