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

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