Files
oc-sentinel/src/OCSentinelCli/AttackScanner.cs
OfficeCom Codex 94f5be8953
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
Persist NinjaOne context for scheduled scans
2026-07-31 01:59:40 +02:00

603 lines
22 KiB
C#

using System.Diagnostics.Eventing.Reader;
using System.Globalization;
using System.Net;
using System.Runtime.Versioning;
using System.Text.RegularExpressions;
using OCSentinelCli.Configuration;
namespace OCSentinelCli;
[SupportedOSPlatform("windows")]
internal sealed class AttackScanner
{
private static readonly string[] DefaultFtpRoots =
[
@"C:\inetpub\logs\LogFiles",
@"D:\inetpub\logs\LogFiles"
];
private static readonly string[] DefaultFileZillaRoots =
[
@"C:\Program Files (x86)\FileZilla Server\Logs",
@"D:\Program Files (x86)\FileZilla Server\Logs"
];
public ScanResult Run(ScanOptions options)
{
DateTimeOffset startedAtUtc = DateTimeOffset.UtcNow;
ScannerConfiguration configuration = LoadConfiguration(options);
var attacks = new List<AttackEvent>();
var errors = new List<string>();
DateTimeOffset since = DateTimeOffset.Now.AddDays(-options.LookbackDays);
ScanWindowsLogons(attacks, errors, since);
ScanSqlLogons(attacks, errors, since);
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)
{
attacks = attacks
.Where(attack => !configuration.ExcludedIps.Contains(attack.SourceIp, StringComparer.OrdinalIgnoreCase))
.ToList();
}
attacks.Sort(static (left, right) => left.Timestamp.CompareTo(right.Timestamp));
List<AggregatedAttack> topSources = attacks
.GroupBy(static attack => attack.SourceIp)
.Select(group => AggregatedAttack.FromGroup(group))
.OrderByDescending(static aggregate => aggregate.Count)
.ThenBy(static aggregate => aggregate.SourceIp, StringComparer.OrdinalIgnoreCase)
.Take(options.TopCount)
.ToList();
int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count();
AlertAssessment baseAssessment = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta);
string baseAlertState = baseAssessment.State;
string baseAlertReason = baseAssessment.Reason;
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
? VulnerabilityCorrelationSummary.Empty()
: VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors);
CorrelationAssessment correlationAssessment = VulnerabilityCorrelation.Assess(baseAlertState, attacks.Count, vulnerabilityCorrelation, configuration);
DateTimeOffset generatedAtLocal = DateTimeOffset.Now;
DateTimeOffset generatedAtUtc = DateTimeOffset.UtcNow;
return new ScanResult
{
SchemaVersion = "2.0",
MachineName = Environment.MachineName,
NinjaOne = GetNinjaOneContext(options, errors),
GeneratedAtLocal = generatedAtLocal,
GeneratedAtUtc = generatedAtUtc,
ClientVersion = BuildMetadata.Version,
LookbackDays = options.LookbackDays,
TotalEvents = attacks.Count,
UniqueIpCount = uniqueIpCount,
AlertState = correlationAssessment.FinalAlertState,
AlertReason = correlationAssessment.CorrelationReason == "No CVE correlation applied." ? baseAlertReason : correlationAssessment.CorrelationReason,
BaseAlertState = baseAlertState,
BaseAlertReason = baseAlertReason,
VulnerabilityCorrelation = vulnerabilityCorrelation,
RansomwareBeta = ransomwareBeta,
Runtime = new ScanRuntimeMetadata
{
StartedAtUtc = startedAtUtc,
FinishedAtUtc = generatedAtUtc,
UploadAttempted = false
},
Events = attacks,
TopSources = topSources,
Errors = errors
};
}
private static NinjaOneContext GetNinjaOneContext(ScanOptions options, List<string> errors)
{
ClientConfiguration? clientConfiguration = null;
if (!string.IsNullOrWhiteSpace(options.ClientConfigPath))
{
try
{
clientConfiguration = ClientConfiguration.Load(options.ClientConfigPath);
}
catch (Exception exception)
{
errors.Add($"Could not load persisted NinjaOne context: {exception.Message}");
}
}
return new NinjaOneContext
{
OrganizationId = ReadContextValue("NINJA_ORGANIZATION_ID", clientConfiguration?.NinjaOrganizationId),
OrganizationName = ReadContextValue("NINJA_ORGANIZATION_NAME", clientConfiguration?.NinjaOrganizationName),
MachineId = ReadContextValue("NINJA_AGENT_MACHINE_ID", clientConfiguration?.NinjaMachineId),
NodeId = ReadContextValue("NINJA_AGENT_NODE_ID", clientConfiguration?.NinjaNodeId),
LocationId = ReadContextValue("NINJA_LOCATION_ID", clientConfiguration?.NinjaLocationId),
LocationName = ReadContextValue("NINJA_LOCATION_NAME", clientConfiguration?.NinjaLocationName)
};
}
private static string ReadContextValue(string environmentName, string? persistedValue)
{
string currentValue = ReadEnvironmentVariable(environmentName);
return string.IsNullOrWhiteSpace(currentValue) ? persistedValue?.Trim() ?? string.Empty : currentValue;
}
private static string ReadEnvironmentVariable(string name)
{
return Environment.GetEnvironmentVariable(name)?.Trim() ?? string.Empty;
}
private static ScannerConfiguration LoadConfiguration(ScanOptions options)
{
if (string.IsNullOrWhiteSpace(options.ConfigPath))
{
return new ScannerConfiguration();
}
return ScannerConfiguration.Load(options.ConfigPath);
}
private static void ScanWindowsLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since)
{
const string query = "*[System/EventID=4625]";
TryScanEventLog("Security", query, errors, eventRecord =>
{
if (!TryGetTimestamp(eventRecord, since, out DateTimeOffset timestamp))
{
return;
}
string ip = ReadProperty(eventRecord, 19);
if (string.IsNullOrWhiteSpace(ip) || ip == "-")
{
return;
}
AddAttack(attacks, new AttackEvent
{
Timestamp = timestamp,
SourceIp = ip,
Target = "Windows login",
Username = ReadProperty(eventRecord, 5, "[unknown]"),
Source = "Security",
InstanceId = 4625
});
});
}
private static void ScanSqlLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since)
{
const string query = "*[System/EventID=18456]";
TryScanEventLog("Application", query, errors, eventRecord =>
{
if (!TryGetTimestamp(eventRecord, since, out DateTimeOffset timestamp))
{
return;
}
string ip = ExtractIp(ReadProperty(eventRecord, 2));
if (string.IsNullOrWhiteSpace(ip))
{
return;
}
AddAttack(attacks, new AttackEvent
{
Timestamp = timestamp,
SourceIp = ip,
Target = "SQL Server",
Username = ReadProperty(eventRecord, 0, "[unknown]"),
Source = "Application",
InstanceId = 18456
});
});
}
private static void ScanExchangeLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since)
{
const string query = "*[System/EventID=1035]";
TryScanEventLog("Application", query, errors, eventRecord =>
{
if (!TryGetTimestamp(eventRecord, since, out DateTimeOffset timestamp))
{
return;
}
string ip = ReadProperty(eventRecord, 3);
if (string.IsNullOrWhiteSpace(ip) || ip.Length <= 4)
{
return;
}
AddAttack(attacks, new AttackEvent
{
Timestamp = timestamp,
SourceIp = ip,
Target = "Exchange",
Username = "[not logged]",
Source = "Application",
InstanceId = 1035
});
});
}
private static void ScanIisFtpLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
{
IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots;
foreach (string root in roots)
{
try
{
if (!Directory.Exists(root))
{
continue;
}
foreach (string directory in Directory.GetDirectories(root, "FTP*"))
{
foreach (string file in Directory.GetFiles(directory, "*.log"))
{
ParseIisFtpLogFile(file, attacks, errors, since);
}
}
}
catch (Exception ex)
{
errors.Add($"FTP scan failed for {root}: {ex.Message}");
}
}
}
private static void ParseIisFtpLogFile(string filePath, List<AttackEvent> attacks, List<string> errors, DateTimeOffset since)
{
string username = "[unknown]";
try
{
foreach (string line in File.ReadLines(filePath))
{
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
string[] parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 9)
{
continue;
}
if (!DateTime.TryParse(
$"{parts[0]} {parts[1]}",
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out DateTime timestampUtc))
{
continue;
}
var timestamp = new DateTimeOffset(timestampUtc).ToLocalTime();
if (timestamp < since)
{
continue;
}
string sourceIp = parts[2];
string command = parts[6];
string parameter = parts[7];
string status = parts[8];
if (string.Equals(command, "USER", StringComparison.OrdinalIgnoreCase))
{
username = parameter;
}
else if (string.Equals(command, "PASS", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(status, "230", StringComparison.OrdinalIgnoreCase))
{
AddAttack(attacks, new AttackEvent
{
Timestamp = timestamp,
SourceIp = sourceIp,
Target = "FTP login",
Username = username,
Source = "IIS FTP",
InstanceId = -1
});
}
}
}
catch (Exception ex)
{
errors.Add($"FTP log parse failed for {filePath}: {ex.Message}");
}
}
private static void ScanFileZillaLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
{
IEnumerable<string> roots = configuration.FileZillaRoots.Count > 0 ? configuration.FileZillaRoots : DefaultFileZillaRoots;
foreach (string root in roots)
{
try
{
if (!Directory.Exists(root))
{
continue;
}
foreach (string file in Directory.GetFiles(root, "*.log"))
{
ParseFileZillaLogFile(file, attacks, errors, since);
}
}
catch (Exception ex)
{
errors.Add($"FileZilla scan failed for {root}: {ex.Message}");
}
}
}
private static void ParseFileZillaLogFile(string filePath, List<AttackEvent> attacks, List<string> errors, DateTimeOffset since)
{
string username = "[unknown]";
try
{
foreach (string line in File.ReadLines(filePath))
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
int closeParen = line.IndexOf(')');
if (closeParen < 0)
{
continue;
}
string remainder = line[(closeParen + 1)..];
int dash = remainder.IndexOf('-');
if (dash < 0)
{
continue;
}
string timestampText = remainder[..dash].Trim();
remainder = remainder[(dash + 1)..].TrimStart();
if (!DateTime.TryParse(timestampText, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsedTimestamp))
{
continue;
}
DateTimeOffset timestamp = new(parsedTimestamp);
if (timestamp < since)
{
continue;
}
if (remainder.Contains("(not logged in)", StringComparison.OrdinalIgnoreCase))
{
int idx = remainder.IndexOf(')');
if (idx >= 0 && idx + 1 < remainder.Length)
{
remainder = remainder[(idx + 1)..];
}
}
int openIp = remainder.IndexOf('(');
int closeIp = remainder.IndexOf(')');
if (openIp < 0 || closeIp <= openIp)
{
continue;
}
string ip = remainder[(openIp + 1)..closeIp].Trim();
string message = remainder[(closeIp + 1)..].Trim();
if (message.Contains("password incorrect", StringComparison.OrdinalIgnoreCase))
{
AddAttack(attacks, new AttackEvent
{
Timestamp = timestamp,
SourceIp = ip,
Target = "FileZilla FTP login",
Username = username,
Source = "FileZilla",
InstanceId = -1
});
}
else if (message.Contains("> USER", StringComparison.OrdinalIgnoreCase))
{
int userIndex = message.IndexOf("> USER", StringComparison.OrdinalIgnoreCase);
username = message[(userIndex + 6)..].Trim();
}
}
}
catch (Exception ex)
{
errors.Add($"FileZilla log parse failed for {filePath}: {ex.Message}");
}
}
private static void TryScanEventLog(string logName, string query, List<string> errors, Action<EventRecord> processRecord)
{
try
{
using var reader = new EventLogReader(new EventLogQuery(logName, PathType.LogName, query));
for (EventRecord? eventRecord = reader.ReadEvent(); eventRecord != null; eventRecord = reader.ReadEvent())
{
using (eventRecord)
{
processRecord(eventRecord);
}
}
}
catch (Exception ex)
{
errors.Add($"Event log scan failed for {logName} ({query}): {ex.Message}");
}
}
private static bool TryGetTimestamp(EventRecord eventRecord, DateTimeOffset since, out DateTimeOffset timestamp)
{
if (!eventRecord.TimeCreated.HasValue)
{
timestamp = default;
return false;
}
timestamp = new DateTimeOffset(eventRecord.TimeCreated.Value);
return timestamp >= since;
}
private static string ReadProperty(EventRecord eventRecord, int index, string fallback = "")
{
if (index < 0 || index >= eventRecord.Properties.Count)
{
return fallback;
}
object? value = eventRecord.Properties[index].Value;
return value?.ToString()?.Trim() ?? fallback;
}
private static void AddAttack(List<AttackEvent> attacks, AttackEvent attack)
{
if (string.IsNullOrWhiteSpace(attack.SourceIp))
{
return;
}
if (!LooksLikeIpAddress(attack.SourceIp))
{
return;
}
attacks.Add(attack with { SourceIp = attack.SourceIp.Trim() });
}
private static string ExtractIp(string input)
{
Match match = Regex.Match(input, @"\b(?:\d{1,3}\.){3}\d{1,3}\b");
return match.Success ? match.Value : string.Empty;
}
private static bool LooksLikeIpAddress(string input)
{
return IPAddress.TryParse(input, out _);
}
private static AlertAssessment AssessAttackActivity(IReadOnlyList<AttackEvent> attacks, int uniqueIpCount, ScannerConfiguration configuration)
{
if (attacks.Count == 0)
{
return new AlertAssessment("ok", "No failed login activity observed.");
}
TimeSpan window = TimeSpan.FromMinutes(configuration.LoginBurstWindowMinutes);
int largestBurst = attacks
.GroupBy(attack => (attack.SourceIp, attack.Username, attack.Target))
.Select(group => GetPeakEventCount(group.OrderBy(attack => attack.Timestamp).ToList(), window))
.DefaultIfEmpty(0)
.Max();
int largestSpray = attacks
.GroupBy(attack => attack.SourceIp)
.Select(group => GetPeakDistinctAccountCount(group.OrderBy(attack => attack.Timestamp).ToList(), window))
.DefaultIfEmpty(0)
.Max();
if (largestBurst >= configuration.CriticalLoginBurstCount || largestSpray >= configuration.CriticalSprayAccountCount)
{
return new AlertAssessment("critical", $"High-confidence login attack pattern: burst={largestBurst}, sprayed accounts={largestSpray}, window={configuration.LoginBurstWindowMinutes}m.");
}
if (largestBurst >= configuration.WarningLoginBurstCount || largestSpray >= configuration.WarningSprayAccountCount)
{
return new AlertAssessment("warning", $"Suspicious login pattern: burst={largestBurst}, sprayed accounts={largestSpray}, window={configuration.LoginBurstWindowMinutes}m.");
}
int criticalEventThreshold = Math.Max(configuration.CriticalEventThreshold, configuration.CriticalLoginBurstCount);
int criticalIpThreshold = Math.Max(configuration.CriticalUniqueIpThreshold, configuration.CriticalSprayAccountCount);
int warningEventThreshold = Math.Max(configuration.WarningEventThreshold, configuration.WarningLoginBurstCount * 2);
int warningIpThreshold = Math.Max(configuration.WarningUniqueIpThreshold, configuration.WarningSprayAccountCount);
if (attacks.Count >= criticalEventThreshold || uniqueIpCount >= criticalIpThreshold)
{
return new AlertAssessment("critical", $"Critical volume threshold reached: events={attacks.Count}, unique IPs={uniqueIpCount}.");
}
if (attacks.Count >= warningEventThreshold || uniqueIpCount >= warningIpThreshold)
{
return new AlertAssessment("warning", $"Elevated failed-login volume: events={attacks.Count}, unique IPs={uniqueIpCount}.");
}
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;
int peak = 0;
for (int end = 0; end < events.Count; end++)
{
while (events[end].Timestamp - events[start].Timestamp > window)
{
start++;
}
peak = Math.Max(peak, end - start + 1);
}
return peak;
}
private static int GetPeakDistinctAccountCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
{
var accounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
int start = 0;
int peak = 0;
for (int end = 0; end < events.Count; end++)
{
accounts[events[end].Username] = accounts.GetValueOrDefault(events[end].Username) + 1;
while (events[end].Timestamp - events[start].Timestamp > window)
{
string account = events[start].Username;
accounts[account]--;
if (accounts[account] == 0)
{
accounts.Remove(account);
}
start++;
}
peak = Math.Max(peak, accounts.Count);
}
return peak;
}
private sealed record AlertAssessment(string State, string Reason);
}