Add Exchange IIS service telemetry
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user