Расширить детектор подозрительной активности
Правило distinct_logins_per_ip (перебор логинов с одного IP) в SuspiciousActivityService; real-time учёт SuspiciousActivityReporter: метрика deal.security.suspicious{kind} и warn-лог на 429/блокировке входа. Закрывает BL-SUSPICIOUS.
This commit is contained in:
+1
-1
@@ -45,7 +45,7 @@
|
|||||||
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
|
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
|
||||||
| BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE |
|
| BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE |
|
||||||
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
|
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
|
||||||
| BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG |
|
| BL-SUSPICIOUS | **Сделано (2026-09-11):** детектор `SuspiciousActivityService` расширен правилом `distinct_logins_per_ip` (перебор разных логинов с одного IP, порог `DistinctLoginsPerIpThreshold`); плюс real-time `SuspiciousActivityReporter` — метрика `deal.security.suspicious{kind}` + warn-лог на 429 rate limiter (`rate_limit`) и блокировке входа (`login_blocked`) | ТЗ §10.5, этап 12 | P3 | DONE |
|
||||||
|
|
||||||
## 5. Технический долг (качество/архитектура)
|
## 5. Технический долг (качество/архитектура)
|
||||||
|
|
||||||
|
|||||||
@@ -1387,7 +1387,10 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
|
|||||||
- **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}`
|
- **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}`
|
||||||
(общий `RuntimeDepthsCollector`, без дублей SQL).
|
(общий `RuntimeDepthsCollector`, без дублей SQL).
|
||||||
- **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious`
|
- **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious`
|
||||||
(всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту; пороги — константы).
|
(всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту, перебор разных
|
||||||
|
логинов с одного IP `distinct_logins_per_ip`; пороги — константы). Плюс real-time учёт
|
||||||
|
`SuspiciousActivityReporter`: метрика `deal.security.suspicious{kind}` и предупреждающий лог на 429
|
||||||
|
rate limiter (`rate_limit`) и блокировке входа (`login_blocked`).
|
||||||
- **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде.
|
- **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде.
|
||||||
|
|
||||||
Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API —
|
Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API —
|
||||||
|
|||||||
@@ -46,12 +46,15 @@ public static class AuthEndpoints
|
|||||||
IOptions<CookieOptions> cookieOptions,
|
IOptions<CookieOptions> cookieOptions,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
LoginAttemptGuard loginAttemptGuard)
|
LoginAttemptGuard loginAttemptGuard,
|
||||||
|
SuspiciousActivityReporter suspicious)
|
||||||
{
|
{
|
||||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||||
|
|
||||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||||
{
|
{
|
||||||
|
// Событие подозрительной активности: серия неудачных попыток входа → блокировка ключа.
|
||||||
|
suspicious.Report(SuspiciousActivityReporter.LoginBlockedKind, ClientIp(context));
|
||||||
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,13 @@ public static class RateLimitPolicies
|
|||||||
|
|
||||||
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
|
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
HttpContext http = context.HttpContext;
|
||||||
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
// Событие подозрительной активности: сработал rate limiter (актор — тенант либо IP анонима).
|
||||||
|
SuspiciousActivityReporter? reporter = http.RequestServices.GetService<SuspiciousActivityReporter>();
|
||||||
|
string actor = http.GetCurrentUser() is { } user ? user.TenantId.ToString("N") : ClientKey(http);
|
||||||
|
reporter?.Report(SuspiciousActivityReporter.RateLimitKind, actor);
|
||||||
|
|
||||||
|
http.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
await http.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ builder.Services.AddSingleton(rateLimitOptions);
|
|||||||
// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
|
// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
|
||||||
// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
|
// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
|
||||||
builder.Services.AddScoped<LoginAttemptGuard>();
|
builder.Services.AddScoped<LoginAttemptGuard>();
|
||||||
|
builder.Services.AddScoped<SuspiciousActivityReporter>();
|
||||||
if (rateLimitOptions.Enabled)
|
if (rateLimitOptions.Enabled)
|
||||||
{
|
{
|
||||||
builder.Services.AddDealRateLimiter(rateLimitOptions);
|
builder.Services.AddDealRateLimiter(rateLimitOptions);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Deal.SharedKernel.Observability;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Deal.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Признаки подозрительной активности и их учёт (метрика + предупреждающий лог).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Логгер событий.</param>
|
||||||
|
public sealed class SuspiciousActivityReporter(ILogger<SuspiciousActivityReporter> logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Вид: сработал rate limiter (слишком много запросов).
|
||||||
|
/// </summary>
|
||||||
|
public const string RateLimitKind = "rate_limit";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вид: вход заблокирован после серии неудачных попыток.
|
||||||
|
/// </summary>
|
||||||
|
public const string LoginBlockedKind = "login_blocked";
|
||||||
|
|
||||||
|
// Актор неизвестен (нет ни тенанта, ни IP).
|
||||||
|
private const string UnknownActor = "-";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Учитывает событие подозрительной активности.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="kind">Вид события (константы класса).</param>
|
||||||
|
/// <param name="actor">Актор: id тенанта либо IP; null/пусто — «-».</param>
|
||||||
|
public void Report(string kind, string? actor)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
|
||||||
|
string resolvedActor = string.IsNullOrWhiteSpace(actor) ? UnknownActor : actor;
|
||||||
|
DealMetrics.SecurityEvents.Add(1, new TagList { { DealMetrics.SecurityKindTagName, kind } });
|
||||||
|
logger.LogWarning("Подозрительная активность: {Kind}, actor={Actor}", kind, resolvedActor);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@ public sealed class SuspiciousActivityService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const int AuthFailuresPerTenantThreshold = 20;
|
public const int AuthFailuresPerTenantThreshold = 20;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Порог числа разных логинов в неудачных входах с одного IP за окно.
|
||||||
|
/// </summary>
|
||||||
|
public const int DistinctLoginsPerIpThreshold = 5;
|
||||||
|
|
||||||
// Кратность порога, с которой уровень поднимается до high (2× порог).
|
// Кратность порога, с которой уровень поднимается до high (2× порог).
|
||||||
private const int HighSeverityMultiplier = 2;
|
private const int HighSeverityMultiplier = 2;
|
||||||
|
|
||||||
@@ -62,6 +67,11 @@ public sealed class SuspiciousActivityService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
|
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Правило: перебор разных логинов с одного IP.
|
||||||
|
/// </summary>
|
||||||
|
public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Уровень находки
|
/// Уровень находки
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -126,6 +136,7 @@ public sealed class SuspiciousActivityService
|
|||||||
AddFailedLoginsPerLogin(records, findings);
|
AddFailedLoginsPerLogin(records, findings);
|
||||||
AddManyIpsPerActor(records, findings);
|
AddManyIpsPerActor(records, findings);
|
||||||
AddAuthFailuresPerTenant(records, findings);
|
AddAuthFailuresPerTenant(records, findings);
|
||||||
|
AddDistinctLoginsPerIp(records, findings);
|
||||||
|
|
||||||
findings.Sort(static (left, right) =>
|
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: Записи окна.
|
// records: Записи окна.
|
||||||
// predicate: Отбор записей правила.
|
// predicate: Отбор записей правила.
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ public static class DealMetrics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string TenantTagName = "tenant";
|
public const string TenantTagName = "tenant";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метки вида события безопасности
|
||||||
|
/// </summary>
|
||||||
|
public const string SecurityKindTagName = "kind";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Имя метрики доли израсходованного ИИ-бюджета
|
/// Имя метрики доли израсходованного ИИ-бюджета
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -82,6 +87,12 @@ public static class DealMetrics
|
|||||||
public static readonly Counter<long> AuditEvents =
|
public static readonly Counter<long> AuditEvents =
|
||||||
Meter.CreateCounter<long>("deal.audit.events", description: "Записи аудита по типам и акторам.");
|
Meter.CreateCounter<long>("deal.audit.events", description: "Записи аудита по типам и акторам.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// События подозрительной активности по видам
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Counter<long> SecurityEvents =
|
||||||
|
Meter.CreateCounter<long>("deal.security.suspicious", description: "События подозрительной активности по видам (rate_limit, login_blocked).");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Суммарная глубина очереди пайплайна
|
/// Суммарная глубина очереди пайплайна
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Deal.Api.Services;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit-тесты учёта подозрительной активности
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SuspiciousActivityReporterTests
|
||||||
|
{
|
||||||
|
// Актор сценариев.
|
||||||
|
private const string Actor = "tenant-1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Report инкрементит deal.security.suspicious с меткой kind
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Report_IncrementsSecurityCounter_WithKindTag()
|
||||||
|
{
|
||||||
|
using var capture = new SecurityEventCapture();
|
||||||
|
var reporter = new SuspiciousActivityReporter(new RecordingLogger());
|
||||||
|
|
||||||
|
reporter.Report(SuspiciousActivityReporter.RateLimitKind, Actor);
|
||||||
|
|
||||||
|
Assert.Equal(1, capture.Sum(SuspiciousActivityReporter.RateLimitKind));
|
||||||
|
(string? Kind, long Value) measurement = Assert.Single(
|
||||||
|
capture.Snapshot(),
|
||||||
|
item => item.Kind == SuspiciousActivityReporter.RateLimitKind);
|
||||||
|
Assert.Equal(1, measurement.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Report пишет предупреждение с видом и актором
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Report_WritesWarningWithKindAndActor()
|
||||||
|
{
|
||||||
|
var logger = new RecordingLogger();
|
||||||
|
var reporter = new SuspiciousActivityReporter(logger);
|
||||||
|
|
||||||
|
reporter.Report(SuspiciousActivityReporter.LoginBlockedKind, Actor);
|
||||||
|
|
||||||
|
LogEntry entry = Assert.Single(logger.Entries);
|
||||||
|
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||||
|
Assert.Contains(SuspiciousActivityReporter.LoginBlockedKind, entry.Message);
|
||||||
|
Assert.Contains($"actor={Actor}", entry.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// null/пусто/пробелы actor → в логе «-»
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actor">Пустой актор сценария.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Report_UnknownActor_WritesDash(string? actor)
|
||||||
|
{
|
||||||
|
var logger = new RecordingLogger();
|
||||||
|
var reporter = new SuspiciousActivityReporter(logger);
|
||||||
|
|
||||||
|
reporter.Report(SuspiciousActivityReporter.RateLimitKind, actor);
|
||||||
|
|
||||||
|
LogEntry entry = Assert.Single(logger.Entries);
|
||||||
|
Assert.Contains("actor=-", entry.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пустой/пробельный kind → ArgumentException
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="kind">Пустой вид события сценария.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Report_EmptyOrWhitespaceKind_Throws(string? kind)
|
||||||
|
{
|
||||||
|
var reporter = new SuspiciousActivityReporter(new RecordingLogger());
|
||||||
|
|
||||||
|
Assert.ThrowsAny<ArgumentException>(() => reporter.Report(kind!, Actor));
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory логгер: копит записи с уровнем, сообщением и исключением.
|
||||||
|
private sealed class RecordingLogger : ILogger<SuspiciousActivityReporter>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Записи лога в порядке поступления.
|
||||||
|
/// </summary>
|
||||||
|
public List<LogEntry> Entries { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IDisposable? BeginScope<TState>(TState state)
|
||||||
|
where TState : notnull => null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
=> Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись лога, снятая in-memory логгером.
|
||||||
|
private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception);
|
||||||
|
}
|
||||||
@@ -137,6 +137,91 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
Assert.Equal(Tenant.ToString("D"), finding.Subject);
|
Assert.Equal(Tenant.ToString("D"), finding.Subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold()
|
||||||
|
{
|
||||||
|
var store = new FakeAuditLogStore();
|
||||||
|
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
|
||||||
|
{
|
||||||
|
SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i);
|
||||||
|
}
|
||||||
|
|
||||||
|
SuspiciousActivityService service = Create(store);
|
||||||
|
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
SuspiciousFindingDto finding = Assert.Single(
|
||||||
|
report.Items,
|
||||||
|
item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
|
||||||
|
Assert.Equal("10.3.0.1", finding.Subject);
|
||||||
|
Assert.Equal(SuspiciousActivityService.DistinctLoginsPerIpThreshold, finding.Count);
|
||||||
|
Assert.Equal(SuspiciousActivityService.SeverityMedium, finding.Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh()
|
||||||
|
{
|
||||||
|
var store = new FakeAuditLogStore();
|
||||||
|
int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2;
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
SeedFailed(store, ip: "10.3.0.2", login: $"user{i}", minutesAgo: i);
|
||||||
|
}
|
||||||
|
|
||||||
|
SuspiciousActivityService service = Create(store);
|
||||||
|
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
SuspiciousFindingDto finding = Assert.Single(
|
||||||
|
report.Items,
|
||||||
|
item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
|
||||||
|
Assert.Equal(count, finding.Count);
|
||||||
|
Assert.Equal(SuspiciousActivityService.SeverityHigh, finding.Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding()
|
||||||
|
{
|
||||||
|
var store = new FakeAuditLogStore();
|
||||||
|
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++)
|
||||||
|
{
|
||||||
|
SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i);
|
||||||
|
}
|
||||||
|
|
||||||
|
SuspiciousActivityService service = Create(store);
|
||||||
|
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding()
|
||||||
|
{
|
||||||
|
var store = new FakeAuditLogStore();
|
||||||
|
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++)
|
||||||
|
{
|
||||||
|
SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i);
|
||||||
|
}
|
||||||
|
|
||||||
|
SuspiciousActivityService service = Create(store);
|
||||||
|
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored()
|
||||||
|
{
|
||||||
|
var store = new FakeAuditLogStore();
|
||||||
|
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
|
||||||
|
{
|
||||||
|
SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i);
|
||||||
|
}
|
||||||
|
|
||||||
|
SuspiciousActivityService service = Create(store);
|
||||||
|
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
|
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
|
||||||
{
|
{
|
||||||
@@ -171,5 +256,24 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пишет запись «успешный вход» с заданным временем.
|
||||||
|
private static void SeedSuccess(
|
||||||
|
FakeAuditLogStore store,
|
||||||
|
string ip,
|
||||||
|
string login,
|
||||||
|
int minutesAgo)
|
||||||
|
{
|
||||||
|
store.AppendAsync(
|
||||||
|
new AuditRecordDto(
|
||||||
|
AuditEvents.TenantLoginOk,
|
||||||
|
AuditActorTypes.Tenant,
|
||||||
|
ActorId: null,
|
||||||
|
TenantId: null,
|
||||||
|
Ip: ip,
|
||||||
|
DetailJson: AuditService.ToDetailJson(new { login }),
|
||||||
|
At: Now.AddMinutes(-minutesAgo)),
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now);
|
private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,39 @@ public sealed class LoginAttemptEndpointHttpTests
|
|||||||
rateLimitOptions: EnabledRateLimitOptions());
|
rateLimitOptions: EnabledRateLimitOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Учёт подозрительной активности ──────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Блокировка входа после серии неудач → событие login_blocked в метрике
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_WhenBlocked_ReportsLoginBlockedSecurityEvent()
|
||||||
|
{
|
||||||
|
using var capture = new SecurityEventCapture();
|
||||||
|
|
||||||
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
|
new FakeOperatorAuthStore(),
|
||||||
|
NewUserStore(),
|
||||||
|
async (baseAddress, _, _, _, _, _, _) =>
|
||||||
|
{
|
||||||
|
HttpClient client = CreateClient(baseAddress);
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < MaxAttempts; attempt++)
|
||||||
|
{
|
||||||
|
using HttpResponseMessage failed = await PostJsonAsync(
|
||||||
|
client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword });
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
using HttpResponseMessage blocked = await PostJsonAsync(
|
||||||
|
client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password });
|
||||||
|
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
|
||||||
|
},
|
||||||
|
rateLimitOptions: EnabledRateLimitOptions());
|
||||||
|
|
||||||
|
Assert.True(capture.Sum(SuspiciousActivityReporter.LoginBlockedKind) >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Хелперы ─────────────────────────────────────────────────────────
|
// ─── Хелперы ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static RateLimitOptions EnabledRateLimitOptions() =>
|
private static RateLimitOptions EnabledRateLimitOptions() =>
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ internal static class OperatorAuthHttpHost
|
|||||||
builder.Services.AddSingleton(effectiveRateLimitOptions);
|
builder.Services.AddSingleton(effectiveRateLimitOptions);
|
||||||
builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore());
|
builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore());
|
||||||
builder.Services.AddScoped<LoginAttemptGuard>();
|
builder.Services.AddScoped<LoginAttemptGuard>();
|
||||||
|
builder.Services.AddScoped<SuspiciousActivityReporter>();
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
app.UseMiddleware<SessionMiddleware>();
|
app.UseMiddleware<SessionMiddleware>();
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using Deal.SharedKernel.Observability;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Слушатель счётчика подозрительной активности deal.security.suspicious.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SecurityEventCapture : IDisposable
|
||||||
|
{
|
||||||
|
// Защита накопленных измерений (callback может прийти из потока запроса).
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
private readonly List<(string? Kind, long Value)> _measurements = [];
|
||||||
|
|
||||||
|
private readonly MeterListener _listener;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Начинает слушать счётчик подозрительной активности.
|
||||||
|
/// </summary>
|
||||||
|
public SecurityEventCapture()
|
||||||
|
{
|
||||||
|
_listener = new MeterListener();
|
||||||
|
_listener.InstrumentPublished = (instrument, current) =>
|
||||||
|
{
|
||||||
|
if (instrument.Meter.Name == DealMetrics.MeterName
|
||||||
|
&& instrument.Name == DealMetrics.SecurityEvents.Name)
|
||||||
|
{
|
||||||
|
current.EnableMeasurementEvents(instrument);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
|
||||||
|
{
|
||||||
|
string? kind = null;
|
||||||
|
foreach (KeyValuePair<string, object?> tag in tags)
|
||||||
|
{
|
||||||
|
if (tag.Key == DealMetrics.SecurityKindTagName)
|
||||||
|
{
|
||||||
|
kind = tag.Value?.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_measurements.Add((kind, value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_listener.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Снимок измерений с момента создания слушателя.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Пары (вид события, значение инкремента).</returns>
|
||||||
|
public IReadOnlyList<(string? Kind, long Value)> Snapshot()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return [.. _measurements];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сумма инкрементов по виду события.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="kind">Вид события (метка kind).</param>
|
||||||
|
/// <returns>Суммарное значение измерений с указанным видом.</returns>
|
||||||
|
public long Sum(string kind)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _measurements.Where(measurement => measurement.Kind == kind).Sum(measurement => measurement.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose() => _listener.Dispose();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user