Add Exchange IIS service telemetry
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 52s

This commit is contained in:
OfficeCom Codex
2026-08-02 23:50:12 +02:00
parent c73bab139b
commit cc77c45a10
7 changed files with 204 additions and 3 deletions

View File

@@ -28,6 +28,10 @@
"C:\\inetpub\\logs\\LogFiles", "C:\\inetpub\\logs\\LogFiles",
"D:\\inetpub\\logs\\LogFiles" "D:\\inetpub\\logs\\LogFiles"
], ],
"iisLogRoots": [
"C:\\inetpub\\logs\\LogFiles",
"D:\\inetpub\\logs\\LogFiles"
],
"fileZillaRoots": [ "fileZillaRoots": [
"C:\\Program Files (x86)\\FileZilla Server\\Logs", "C:\\Program Files (x86)\\FileZilla Server\\Logs",
"D:\\Program Files (x86)\\FileZilla Server\\Logs" "D:\\Program Files (x86)\\FileZilla Server\\Logs"

View File

@@ -16,6 +16,12 @@ internal sealed class AttackScanner
@"D:\inetpub\logs\LogFiles" @"D:\inetpub\logs\LogFiles"
]; ];
private static readonly string[] DefaultIisLogRoots =
[
@"C:\inetpub\logs\LogFiles",
@"D:\inetpub\logs\LogFiles"
];
private static readonly string[] DefaultFileZillaRoots = private static readonly string[] DefaultFileZillaRoots =
[ [
@"C:\Program Files (x86)\FileZilla Server\Logs", @"C:\Program Files (x86)\FileZilla Server\Logs",
@@ -33,6 +39,7 @@ internal sealed class AttackScanner
ScanWindowsLogons(attacks, errors, since); ScanWindowsLogons(attacks, errors, since);
ScanSqlLogons(attacks, errors, since); ScanSqlLogons(attacks, errors, since);
ScanExchangeLogons(attacks, errors, since); ScanExchangeLogons(attacks, errors, since);
ScanExchangeIisLogons(attacks, errors, since, configuration);
ScanIisFtpLogs(attacks, errors, since, configuration); ScanIisFtpLogs(attacks, errors, since, configuration);
ScanFileZillaLogs(attacks, errors, since, configuration); ScanFileZillaLogs(attacks, errors, since, configuration);
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors); RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
@@ -225,6 +232,40 @@ internal sealed class AttackScanner
}); });
} }
private static void ScanExchangeIisLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
{
IEnumerable<string> 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<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration) private static void ScanIisFtpLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
{ {
IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots; IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots;

View File

@@ -56,6 +56,8 @@ internal sealed record ScannerConfiguration
public List<string> FtpRoots { get; init; } = []; public List<string> FtpRoots { get; init; } = [];
public List<string> IisLogRoots { get; init; } = [];
public List<string> FileZillaRoots { get; init; } = []; public List<string> FileZillaRoots { get; init; } = [];
public List<string> ExcludedIps { get; init; } = []; public List<string> ExcludedIps { get; init; } = [];

View 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;
}
}

View File

@@ -13,6 +13,12 @@ internal sealed record AttackEvent
public string Username { get; init; } = string.Empty; public string Username { get; init; } = string.Empty;
public string Source { 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 internal sealed record AggregatedAttack
@@ -33,6 +39,12 @@ internal sealed record AggregatedAttack
public List<string> Sources { get; init; } = []; public List<string> Sources { get; init; } = [];
public List<string> Services { get; init; } = [];
public List<int> DestinationPorts { get; init; } = [];
public List<string> Endpoints { get; init; } = [];
public static AggregatedAttack FromGroup(IGrouping<string, AttackEvent> group) public static AggregatedAttack FromGroup(IGrouping<string, AttackEvent> group)
{ {
List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList(); List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList();
@@ -48,7 +60,10 @@ internal sealed record AggregatedAttack
RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen), RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen),
Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
Usernames = ordered.Select(static attack => attack.Username).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()
}; };
} }

View File

@@ -9,10 +9,10 @@
<RootNamespace>OCSentinelCli</RootNamespace> <RootNamespace>OCSentinelCli</RootNamespace>
<Product>OfficeCom Sentinel</Product> <Product>OfficeCom Sentinel</Product>
<Company>OfficeCom</Company> <Company>OfficeCom</Company>
<Version>1.5.0-beta.5</Version> <Version>1.5.0-beta.6</Version>
<AssemblyVersion>1.5.0.0</AssemblyVersion> <AssemblyVersion>1.5.0.0</AssemblyVersion>
<FileVersion>1.5.0.0</FileVersion> <FileVersion>1.5.0.0</FileVersion>
<InformationalVersion>1.5.0-beta.5</InformationalVersion> <InformationalVersion>1.5.0-beta.6</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -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);
}
}