namespace OCSentinelCli; internal static class ExchangeIisAccountCorrelator { private static readonly TimeSpan CorrelationWindow = TimeSpan.FromMinutes(2); internal static List Enrich(IReadOnlyList attacks) { List securityFailures = attacks .Where(IsNetworkSecurityFailure) .Where(HasRecordedAccount) .ToList(); return attacks.Select(attack => IsUnresolvedExchangeIisFailure(attack) ? EnrichFromSecurityFailure(attack, securityFailures) : attack).ToList(); } private static AttackEvent EnrichFromSecurityFailure(AttackEvent iisFailure, IReadOnlyList securityFailures) { List accounts = securityFailures .Where(failure => string.Equals(failure.SourceIp, iisFailure.SourceIp, StringComparison.OrdinalIgnoreCase)) .Where(failure => (failure.Timestamp - iisFailure.Timestamp).Duration() <= CorrelationWindow) .Select(failure => failure.Username) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); // A shared NAT address can produce concurrent failures; do not guess between accounts. return accounts.Count == 1 ? iisFailure with { Username = accounts[0] } : iisFailure; } private static bool IsUnresolvedExchangeIisFailure(AttackEvent attack) { return string.Equals(attack.Source, "IIS W3C", StringComparison.OrdinalIgnoreCase) && (string.Equals(attack.Username, "[not logged]", StringComparison.OrdinalIgnoreCase) || string.Equals(attack.Username, "[unknown]", StringComparison.OrdinalIgnoreCase)); } private static bool IsNetworkSecurityFailure(AttackEvent attack) { return attack.InstanceId == 4625 && string.Equals(attack.Source, "Security", StringComparison.OrdinalIgnoreCase) && string.Equals(attack.Service, "Network", StringComparison.OrdinalIgnoreCase) && string.Equals(attack.Target, "Windows login", StringComparison.OrdinalIgnoreCase); } private static bool HasRecordedAccount(AttackEvent attack) { return !string.IsNullOrWhiteSpace(attack.Username) && !string.Equals(attack.Username, "[unknown]", StringComparison.OrdinalIgnoreCase) && !string.Equals(attack.Username, "[not logged]", StringComparison.OrdinalIgnoreCase); } }