497 lines
17 KiB
C#
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."
|
|
};
|
|
}
|
|
}
|