mirror of
https://github.com/sameerk27/vigil365.git
synced 2026-07-02 10:39:35 +02:00
feat(alerting): per-alert snooze and silent auto-resolve (#2)
feat(alerting): per-alert snooze and silent auto-resolve
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "M365SecurityDashboard.Api", "src\M365SecurityDashboard.Api\M365SecurityDashboard.Api.csproj", "{A0AD3D97-983E-4665-B8A0-BB72D07686B6}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8CEF94CD-3FDE-42A2-B93C-4D9552702532}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "M365SecurityDashboard.Api.Tests", "src\M365SecurityDashboard.Api.Tests\M365SecurityDashboard.Api.Tests.csproj", "{B8504228-F65D-4D8B-B972-B6471E615FB9}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -14,5 +18,12 @@ Global
|
||||
{A0AD3D97-983E-4665-B8A0-BB72D07686B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A0AD3D97-983E-4665-B8A0-BB72D07686B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A0AD3D97-983E-4665-B8A0-BB72D07686B6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B8504228-F65D-4D8B-B972-B6471E615FB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B8504228-F65D-4D8B-B972-B6471E615FB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B8504228-F65D-4D8B-B972-B6471E615FB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B8504228-F65D-4D8B-B972-B6471E615FB9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{B8504228-F65D-4D8B-B972-B6471E615FB9} = {8CEF94CD-3FDE-42A2-B93C-4D9552702532}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
using M365SecurityDashboard.Api.Data;
|
||||
using M365SecurityDashboard.Api.Models;
|
||||
using M365SecurityDashboard.Api.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace M365SecurityDashboard.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Focused tests for the auto-resolve path in <see cref="AlertEvaluator"/>.
|
||||
/// Covers streak increment / reset, transition to <c>auto_resolved</c>,
|
||||
/// terminal-state skip, debounce-of-one, no notification dispatch on
|
||||
/// resolution, and preservation of user-set fields.
|
||||
/// </summary>
|
||||
public class AlertEvaluatorAutoResolveTests
|
||||
{
|
||||
private const string RiskyUsersMetric = "riskyUsersCount";
|
||||
private const int Threshold = 3;
|
||||
|
||||
private static AlertEvaluator BuildEvaluator(AppDbContext db, int autoResolveDebounceCycles = 2)
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new AlertingOptions
|
||||
{
|
||||
AutoResolveDebounceCycles = autoResolveDebounceCycles,
|
||||
});
|
||||
|
||||
// The auto-resolve loop never calls DispatchAsync; we still need a
|
||||
// real NotificationSender because AlertEvaluator takes it in its
|
||||
// primary constructor. All channels are disabled below, so any
|
||||
// accidental dispatch attempt writes no NotificationLog rows.
|
||||
var sender = new NotificationSender(
|
||||
new NullHttpClientFactory(),
|
||||
new SecretProtector(NullLogger<SecretProtector>.Instance),
|
||||
NullLogger<NotificationSender>.Instance);
|
||||
|
||||
return new AlertEvaluator(
|
||||
db,
|
||||
sender,
|
||||
options,
|
||||
NullLogger<AlertEvaluator>.Instance);
|
||||
}
|
||||
|
||||
private static AlertPolicy RiskyUsersPolicy(int threshold = Threshold) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Risky Users",
|
||||
Enabled = true,
|
||||
Category = "identity",
|
||||
Metric = RiskyUsersMetric,
|
||||
Threshold = threshold,
|
||||
Severity = "high",
|
||||
Condition = "Risky users ≥ 1",
|
||||
SuppressionMinutes = 60,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
};
|
||||
|
||||
private static void SeedOpenRiskyUsers(AppDbContext db, int count)
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
db.SecurityAlerts.Add(new SecurityAlert
|
||||
{
|
||||
AlertType = "RiskyUser",
|
||||
Severity = AlertSeverity.High,
|
||||
Service = M365ServiceArea.EntraId,
|
||||
Title = $"risky-{i}",
|
||||
DetectedAt = DateTimeOffset.UtcNow.AddHours(-i),
|
||||
IsResolved = false,
|
||||
});
|
||||
}
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Streak_IncrementsOnBelowThresholdObservation()
|
||||
{
|
||||
using var db = TestAppDbContextFactory.Create();
|
||||
var policy = RiskyUsersPolicy();
|
||||
db.AlertPolicies.Add(policy);
|
||||
db.TriggeredAlerts.Add(new TriggeredAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PolicyId = policy.Id,
|
||||
PolicyName = policy.Name,
|
||||
Severity = policy.Severity,
|
||||
Category = policy.Category,
|
||||
Condition = policy.Condition,
|
||||
MetricValue = 5,
|
||||
Threshold = policy.Threshold,
|
||||
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
Status = "new",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
SeedOpenRiskyUsers(db, count: 0); // metric = 0, below threshold
|
||||
|
||||
var evaluator = BuildEvaluator(db);
|
||||
await evaluator.EvaluateAsync(CancellationToken.None);
|
||||
|
||||
var alert = await db.TriggeredAlerts.SingleAsync();
|
||||
Assert.Equal(1, alert.BelowThresholdStreakCount);
|
||||
Assert.Equal("new", alert.Status);
|
||||
Assert.NotNull(alert.LastEvaluatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoResolve_AfterNConsecutiveBelow()
|
||||
{
|
||||
using var db = TestAppDbContextFactory.Create();
|
||||
var policy = RiskyUsersPolicy();
|
||||
db.AlertPolicies.Add(policy);
|
||||
db.TriggeredAlerts.Add(new TriggeredAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PolicyId = policy.Id,
|
||||
PolicyName = policy.Name,
|
||||
Severity = policy.Severity,
|
||||
Category = policy.Category,
|
||||
Condition = policy.Condition,
|
||||
MetricValue = 5,
|
||||
Threshold = policy.Threshold,
|
||||
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
Status = "new",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
SeedOpenRiskyUsers(db, count: 0); // metric = 0, below threshold
|
||||
|
||||
var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 2);
|
||||
|
||||
// First evaluation: streak goes from 0 to 1, status stays "new".
|
||||
await evaluator.EvaluateAsync(CancellationToken.None);
|
||||
var afterFirst = await db.TriggeredAlerts.SingleAsync();
|
||||
Assert.Equal(1, afterFirst.BelowThresholdStreakCount);
|
||||
Assert.Equal("new", afterFirst.Status);
|
||||
|
||||
// Second consecutive below-threshold evaluation: streak hits 2, auto-resolves.
|
||||
await evaluator.EvaluateAsync(CancellationToken.None);
|
||||
var afterSecond = await db.TriggeredAlerts.SingleAsync();
|
||||
Assert.Equal("auto_resolved", afterSecond.Status);
|
||||
Assert.Equal(2, afterSecond.BelowThresholdStreakCount);
|
||||
|
||||
// Resolution is silent: no NotificationLog rows for this alert.
|
||||
Assert.Empty(await db.NotificationLogs.Where(l => l.TriggeredAlertId == afterSecond.Id).ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Streak_ResetsOnAboveThreshold()
|
||||
{
|
||||
using var db = TestAppDbContextFactory.Create();
|
||||
var policy = RiskyUsersPolicy();
|
||||
db.AlertPolicies.Add(policy);
|
||||
db.TriggeredAlerts.Add(new TriggeredAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PolicyId = policy.Id,
|
||||
PolicyName = policy.Name,
|
||||
Severity = policy.Severity,
|
||||
Category = policy.Category,
|
||||
Condition = policy.Condition,
|
||||
MetricValue = 5,
|
||||
Threshold = policy.Threshold,
|
||||
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
Status = "new",
|
||||
BelowThresholdStreakCount = 1, // already had one below observation
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
SeedOpenRiskyUsers(db, count: 5); // metric = 5, above threshold
|
||||
|
||||
var evaluator = BuildEvaluator(db);
|
||||
await evaluator.EvaluateAsync(CancellationToken.None);
|
||||
|
||||
var alert = await db.TriggeredAlerts.SingleAsync();
|
||||
Assert.Equal(0, alert.BelowThresholdStreakCount);
|
||||
Assert.Equal("new", alert.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoResolve_DebounceOneWithCyclesEqualsOne()
|
||||
{
|
||||
using var db = TestAppDbContextFactory.Create();
|
||||
var policy = RiskyUsersPolicy();
|
||||
db.AlertPolicies.Add(policy);
|
||||
db.TriggeredAlerts.Add(new TriggeredAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PolicyId = policy.Id,
|
||||
PolicyName = policy.Name,
|
||||
Severity = policy.Severity,
|
||||
Category = policy.Category,
|
||||
Condition = policy.Condition,
|
||||
MetricValue = 5,
|
||||
Threshold = policy.Threshold,
|
||||
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
Status = "new",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
SeedOpenRiskyUsers(db, count: 0);
|
||||
|
||||
var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 1);
|
||||
await evaluator.EvaluateAsync(CancellationToken.None);
|
||||
|
||||
var alert = await db.TriggeredAlerts.SingleAsync();
|
||||
Assert.Equal("auto_resolved", alert.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoResolve_SkipsTerminalStates()
|
||||
{
|
||||
using var db = TestAppDbContextFactory.Create();
|
||||
var policy = RiskyUsersPolicy();
|
||||
db.AlertPolicies.Add(policy);
|
||||
|
||||
var resolvedId = Guid.NewGuid();
|
||||
var autoResolvedId = Guid.NewGuid();
|
||||
db.TriggeredAlerts.AddRange(
|
||||
new TriggeredAlert
|
||||
{
|
||||
Id = resolvedId,
|
||||
PolicyId = policy.Id,
|
||||
PolicyName = policy.Name,
|
||||
Severity = policy.Severity,
|
||||
Category = policy.Category,
|
||||
Condition = policy.Condition,
|
||||
MetricValue = 5,
|
||||
Threshold = policy.Threshold,
|
||||
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
Status = "resolved",
|
||||
BelowThresholdStreakCount = 1,
|
||||
},
|
||||
new TriggeredAlert
|
||||
{
|
||||
Id = autoResolvedId,
|
||||
PolicyId = policy.Id,
|
||||
PolicyName = policy.Name,
|
||||
Severity = policy.Severity,
|
||||
Category = policy.Category,
|
||||
Condition = policy.Condition,
|
||||
MetricValue = 5,
|
||||
Threshold = policy.Threshold,
|
||||
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
Status = "auto_resolved",
|
||||
BelowThresholdStreakCount = 2,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
SeedOpenRiskyUsers(db, count: 0);
|
||||
|
||||
var evaluator = BuildEvaluator(db);
|
||||
await evaluator.EvaluateAsync(CancellationToken.None);
|
||||
|
||||
// The auto-resolve loop filters out terminal-state alerts, so it
|
||||
// does not touch their streak counter, their status, or stamp a
|
||||
// LastEvaluatedAt on them. A terminal state cannot re-open.
|
||||
var resolvedRow = await db.TriggeredAlerts.AsNoTracking().SingleAsync(a => a.Id == resolvedId);
|
||||
Assert.Equal("resolved", resolvedRow.Status);
|
||||
Assert.Equal(1, resolvedRow.BelowThresholdStreakCount);
|
||||
Assert.Null(resolvedRow.LastEvaluatedAt);
|
||||
|
||||
var autoResolvedRow = await db.TriggeredAlerts.AsNoTracking().SingleAsync(a => a.Id == autoResolvedId);
|
||||
Assert.Equal("auto_resolved", autoResolvedRow.Status);
|
||||
Assert.Equal(2, autoResolvedRow.BelowThresholdStreakCount);
|
||||
Assert.Null(autoResolvedRow.LastEvaluatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoResolve_DoesNotDispatchNotification()
|
||||
{
|
||||
using var db = TestAppDbContextFactory.Create();
|
||||
var policy = RiskyUsersPolicy();
|
||||
db.AlertPolicies.Add(policy);
|
||||
|
||||
// Pre-seed a notification settings row with a Teams URL that would
|
||||
// fail to deliver. If the auto-resolve path accidentally calls
|
||||
// DispatchAsync, the failure would be logged here.
|
||||
db.NotificationSettings.Add(new NotificationSettings
|
||||
{
|
||||
Id = 1,
|
||||
TeamsEnabled = true,
|
||||
TeamsWebhookUrl = "http://127.0.0.1:1/this-port-is-closed",
|
||||
EmailEnabled = false,
|
||||
WebhookEnabled = false,
|
||||
SmtpPort = 587,
|
||||
MinSeverity = "low",
|
||||
});
|
||||
|
||||
var alertId = Guid.NewGuid();
|
||||
db.TriggeredAlerts.Add(new TriggeredAlert
|
||||
{
|
||||
Id = alertId,
|
||||
PolicyId = policy.Id,
|
||||
PolicyName = policy.Name,
|
||||
Severity = policy.Severity,
|
||||
Category = policy.Category,
|
||||
Condition = policy.Condition,
|
||||
MetricValue = 5,
|
||||
Threshold = policy.Threshold,
|
||||
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
Status = "new",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
SeedOpenRiskyUsers(db, count: 0);
|
||||
|
||||
var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 1);
|
||||
await evaluator.EvaluateAsync(CancellationToken.None);
|
||||
|
||||
var alert = await db.TriggeredAlerts.SingleAsync();
|
||||
Assert.Equal("auto_resolved", alert.Status);
|
||||
// No log entry for this alert: auto-resolve is silent.
|
||||
Assert.Empty(await db.NotificationLogs.Where(l => l.TriggeredAlertId == alert.Id).ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoResolve_PreservesAcknowledgedAt()
|
||||
{
|
||||
using var db = TestAppDbContextFactory.Create();
|
||||
var policy = RiskyUsersPolicy();
|
||||
db.AlertPolicies.Add(policy);
|
||||
var ackAt = DateTimeOffset.UtcNow.AddMinutes(-15);
|
||||
db.TriggeredAlerts.Add(new TriggeredAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PolicyId = policy.Id,
|
||||
PolicyName = policy.Name,
|
||||
Severity = policy.Severity,
|
||||
Category = policy.Category,
|
||||
Condition = policy.Condition,
|
||||
MetricValue = 5,
|
||||
Threshold = policy.Threshold,
|
||||
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
Status = "acknowledged",
|
||||
AcknowledgedAt = ackAt,
|
||||
AcknowledgedBy = "dashboard",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
SeedOpenRiskyUsers(db, count: 0);
|
||||
|
||||
var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 1);
|
||||
await evaluator.EvaluateAsync(CancellationToken.None);
|
||||
|
||||
var alert = await db.TriggeredAlerts.SingleAsync();
|
||||
Assert.Equal("auto_resolved", alert.Status);
|
||||
Assert.Equal(ackAt, alert.AcknowledgedAt);
|
||||
Assert.Equal("dashboard", alert.AcknowledgedBy);
|
||||
}
|
||||
|
||||
/// <summary>No-op <see cref="IHttpClientFactory"/> for tests that never make HTTP calls.</summary>
|
||||
private sealed class NullHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\M365SecurityDashboard.Api\M365SecurityDashboard.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
using M365SecurityDashboard.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace M365SecurityDashboard.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test helper that returns a fresh in-memory <see cref="AppDbContext"/>
|
||||
/// for each call. Each test gets its own database (named by a unique Guid)
|
||||
/// so state cannot leak between tests.
|
||||
/// </summary>
|
||||
internal static class TestAppDbContextFactory
|
||||
{
|
||||
public static AppDbContext Create()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,18 @@ public static class AlertingSchema
|
||||
|
||||
IF COL_LENGTH(N'[CollectionRuns]', 'SourceFailureDetails') IS NULL
|
||||
ALTER TABLE [CollectionRuns] ADD [SourceFailureDetails] nvarchar(max) NULL;
|
||||
|
||||
IF COL_LENGTH(N'[TriggeredAlerts]', 'SnoozedUntil') IS NULL
|
||||
ALTER TABLE [TriggeredAlerts] ADD [SnoozedUntil] datetimeoffset NULL;
|
||||
|
||||
IF COL_LENGTH(N'[TriggeredAlerts]', 'SnoozedBy') IS NULL
|
||||
ALTER TABLE [TriggeredAlerts] ADD [SnoozedBy] nvarchar(120) NULL;
|
||||
|
||||
IF COL_LENGTH(N'[TriggeredAlerts]', 'BelowThresholdStreakCount') IS NULL
|
||||
ALTER TABLE [TriggeredAlerts] ADD [BelowThresholdStreakCount] int NOT NULL CONSTRAINT [DF_TriggeredAlerts_Streak] DEFAULT 0;
|
||||
|
||||
IF COL_LENGTH(N'[TriggeredAlerts]', 'LastEvaluatedAt') IS NULL
|
||||
ALTER TABLE [TriggeredAlerts] ADD [LastEvaluatedAt] datetimeoffset NULL;
|
||||
""";
|
||||
|
||||
private static readonly (string Name, string Category, string Metric, int Threshold, string Severity, string Condition)[] Defaults =
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace M365SecurityDashboard.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the server-side alerting engine. Bound from the
|
||||
/// "Alerting" section of <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
public sealed class AlertingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of consecutive evaluation cycles the underlying metric must be
|
||||
/// below a triggered alert's threshold before the alert auto-resolves.
|
||||
/// With the default collection interval of 15 minutes and a value of 2,
|
||||
/// an alert auto-resolves ~30 minutes after the metric recovers.
|
||||
/// </summary>
|
||||
public int AutoResolveDebounceCycles { get; set; } = 2;
|
||||
}
|
||||
@@ -39,4 +39,17 @@ public sealed class TriggeredAlert
|
||||
|
||||
/// <summary>Whether outbound notifications were dispatched for this alert.</summary>
|
||||
public bool Notified { get; set; }
|
||||
|
||||
/// <summary>If set, the alert is silenced until this timestamp. Status remains "new" or "acknowledged".</summary>
|
||||
public DateTimeOffset? SnoozedUntil { get; set; }
|
||||
|
||||
/// <summary>Identity of the actor who snoozed this alert. Placeholder string ("dashboard") until auth lands.</summary>
|
||||
[MaxLength(120)]
|
||||
public string? SnoozedBy { get; set; }
|
||||
|
||||
/// <summary>Number of consecutive evaluation cycles the underlying metric has been below this alert's threshold. Reset on any above-threshold observation.</summary>
|
||||
public int BelowThresholdStreakCount { get; set; }
|
||||
|
||||
/// <summary>When the evaluator last inspected this alert (used for diagnostics and the auto-resolve debounce).</summary>
|
||||
public DateTimeOffset? LastEvaluatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseWindowsService();
|
||||
builder.Services.Configure<GraphOptions>(builder.Configuration.GetSection("Graph"));
|
||||
builder.Services.Configure<AlertingOptions>(builder.Configuration.GetSection("Alerting"));
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
builder.Services.AddHttpClient<GraphApiClient>();
|
||||
@@ -1077,6 +1078,35 @@ app.MapPost("/api/triggered-alerts/{id:guid}/resolve", async (AppDbContext db, G
|
||||
return Results.Ok(t);
|
||||
});
|
||||
|
||||
// Per-alert snooze. Body: { "until": "2026-06-22T18:00:00Z" } or { "durationHours": 4|24|168 }.
|
||||
// Until wins if both are supplied; durationHours defaults to 24 if neither is supplied.
|
||||
app.MapPost("/api/triggered-alerts/{id:guid}/snooze", async (
|
||||
AppDbContext db, Guid id, SnoozeRequest input, CancellationToken ct) =>
|
||||
{
|
||||
var t = await db.TriggeredAlerts.FindAsync([id], ct);
|
||||
if (t is null) return Results.NotFound();
|
||||
if (t.Status is "resolved" or "auto_resolved")
|
||||
return Results.BadRequest(new { error = "Cannot snooze a terminal alert." });
|
||||
|
||||
var until = input.Until
|
||||
?? (input.DurationHours is { } h ? DateTimeOffset.UtcNow.AddHours(h) : DateTimeOffset.UtcNow.AddHours(24));
|
||||
t.SnoozedUntil = until;
|
||||
t.SnoozedBy = "dashboard";
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(t);
|
||||
});
|
||||
|
||||
app.MapPost("/api/triggered-alerts/{id:guid}/unsnooze", async (
|
||||
AppDbContext db, Guid id, CancellationToken ct) =>
|
||||
{
|
||||
var t = await db.TriggeredAlerts.FindAsync([id], ct);
|
||||
if (t is null) return Results.NotFound();
|
||||
t.SnoozedUntil = null;
|
||||
t.SnoozedBy = null;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(t);
|
||||
});
|
||||
|
||||
// Manually run an evaluation pass (used by the dashboard "refresh" + on-demand check)
|
||||
app.MapPost("/api/alert-policies/evaluate", async (AlertEvaluator evaluator, CancellationToken ct) =>
|
||||
{
|
||||
@@ -1150,3 +1180,6 @@ app.MapGet("/api/notification-log", async (AppDbContext db, CancellationToken ct
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>Body shape for POST /api/triggered-alerts/{id}/snooze.</summary>
|
||||
public sealed record SnoozeRequest(DateTimeOffset? Until, int? DurationHours);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using M365SecurityDashboard.Api.Data;
|
||||
using M365SecurityDashboard.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace M365SecurityDashboard.Api.Services;
|
||||
|
||||
@@ -8,10 +9,12 @@ namespace M365SecurityDashboard.Api.Services;
|
||||
/// Evaluates all enabled <see cref="AlertPolicy"/> rows against the latest
|
||||
/// collected data and persists new <see cref="TriggeredAlert"/> rows. Runs
|
||||
/// server-side after every collection cycle so alerts fire without a browser.
|
||||
/// Also auto-resolves stale alerts whose underlying metric has recovered.
|
||||
/// </summary>
|
||||
public sealed class AlertEvaluator(
|
||||
AppDbContext db,
|
||||
NotificationSender sender,
|
||||
IOptions<AlertingOptions> options,
|
||||
ILogger<AlertEvaluator> logger)
|
||||
{
|
||||
public async Task<int> EvaluateAsync(CancellationToken ct)
|
||||
@@ -26,6 +29,10 @@ public sealed class AlertEvaluator(
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var fired = 0;
|
||||
|
||||
// Map PolicyId -> Metric key so the auto-resolve loop below can look up
|
||||
// each open alert's current metric without re-querying the policy table.
|
||||
var policyMetricById = policies.ToDictionary(p => p.Id, p => p.Metric);
|
||||
|
||||
foreach (var policy in policies)
|
||||
{
|
||||
var value = metrics.GetValueOrDefault(policy.Metric, 0);
|
||||
@@ -67,10 +74,41 @@ public sealed class AlertEvaluator(
|
||||
}
|
||||
}
|
||||
|
||||
if (fired > 0)
|
||||
// Auto-resolve: scan non-terminal alerts and update streak counters.
|
||||
// Resolves silently — no notification dispatch, no NotificationLog write.
|
||||
var streakTarget = Math.Max(1, options.Value.AutoResolveDebounceCycles);
|
||||
var openAlerts = await db.TriggeredAlerts
|
||||
.Where(t => t.Status != "resolved" && t.Status != "auto_resolved")
|
||||
.ToListAsync(ct);
|
||||
var autoResolved = 0;
|
||||
foreach (var alert in openAlerts)
|
||||
{
|
||||
if (!policyMetricById.TryGetValue(alert.PolicyId, out var metricKey)) continue;
|
||||
var current = metrics.GetValueOrDefault(metricKey, 0);
|
||||
|
||||
if (current < alert.Threshold)
|
||||
{
|
||||
alert.BelowThresholdStreakCount++;
|
||||
if (alert.BelowThresholdStreakCount >= streakTarget)
|
||||
{
|
||||
alert.Status = "auto_resolved";
|
||||
autoResolved++;
|
||||
}
|
||||
}
|
||||
else if (alert.BelowThresholdStreakCount != 0)
|
||||
{
|
||||
alert.BelowThresholdStreakCount = 0;
|
||||
}
|
||||
alert.LastEvaluatedAt = now;
|
||||
}
|
||||
|
||||
if (fired > 0 || autoResolved > 0)
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
logger.LogInformation("Alert evaluation fired {Count} new alert(s)", fired);
|
||||
if (fired > 0)
|
||||
logger.LogInformation("Alert evaluation fired {Count} new alert(s)", fired);
|
||||
if (autoResolved > 0)
|
||||
logger.LogInformation("Auto-resolved {Count} alert(s) after metric recovery", autoResolved);
|
||||
}
|
||||
return fired;
|
||||
}
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
"ExchangeQuarantinePath": "/beta/security/alerts_v2?$top=50&$filter=serviceSource%20eq%20'microsoftDefenderForOffice365'%20and%20category%20eq%20'EmailMalware'",
|
||||
"MailFlowIssuesPath": "/v1.0/admin/serviceAnnouncement/issues?$top=50&$filter=service%20eq%20'Exchange%20Online'%20and%20isResolved%20eq%20false"
|
||||
},
|
||||
"Alerting": {
|
||||
"AutoResolveDebounceCycles": 2
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -153,9 +153,13 @@ interface TriggeredAlert {
|
||||
metricValue: number;
|
||||
threshold: number;
|
||||
triggeredAt: string;
|
||||
status: "new" | "acknowledged" | "resolved";
|
||||
status: "new" | "acknowledged" | "snoozed" | "auto_resolved" | "resolved";
|
||||
acknowledgedAt?: string;
|
||||
acknowledgedBy?: string;
|
||||
snoozedUntil?: string;
|
||||
snoozedBy?: string;
|
||||
belowThresholdStreakCount?: number;
|
||||
lastEvaluatedAt?: string;
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
@@ -212,6 +216,12 @@ const acApi = {
|
||||
async getLog(): Promise<NotificationLogEntry[]> {
|
||||
try { const r = await fetch(`${apiBase}/api/notification-log`); return r.ok ? await r.json() : []; } catch { return []; }
|
||||
},
|
||||
async snooze(id: string, durationHours: 4 | 24 | 168): Promise<boolean> {
|
||||
try { const r = await fetch(`${apiBase}/api/triggered-alerts/${id}/snooze`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ durationHours }) }); return r.ok; } catch { return false; }
|
||||
},
|
||||
async unsnooze(id: string): Promise<boolean> {
|
||||
try { const r = await fetch(`${apiBase}/api/triggered-alerts/${id}/unsnooze`, { method: "POST" }); return r.ok; } catch { return false; }
|
||||
},
|
||||
};
|
||||
|
||||
const SEVERITIES: AlertSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"];
|
||||
@@ -3508,7 +3518,11 @@ function sevToneAC(s: string): Tone {
|
||||
return s === "critical" ? "error" : s === "high" ? "error" : s === "medium" ? "warning" : "info";
|
||||
}
|
||||
function statusTone(s: string): Tone {
|
||||
return s === "new" ? "error" : s === "acknowledged" ? "warning" : "good";
|
||||
return s === "new" ? "error"
|
||||
: s === "acknowledged" ? "warning"
|
||||
: s === "snoozed" ? "neutral"
|
||||
: s === "auto_resolved" ? "info"
|
||||
: "good"; // resolved
|
||||
}
|
||||
|
||||
function PolicyModal({ policy, onSave, onClose }: {
|
||||
@@ -3784,6 +3798,14 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: {
|
||||
if (await acApi.resolve(id)) { showToast("Alert resolved"); await onChanged(); }
|
||||
};
|
||||
|
||||
const snooze = async (id: string, durationHours: 4 | 24 | 168) => {
|
||||
if (await acApi.snooze(id, durationHours)) { showToast(`Snoozed for ${durationHours}h`); await onChanged(); }
|
||||
};
|
||||
|
||||
const unsnooze = async (id: string) => {
|
||||
if (await acApi.unsnooze(id)) { showToast("Snooze cleared"); await onChanged(); }
|
||||
};
|
||||
|
||||
const handleSavePolicy = async (p: AlertPolicy) => {
|
||||
const exists = policies.some(x => x.id === p.id);
|
||||
const ok = exists ? await acApi.updatePolicy(p) : !!(await acApi.createPolicy(p));
|
||||
@@ -3845,6 +3867,8 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: {
|
||||
<DetailField label="Status" value={selectedTriggered.status}/>
|
||||
<DetailField label="Triggered" value={fmtDate(selectedTriggered.triggeredAt)}/>
|
||||
{selectedTriggered.acknowledgedAt && <DetailField label="Acknowledged" value={fmtDate(selectedTriggered.acknowledgedAt)}/>}
|
||||
{selectedTriggered.snoozedUntil && <DetailField label="Snoozed until" value={fmtDate(selectedTriggered.snoozedUntil)}/>}
|
||||
{selectedTriggered.lastEvaluatedAt && <DetailField label="Last evaluated" value={fmtDate(selectedTriggered.lastEvaluatedAt)}/>}
|
||||
</div>
|
||||
<div className="detail-modal-footer">
|
||||
<button className="dm-close-btn" onClick={() => setSelectedTriggered(null)}>Close</button>
|
||||
@@ -3946,6 +3970,9 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: {
|
||||
<span className={`sev-dot sev-${a.severity}`}/>
|
||||
<span className="mr-user" style={{ flex:1 }}>{a.policyName}</span>
|
||||
<Badge label={a.status} tone={statusTone(a.status)}/>
|
||||
{a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && (
|
||||
<span style={{ fontSize:10, color:"var(--color-muted)" }}>Z until {relTime(a.snoozedUntil)}</span>
|
||||
)}
|
||||
<span className="mr-date">{relTime(a.triggeredAt)}</span>
|
||||
{a.status === "new" && (
|
||||
<button className="btn-ack" onClick={e => { e.stopPropagation(); acknowledge(a.id); }}>Ack</button>
|
||||
@@ -3980,6 +4007,8 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: {
|
||||
<option value="">All statuses</option>
|
||||
<option value="new">New</option>
|
||||
<option value="acknowledged">Acknowledged</option>
|
||||
<option value="snoozed">Snoozed</option>
|
||||
<option value="auto_resolved">Auto-resolved</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
</select>
|
||||
<ExportDropdown rows={filteredTA.map(a=>({ Policy:a.policyName, Severity:a.severity, Category:a.category, Condition:a.condition, MetricValue:a.metricValue, Threshold:a.threshold, Triggered:a.triggeredAt, Status:a.status }))} filename="triggered-alerts.csv"/>
|
||||
@@ -4007,10 +4036,29 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: {
|
||||
<td style={{ fontWeight:600 }}>{a.metricValue}</td>
|
||||
<td>{a.threshold}</td>
|
||||
<td className="al-date">{relTime(a.triggeredAt)}</td>
|
||||
<td><Badge label={a.status} tone={statusTone(a.status)}/></td>
|
||||
<td onClick={e=>e.stopPropagation()} style={{ display:"flex", gap:4, alignItems:"center" }}>
|
||||
<td><Badge label={a.status} tone={statusTone(a.status)}/>
|
||||
{a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && (
|
||||
<div style={{ fontSize:10, color:"var(--color-muted)", marginTop:2 }}>snoozed until {relTime(a.snoozedUntil)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td onClick={e=>e.stopPropagation()} style={{ display:"flex", gap:4, alignItems:"center", flexWrap:"wrap" }}>
|
||||
{a.status === "new" && <button className="btn-ack" onClick={()=>acknowledge(a.id)}>Acknowledge</button>}
|
||||
{a.status !== "resolved" && <button className="btn-resolve" onClick={()=>resolve(a.id)}>Resolve</button>}
|
||||
{a.status !== "resolved" && a.status !== "auto_resolved" && (
|
||||
<select className="filter-sel" style={{ padding:"3px 6px", fontSize:11 }} defaultValue=""
|
||||
onChange={e => { const h = Number(e.target.value); if (h) snooze(a.id, h as 4|24|168); e.currentTarget.value = ""; }}
|
||||
title="Snooze for…">
|
||||
<option value="" disabled>Snooze…</option>
|
||||
<option value="4">4h</option>
|
||||
<option value="24">24h</option>
|
||||
<option value="168">7d</option>
|
||||
</select>
|
||||
)}
|
||||
{a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && (
|
||||
<button className="btn-apply" style={{ padding:"3px 8px", fontSize:11 }}
|
||||
onClick={() => unsnooze(a.id)}
|
||||
title={`Snoozed until ${a.snoozedUntil}`}>Unsnooze</button>
|
||||
)}
|
||||
{a.status !== "resolved" && a.status !== "auto_resolved" && <button className="btn-resolve" onClick={()=>resolve(a.id)}>Resolve</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user