From b933fce488a0ec2a77fea2cf084c17298c4b4c8b Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Thu, 14 May 2026 20:25:14 +1000 Subject: [PATCH] Add Slack alert webhook notifications --- README.md | 11 + .../Tawny.Api/Controllers/AlertsController.cs | 6 + .../Controllers/TelemetryController.cs | 1 + backend/src/Tawny.Api/Models/AlertDtos.cs | 3 + backend/src/Tawny.Api/Program.cs | 5 +- backend/src/Tawny.Api/Services/AlertSink.cs | 15 ++ .../src/Tawny.Api/Services/SlackAlertSink.cs | 192 ++++++++++++++++++ backend/src/Tawny.Api/appsettings.json | 7 + backend/src/Tawny.Domain/Entities/Alert.cs | 3 + backend/src/Tawny.Domain/Enums.cs | 8 + ...260514061500_AddSlackAlertNotifications.cs | 55 +++++ .../Migrations/TawnyDbContextModelSnapshot.cs | 10 + .../Tawny.Infrastructure/TawnyDbContext.cs | 1 + .../Tawny.Api.Tests/SlackAlertSinkTests.cs | 108 ++++++++++ docker/docker-compose.yml | 5 + docs/production.md | 24 +++ web/app/alerts/alerts-table.tsx | 44 +++- 17 files changed, 493 insertions(+), 5 deletions(-) create mode 100644 backend/src/Tawny.Api/Services/SlackAlertSink.cs create mode 100644 backend/src/Tawny.Infrastructure/Migrations/20260514061500_AddSlackAlertNotifications.cs create mode 100644 backend/tests/Tawny.Api.Tests/SlackAlertSinkTests.cs diff --git a/README.md b/README.md index 9542bbf..8cfb11e 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,17 @@ Each alert is emitted as one syslog event with a flat JSON body containing Tawny In Docker-based Wazuh deployments, check `/var/ossec/logs/ossec.log` after the first send. If Wazuh logs `Message from 'x.x.x.x' not allowed`, add that exact IP to the Wazuh syslog `` list and restart the manager container. +## Slack sink + +Tawny can also post newly generated alerts to a Slack incoming webhook. Delivery state is stored on each alert as `not_configured`, `pending`, `sent`, or `failed` and is visible in the alerts table. + +```bash +TAWNY_SLACK_ENABLED=true +TAWNY_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... +TAWNY_SLACK_USERNAME=Tawny +TAWNY_SLACK_ICON_EMOJI=:rotating_light: +``` + ## Security notes - Agent JWTs are bearer tokens. Anyone with the file on disk can impersonate the agent. Mitigate later with the OS keystore. diff --git a/backend/src/Tawny.Api/Controllers/AlertsController.cs b/backend/src/Tawny.Api/Controllers/AlertsController.cs index 1d6a6ad..019da83 100644 --- a/backend/src/Tawny.Api/Controllers/AlertsController.cs +++ b/backend/src/Tawny.Api/Controllers/AlertsController.cs @@ -49,6 +49,9 @@ public async Task>> List( a.TelemetryEvent.Payload, a.Severity, a.Status, + a.SlackNotificationStatus, + a.SlackNotifiedAt, + a.SlackNotificationError, a.Title, a.Description, a.CreatedAt, @@ -72,6 +75,9 @@ public async Task>> List( JsonSerializer.Deserialize(a.Payload), a.Severity, a.Status, + a.SlackNotificationStatus, + a.SlackNotifiedAt, + a.SlackNotificationError, a.Title, a.Description, a.CreatedAt)).ToList()); diff --git a/backend/src/Tawny.Api/Controllers/TelemetryController.cs b/backend/src/Tawny.Api/Controllers/TelemetryController.cs index e313007..2044785 100644 --- a/backend/src/Tawny.Api/Controllers/TelemetryController.cs +++ b/backend/src/Tawny.Api/Controllers/TelemetryController.cs @@ -90,6 +90,7 @@ await alertSink.PublishAsync( alerts, events.ToDictionary(e => e.Id), ct); + await db.SaveChangesAsync(ct); } return Accepted(); diff --git a/backend/src/Tawny.Api/Models/AlertDtos.cs b/backend/src/Tawny.Api/Models/AlertDtos.cs index f8f53f3..d7ad793 100644 --- a/backend/src/Tawny.Api/Models/AlertDtos.cs +++ b/backend/src/Tawny.Api/Models/AlertDtos.cs @@ -58,6 +58,9 @@ public record AlertResponse( JsonElement Payload, AlertSeverity Severity, AlertStatus Status, + AlertNotificationStatus SlackNotificationStatus, + DateTimeOffset? SlackNotifiedAt, + string? SlackNotificationError, string Title, string? Description, DateTimeOffset CreatedAt); diff --git a/backend/src/Tawny.Api/Program.cs b/backend/src/Tawny.Api/Program.cs index 2e32263..fed5ee3 100644 --- a/backend/src/Tawny.Api/Program.cs +++ b/backend/src/Tawny.Api/Program.cs @@ -25,6 +25,7 @@ builder.Services.Configure(builder.Configuration.GetSection("Tawny:Retention")); builder.Services.Configure(builder.Configuration.GetSection("Tawny:TelemetryBackup")); builder.Services.Configure(builder.Configuration.GetSection("Tawny:Wazuh")); +builder.Services.Configure(builder.Configuration.GetSection("Tawny:Slack")); builder.Services.Configure(TawnyAuthSchemes.WebUser, opt => { opt.HmacSecret = builder.Configuration["Tawny:WebUserHmacSecret"] ?? ""; @@ -37,7 +38,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); builder.Services.AddRateLimiter(options => { options.AddPolicy("agent-events", httpContext => diff --git a/backend/src/Tawny.Api/Services/AlertSink.cs b/backend/src/Tawny.Api/Services/AlertSink.cs index ad6425b..b45b94e 100644 --- a/backend/src/Tawny.Api/Services/AlertSink.cs +++ b/backend/src/Tawny.Api/Services/AlertSink.cs @@ -19,3 +19,18 @@ public Task PublishAsync( IReadOnlyDictionary telemetryEvents, CancellationToken ct) => Task.CompletedTask; } + +public sealed class CompositeAlertSink( + WazuhAlertSink wazuh, + SlackAlertSink slack) : IAlertSink +{ + public async Task PublishAsync( + Agent agent, + IReadOnlyList alerts, + IReadOnlyDictionary telemetryEvents, + CancellationToken ct) + { + await wazuh.PublishAsync(agent, alerts, telemetryEvents, ct); + await slack.PublishAsync(agent, alerts, telemetryEvents, ct); + } +} diff --git a/backend/src/Tawny.Api/Services/SlackAlertSink.cs b/backend/src/Tawny.Api/Services/SlackAlertSink.cs new file mode 100644 index 0000000..e152faa --- /dev/null +++ b/backend/src/Tawny.Api/Services/SlackAlertSink.cs @@ -0,0 +1,192 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using Tawny.Domain; +using Tawny.Domain.Entities; + +namespace Tawny.Api.Services; + +public sealed class SlackSinkOptions +{ + public bool Enabled { get; set; } + public string WebhookUrl { get; set; } = ""; + public string Username { get; set; } = "Tawny"; + public string IconEmoji { get; set; } = ":rotating_light:"; + public int TimeoutSeconds { get; set; } = 5; +} + +public sealed class SlackAlertSink( + HttpClient http, + IOptions options, + TimeProvider timeProvider, + ILogger log) : IAlertSink +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + private readonly SlackSinkOptions _options = options.Value; + + public async Task PublishAsync( + Agent agent, + IReadOnlyList alerts, + IReadOnlyDictionary telemetryEvents, + CancellationToken ct) + { + if (!_options.Enabled || alerts.Count == 0) + { + return; + } + + if (!Uri.TryCreate(_options.WebhookUrl, UriKind.Absolute, out var webhookUri) + || webhookUri.Scheme != Uri.UriSchemeHttps) + { + MarkFailed(alerts, "Slack sink is enabled but Tawny:Slack:WebhookUrl is not a valid HTTPS URL."); + log.LogWarning("Slack sink is enabled but Tawny:Slack:WebhookUrl is not a valid HTTPS URL."); + return; + } + + foreach (var alert in alerts) + { + if (alert.SlackNotificationStatus == AlertNotificationStatus.Sent) + { + continue; + } + + alert.SlackNotificationStatus = AlertNotificationStatus.Pending; + alert.SlackNotificationError = null; + telemetryEvents.TryGetValue(alert.TelemetryEventId, out var telemetryEvent); + + try + { + await SendAsync(webhookUri, agent, alert, telemetryEvent, ct); + alert.SlackNotificationStatus = AlertNotificationStatus.Sent; + alert.SlackNotifiedAt = timeProvider.GetUtcNow(); + alert.SlackNotificationError = null; + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + alert.SlackNotificationStatus = AlertNotificationStatus.Failed; + alert.SlackNotificationError = Truncate(ex.Message, 1024); + log.LogWarning(ex, "Failed to publish alert {AlertId} to Slack sink.", alert.Id); + } + } + } + + private async Task SendAsync( + Uri webhookUri, + Agent agent, + Alert alert, + TelemetryEvent? telemetryEvent, + CancellationToken ct) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_options.TimeoutSeconds, 1, 60))); + + var payload = SlackPayloadFormatter.Format(_options, agent, alert, telemetryEvent); + var json = JsonSerializer.Serialize(payload, JsonOptions); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await http.PostAsync(webhookUri, content, timeout.Token); + if (response.IsSuccessStatusCode) + { + return; + } + + var body = await response.Content.ReadAsStringAsync(timeout.Token); + throw new HttpRequestException( + $"Slack webhook returned {(int)response.StatusCode} {response.StatusCode}: {Truncate(body, 300)}", + null, + response.StatusCode); + } + + private static void MarkFailed(IReadOnlyList alerts, string message) + { + foreach (var alert in alerts) + { + alert.SlackNotificationStatus = AlertNotificationStatus.Failed; + alert.SlackNotificationError = Truncate(message, 1024); + } + } + + private static string Truncate(string value, int maxLength) + { + if (value.Length <= maxLength) + { + return value; + } + + return value[..maxLength]; + } +} + +public static class SlackPayloadFormatter +{ + public static object Format( + SlackSinkOptions options, + Agent agent, + Alert alert, + TelemetryEvent? telemetryEvent) + { + var severity = alert.Severity.ToString().ToLowerInvariant(); + var status = alert.Status.ToString().ToLowerInvariant(); + var eventType = telemetryEvent?.EventType.ToString() ?? "Unknown"; + var title = SlackEscape(alert.Title); + var description = SlackEscape(alert.Description ?? "No alert description captured."); + var hostname = SlackEscape(agent.Hostname); + var createdAt = alert.CreatedAt.ToUniversalTime().ToString("u"); + + return new + { + text = $"[{severity}] {alert.Title} on {agent.Hostname}", + username = string.IsNullOrWhiteSpace(options.Username) ? "Tawny" : options.Username, + icon_emoji = string.IsNullOrWhiteSpace(options.IconEmoji) ? ":rotating_light:" : options.IconEmoji, + blocks = new object[] + { + new + { + type = "section", + text = new + { + type = "mrkdwn", + text = $"*{title}*\n{Truncate(description, 1800)}", + }, + }, + new + { + type = "section", + fields = new object[] + { + Field("Severity", severity), + Field("Status", status), + Field("Agent", hostname), + Field("Event", eventType), + Field("Alert ID", alert.Id.ToString()), + Field("Created", createdAt), + }, + }, + }, + }; + } + + private static object Field(string title, string value) => new + { + type = "mrkdwn", + text = $"*{title}:*\n{SlackEscape(value)}", + }; + + private static string SlackEscape(string value) => value + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal); + + private static string Truncate(string value, int maxLength) + { + if (value.Length <= maxLength) + { + return value; + } + + return value[..maxLength]; + } +} diff --git a/backend/src/Tawny.Api/appsettings.json b/backend/src/Tawny.Api/appsettings.json index ece2c39..8ff223b 100644 --- a/backend/src/Tawny.Api/appsettings.json +++ b/backend/src/Tawny.Api/appsettings.json @@ -32,6 +32,13 @@ "AppName": "tawny", "Hostname": "", "MaxMessageBytes": 8192 + }, + "Slack": { + "Enabled": false, + "WebhookUrl": "", + "Username": "Tawny", + "IconEmoji": ":rotating_light:", + "TimeoutSeconds": 5 } }, "Serilog": { diff --git a/backend/src/Tawny.Domain/Entities/Alert.cs b/backend/src/Tawny.Domain/Entities/Alert.cs index 3185b61..996a539 100644 --- a/backend/src/Tawny.Domain/Entities/Alert.cs +++ b/backend/src/Tawny.Domain/Entities/Alert.cs @@ -8,6 +8,9 @@ public class Alert public long TelemetryEventId { get; set; } public AlertSeverity Severity { get; set; } public AlertStatus Status { get; set; } = AlertStatus.Open; + public AlertNotificationStatus SlackNotificationStatus { get; set; } = AlertNotificationStatus.NotConfigured; + public DateTimeOffset? SlackNotifiedAt { get; set; } + public string? SlackNotificationError { get; set; } public required string Title { get; set; } public string? Description { get; set; } public DateTimeOffset CreatedAt { get; set; } diff --git a/backend/src/Tawny.Domain/Enums.cs b/backend/src/Tawny.Domain/Enums.cs index 0489c2e..3de1235 100644 --- a/backend/src/Tawny.Domain/Enums.cs +++ b/backend/src/Tawny.Domain/Enums.cs @@ -52,6 +52,14 @@ public enum AlertStatus Resolved = 2, } +public enum AlertNotificationStatus +{ + NotConfigured = 0, + Pending = 1, + Sent = 2, + Failed = 3, +} + public enum AlertRuleOperator { Exists = 0, diff --git a/backend/src/Tawny.Infrastructure/Migrations/20260514061500_AddSlackAlertNotifications.cs b/backend/src/Tawny.Infrastructure/Migrations/20260514061500_AddSlackAlertNotifications.cs new file mode 100644 index 0000000..b990d42 --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Migrations/20260514061500_AddSlackAlertNotifications.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Tawny.Infrastructure; + +#nullable disable + +namespace Tawny.Infrastructure.Migrations +{ + /// + [DbContext(typeof(TawnyDbContext))] + [Migration("20260514061500_AddSlackAlertNotifications")] + public partial class AddSlackAlertNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SlackNotificationError", + table: "Alerts", + type: "nvarchar(1024)", + maxLength: 1024, + nullable: true); + + migrationBuilder.AddColumn( + name: "SlackNotificationStatus", + table: "Alerts", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "SlackNotifiedAt", + table: "Alerts", + type: "datetimeoffset", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SlackNotificationError", + table: "Alerts"); + + migrationBuilder.DropColumn( + name: "SlackNotificationStatus", + table: "Alerts"); + + migrationBuilder.DropColumn( + name: "SlackNotifiedAt", + table: "Alerts"); + } + } +} diff --git a/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs b/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs index 2d621e0..0701bba 100644 --- a/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs +++ b/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs @@ -139,6 +139,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Severity") .HasColumnType("int"); + b.Property("SlackNotificationError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("SlackNotificationStatus") + .HasColumnType("int"); + + b.Property("SlackNotifiedAt") + .HasColumnType("datetimeoffset"); + b.Property("Status") .HasColumnType("int"); diff --git a/backend/src/Tawny.Infrastructure/TawnyDbContext.cs b/backend/src/Tawny.Infrastructure/TawnyDbContext.cs index db66829..dc394a6 100644 --- a/backend/src/Tawny.Infrastructure/TawnyDbContext.cs +++ b/backend/src/Tawny.Infrastructure/TawnyDbContext.cs @@ -113,6 +113,7 @@ protected override void OnModelCreating(ModelBuilder b) e.HasKey(a => a.Id); e.Property(a => a.Title).HasMaxLength(255).IsRequired(); e.Property(a => a.Description).HasColumnType("nvarchar(max)"); + e.Property(a => a.SlackNotificationError).HasMaxLength(1024); e.HasOne(a => a.AlertRule) .WithMany(r => r.Alerts) .HasForeignKey(a => a.AlertRuleId) diff --git a/backend/tests/Tawny.Api.Tests/SlackAlertSinkTests.cs b/backend/tests/Tawny.Api.Tests/SlackAlertSinkTests.cs new file mode 100644 index 0000000..8927fa4 --- /dev/null +++ b/backend/tests/Tawny.Api.Tests/SlackAlertSinkTests.cs @@ -0,0 +1,108 @@ +using System.Net; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Tawny.Api.Services; +using Tawny.Domain; +using Tawny.Domain.Entities; +using Xunit; + +namespace Tawny.Api.Tests; + +public class SlackAlertSinkTests +{ + [Fact] + public async Task PublishAsync_SendsWebhookAndMarksAlertSent() + { + var now = DateTimeOffset.Parse("2026-05-14T08:00:00Z"); + var handler = new RecordingHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)); + var sink = CreateSink(handler, now); + var agent = CreateAgent(); + var alert = CreateAlert(agent); + + await sink.PublishAsync(agent, [alert], new Dictionary(), CancellationToken.None); + + handler.RequestCount.Should().Be(1); + handler.LastUri.Should().Be("https://hooks.slack.test/services/test"); + handler.LastBody.Should().Contain("Suspicious process"); + handler.LastBody.Should().Contain("linux-host-01"); + alert.SlackNotificationStatus.Should().Be(AlertNotificationStatus.Sent); + alert.SlackNotifiedAt.Should().Be(now); + alert.SlackNotificationError.Should().BeNull(); + } + + [Fact] + public async Task PublishAsync_MarksAlertFailedWhenWebhookRejectsRequest() + { + var handler = new RecordingHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + Content = new StringContent("rate limited"), + }); + var sink = CreateSink(handler, DateTimeOffset.UtcNow); + var agent = CreateAgent(); + var alert = CreateAlert(agent); + + await sink.PublishAsync(agent, [alert], new Dictionary(), CancellationToken.None); + + alert.SlackNotificationStatus.Should().Be(AlertNotificationStatus.Failed); + alert.SlackNotificationError.Should().Contain("429"); + alert.SlackNotificationError.Should().Contain("rate limited"); + } + + private static SlackAlertSink CreateSink(RecordingHandler handler, DateTimeOffset now) + { + var http = new HttpClient(handler); + var options = Options.Create(new SlackSinkOptions + { + Enabled = true, + WebhookUrl = "https://hooks.slack.test/services/test", + }); + return new SlackAlertSink(http, options, new StaticTimeProvider(now), NullLogger.Instance); + } + + private static Agent CreateAgent() => new() + { + Id = Guid.NewGuid(), + TenantId = Guid.NewGuid(), + Hostname = "linux-host-01", + OperatingSystem = AgentPlatform.Linux, + OsVersion = "6.12", + Architecture = AgentArchitecture.Arm64, + AgentVersion = "0.1.0", + EnrolledAt = DateTimeOffset.UtcNow, + }; + + private static Alert CreateAlert(Agent agent) => new() + { + Id = 7, + AgentId = agent.Id, + AlertRuleId = Guid.NewGuid(), + TelemetryEventId = 42, + Severity = AlertSeverity.High, + Title = "Suspicious process", + Description = "Matched suspicious.exe.", + CreatedAt = DateTimeOffset.Parse("2026-05-14T08:00:02Z"), + }; + + private sealed class StaticTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private sealed class RecordingHandler(Func responder) : HttpMessageHandler + { + public int RequestCount { get; private set; } + public string? LastUri { get; private set; } + public string? LastBody { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestCount++; + LastUri = request.RequestUri?.ToString(); + LastBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + return responder(request); + } + } +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 049577c..1e434d6 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -44,6 +44,11 @@ services: Tawny__Wazuh__AppName: "${TAWNY_WAZUH_APP_NAME:-tawny}" Tawny__Wazuh__Hostname: "${TAWNY_WAZUH_HOSTNAME:-}" Tawny__Wazuh__MaxMessageBytes: "${TAWNY_WAZUH_MAX_MESSAGE_BYTES:-8192}" + Tawny__Slack__Enabled: "${TAWNY_SLACK_ENABLED:-false}" + Tawny__Slack__WebhookUrl: "${TAWNY_SLACK_WEBHOOK_URL:-}" + Tawny__Slack__Username: "${TAWNY_SLACK_USERNAME:-Tawny}" + Tawny__Slack__IconEmoji: "${TAWNY_SLACK_ICON_EMOJI:-:rotating_light:}" + Tawny__Slack__TimeoutSeconds: "${TAWNY_SLACK_TIMEOUT_SECONDS:-5}" ports: - "${TAWNY_API_PORT:-5080}:5080" volumes: diff --git a/docs/production.md b/docs/production.md index c3088e4..3d9ec97 100644 --- a/docs/production.md +++ b/docs/production.md @@ -152,3 +152,27 @@ In the Wazuh dashboard, search `wazuh-alerts-*` over the last 24 hours for: ```text rule.id:110500 OR tawny_alert OR "Linux Download To Temp Path" ``` + +## Slack alert sink + +Slack alerting is disabled by default. Create a Slack incoming webhook and configure the API with: + +```bash +Tawny__Slack__Enabled=true +Tawny__Slack__WebhookUrl=https://hooks.slack.com/services/... +Tawny__Slack__Username=Tawny +Tawny__Slack__IconEmoji=:rotating_light: +Tawny__Slack__TimeoutSeconds=5 +``` + +For Docker deployments, use the matching environment variables: + +```bash +TAWNY_SLACK_ENABLED=true +TAWNY_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... +TAWNY_SLACK_USERNAME=Tawny +TAWNY_SLACK_ICON_EMOJI=:rotating_light: +TAWNY_SLACK_TIMEOUT_SECONDS=5 +``` + +Only new alerts generated after Slack is enabled are posted. Tawny records Slack delivery state on the alert row so the dashboard can show whether the webhook send was `sent`, `failed`, `pending`, or `not_configured`. diff --git a/web/app/alerts/alerts-table.tsx b/web/app/alerts/alerts-table.tsx index 8e90d42..30afb84 100644 --- a/web/app/alerts/alerts-table.tsx +++ b/web/app/alerts/alerts-table.tsx @@ -8,6 +8,7 @@ import { cn } from "@/lib/cn"; type Severity = "low" | "medium" | "high" | "critical"; type AlertStatus = "open" | "acknowledged" | "resolved"; +type AlertNotificationStatus = "not_configured" | "pending" | "sent" | "failed"; type EventType = | "process_snapshot" | "network_snapshot" @@ -34,6 +35,9 @@ export type Alert = { payload: unknown; severity: Severity; status: AlertStatus; + slack_notification_status: AlertNotificationStatus; + slack_notified_at: string | null; + slack_notification_error: string | null; title: string; description: string | null; created_at: string; @@ -110,6 +114,13 @@ const statusTone: Record = { resolved: "bg-[color:var(--color-muted)] text-[color:var(--color-muted-foreground)] ring-[color:var(--color-border)]", }; +const notificationTone: Record = { + not_configured: "bg-[color:var(--color-muted)] text-[color:var(--color-muted-foreground)] ring-[color:var(--color-border)]", + pending: "bg-[color:var(--color-accent)]/12 text-[color:var(--color-accent)] ring-[color:var(--color-accent)]/25", + sent: "bg-[color:var(--color-success)]/12 text-[color:var(--color-success)] ring-[color:var(--color-success)]/25", + failed: "bg-[color:var(--color-danger)]/12 text-[color:var(--color-danger)] ring-[color:var(--color-danger)]/25", +}; + const eventTypeLabels: Record = { process_snapshot: "Process", network_snapshot: "Network", @@ -122,12 +133,14 @@ const eventTypeLabels: Record = { export function AlertsTable({ alerts }: { alerts: Alert[] }) { const [expanded, setExpanded] = useState(alerts[0]?.id ?? null); const openCount = alerts.filter((alert) => alert.status === "open").length; + const slackSentCount = alerts.filter((alert) => alert.slack_notification_status === "sent").length; return (
+

