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
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

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.

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.
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.

## Screenshots

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

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

![Detections](docs/screenshots/detections.png)

![Alerts](docs/screenshots/alerts.png)

![Agents](docs/screenshots/agents.png)

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

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

![Light detections](docs/screenshots/light/detections.png)

![Light alerts](docs/screenshots/light/alerts.png)

![Light agents](docs/screenshots/light/agents.png)

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

</details>

<details open>
<summary><strong>Wazuh integration</strong></summary>

![Wazuh Tawny events](docs/screenshots/integrations/wazuh-tawny-events.png)

![Wazuh Tawny event fields](docs/screenshots/integrations/wazuh-tawny-event-detail.png)

</details>

Generate README-ready product screenshots from the running Docker stack:

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

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.

## Wazuh sink

Tawny can forward generated alerts to Wazuh over syslog. Enable the sink by pointing the API at a Wazuh manager or syslog listener:

```bash
TAWNY_WAZUH_ENABLED=true
TAWNY_WAZUH_HOST=wazuh-manager.example.com
TAWNY_WAZUH_PORT=514
TAWNY_WAZUH_PROTOCOL=udp
```

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.

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.

## 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
8 changes: 7 additions & 1 deletion backend/src/Tawny.Api/Controllers/TelemetryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ public class TelemetryController(
TawnyDbContext db,
AuditLogger audit,
IValidator<IngestEventsRequest> validator,
AlertRuleEvaluator alertRules) : ControllerBase
AlertRuleEvaluator alertRules,
IAlertSink alertSink) : ControllerBase
{
private const int MaxRequestBytes = 1024 * 1024;
private const int DefaultLimit = 50;
Expand Down Expand Up @@ -84,6 +85,11 @@ public async Task<IActionResult> Ingest(
received_at = receivedAt,
});
await db.SaveChangesAsync(ct);
await alertSink.PublishAsync(
agent,
alerts,
events.ToDictionary(e => e.Id),
ct);
}

return Accepted();
Expand Down
2 changes: 2 additions & 0 deletions backend/src/Tawny.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
builder.Services.Configure<EnrollmentOptions>(builder.Configuration.GetSection("Tawny:Enrollment"));
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<WebUserAuthOptions>(TawnyAuthSchemes.WebUser, opt =>
{
opt.HmacSecret = builder.Configuration["Tawny:WebUserHmacSecret"] ?? "";
Expand All @@ -36,6 +37,7 @@
builder.Services.AddScoped<AuditLogger>();
builder.Services.AddScoped<AlertRuleEvaluator>();
builder.Services.AddScoped<SigmaRuleImporter>();
builder.Services.AddSingleton<IAlertSink, WazuhAlertSink>();
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("agent-events", httpContext =>
Expand Down
21 changes: 21 additions & 0 deletions backend/src/Tawny.Api/Services/AlertSink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Tawny.Domain.Entities;

namespace Tawny.Api.Services;

public interface IAlertSink
{
Task PublishAsync(
Agent agent,
IReadOnlyList<Alert> alerts,
IReadOnlyDictionary<long, TelemetryEvent> telemetryEvents,
CancellationToken ct);
}

public sealed class NoopAlertSink : IAlertSink
{
public Task PublishAsync(
Agent agent,
IReadOnlyList<Alert> alerts,
IReadOnlyDictionary<long, TelemetryEvent> telemetryEvents,
CancellationToken ct) => Task.CompletedTask;
}
188 changes: 188 additions & 0 deletions backend/src/Tawny.Api/Services/WazuhAlertSink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
using System.Globalization;
using System.Net.Sockets;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using Tawny.Domain;
using Tawny.Domain.Entities;

namespace Tawny.Api.Services;

public sealed class WazuhSinkOptions
{
public bool Enabled { get; set; }
public string Host { get; set; } = "";
public int Port { get; set; } = 514;
public string Protocol { get; set; } = "udp";
public int Facility { get; set; } = 16;
public string AppName { get; set; } = "tawny";
public string Hostname { get; set; } = "";
public int MaxMessageBytes { get; set; } = 8192;
}

public sealed class WazuhAlertSink(
IOptions<WazuhSinkOptions> options,
ILogger<WazuhAlertSink> log) : IAlertSink
{
private readonly WazuhSinkOptions _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 (string.IsNullOrWhiteSpace(_options.Host))
{
log.LogWarning("Wazuh sink is enabled but Tawny:Wazuh:Host is empty.");
return;
}
if (_options.Port is < 1 or > 65535)
{
log.LogWarning("Wazuh sink is enabled but Tawny:Wazuh:Port is outside the valid range.");
return;
}
if (!string.Equals(_options.Protocol, "udp", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(_options.Protocol, "tcp", StringComparison.OrdinalIgnoreCase))
{
log.LogWarning("Wazuh sink is enabled but Tawny:Wazuh:Protocol must be udp or tcp.");
return;
}

foreach (var alert in alerts)
{
telemetryEvents.TryGetValue(alert.TelemetryEventId, out var telemetryEvent);
var message = WazuhSyslogFormatter.Format(_options, agent, alert, telemetryEvent);
try
{
await SendAsync(message, ct);
}
catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException)
{
log.LogWarning(ex, "Failed to publish alert {AlertId} to Wazuh sink.", alert.Id);
}
}
log.LogInformation(
"Published {AlertCount} alert(s) to Wazuh sink {Host}:{Port}/{Protocol}.",
alerts.Count,
_options.Host,
_options.Port,
_options.Protocol);
}

private async Task SendAsync(string message, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(message);
if (string.Equals(_options.Protocol, "tcp", StringComparison.OrdinalIgnoreCase))
{
using var tcp = new TcpClient();
await tcp.ConnectAsync(_options.Host, _options.Port, ct);
await using var stream = tcp.GetStream();
await stream.WriteAsync(bytes, ct);
await stream.WriteAsync("\n"u8.ToArray(), ct);
return;
}

using var udp = new UdpClient();
await udp.SendAsync(bytes, _options.Host, _options.Port, ct);
}
}

public static class WazuhSyslogFormatter
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },
};

