Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<allowed-ips>` 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.
Expand Down
6 changes: 6 additions & 0 deletions backend/src/Tawny.Api/Controllers/AlertsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ public async Task<ActionResult<IReadOnlyList<AlertResponse>>> List(
a.TelemetryEvent.Payload,
a.Severity,
a.Status,
a.SlackNotificationStatus,
a.SlackNotifiedAt,
a.SlackNotificationError,
a.Title,
a.Description,
a.CreatedAt,
Expand All @@ -72,6 +75,9 @@ public async Task<ActionResult<IReadOnlyList<AlertResponse>>> List(
JsonSerializer.Deserialize<JsonElement>(a.Payload),
a.Severity,
a.Status,
a.SlackNotificationStatus,
a.SlackNotifiedAt,
a.SlackNotificationError,
a.Title,
a.Description,
a.CreatedAt)).ToList());
Expand Down
1 change: 1 addition & 0 deletions backend/src/Tawny.Api/Controllers/TelemetryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ await alertSink.PublishAsync(
alerts,
events.ToDictionary(e => e.Id),
ct);
await db.SaveChangesAsync(ct);
}

return Accepted();
Expand Down
3 changes: 3 additions & 0 deletions backend/src/Tawny.Api/Models/AlertDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
5 changes: 4 additions & 1 deletion backend/src/Tawny.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
builder.Services.Configure<RetentionOptions>(builder.Configuration.GetSection("Tawny:Retention"));
builder.Services.Configure<TelemetryBackupOptions>(builder.Configuration.GetSection("Tawny:TelemetryBackup"));
builder.Services.Configure<WazuhSinkOptions>(builder.Configuration.GetSection("Tawny:Wazuh"));
builder.Services.Configure<SlackSinkOptions>(builder.Configuration.GetSection("Tawny:Slack"));
builder.Services.Configure<WebUserAuthOptions>(TawnyAuthSchemes.WebUser, opt =>
{
opt.HmacSecret = builder.Configuration["Tawny:WebUserHmacSecret"] ?? "";
Expand All @@ -37,7 +38,9 @@
builder.Services.AddScoped<AuditLogger>();
builder.Services.AddScoped<AlertRuleEvaluator>();
builder.Services.AddScoped<SigmaRuleImporter>();
builder.Services.AddSingleton<IAlertSink, WazuhAlertSink>();
builder.Services.AddSingleton<WazuhAlertSink>();
builder.Services.AddHttpClient<SlackAlertSink>();
builder.Services.AddScoped<IAlertSink, CompositeAlertSink>();
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("agent-events", httpContext =>
Expand Down
15 changes: 15 additions & 0 deletions backend/src/Tawny.Api/Services/AlertSink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,18 @@ public Task PublishAsync(
IReadOnlyDictionary<long, TelemetryEvent> telemetryEvents,
CancellationToken ct) => Task.CompletedTask;
}

public sealed class CompositeAlertSink(
WazuhAlertSink wazuh,
SlackAlertSink slack) : IAlertSink
{
public async Task PublishAsync(
Agent agent,
IReadOnlyList<Alert> alerts,
IReadOnlyDictionary<long, TelemetryEvent> telemetryEvents,
CancellationToken ct)
{
await wazuh.PublishAsync(agent, alerts, telemetryEvents, ct);
await slack.PublishAsync(agent, alerts, telemetryEvents, ct);
}
}
192 changes: 192 additions & 0 deletions backend/src/Tawny.Api/Services/SlackAlertSink.cs
Original file line number Diff line number Diff line change
@@ -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<SlackSinkOptions> options,
TimeProvider timeProvider,
ILogger<SlackAlertSink> 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<Alert> alerts,
IReadOnlyDictionary<long, TelemetryEvent> 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<Alert> 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("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);

private static string Truncate(string value, int maxLength)
{
if (value.Length <= maxLength)
{
return value;
}

return value[..maxLength];
}
}
7 changes: 7 additions & 0 deletions backend/src/Tawny.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
"AppName": "tawny",
"Hostname": "",
"MaxMessageBytes": 8192
},
"Slack": {
"Enabled": false,
"WebhookUrl": "",
"Username": "Tawny",
"IconEmoji": ":rotating_light:",
"TimeoutSeconds": 5
}
},
"Serilog": {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/Tawny.Domain/Entities/Alert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
8 changes: 8 additions & 0 deletions backend/src/Tawny.Domain/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Tawny.Infrastructure;

#nullable disable

namespace Tawny.Infrastructure.Migrations
{
/// <inheritdoc />
[DbContext(typeof(TawnyDbContext))]
[Migration("20260514061500_AddSlackAlertNotifications")]
public partial class AddSlackAlertNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SlackNotificationError",
table: "Alerts",
type: "nvarchar(1024)",
maxLength: 1024,
nullable: true);

migrationBuilder.AddColumn<int>(
name: "SlackNotificationStatus",
table: "Alerts",
type: "int",
nullable: false,
defaultValue: 0);

migrationBuilder.AddColumn<DateTimeOffset>(
name: "SlackNotifiedAt",
table: "Alerts",
type: "datetimeoffset",
nullable: true);
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SlackNotificationError",
table: "Alerts");

migrationBuilder.DropColumn(
name: "SlackNotificationStatus",
table: "Alerts");

migrationBuilder.DropColumn(
name: "SlackNotifiedAt",
table: "Alerts");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<int>("Severity")
.HasColumnType("int");

b.Property<string>("SlackNotificationError")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");

b.Property<int>("SlackNotificationStatus")
.HasColumnType("int");

b.Property<DateTimeOffset?>("SlackNotifiedAt")
.HasColumnType("datetimeoffset");

b.Property<int>("Status")
.HasColumnType("int");

Expand Down
Loading
Loading