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(); var errors = new List(); 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 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 attacks, List 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 attacks, List 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 attacks, List 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 attacks, List errors, DateTimeOffset since, ScannerConfiguration configuration) { IEnumerable 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 attacks, List 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 attacks, List errors, DateTimeOffset since, ScannerConfiguration configuration) { IEnumerable 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 attacks, List 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 errors, Action 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 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." }; } }