Correlate failed login activity before alerting
This commit is contained in:
@@ -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": [
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 new AlertAssessment("critical", $"Critical volume threshold reached: events={attacks.Count}, unique IPs={uniqueIpCount}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attacks.Count >= warningEventThreshold || uniqueIpCount >= warningIpThreshold)
|
||||||
|
{
|
||||||
|
return new AlertAssessment("warning", $"Elevated failed-login volume: events={attacks.Count}, unique IPs={uniqueIpCount}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AlertAssessment("ok", $"Low-volume login errors observed: events={attacks.Count}, unique IPs={uniqueIpCount}; no burst or password-spraying pattern detected.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetAlertReason(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration, string alertState)
|
private static int GetPeakEventCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
|
||||||
{
|
{
|
||||||
return alertState switch
|
int start = 0;
|
||||||
|
int peak = 0;
|
||||||
|
for (int end = 0; end < events.Count; end++)
|
||||||
{
|
{
|
||||||
"critical" => $"Critical threshold reached. Events={totalEvents}/{configuration.CriticalEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.CriticalUniqueIpThreshold}.",
|
while (events[end].Timestamp - events[start].Timestamp > window)
|
||||||
"warning" => $"Warning threshold reached. Events={totalEvents}/{configuration.WarningEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.WarningUniqueIpThreshold}.",
|
{
|
||||||
_ => "No thresholds exceeded."
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user