diff --git a/config/ocsentinel-settings.example.json b/config/ocsentinel-settings.example.json index c2c8d97..c22ba2e 100644 --- a/config/ocsentinel-settings.example.json +++ b/config/ocsentinel-settings.example.json @@ -28,6 +28,10 @@ "C:\\inetpub\\logs\\LogFiles", "D:\\inetpub\\logs\\LogFiles" ], + "iisLogRoots": [ + "C:\\inetpub\\logs\\LogFiles", + "D:\\inetpub\\logs\\LogFiles" + ], "fileZillaRoots": [ "C:\\Program Files (x86)\\FileZilla Server\\Logs", "D:\\Program Files (x86)\\FileZilla Server\\Logs" diff --git a/src/OCSentinelCli/AttackScanner.cs b/src/OCSentinelCli/AttackScanner.cs index 6aebfe1..06256bc 100644 --- a/src/OCSentinelCli/AttackScanner.cs +++ b/src/OCSentinelCli/AttackScanner.cs @@ -16,6 +16,12 @@ internal sealed class AttackScanner @"D:\inetpub\logs\LogFiles" ]; + private static readonly string[] DefaultIisLogRoots = + [ + @"C:\inetpub\logs\LogFiles", + @"D:\inetpub\logs\LogFiles" + ]; + private static readonly string[] DefaultFileZillaRoots = [ @"C:\Program Files (x86)\FileZilla Server\Logs", @@ -33,6 +39,7 @@ internal sealed class AttackScanner ScanWindowsLogons(attacks, errors, since); ScanSqlLogons(attacks, errors, since); ScanExchangeLogons(attacks, errors, since); + ScanExchangeIisLogons(attacks, errors, since, configuration); ScanIisFtpLogs(attacks, errors, since, configuration); ScanFileZillaLogs(attacks, errors, since, configuration); RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors); @@ -225,6 +232,40 @@ internal sealed class AttackScanner }); } + private static void ScanExchangeIisLogons(List attacks, List errors, DateTimeOffset since, ScannerConfiguration configuration) + { + IEnumerable roots = configuration.IisLogRoots.Count > 0 ? configuration.IisLogRoots : DefaultIisLogRoots; + foreach (string root in roots) + { + try + { + if (!Directory.Exists(root)) + { + continue; + } + + foreach (string directory in Directory.GetDirectories(root, "W3SVC*")) + { + foreach (string file in Directory.GetFiles(directory, "*.log").Where(path => File.GetLastWriteTime(path) >= since.LocalDateTime.Date)) + { + try + { + attacks.AddRange(ExchangeIisLogParser.ParseLines(File.ReadLines(file), since)); + } + catch (Exception exception) + { + errors.Add($"Exchange IIS log parse failed for {file}: {exception.Message}"); + } + } + } + } + catch (Exception exception) + { + errors.Add($"Exchange IIS log scan failed for {root}: {exception.Message}"); + } + } + } + private static void ScanIisFtpLogs(List attacks, List errors, DateTimeOffset since, ScannerConfiguration configuration) { IEnumerable roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots; diff --git a/src/OCSentinelCli/Configuration.cs b/src/OCSentinelCli/Configuration.cs index f55c193..68c43ce 100644 --- a/src/OCSentinelCli/Configuration.cs +++ b/src/OCSentinelCli/Configuration.cs @@ -56,6 +56,8 @@ internal sealed record ScannerConfiguration public List FtpRoots { get; init; } = []; + public List IisLogRoots { get; init; } = []; + public List FileZillaRoots { get; init; } = []; public List ExcludedIps { get; init; } = []; diff --git a/src/OCSentinelCli/ExchangeIisLogParser.cs b/src/OCSentinelCli/ExchangeIisLogParser.cs new file mode 100644 index 0000000..b1567ee --- /dev/null +++ b/src/OCSentinelCli/ExchangeIisLogParser.cs @@ -0,0 +1,97 @@ +using System.Globalization; + +namespace OCSentinelCli; + +internal static class ExchangeIisLogParser +{ + internal static IEnumerable ParseLines(IEnumerable lines, DateTimeOffset since) + { + Dictionary? 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 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; + } +} diff --git a/src/OCSentinelCli/Models.cs b/src/OCSentinelCli/Models.cs index 425b086..5b4220f 100644 --- a/src/OCSentinelCli/Models.cs +++ b/src/OCSentinelCli/Models.cs @@ -13,6 +13,12 @@ internal sealed record AttackEvent public string Username { get; init; } = string.Empty; public string Source { get; init; } = string.Empty; + + public string Service { get; init; } = string.Empty; + + public int? DestinationPort { get; init; } + + public string Endpoint { get; init; } = string.Empty; } internal sealed record AggregatedAttack @@ -33,6 +39,12 @@ internal sealed record AggregatedAttack public List Sources { get; init; } = []; + public List Services { get; init; } = []; + + public List DestinationPorts { get; init; } = []; + + public List Endpoints { get; init; } = []; + public static AggregatedAttack FromGroup(IGrouping group) { List ordered = group.OrderBy(static attack => attack.Timestamp).ToList(); @@ -48,7 +60,10 @@ internal sealed record AggregatedAttack RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen), Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), Usernames = ordered.Select(static attack => attack.Username).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), - Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList() + Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), + Services = ordered.Select(static attack => attack.Service).Where(static service => !string.IsNullOrWhiteSpace(service)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), + DestinationPorts = ordered.Select(static attack => attack.DestinationPort).Where(static port => port.HasValue).Select(static port => port!.Value).Distinct().Order().ToList(), + Endpoints = ordered.Select(static attack => attack.Endpoint).Where(static endpoint => !string.IsNullOrWhiteSpace(endpoint)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList() }; } diff --git a/src/OCSentinelCli/OCSentinelCli.csproj b/src/OCSentinelCli/OCSentinelCli.csproj index 7e7e2da..27cc7a3 100644 --- a/src/OCSentinelCli/OCSentinelCli.csproj +++ b/src/OCSentinelCli/OCSentinelCli.csproj @@ -9,10 +9,10 @@ OCSentinelCli OfficeCom Sentinel OfficeCom - 1.5.0-beta.5 + 1.5.0-beta.6 1.5.0.0 1.5.0.0 - 1.5.0-beta.5 + 1.5.0-beta.6 diff --git a/tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs b/tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs new file mode 100644 index 0000000..c3c0d04 --- /dev/null +++ b/tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs @@ -0,0 +1,42 @@ +using OCSentinelCli; +using Xunit; + +namespace OCSentinelCli.Tests; + +public sealed class ExchangeIisLogParserTests +{ + [Fact] + public void ParsesFailedOwaLoginWithActualIisPort() + { + string[] lines = + [ + "#Fields: date time s-ip cs-method cs-uri-stem cs-username c-ip s-port sc-status", + "2026-08-02 04:15:00 10.0.0.10 POST /owa/auth.owa - 203.0.113.20 443 401" + ]; + + AttackEvent attack = Assert.Single(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero))); + + Assert.Equal("Exchange OWA login", attack.Target); + Assert.Equal("OWA", attack.Service); + Assert.Equal(443, attack.DestinationPort); + Assert.Equal("/owa/auth.owa", attack.Endpoint); + Assert.Equal("203.0.113.20", attack.SourceIp); + } + + [Fact] + public void ParsesMapiAndIgnoresSuccessfulRequests() + { + string[] lines = + [ + "#Fields: date time cs-uri-stem cs-username c-ip s-port sc-status", + "2026-08-02 04:15:00 /mapi/emsmdb/ user@example.test 198.51.100.8 444 403", + "2026-08-02 04:16:00 /ecp/ user@example.test 198.51.100.9 443 200" + ]; + + AttackEvent attack = Assert.Single(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero))); + + Assert.Equal("MAPI/HTTP", attack.Service); + Assert.Equal(444, attack.DestinationPort); + Assert.Equal("user@example.test", attack.Username); + } +}