@@ -135,8 +148,8 @@ export function AlertsTable({ alerts }: { alerts: Alert[] }) {

-
- +
+
+ {alerts.length === 0 && ( - @@ -222,13 +236,18 @@ function FragmentRow({ + {isExpanded ? ( - @@ -244,6 +263,7 @@ function AlertDetails({ alert, evidence }: { alert: Alert; evidence: EvidenceSec +
@@ -513,6 +533,22 @@ function formatPredicate(alert: Alert) { return `${alert.rule_payload_path ?? "event"} ${alert.rule_operator}${alert.rule_match_value ? ` ${alert.rule_match_value}` : ""}`; } +function formatNotificationStatus(status: AlertNotificationStatus) { + return status.replace("_", " "); +} + +function formatSlackDelivery(alert: Alert) { + if (alert.slack_notification_status === "sent" && alert.slack_notified_at) { + return `Sent ${formatDate(alert.slack_notified_at)}`; + } + + if (alert.slack_notification_status === "failed") { + return alert.slack_notification_error ? `Failed: ${alert.slack_notification_error}` : "Failed"; + } + + return formatNotificationStatus(alert.slack_notification_status); +} + function formatDate(value: string) { return new Intl.DateTimeFormat(undefined, { month: "short",
@@ -145,13 +158,14 @@ export function AlertsTable({ alerts }: { alerts: Alert[] }) { Agent Severity StatusSlack Created
+ No alerts have fired yet.
{alert.status} + + {formatNotificationStatus(alert.slack_notification_status)} + + {formatDate(alert.created_at)}
+