diff --git a/M365SecurityAlertDashboard.sln b/M365SecurityAlertDashboard.sln index 4085e4c..6dec17b 100644 --- a/M365SecurityAlertDashboard.sln +++ b/M365SecurityAlertDashboard.sln @@ -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 diff --git a/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs b/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs new file mode 100644 index 0000000..4efaed3 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs @@ -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; + +/// +/// Focused tests for the auto-resolve path in . +/// Covers streak increment / reset, transition to auto_resolved, +/// terminal-state skip, debounce-of-one, no notification dispatch on +/// resolution, and preservation of user-set fields. +/// +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.Instance), + NullLogger.Instance); + + return new AlertEvaluator( + db, + sender, + options, + NullLogger.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); + } + + /// No-op for tests that never make HTTP calls. + private sealed class NullHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj b/src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj new file mode 100644 index 0000000..8a32bde --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/src/M365SecurityDashboard.Api.Tests/TestAppDbContextFactory.cs b/src/M365SecurityDashboard.Api.Tests/TestAppDbContextFactory.cs new file mode 100644 index 0000000..8e0a766 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/TestAppDbContextFactory.cs @@ -0,0 +1,20 @@ +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// Test helper that returns a fresh in-memory +/// for each call. Each test gets its own database (named by a unique Guid) +/// so state cannot leak between tests. +/// +internal static class TestAppDbContextFactory +{ + public static AppDbContext Create() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new AppDbContext(options); + } +} diff --git a/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs b/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs index 72a0cef..19994ce 100644 --- a/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs +++ b/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs @@ -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 = diff --git a/src/M365SecurityDashboard.Api/Models/AlertingOptions.cs b/src/M365SecurityDashboard.Api/Models/AlertingOptions.cs new file mode 100644 index 0000000..5890f37 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/AlertingOptions.cs @@ -0,0 +1,16 @@ +namespace M365SecurityDashboard.Api.Models; + +/// +/// Configuration for the server-side alerting engine. Bound from the +/// "Alerting" section of appsettings.json. +/// +public sealed class AlertingOptions +{ + /// + /// 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. + /// + public int AutoResolveDebounceCycles { get; set; } = 2; +} diff --git a/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs b/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs index 9ce7a47..a23425c 100644 --- a/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs +++ b/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs @@ -39,4 +39,17 @@ public sealed class TriggeredAlert /// Whether outbound notifications were dispatched for this alert. public bool Notified { get; set; } + + /// If set, the alert is silenced until this timestamp. Status remains "new" or "acknowledged". + public DateTimeOffset? SnoozedUntil { get; set; } + + /// Identity of the actor who snoozed this alert. Placeholder string ("dashboard") until auth lands. + [MaxLength(120)] + public string? SnoozedBy { get; set; } + + /// Number of consecutive evaluation cycles the underlying metric has been below this alert's threshold. Reset on any above-threshold observation. + public int BelowThresholdStreakCount { get; set; } + + /// When the evaluator last inspected this alert (used for diagnostics and the auto-resolve debounce). + public DateTimeOffset? LastEvaluatedAt { get; set; } } diff --git a/src/M365SecurityDashboard.Api/Program.cs b/src/M365SecurityDashboard.Api/Program.cs index 73e8225..dec3942 100644 --- a/src/M365SecurityDashboard.Api/Program.cs +++ b/src/M365SecurityDashboard.Api/Program.cs @@ -10,6 +10,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Host.UseWindowsService(); builder.Services.Configure(builder.Configuration.GetSection("Graph")); +builder.Services.Configure(builder.Configuration.GetSection("Alerting")); builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddHttpClient(); @@ -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(); + +/// Body shape for POST /api/triggered-alerts/{id}/snooze. +public sealed record SnoozeRequest(DateTimeOffset? Until, int? DurationHours); diff --git a/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs b/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs index 7c62dcc..c8c951e 100644 --- a/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs +++ b/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs @@ -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 rows against the latest /// collected data and persists new rows. Runs /// server-side after every collection cycle so alerts fire without a browser. +/// Also auto-resolves stale alerts whose underlying metric has recovered. /// public sealed class AlertEvaluator( AppDbContext db, NotificationSender sender, + IOptions options, ILogger logger) { public async Task 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; } diff --git a/src/M365SecurityDashboard.Api/appsettings.json b/src/M365SecurityDashboard.Api/appsettings.json index 86758ae..24cf5eb 100644 --- a/src/M365SecurityDashboard.Api/appsettings.json +++ b/src/M365SecurityDashboard.Api/appsettings.json @@ -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": "*" } diff --git a/src/m365-security-dashboard-client/src/main.tsx b/src/m365-security-dashboard-client/src/main.tsx index cfc34b0..700bf82 100644 --- a/src/m365-security-dashboard-client/src/main.tsx +++ b/src/m365-security-dashboard-client/src/main.tsx @@ -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 { 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 { + 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 { + 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 }: { {selectedTriggered.acknowledgedAt && } + {selectedTriggered.snoozedUntil && } + {selectedTriggered.lastEvaluatedAt && }
@@ -3946,6 +3970,9 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: { {a.policyName} + {a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && ( + Z until {relTime(a.snoozedUntil)} + )} {relTime(a.triggeredAt)} {a.status === "new" && ( @@ -3980,6 +4007,8 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: { + + ({ 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 }: { {a.metricValue} {a.threshold} {relTime(a.triggeredAt)} - - e.stopPropagation()} style={{ display:"flex", gap:4, alignItems:"center" }}> + + {a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && ( +
snoozed until {relTime(a.snoozedUntil)}
+ )} + + e.stopPropagation()} style={{ display:"flex", gap:4, alignItems:"center", flexWrap:"wrap" }}> {a.status === "new" && } - {a.status !== "resolved" && } + {a.status !== "resolved" && a.status !== "auto_resolved" && ( + + )} + {a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && ( + + )} + {a.status !== "resolved" && a.status !== "auto_resolved" && } ))}