Compare commits
5 Commits
v1.5.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e10a71820 | ||
|
|
7f4cd291e7 | ||
|
|
705b543e5a | ||
|
|
2ca50a4ee9 | ||
|
|
d1f78a38fd |
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"warningEventThreshold": 10,
|
"warningEventThreshold": 10,
|
||||||
|
"maxReportedEvents": 1000,
|
||||||
"criticalEventThreshold": 30,
|
"criticalEventThreshold": 30,
|
||||||
"warningUniqueIpThreshold": 5,
|
"warningUniqueIpThreshold": 5,
|
||||||
"criticalUniqueIpThreshold": 12,
|
"criticalUniqueIpThreshold": 12,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"channel": "beta",
|
"channel": "beta",
|
||||||
"version": "1.5.0-beta.5",
|
"version": "1.5.0-beta.7",
|
||||||
"publishedAtUtc": "2026-07-31T23:44:23.3751992Z",
|
"publishedAtUtc": "2026-08-02T23:20:59.4454466Z",
|
||||||
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.5/OCSentinelClient-win-x64.zip",
|
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.7/OCSentinelClient-win-x64.zip",
|
||||||
"sha256": "d1ac7e9cc9e0da5ea9cbff5e2be5795a5e691c89bbe329df265254c1b4898dbf",
|
"sha256": "b40da61499d0d9b5f95922f84aebbb3b6de6bf8a2ba2196873ecf2fab1789c0d",
|
||||||
"minUpdaterVersion": "1.0.0"
|
"minUpdaterVersion": "1.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ internal sealed class AttackScanner
|
|||||||
ScanExchangeIisLogons(attacks, errors, since, configuration);
|
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);
|
||||||
|
attacks = ExchangeIisAccountCorrelator.Enrich(attacks);
|
||||||
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
|
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
|
||||||
|
|
||||||
if (configuration.ExcludedIps.Count > 0)
|
if (configuration.ExcludedIps.Count > 0)
|
||||||
@@ -53,6 +54,14 @@ internal sealed class AttackScanner
|
|||||||
|
|
||||||
attacks.Sort(static (left, right) => left.Timestamp.CompareTo(right.Timestamp));
|
attacks.Sort(static (left, right) => left.Timestamp.CompareTo(right.Timestamp));
|
||||||
|
|
||||||
|
int totalEventCount = attacks.Count;
|
||||||
|
int maxReportedEvents = Math.Clamp(configuration.MaxReportedEvents, 100, 5000);
|
||||||
|
List<AttackEvent> reportedEvents = attacks
|
||||||
|
.OrderByDescending(static attack => attack.Timestamp)
|
||||||
|
.Take(maxReportedEvents)
|
||||||
|
.OrderBy(static attack => attack.Timestamp)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
List<AggregatedAttack> topSources = attacks
|
List<AggregatedAttack> topSources = attacks
|
||||||
.GroupBy(static attack => attack.SourceIp)
|
.GroupBy(static attack => attack.SourceIp)
|
||||||
.Select(group => AggregatedAttack.FromGroup(group))
|
.Select(group => AggregatedAttack.FromGroup(group))
|
||||||
@@ -81,7 +90,9 @@ internal sealed class AttackScanner
|
|||||||
GeneratedAtUtc = generatedAtUtc,
|
GeneratedAtUtc = generatedAtUtc,
|
||||||
ClientVersion = BuildMetadata.Version,
|
ClientVersion = BuildMetadata.Version,
|
||||||
LookbackDays = options.LookbackDays,
|
LookbackDays = options.LookbackDays,
|
||||||
TotalEvents = attacks.Count,
|
TotalEvents = totalEventCount,
|
||||||
|
ReportedEventCount = reportedEvents.Count,
|
||||||
|
EventsTruncated = reportedEvents.Count < totalEventCount,
|
||||||
UniqueIpCount = uniqueIpCount,
|
UniqueIpCount = uniqueIpCount,
|
||||||
AlertState = correlationAssessment.FinalAlertState,
|
AlertState = correlationAssessment.FinalAlertState,
|
||||||
AlertReason = correlationAssessment.CorrelationReason == "No CVE correlation applied." ? baseAlertReason : correlationAssessment.CorrelationReason,
|
AlertReason = correlationAssessment.CorrelationReason == "No CVE correlation applied." ? baseAlertReason : correlationAssessment.CorrelationReason,
|
||||||
@@ -95,7 +106,7 @@ internal sealed class AttackScanner
|
|||||||
FinishedAtUtc = generatedAtUtc,
|
FinishedAtUtc = generatedAtUtc,
|
||||||
UploadAttempted = false
|
UploadAttempted = false
|
||||||
},
|
},
|
||||||
Events = attacks,
|
Events = reportedEvents,
|
||||||
TopSources = topSources,
|
TopSources = topSources,
|
||||||
Errors = errors
|
Errors = errors
|
||||||
};
|
};
|
||||||
@@ -171,6 +182,7 @@ internal sealed class AttackScanner
|
|||||||
Target = "Windows login",
|
Target = "Windows login",
|
||||||
Username = ReadProperty(eventRecord, 5, "[unknown]"),
|
Username = ReadProperty(eventRecord, 5, "[unknown]"),
|
||||||
Source = "Security",
|
Source = "Security",
|
||||||
|
Service = ReadProperty(eventRecord, 10) == "3" ? "Network" : string.Empty,
|
||||||
InstanceId = 4625
|
InstanceId = 4625
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ internal sealed record ScannerConfiguration
|
|||||||
{
|
{
|
||||||
public int WarningEventThreshold { get; init; } = 10;
|
public int WarningEventThreshold { get; init; } = 10;
|
||||||
|
|
||||||
|
public int MaxReportedEvents { get; init; } = 1000;
|
||||||
|
|
||||||
public int CriticalEventThreshold { get; init; } = 30;
|
public int CriticalEventThreshold { get; init; } = 30;
|
||||||
|
|
||||||
public int WarningUniqueIpThreshold { get; init; } = 5;
|
public int WarningUniqueIpThreshold { get; init; } = 5;
|
||||||
|
|||||||
53
src/OCSentinelCli/ExchangeIisAccountCorrelator.cs
Normal file
53
src/OCSentinelCli/ExchangeIisAccountCorrelator.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
|
internal static class ExchangeIisAccountCorrelator
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan CorrelationWindow = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
internal static List<AttackEvent> Enrich(IReadOnlyList<AttackEvent> attacks)
|
||||||
|
{
|
||||||
|
List<AttackEvent> 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<AttackEvent> securityFailures)
|
||||||
|
{
|
||||||
|
List<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,13 @@ namespace OCSentinelCli;
|
|||||||
|
|
||||||
internal static class ExchangeIisLogParser
|
internal static class ExchangeIisLogParser
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan AuthenticationCompletionWindow = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
internal static IEnumerable<AttackEvent> ParseLines(IEnumerable<string> lines, DateTimeOffset since)
|
internal static IEnumerable<AttackEvent> ParseLines(IEnumerable<string> lines, DateTimeOffset since)
|
||||||
{
|
{
|
||||||
Dictionary<string, int>? fields = null;
|
Dictionary<string, int>? fields = null;
|
||||||
|
var pendingFailures = new List<ExchangeIisObservation>();
|
||||||
|
var attacks = new List<AttackEvent>();
|
||||||
|
|
||||||
foreach (string line in lines)
|
foreach (string line in lines)
|
||||||
{
|
{
|
||||||
@@ -18,51 +22,77 @@ internal static class ExchangeIisLogParser
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || fields is null)
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || fields is null || !TryParseObservation(fields, line, since, out ExchangeIisObservation? observation))
|
||||||
{
|
{
|
||||||
continue;
|
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<ExchangeIisObservation> pendingFailures, List<AttackEvent> 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<string, int> fields, string line, DateTimeOffset since, out ExchangeIisObservation? observation)
|
||||||
|
{
|
||||||
|
observation = null;
|
||||||
string[] values = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
string[] values = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (!TryValue(fields, values, "date", out string date) || !TryValue(fields, values, "time", out string time)
|
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, "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))
|
|| !TryValue(fields, values, "sc-status", out string statusText) || !int.TryParse(statusText, out int status)
|
||||||
|
|| !TryClassify(path, out string service))
|
||||||
{
|
{
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status is not 401 and not 403 || !TryClassify(path, out string service))
|
if ((status < 200 || status >= 400) && status is not 401 and not 403)
|
||||||
{
|
{
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc))
|
if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc))
|
||||||
{
|
{
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTimeOffset timestamp = new(timestampUtc, TimeSpan.Zero);
|
DateTimeOffset timestamp = new(timestampUtc, TimeSpan.Zero);
|
||||||
if (timestamp < since || string.IsNullOrWhiteSpace(sourceIp) || sourceIp == "-")
|
if (timestamp < since || string.IsNullOrWhiteSpace(sourceIp) || sourceIp == "-")
|
||||||
{
|
{
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int? destinationPort = TryValue(fields, values, "s-port", out string portText) && int.TryParse(portText, out int parsedPort) ? parsedPort : null;
|
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 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;
|
||||||
|
|
||||||
yield return new AttackEvent
|
observation = new ExchangeIisObservation(timestamp, sourceIp, path, service, destinationPort, username, userAgent, status, substatus);
|
||||||
{
|
return true;
|
||||||
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)
|
private static bool TryValue(IReadOnlyDictionary<string, int> fields, IReadOnlyList<string> values, string field, out string value)
|
||||||
@@ -94,4 +124,45 @@ internal static class ExchangeIisLogParser
|
|||||||
};
|
};
|
||||||
return service.Length > 0;
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ internal sealed record ScanResult
|
|||||||
|
|
||||||
public int TotalEvents { get; init; }
|
public int TotalEvents { get; init; }
|
||||||
|
|
||||||
|
public int ReportedEventCount { get; init; }
|
||||||
|
|
||||||
|
public bool EventsTruncated { get; init; }
|
||||||
|
|
||||||
public int UniqueIpCount { get; init; }
|
public int UniqueIpCount { get; init; }
|
||||||
|
|
||||||
public string AlertState { get; init; } = "ok";
|
public string AlertState { get; init; } = "ok";
|
||||||
|
|||||||
@@ -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.6</Version>
|
<Version>1.5.0-beta.7</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.6</InformationalVersion>
|
<InformationalVersion>1.5.0-beta.7</InformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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<AttackEvent> 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<AttackEvent> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -39,4 +39,29 @@ public sealed class ExchangeIisLogParserTests
|
|||||||
Assert.Equal(444, attack.DestinationPort);
|
Assert.Equal(444, attack.DestinationPort);
|
||||||
Assert.Equal("user@example.test", attack.Username);
|
Assert.Equal("user@example.test", attack.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IgnoresNormalIisAuthenticationHandshake()
|
||||||
|
{
|
||||||
|
string[] lines =
|
||||||
|
[
|
||||||
|
"#Fields: date time cs-uri-stem cs-username c-ip s-port cs(User-Agent) sc-status sc-substatus",
|
||||||
|
"2026-08-02 04:15:00 /mapi/emsmdb/ - 198.51.100.8 443 Outlook 401 1",
|
||||||
|
"2026-08-02 04:15:01 /mapi/emsmdb/ user@example.test 198.51.100.8 443 Outlook 200 0"
|
||||||
|
];
|
||||||
|
|
||||||
|
Assert.Empty(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IgnoresNonCredentialIis401Substatus()
|
||||||
|
{
|
||||||
|
string[] lines =
|
||||||
|
[
|
||||||
|
"#Fields: date time cs-uri-stem cs-username c-ip s-port sc-status sc-substatus",
|
||||||
|
"2026-08-02 04:15:00 /ews/Exchange.asmx - 198.51.100.8 443 401 0"
|
||||||
|
];
|
||||||
|
|
||||||
|
Assert.Empty(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user