Remove legacy AttackTracer repo content
Some checks failed
OfficeCom Sentinel Client / build-client (push) Has been cancelled
Some checks failed
OfficeCom Sentinel Client / build-client (push) Has been cancelled
This commit is contained in:
477
src/OCSentinelCli/AttackScanner.cs
Normal file
477
src/OCSentinelCli/AttackScanner.cs
Normal file
@@ -0,0 +1,477 @@
|
||||
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,
|
||||
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 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."
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user