Skip to content

Commit b1b3d56

Browse files
committed
Add Wazuh syslog alert sink
1 parent db389f5 commit b1b3d56

36 files changed

Lines changed: 565 additions & 3 deletions

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
99
Tawny is a self-hosted, lightweight EDR (endpoint detection and response) system. A tiny Zig agent runs on Windows, macOS, and Linux, ships telemetry to a .NET 10 backend over HTTPS, and surfaces it through a polished Next.js 16 dashboard. Hangfire handles offline detection, retention, backups, and agent update checks.
1010

11-
The MVP is intentionally small. No kernel hooks, no driver signing, no SIEM-grade ingestion. Clean architecture, real telemetry, and a UI that looks like a product.
11+
The MVP is intentionally small. No kernel hooks, no driver signing, and no attempt to replace a SIEM. Clean architecture, real telemetry, Wazuh alert forwarding, and a UI that looks like a product.
1212

1313
## Screenshots
1414

@@ -19,6 +19,10 @@ The MVP is intentionally small. No kernel hooks, no driver signing, no SIEM-grad
1919

2020
![Command palette](docs/screenshots/command-palette.png)
2121

22+
![Detections](docs/screenshots/detections.png)
23+
24+
![Alerts](docs/screenshots/alerts.png)
25+
2226
![Agents](docs/screenshots/agents.png)
2327

2428
![Agent detail](docs/screenshots/agent-detail-processes.png)
@@ -42,6 +46,10 @@ The MVP is intentionally small. No kernel hooks, no driver signing, no SIEM-grad
4246

4347
![Light command palette](docs/screenshots/light/command-palette.png)
4448

49+
![Light detections](docs/screenshots/light/detections.png)
50+
51+
![Light alerts](docs/screenshots/light/alerts.png)
52+
4553
![Light agents](docs/screenshots/light/agents.png)
4654

4755
![Light agent detail](docs/screenshots/light/agent-detail-processes.png)
@@ -58,6 +66,15 @@ The MVP is intentionally small. No kernel hooks, no driver signing, no SIEM-grad
5866

5967
</details>
6068

69+
<details open>
70+
<summary><strong>Wazuh integration</strong></summary>
71+
72+
![Wazuh Tawny events](docs/screenshots/integrations/wazuh-tawny-events.png)
73+
74+
![Wazuh Tawny event fields](docs/screenshots/integrations/wazuh-tawny-event-detail.png)
75+
76+
</details>
77+
6178
Generate README-ready product screenshots from the running Docker stack:
6279

