Расширить детектор подозрительной активности
Правило distinct_logins_per_ip (перебор логинов с одного IP) в SuspiciousActivityService; real-time учёт SuspiciousActivityReporter: метрика deal.security.suspicious{kind} и warn-лог на 429/блокировке входа. Закрывает BL-SUSPICIOUS.
This commit is contained in:
@@ -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: Отбор записей правила.
|
||||
|
||||
Reference in New Issue
Block a user