Correlate failed login activity before alerting
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s

This commit is contained in:
OfficeCom Codex
2026-07-29 00:50:14 +02:00
parent 66dcfe09b6
commit 64841d36e7
4 changed files with 104 additions and 26 deletions

View File

@@ -1,8 +1,13 @@
{ {
"warningEventThreshold": 1, "warningEventThreshold": 10,
"criticalEventThreshold": 20, "criticalEventThreshold": 30,
"warningUniqueIpThreshold": 1, "warningUniqueIpThreshold": 5,
"criticalUniqueIpThreshold": 10, "criticalUniqueIpThreshold": 12,
"loginBurstWindowMinutes": 15,
"warningLoginBurstCount": 5,
"criticalLoginBurstCount": 20,
"warningSprayAccountCount": 5,
"criticalSprayAccountCount": 10,
"correlationWarningCveThreshold": 1, "correlationWarningCveThreshold": 1,
"correlationCriticalCveThreshold": 1, "correlationCriticalCveThreshold": 1,
"ftpRoots": [ "ftpRoots": [

View File

@@ -53,8 +53,9 @@ internal sealed class AttackScanner
.ToList(); .ToList();
int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count(); int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count();
string baseAlertState = GetAlertState(attacks.Count, uniqueIpCount, configuration); AlertAssessment baseAssessment = AssessAttackActivity(attacks, uniqueIpCount, configuration);
string baseAlertReason = GetAlertReason(attacks.Count, uniqueIpCount, configuration, baseAlertState); string baseAlertState = baseAssessment.State;
string baseAlertReason = baseAssessment.Reason;
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath) VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
? VulnerabilityCorrelationSummary.Empty() ? VulnerabilityCorrelationSummary.Empty()
: VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors); : VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors);
@@ -469,28 +470,90 @@ internal sealed class AttackScanner
return IPAddress.TryParse(input, out _); return IPAddress.TryParse(input, out _);
} }
private static string GetAlertState(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration) private static AlertAssessment AssessAttackActivity(IReadOnlyList<AttackEvent> attacks, int uniqueIpCount, ScannerConfiguration configuration)
{ {
if (totalEvents >= configuration.CriticalEventThreshold || uniqueIpCount >= configuration.CriticalUniqueIpThreshold) if (attacks.Count == 0)
{ {
return "critical"; return new AlertAssessment("ok", "No failed login activity observed.");
} }
if (totalEvents >= configuration.WarningEventThreshold || uniqueIpCount >= configuration.WarningUniqueIpThreshold) TimeSpan window = TimeSpan.FromMinutes(configuration.LoginBurstWindowMinutes);
int largestBurst = attacks
.GroupBy(attack => (attack.SourceIp, attack.Username, attack.Target))
.Select(group => GetPeakEventCount(group.OrderBy(attack => attack.Timestamp).ToList(), window))
.DefaultIfEmpty(0)
.Max();
int largestSpray = attacks
.GroupBy(attack => attack.SourceIp)
.Select(group => GetPeakDistinctAccountCount(group.OrderBy(attack => attack.Timestamp).ToList(), window))
.DefaultIfEmpty(0)
.Max();
if (largestBurst >= configuration.CriticalLoginBurstCount || largestSpray >= configuration.CriticalSprayAccountCount)
{ {
return "warning"; return new AlertAssessment("critical", $"High-confidence login attack pattern: burst={largestBurst}, sprayed accounts={largestSpray}, window={configuration.LoginBurstWindowMinutes}m.");
} }
return "ok"; if (largestBurst >= configuration.WarningLoginBurstCount || largestSpray >= configuration.WarningSprayAccountCount)
{
return new AlertAssessment("warning", $"Suspicious login pattern: burst={largestBurst}, sprayed accounts={largestSpray}, window={configuration.LoginBurstWindowMinutes}m.");
} }
private static string GetAlertReason(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration, string alertState) int criticalEventThreshold = Math.Max(configuration.CriticalEventThreshold, configuration.CriticalLoginBurstCount);
int criticalIpThreshold = Math.Max(configuration.CriticalUniqueIpThreshold, configuration.CriticalSprayAccountCount);
int warningEventThreshold = Math.Max(configuration.WarningEventThreshold, configuration.WarningLoginBurstCount * 2);
int warningIpThreshold = Math.Max(configuration.WarningUniqueIpThreshold, configuration.WarningSprayAccountCount);
if (attacks.Count >= criticalEventThreshold || uniqueIpCount >= criticalIpThreshold)
{ {
return alertState switch return new AlertAssessment("critical", $"Critical volume threshold reached: events={attacks.Count}, unique IPs={uniqueIpCount}.");
}
if (attacks.Count >= warningEventThreshold || uniqueIpCount >= warningIpThreshold)
{ {
"critical" => $"Critical threshold reached. Events={totalEvents}/{configuration.CriticalEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.CriticalUniqueIpThreshold}.", return new AlertAssessment("warning", $"Elevated failed-login volume: events={attacks.Count}, unique IPs={uniqueIpCount}.");
"warning" => $"Warning threshold reached. Events={totalEvents}/{configuration.WarningEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.WarningUniqueIpThreshold}.",
_ => "No thresholds exceeded."
};
} }
return new AlertAssessment("ok", $"Low-volume login errors observed: events={attacks.Count}, unique IPs={uniqueIpCount}; no burst or password-spraying pattern detected.");
}
private static int GetPeakEventCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
{
int start = 0;
int peak = 0;
for (int end = 0; end < events.Count; end++)
{
while (events[end].Timestamp - events[start].Timestamp > window)
{
start++;
}
peak = Math.Max(peak, end - start + 1);
}
return peak;
}
private static int GetPeakDistinctAccountCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
{
var accounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
int start = 0;
int peak = 0;
for (int end = 0; end < events.Count; end++)
{
accounts[events[end].Username] = accounts.GetValueOrDefault(events[end].Username) + 1;
while (events[end].Timestamp - events[start].Timestamp > window)
{
string account = events[start].Username;
accounts[account]--;
if (accounts[account] == 0)
{
accounts.Remove(account);
}
start++;
}
peak = Math.Max(peak, accounts.Count);
}
return peak;
}
private sealed record AlertAssessment(string State, string Reason);
} }

