Add Exchange IIS service telemetry
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 52s

This commit is contained in:
OfficeCom Codex
2026-08-02 23:50:12 +02:00
parent c73bab139b
commit cc77c45a10
7 changed files with 204 additions and 3 deletions

View File

@@ -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;

View File

@@ -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; } = [];

View 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;
}
}

View File

@@ -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()
};
}

View File

@@ -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>