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(); 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); 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 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 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 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 AlertAssessment AssessAttackActivity(IReadOnlyList 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 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 events, TimeSpan window) { var accounts = new Dictionary(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); }