public static string Format(
WazuhSinkOptions options,
Agent agent,
Alert alert,
TelemetryEvent? telemetryEvent)
{
var timestamp = alert.CreatedAt.ToUniversalTime().ToString("MMM dd HH:mm:ss", CultureInfo.InvariantCulture);
var hostname = SanitizeSyslogToken(string.IsNullOrWhiteSpace(options.Hostname)
? Environment.MachineName
: options.Hostname);
var appName = SanitizeSyslogToken(options.AppName);
var priority = Math.Clamp(options.Facility, 0, 23) * 8 + Severity(alert.Severity);

var eventJson = BuildJson(agent, alert, telemetryEvent, includeTelemetryPayload: true);
var message = $"<{priority}>{timestamp} {hostname} {appName}: {eventJson}";
var maxBytes = Math.Max(options.MaxMessageBytes, 1024);
if (Encoding.UTF8.GetByteCount(message) <= maxBytes)
{
return message;
}

eventJson = BuildJson(agent, alert, telemetryEvent, includeTelemetryPayload: false);
return $"<{priority}>{timestamp} {hostname} {appName}: {eventJson}";
}

private static string BuildJson(
Agent agent,
Alert alert,
TelemetryEvent? telemetryEvent,
bool includeTelemetryPayload)
{
var payload = includeTelemetryPayload && telemetryEvent is not null
? telemetryEvent.Payload
: null;

return JsonSerializer.Serialize(new
{
integration = "tawny",
event_kind = "alert",
alert_id = alert.Id,
alert_title = alert.Title,
alert_description = alert.Description,
alert_severity = alert.Severity,
alert_status = alert.Status,
alert_created_at = alert.CreatedAt,
rule_id = alert.AlertRuleId,
agent_id = agent.Id,
tenant_id = agent.TenantId,
agent_hostname = agent.Hostname,
agent_os = agent.OperatingSystem,
agent_os_version = agent.OsVersion,
agent_architecture = agent.Architecture,
agent_version = agent.AgentVersion,
telemetry_id = telemetryEvent?.Id,
telemetry_type = telemetryEvent?.EventType,
telemetry_occurred_at = telemetryEvent?.OccurredAt,
telemetry_received_at = telemetryEvent?.ReceivedAt,
telemetry_payload_json = payload,
telemetry_payload_omitted = telemetryEvent is not null && !includeTelemetryPayload,
}, JsonOptions);
}

private static int Severity(AlertSeverity severity) => severity switch
{
AlertSeverity.Critical => 2,
AlertSeverity.High => 3,
AlertSeverity.Medium => 4,
AlertSeverity.Low => 5,
_ => 5,
};

private static string SanitizeSyslogToken(string value)
{
var token = string.IsNullOrWhiteSpace(value) ? "tawny" : value.Trim();
var builder = new StringBuilder(token.Length);
foreach (var c in token)
{
builder.Append(char.IsWhiteSpace(c) || c == ':' ? '-' : c);
}
return builder.ToString();
}
}
10 changes: 10 additions & 0 deletions backend/src/Tawny.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@
"LocalPath": "backups/telemetry",
"S3Bucket": "",
"S3Prefix": "telemetry"
},
"Wazuh": {
"Enabled": false,
"Host": "",
"Port": 514,
"Protocol": "udp",
"Facility": 16,
"AppName": "tawny",
"Hostname": "",
"MaxMessageBytes": 8192
}
},
"Serilog": {
Expand Down
Loading
Loading