View File

@@ -4,13 +4,23 @@ namespace OCSentinelCli;
internal sealed record ScannerConfiguration internal sealed record ScannerConfiguration
{ {
public int WarningEventThreshold { get; init; } = 1; public int WarningEventThreshold { get; init; } = 10;
public int CriticalEventThreshold { get; init; } = 20; public int CriticalEventThreshold { get; init; } = 30;
public int WarningUniqueIpThreshold { get; init; } = 1; public int WarningUniqueIpThreshold { get; init; } = 5;
public int CriticalUniqueIpThreshold { get; init; } = 10; public int CriticalUniqueIpThreshold { get; init; } = 12;
public int LoginBurstWindowMinutes { get; init; } = 15;
public int WarningLoginBurstCount { get; init; } = 5;
public int CriticalLoginBurstCount { get; init; } = 20;
public int WarningSprayAccountCount { get; init; } = 5;
public int CriticalSprayAccountCount { get; init; } = 10;
public int CorrelationWarningCveThreshold { get; init; } = 1; public int CorrelationWarningCveThreshold { get; init; } = 1;

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.3.7</Version> <Version>1.4.0</Version>
<AssemblyVersion>1.3.7.0</AssemblyVersion> <AssemblyVersion>1.4.0.0</AssemblyVersion>
<FileVersion>1.3.7.0</FileVersion> <FileVersion>1.4.0.0</FileVersion>
<InformationalVersion>1.3.7</InformationalVersion> <InformationalVersion>1.4.0</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>