6380
```bash
@@ -237,6 +254,21 @@ Post-MVP: Linux agent (eBPF), kernel-level collection, broader Sigma coverage, r
237254

238255
Alert rules are moving toward Sigma-compatible detection-as-code instead of a custom Tawny rule language. The current importer accepts a focused Sigma subset: `title`, `id`, `description`, `logsource`, one named `detection` selection, a single-selection `condition`, and `level`. Tawny compiles that into its event matcher and keeps the original Sigma YAML with the rule so the supported subset can grow without inventing a parallel format.
239256

257+
## Wazuh sink
258+
259+
Tawny can forward generated alerts to Wazuh over syslog. Enable the sink by pointing the API at a Wazuh manager or syslog listener:
260+
261+
```bash
262+
TAWNY_WAZUH_ENABLED=true
263+
TAWNY_WAZUH_HOST=wazuh-manager.example.com
264+
TAWNY_WAZUH_PORT=514
265+
TAWNY_WAZUH_PROTOCOL=udp
266+
```
267+
268+
Each alert is emitted as one syslog event with a flat JSON body containing Tawny tenant, agent, alert, rule, telemetry event, and matched telemetry payload fields. Configure Wazuh to accept syslog input from the Tawny API host, then install the decoder/rules in `integrations/wazuh/` so Tawny events appear as Wazuh alerts.
269+
270+
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.
271+
240272
## Security notes
241273

242274
- Agent JWTs are bearer tokens. Anyone with the file on disk can impersonate the agent. Mitigate later with the OS keystore.

backend/src/Tawny.Api/Controllers/TelemetryController.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ public class TelemetryController(
1919
TawnyDbContext db,
2020
AuditLogger audit,
2121
IValidator<IngestEventsRequest> validator,
22-
AlertRuleEvaluator alertRules) : ControllerBase
22+
AlertRuleEvaluator alertRules,
23+
IAlertSink alertSink) : ControllerBase
2324
{
2425
private const int MaxRequestBytes = 1024 * 1024;
2526
private const int DefaultLimit = 50;
@@ -84,6 +85,11 @@ public async Task<IActionResult> Ingest(
8485
received_at = receivedAt,
8586
});
8687
await db.SaveChangesAsync(ct);
88+
await alertSink.PublishAsync(
89+
agent,
90+
alerts,
91+
events.ToDictionary(e => e.Id),
92+
ct);
8793
}
8894

8995
return Accepted();

backend/src/Tawny.Api/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
builder.Services.Configure<EnrollmentOptions>(builder.Configuration.GetSection("Tawny:Enrollment"));
2525
builder.Services.Configure<RetentionOptions>(builder.Configuration.GetSection("Tawny:Retention"));
2626
builder.Services.Configure<TelemetryBackupOptions>(builder.Configuration.GetSection("Tawny:TelemetryBackup"));
27+
builder.Services.Configure<WazuhSinkOptions>(builder.Configuration.GetSection("Tawny:Wazuh"));
2728
builder.Services.Configure<WebUserAuthOptions>(TawnyAuthSchemes.WebUser, opt =>
2829
{
2930
opt.HmacSecret = builder.Configuration["Tawny:WebUserHmacSecret"] ?? "";
@@ -36,6 +37,7 @@
3637
builder.Services.AddScoped<AuditLogger>();
3738
builder.Services.AddScoped<AlertRuleEvaluator>();
3839
builder.Services.AddScoped<SigmaRuleImporter>();
40+
builder.Services.AddSingleton<IAlertSink, WazuhAlertSink>();
3941
builder.Services.AddRateLimiter(options =>
4042
{
4143
options.AddPolicy("agent-events", httpContext =>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using Tawny.Domain.Entities;
2+
3+
namespace Tawny.Api.Services;
4+
5+
public interface IAlertSink
6+
{
7+
Task PublishAsync(
8+
Agent agent,
9+
IReadOnlyList<Alert> alerts,
10+
IReadOnlyDictionary<long, TelemetryEvent> telemetryEvents,
11+
CancellationToken ct);
12+
}
13+
14+
public sealed class NoopAlertSink : IAlertSink
15+
{
16+
public Task PublishAsync(
17+
Agent agent,
18+
IReadOnlyList<Alert> alerts,
19+
IReadOnlyDictionary<long, TelemetryEvent> telemetryEvents,
20+
CancellationToken ct) => Task.CompletedTask;
21+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
using System.Globalization;
2+
using System.Net.Sockets;
3+
using System.Text;
4+
using System.Text.Encodings.Web;
5+
using System.Text.Json;
6+
using System.Text.Json.Serialization;
7+
using Microsoft.Extensions.Options;
8+
using Tawny.Domain;
9+
using Tawny.Domain.Entities;
10+
11+
namespace Tawny.Api.Services;
12+
13+
public sealed class WazuhSinkOptions
14+
{
15+
public bool Enabled { get; set; }
16+
public string Host { get; set; } = "";
17+
public int Port { get; set; } = 514;
18+
public string Protocol { get; set; } = "udp";
19+
public int Facility { get; set; } = 16;
20+
public string AppName { get; set; } = "tawny";
21+
public string Hostname { get; set; } = "";
22+
public int MaxMessageBytes { get; set; } = 8192;
23+
}
24+
25+
public sealed class WazuhAlertSink(
26+
IOptions<WazuhSinkOptions> options,
27+
ILogger<WazuhAlertSink> log) : IAlertSink
28+
{
29+
private readonly WazuhSinkOptions _options = options.Value;
30+
31+
public async Task PublishAsync(
32+
Agent agent,
33+
IReadOnlyList<Alert> alerts,
34+
IReadOnlyDictionary<long, TelemetryEvent> telemetryEvents,
35+
CancellationToken ct)
36+
{
37+
if (!_options.Enabled || alerts.Count == 0)
38+
{
39+
return;
40+
}
41+
42+
if (string.IsNullOrWhiteSpace(_options.Host))
43+
{
44+
log.LogWarning("Wazuh sink is enabled but Tawny:Wazuh:Host is empty.");
45+
return;
46+
}
47+
if (_options.Port is < 1 or > 65535)
48+
{
49+
log.LogWarning("Wazuh sink is enabled but Tawny:Wazuh:Port is outside the valid range.");
50+
return;
51+
}
52+
if (!string.Equals(_options.Protocol, "udp", StringComparison.OrdinalIgnoreCase)
53+
&& !string.Equals(_options.Protocol, "tcp", StringComparison.OrdinalIgnoreCase))
54+
{
55+
log.LogWarning("Wazuh sink is enabled but Tawny:Wazuh:Protocol must be udp or tcp.");
56+
return;
57+
}
58+
59+
foreach (var alert in alerts)
60+
{
61+
telemetryEvents.TryGetValue(alert.TelemetryEventId, out var telemetryEvent);
62+
var message = WazuhSyslogFormatter.Format(_options, agent, alert, telemetryEvent);
63+
try
64+
{
65+
await SendAsync(message, ct);
66+
}
67+
catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException)
68+
{
69+
log.LogWarning(ex, "Failed to publish alert {AlertId} to Wazuh sink.", alert.Id);
70+
}
71+
}
72+
log.LogInformation(
73+
"Published {AlertCount} alert(s) to Wazuh sink {Host}:{Port}/{Protocol}.",
74+
alerts.Count,
75+
_options.Host,
76+
_options.Port,
77+
_options.Protocol);
78+
}
79+
80+
private async Task SendAsync(string message, CancellationToken ct)
81+
{
82+
var bytes = Encoding.UTF8.GetBytes(message);
83+
if (string.Equals(_options.Protocol, "tcp", StringComparison.OrdinalIgnoreCase))
84+
{
85+
using var tcp = new TcpClient();
86+
await tcp.ConnectAsync(_options.Host, _options.Port, ct);
87+
await using var stream = tcp.GetStream();
88+
await stream.WriteAsync(bytes, ct);
89+
await stream.WriteAsync("\n"u8.ToArray(), ct);
90+
return;
91+
}
92+
93+
using var udp = new UdpClient();
94+
await udp.SendAsync(bytes, _options.Host, _options.Port, ct);
95+
}
96+
}
97+
98+
public static class WazuhSyslogFormatter
99+
{
100+
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
101+
{
102+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
103+
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
104+
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },
105+
};
106+
107+
public static string Format(
108+
WazuhSinkOptions options,
109+
Agent agent,
110+
Alert alert,
111+
TelemetryEvent? telemetryEvent)
112+
{
113+
var timestamp = alert.CreatedAt.ToUniversalTime().ToString("MMM dd HH:mm:ss", CultureInfo.InvariantCulture);
114+
var hostname = SanitizeSyslogToken(string.IsNullOrWhiteSpace(options.Hostname)
115+
? Environment.MachineName
116+
: options.Hostname);
117+
var appName = SanitizeSyslogToken(options.AppName);
118+
var priority = Math.Clamp(options.Facility, 0, 23) * 8 + Severity(alert.Severity);
119+
120+
var eventJson = BuildJson(agent, alert, telemetryEvent, includeTelemetryPayload: true);
121+
var message = $"<{priority}>{timestamp} {hostname} {appName}: {eventJson}";
122+
var maxBytes = Math.Max(options.MaxMessageBytes, 1024);
123+
if (Encoding.UTF8.GetByteCount(message) <= maxBytes)
124+
{
125+
return message;
126+
}
127+
128+
eventJson = BuildJson(agent, alert, telemetryEvent, includeTelemetryPayload: false);
129+
return $"<{priority}>{timestamp} {hostname} {appName}: {eventJson}";
130+
}
131+
132+
private static string BuildJson(
133+
Agent agent,
134+
Alert alert,
135+
TelemetryEvent? telemetryEvent,
136+
bool includeTelemetryPayload)
137+
{
138+
var payload = includeTelemetryPayload && telemetryEvent is not null
139+
? telemetryEvent.Payload
140+
: null;
141+
142+
return JsonSerializer.Serialize(new
143+
{
144+
integration = "tawny",
145+
event_kind = "alert",
146+
alert_id = alert.Id,
147+
alert_title = alert.Title,
148+
alert_description = alert.Description,
149+
alert_severity = alert.Severity,
150+
alert_status = alert.Status,
151+
alert_created_at = alert.CreatedAt,
152+
rule_id = alert.AlertRuleId,
153+
agent_id = agent.Id,
154+
tenant_id = agent.TenantId,
155+
agent_hostname = agent.Hostname,
156+
agent_os = agent.OperatingSystem,
157+
agent_os_version = agent.OsVersion,
158+
agent_architecture = agent.Architecture,
159+
agent_version = agent.AgentVersion,
160+
telemetry_id = telemetryEvent?.Id,
161+
telemetry_type = telemetryEvent?.EventType,
162+
telemetry_occurred_at = telemetryEvent?.OccurredAt,
163+
telemetry_received_at = telemetryEvent?.ReceivedAt,
164+
telemetry_payload_json = payload,
165+
telemetry_payload_omitted = telemetryEvent is not null && !includeTelemetryPayload,
166+
}, JsonOptions);
167+
}
168+
169+
private static int Severity(AlertSeverity severity) => severity switch
170+
{
171+
AlertSeverity.Critical => 2,
172+
AlertSeverity.High => 3,
173+
AlertSeverity.Medium => 4,
174+
AlertSeverity.Low => 5,
175+
_ => 5,
176+
};
177+
178+
private static string SanitizeSyslogToken(string value)
179+
{
180+
var token = string.IsNullOrWhiteSpace(value) ? "tawny" : value.Trim();
181+
var builder = new StringBuilder(token.Length);
182+
foreach (var c in token)
183+
{
184+
builder.Append(char.IsWhiteSpace(c) || c == ':' ? '-' : c);
185+
}
186+
return builder.ToString();
187+
}
188+
}

backend/src/Tawny.Api/appsettings.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@
2222
"LocalPath": "backups/telemetry",
2323
"S3Bucket": "",
2424
"S3Prefix": "telemetry"
25+
},
26+
"Wazuh": {
27+
"Enabled": false,
28+
"Host": "",
29+
"Port": 514,
30+
"Protocol": "udp",
31+
"Facility": 16,
32+
"AppName": "tawny",
33+
"Hostname": "",
34+
"MaxMessageBytes": 8192
2535
}
2636
},
2737
"Serilog": {

0 commit comments

Comments
 (0)