Расширить детектор подозрительной активности

Правило distinct_logins_per_ip (перебор логинов с одного IP) в SuspiciousActivityService; real-time учёт SuspiciousActivityReporter: метрика deal.security.suspicious{kind} и warn-лог на 429/блокировке входа. Закрывает BL-SUSPICIOUS.
This commit is contained in:
Rustam Khalimov
2026-09-11 18:15:20 +03:00
parent e62dbfafba
commit f4737b9797
13 changed files with 447 additions and 5 deletions
@@ -39,6 +39,11 @@ public sealed class SuspiciousActivityService
/// </summary>
public const int AuthFailuresPerTenantThreshold = 20;
/// <summary>
/// Порог числа разных логинов в неудачных входах с одного IP за окно.
/// </summary>
public const int DistinctLoginsPerIpThreshold = 5;
// Кратность порога, с которой уровень поднимается до high (2× порог).
private const int HighSeverityMultiplier = 2;
@@ -62,6 +67,11 @@ public sealed class SuspiciousActivityService
/// </summary>
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
/// <summary>
/// Правило: перебор разных логинов с одного IP.
/// </summary>
public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip";
/// <summary>
/// Уровень находки
/// </summary>
@@ -126,6 +136,7 @@ public sealed class SuspiciousActivityService
AddFailedLoginsPerLogin(records, findings);
AddManyIpsPerActor(records, findings);
AddAuthFailuresPerTenant(records, findings);
AddDistinctLoginsPerIp(records, findings);
findings.Sort(static (left, right) =>
{
@@ -255,6 +266,48 @@ public sealed class SuspiciousActivityService
}
}
// Правило «перебор разных логинов с одного IP» (credential stuffing).
// records: Записи окна.
// findings: Накопитель находок.
private static void AddDistinctLoginsPerIp(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
{
var loginsByIp = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (AuditRecordDto record in records)
{
if (!record.IsFailedLogin() || string.IsNullOrWhiteSpace(record.Ip))
{
continue;
}
string? login = ExtractLogin(record);
if (string.IsNullOrWhiteSpace(login))
{
continue;
}
if (!loginsByIp.TryGetValue(record.Ip, out HashSet<string>? logins))
{
logins = new HashSet<string>(StringComparer.Ordinal);
loginsByIp[record.Ip] = logins;
}
logins.Add(login);
}
foreach ((string ip, HashSet<string> logins) in loginsByIp)
{
if (logins.Count >= DistinctLoginsPerIpThreshold)
{
findings.Add(new SuspiciousFindingDto(
KindDistinctLoginsPerIp,
SeverityFor(logins.Count, DistinctLoginsPerIpThreshold),
ip,
logins.Count,
$"Разных логинов с IP {ip}: {logins.Count} за окно (перебор)"));
}
}
}
// Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются).
// records: Записи окна.
// predicate: Отбор записей правила.