Files
oc-sentinel/src/OCSentinelCli/AttackScanner.cs
OfficeCom Codex 00778175fd
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
Include NinjaOne identifiers in reports
2026-07-25 02:39:39 +02:00

497 lines
17 KiB
C#

using System.Diagnostics.Eventing.Reader;
using System.Globalization;
using System.Net;
using System.Runtime.Versioning;
using System.Text.RegularExpressions;
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);
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();
string baseAlertState = GetAlertState(attacks.Count, uniqueIpCount, configuration);
string baseAlertReason = GetAlertReason(attacks.Count, uniqueIpCount, configuration, baseAlertState);
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(),
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,
Runtime = new ScanRuntimeMetadata
{
StartedAtUtc = startedAtUtc,
FinishedAtUtc = generatedAtUtc,
UploadAttempted = false
},
Events = attacks,
TopSources = topSources,
Errors = errors
};
}
private static NinjaOneContext GetNinjaOneContext()
{
return new NinjaOneContext
{
OrganizationId = ReadEnvironmentVariable("NINJA_ORGANIZATION_ID"),
OrganizationName = ReadEnvironmentVariable("NINJA_ORGANIZATION_NAME"),
MachineId = ReadEnvironmentVariable("NINJA_AGENT_MACHINE_ID"),
NodeId = ReadEnvironmentVariable("NINJA_AGENT_NODE_ID"),
LocationId = ReadEnvironmentVariable("NINJA_LOCATION_ID"),
LocationName = ReadEnvironmentVariable("NINJA_LOCATION_NAME")
};
}
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 string GetAlertState(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration)
{
if (totalEvents >= configuration.CriticalEventThreshold || uniqueIpCount >= configuration.CriticalUniqueIpThreshold)
{
return "critical";
}
if (totalEvents >= configuration.WarningEventThreshold || uniqueIpCount >= configuration.WarningUniqueIpThreshold)
{
return "warning";
}
return "ok";
}
private static string GetAlertReason(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration, string alertState)
{
return alertState switch
{
"critical" => $"Critical threshold reached. Events={totalEvents}/{configuration.CriticalEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.CriticalUniqueIpThreshold}.",
"warning" => $"Warning threshold reached. Events={totalEvents}/{configuration.WarningEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.WarningUniqueIpThreshold}.",
_ => "No thresholds exceeded."
};
}
}