diff --git a/src/OCSentinelCli/AttackScanner.cs b/src/OCSentinelCli/AttackScanner.cs index 48d6044..58a4a08 100644 --- a/src/OCSentinelCli/AttackScanner.cs +++ b/src/OCSentinelCli/AttackScanner.cs @@ -42,6 +42,7 @@ internal sealed class AttackScanner ScanExchangeIisLogons(attacks, errors, since, configuration); ScanIisFtpLogs(attacks, errors, since, configuration); ScanFileZillaLogs(attacks, errors, since, configuration); + attacks = ExchangeIisAccountCorrelator.Enrich(attacks); RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors); if (configuration.ExcludedIps.Count > 0) @@ -181,6 +182,7 @@ internal sealed class AttackScanner Target = "Windows login", Username = ReadProperty(eventRecord, 5, "[unknown]"), Source = "Security", + Service = ReadProperty(eventRecord, 10) == "3" ? "Network" : string.Empty, InstanceId = 4625 }); }); diff --git a/src/OCSentinelCli/ExchangeIisAccountCorrelator.cs b/src/OCSentinelCli/ExchangeIisAccountCorrelator.cs new file mode 100644 index 0000000..128965f --- /dev/null +++ b/src/OCSentinelCli/ExchangeIisAccountCorrelator.cs @@ -0,0 +1,53 @@ +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); + } +} diff --git a/tests/OCSentinelCli.Tests/ExchangeIisAccountCorrelatorTests.cs b/tests/OCSentinelCli.Tests/ExchangeIisAccountCorrelatorTests.cs new file mode 100644 index 0000000..562d124 --- /dev/null +++ b/tests/OCSentinelCli.Tests/ExchangeIisAccountCorrelatorTests.cs @@ -0,0 +1,58 @@ +using OCSentinelCli; +using Xunit; + +namespace OCSentinelCli.Tests; + +public sealed class ExchangeIisAccountCorrelatorTests +{ + [Fact] + public void EnrichesAnIisFailureFromOneMatchingSecurityFailure() + { + DateTimeOffset timestamp = new(2026, 8, 5, 4, 0, 0, TimeSpan.Zero); + List events = + [ + IisFailure(timestamp), + SecurityFailure(timestamp.AddSeconds(20), "user@example.test") + ]; + + AttackEvent enriched = Assert.Single(ExchangeIisAccountCorrelator.Enrich(events), attack => attack.Source == "IIS W3C"); + + Assert.Equal("user@example.test", enriched.Username); + } + + [Fact] + public void KeepsIisAccountUnresolvedWhenMultipleAccountsMatch() + { + DateTimeOffset timestamp = new(2026, 8, 5, 4, 0, 0, TimeSpan.Zero); + List events = + [ + IisFailure(timestamp), + SecurityFailure(timestamp.AddSeconds(20), "first@example.test"), + SecurityFailure(timestamp.AddSeconds(30), "second@example.test") + ]; + + AttackEvent unresolved = Assert.Single(ExchangeIisAccountCorrelator.Enrich(events), attack => attack.Source == "IIS W3C"); + + Assert.Equal("[not logged]", unresolved.Username); + } + + private static AttackEvent IisFailure(DateTimeOffset timestamp) => new() + { + Timestamp = timestamp, + SourceIp = "198.51.100.8", + Username = "[not logged]", + Source = "IIS W3C", + Target = "Exchange ActiveSync login" + }; + + private static AttackEvent SecurityFailure(DateTimeOffset timestamp, string username) => new() + { + Timestamp = timestamp, + SourceIp = "198.51.100.8", + Username = username, + Source = "Security", + Service = "Network", + Target = "Windows login", + InstanceId = 4625 + }; +}