From 64841d36e7a7c9fded26f9dc2943bd70d12be320 Mon Sep 17 00:00:00 2001 From: OfficeCom Codex Date: Wed, 29 Jul 2026 00:50:14 +0200 Subject: [PATCH] Correlate failed login activity before alerting --- config/ocsentinel-settings.example.json | 13 ++-- src/OCSentinelCli/AttackScanner.cs | 91 +++++++++++++++++++++---- src/OCSentinelCli/Configuration.cs | 18 +++-- src/OCSentinelCli/OCSentinelCli.csproj | 8 +-- 4 files changed, 104 insertions(+), 26 deletions(-) diff --git a/config/ocsentinel-settings.example.json b/config/ocsentinel-settings.example.json index 07aac12..ca244db 100644 --- a/config/ocsentinel-settings.example.json +++ b/config/ocsentinel-settings.example.json @@ -1,8 +1,13 @@ { - "warningEventThreshold": 1, - "criticalEventThreshold": 20, - "warningUniqueIpThreshold": 1, - "criticalUniqueIpThreshold": 10, + "warningEventThreshold": 10, + "criticalEventThreshold": 30, + "warningUniqueIpThreshold": 5, + "criticalUniqueIpThreshold": 12, + "loginBurstWindowMinutes": 15, + "warningLoginBurstCount": 5, + "criticalLoginBurstCount": 20, + "warningSprayAccountCount": 5, + "criticalSprayAccountCount": 10, "correlationWarningCveThreshold": 1, "correlationCriticalCveThreshold": 1, "ftpRoots": [ diff --git a/src/OCSentinelCli/AttackScanner.cs b/src/OCSentinelCli/AttackScanner.cs index 5613c87..2d4abda 100644 --- a/src/OCSentinelCli/AttackScanner.cs +++ b/src/OCSentinelCli/AttackScanner.cs @@ -53,8 +53,9 @@ internal sealed class AttackScanner .ToList(); int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count(); - string baseAlertState = GetAlertState(attacks.Count, uniqueIpCount, configuration); - string baseAlertReason = GetAlertReason(attacks.Count, uniqueIpCount, configuration, baseAlertState); + AlertAssessment baseAssessment = AssessAttackActivity(attacks, uniqueIpCount, configuration); + string baseAlertState = baseAssessment.State; + string baseAlertReason = baseAssessment.Reason; VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath) ? VulnerabilityCorrelationSummary.Empty() : VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors); @@ -469,28 +470,90 @@ internal sealed class AttackScanner return IPAddress.TryParse(input, out _); } - private static string GetAlertState(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration) + private static AlertAssessment AssessAttackActivity(IReadOnlyList 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 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}.", - "warning" => $"Warning threshold reached. Events={totalEvents}/{configuration.WarningEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.WarningUniqueIpThreshold}.", - _ => "No thresholds exceeded." - }; + while (events[end].Timestamp - events[start].Timestamp > window) + { + start++; + } + peak = Math.Max(peak, end - start + 1); + } + return peak; } + + private static int GetPeakDistinctAccountCount(IReadOnlyList events, TimeSpan window) + { + var accounts = new Dictionary(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); } diff --git a/src/OCSentinelCli/Configuration.cs b/src/OCSentinelCli/Configuration.cs index bbaf36f..829d3a7 100644 --- a/src/OCSentinelCli/Configuration.cs +++ b/src/OCSentinelCli/Configuration.cs @@ -4,13 +4,23 @@ namespace OCSentinelCli; 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; diff --git a/src/OCSentinelCli/OCSentinelCli.csproj b/src/OCSentinelCli/OCSentinelCli.csproj index 0cbc088..158c621 100644 --- a/src/OCSentinelCli/OCSentinelCli.csproj +++ b/src/OCSentinelCli/OCSentinelCli.csproj @@ -9,10 +9,10 @@ OCSentinelCli OfficeCom Sentinel OfficeCom - 1.3.7 - 1.3.7.0 - 1.3.7.0 - 1.3.7 + 1.4.0 + 1.4.0.0 + 1.4.0.0 + 1.4.0