Add Exchange IIS service telemetry
This commit is contained in:
@@ -16,6 +16,12 @@ internal sealed class AttackScanner
|
||||
@"D:\inetpub\logs\LogFiles"
|
||||
];
|
||||
|
||||
private static readonly string[] DefaultIisLogRoots =
|
||||
[
|
||||
@"C:\inetpub\logs\LogFiles",
|
||||
@"D:\inetpub\logs\LogFiles"
|
||||
];
|
||||
|
||||
private static readonly string[] DefaultFileZillaRoots =
|
||||
[
|
||||
@"C:\Program Files (x86)\FileZilla Server\Logs",
|
||||
@@ -33,6 +39,7 @@ internal sealed class AttackScanner
|
||||
ScanWindowsLogons(attacks, errors, since);
|
||||
ScanSqlLogons(attacks, errors, since);
|
||||
ScanExchangeLogons(attacks, errors, since);
|
||||
ScanExchangeIisLogons(attacks, errors, since, configuration);
|
||||
ScanIisFtpLogs(attacks, errors, since, configuration);
|
||||
ScanFileZillaLogs(attacks, errors, since, configuration);
|
||||
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
|
||||
@@ -225,6 +232,40 @@ internal sealed class AttackScanner
|
||||
});
|
||||
}
|
||||
|
||||
private static void ScanExchangeIisLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||
{
|
||||
IEnumerable<string> roots = configuration.IisLogRoots.Count > 0 ? configuration.IisLogRoots : DefaultIisLogRoots;
|
||||
foreach (string root in roots)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string directory in Directory.GetDirectories(root, "W3SVC*"))
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(directory, "*.log").Where(path => File.GetLastWriteTime(path) >= since.LocalDateTime.Date))
|
||||
{
|
||||
try
|
||||
{
|
||||
attacks.AddRange(ExchangeIisLogParser.ParseLines(File.ReadLines(file), since));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
errors.Add($"Exchange IIS log parse failed for {file}: {exception.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
errors.Add($"Exchange IIS log scan failed for {root}: {exception.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanIisFtpLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||
{
|
||||
IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots;
|
||||
|
||||
@@ -56,6 +56,8 @@ internal sealed record ScannerConfiguration
|
||||
|
||||
public List<string> FtpRoots { get; init; } = [];
|
||||
|
||||
public List<string> IisLogRoots { get; init; } = [];
|
||||
|
||||
public List<string> FileZillaRoots { get; init; } = [];
|
||||
|
||||
public List<string> ExcludedIps { get; init; } = [];
|
||||
|
||||
97
src/OCSentinelCli/ExchangeIisLogParser.cs
Normal file
97
src/OCSentinelCli/ExchangeIisLogParser.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace OCSentinelCli;
|
||||
|
||||
internal static class ExchangeIisLogParser
|
||||
{
|
||||
internal static IEnumerable<AttackEvent> ParseLines(IEnumerable<string> lines, DateTimeOffset since)
|
||||
{
|
||||
Dictionary<string, int>? fields = null;
|
||||
|
||||
foreach (string line in lines)
|
||||
{
|
||||
if (line.StartsWith("#Fields:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
fields = line[8..].Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select((field, index) => new { Field = field, Index = index })
|
||||
.ToDictionary(item => item.Field, item => item.Index, StringComparer.OrdinalIgnoreCase);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || fields is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string[] values = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (!TryValue(fields, values, "date", out string date) || !TryValue(fields, values, "time", out string time)
|
||||
|| !TryValue(fields, values, "c-ip", out string sourceIp) || !TryValue(fields, values, "cs-uri-stem", out string path)
|
||||
|| !TryValue(fields, values, "sc-status", out string statusText) || !int.TryParse(statusText, out int status))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status is not 401 and not 403 || !TryClassify(path, out string service))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DateTimeOffset timestamp = new(timestampUtc, TimeSpan.Zero);
|
||||
if (timestamp < since || string.IsNullOrWhiteSpace(sourceIp) || sourceIp == "-")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int? destinationPort = TryValue(fields, values, "s-port", out string portText) && int.TryParse(portText, out int parsedPort) ? parsedPort : null;
|
||||
string username = TryValue(fields, values, "cs-username", out string loggedUser) && loggedUser != "-" ? loggedUser : "[not logged]";
|
||||
|
||||
yield return new AttackEvent
|
||||
{
|
||||
Timestamp = timestamp.ToLocalTime(),
|
||||
SourceIp = sourceIp,
|
||||
Target = $"Exchange {service} login",
|
||||
Username = username,
|
||||
Source = "IIS W3C",
|
||||
Service = service,
|
||||
DestinationPort = destinationPort,
|
||||
Endpoint = path,
|
||||
InstanceId = status
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryValue(IReadOnlyDictionary<string, int> fields, IReadOnlyList<string> values, string field, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
if (!fields.TryGetValue(field, out int index) || index >= values.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = values[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryClassify(string path, out string service)
|
||||
{
|
||||
string normalized = path.Trim().ToLowerInvariant();
|
||||
service = normalized switch
|
||||
{
|
||||
var value when value.StartsWith("/owa/") => "OWA",
|
||||
var value when value.StartsWith("/ecp/") => "ECP",
|
||||
var value when value.StartsWith("/mapi/") => "MAPI/HTTP",
|
||||
var value when value.StartsWith("/ews/") => "EWS",
|
||||
var value when value.StartsWith("/microsoft-server-activesync") => "ActiveSync",
|
||||
var value when value.StartsWith("/autodiscover/") => "Autodiscover",
|
||||
var value when value.StartsWith("/rpc/") => "Outlook Anywhere",
|
||||
var value when value.StartsWith("/powershell") => "Exchange PowerShell",
|
||||
_ => string.Empty
|
||||
};
|
||||
return service.Length > 0;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ internal sealed record AttackEvent
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
public int? DestinationPort { get; init; }
|
||||
|
||||
public string Endpoint { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record AggregatedAttack
|
||||
@@ -33,6 +39,12 @@ internal sealed record AggregatedAttack
|
||||
|
||||
public List<string> Sources { get; init; } = [];
|
||||
|
||||
public List<string> Services { get; init; } = [];
|
||||
|
||||
public List<int> DestinationPorts { get; init; } = [];
|
||||
|
||||
public List<string> Endpoints { get; init; } = [];
|
||||
|
||||
public static AggregatedAttack FromGroup(IGrouping<string, AttackEvent> group)
|
||||
{
|
||||
List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList();
|
||||
@@ -48,7 +60,10 @@ internal sealed record AggregatedAttack
|
||||
RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen),
|
||||
Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||
Usernames = ordered.Select(static attack => attack.Username).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||
Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList()
|
||||
Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||
Services = ordered.Select(static attack => attack.Service).Where(static service => !string.IsNullOrWhiteSpace(service)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||
DestinationPorts = ordered.Select(static attack => attack.DestinationPort).Where(static port => port.HasValue).Select(static port => port!.Value).Distinct().Order().ToList(),
|
||||
Endpoints = ordered.Select(static attack => attack.Endpoint).Where(static endpoint => !string.IsNullOrWhiteSpace(endpoint)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<RootNamespace>OCSentinelCli</RootNamespace>
|
||||
<Product>OfficeCom Sentinel</Product>
|
||||
<Company>OfficeCom</Company>
|
||||
<Version>1.5.0-beta.5</Version>
|
||||
<Version>1.5.0-beta.6</Version>
|
||||
<AssemblyVersion>1.5.0.0</AssemblyVersion>
|
||||
<FileVersion>1.5.0.0</FileVersion>
|
||||
<InformationalVersion>1.5.0-beta.5</InformationalVersion>
|
||||
<InformationalVersion>1.5.0-beta.6</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user