using System.Globalization; namespace OCSentinelCli; internal static class ExchangeIisLogParser { private static readonly TimeSpan AuthenticationCompletionWindow = TimeSpan.FromMinutes(2); internal static IEnumerable ParseLines(IEnumerable lines, DateTimeOffset since) { Dictionary? fields = null; var pendingFailures = new List(); var attacks = new List(); 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 || !TryParseObservation(fields, line, since, out ExchangeIisObservation? observation)) { continue; } if (observation is null) { continue; } FlushExpiredCandidates(pendingFailures, attacks, observation.Timestamp); if (observation.IsSuccessfulAuthentication) { pendingFailures.RemoveAll(candidate => candidate.MatchesSuccessfulAuthentication(observation)); } else if (observation.IsCredentialFailure) { pendingFailures.Add(observation); } } attacks.AddRange(pendingFailures.Select(static candidate => candidate.ToAttackEvent())); return attacks; } private static void FlushExpiredCandidates(List pendingFailures, List attacks, DateTimeOffset currentTimestamp) { DateTimeOffset cutoff = currentTimestamp - AuthenticationCompletionWindow; foreach (ExchangeIisObservation candidate in pendingFailures.Where(candidate => candidate.Timestamp < cutoff).ToList()) { attacks.Add(candidate.ToAttackEvent()); pendingFailures.Remove(candidate); } } private static bool TryParseObservation(IReadOnlyDictionary fields, string line, DateTimeOffset since, out ExchangeIisObservation? observation) { observation = null; 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) || !TryClassify(path, out string service)) { return false; } if ((status < 200 || status >= 400) && status is not 401 and not 403) { return false; } if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc)) { return false; } DateTimeOffset timestamp = new(timestampUtc, TimeSpan.Zero); if (timestamp < since || string.IsNullOrWhiteSpace(sourceIp) || sourceIp == "-") { return false; } 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]"; string userAgent = TryValue(fields, values, "cs(User-Agent)", out string parsedUserAgent) && parsedUserAgent != "-" ? parsedUserAgent : string.Empty; string substatus = TryValue(fields, values, "sc-substatus", out string parsedSubstatus) ? parsedSubstatus : string.Empty; observation = new ExchangeIisObservation(timestamp, sourceIp, path, service, destinationPort, username, userAgent, status, substatus); return true; } private static bool TryValue(IReadOnlyDictionary fields, IReadOnlyList 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; } private sealed record ExchangeIisObservation( DateTimeOffset Timestamp, string SourceIp, string Endpoint, string Service, int? DestinationPort, string Username, string UserAgent, int Status, string Substatus) { public bool IsSuccessfulAuthentication => Status is >= 200 and < 400; // IIS 401.0 and 401.2 commonly occur during normal authentication negotiation or server configuration checks. public bool IsCredentialFailure => Status == 403 || (Status == 401 && (string.IsNullOrWhiteSpace(Substatus) || Substatus == "1")); public bool MatchesSuccessfulAuthentication(ExchangeIisObservation success) { return success.IsSuccessfulAuthentication && success.Timestamp >= Timestamp && success.Timestamp - Timestamp <= AuthenticationCompletionWindow && string.Equals(success.SourceIp, SourceIp, StringComparison.OrdinalIgnoreCase) && string.Equals(success.Endpoint, Endpoint, StringComparison.OrdinalIgnoreCase) && success.DestinationPort == DestinationPort && string.Equals(success.UserAgent, UserAgent, StringComparison.OrdinalIgnoreCase); } public AttackEvent ToAttackEvent() => new() { Timestamp = Timestamp.ToLocalTime(), SourceIp = SourceIp, Target = $"Exchange {Service} login", Username = Username, Source = "IIS W3C", Service = Service, DestinationPort = DestinationPort, Endpoint = Endpoint, InstanceId = Status }; } }