diff --git a/README.md b/README.md index df4a865..9542bbf 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) @@ -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) @@ -58,6 +66,15 @@ The MVP is intentionally small. No kernel hooks, no driver signing, no SIEM-grad +
+Wazuh integration + +![Wazuh Tawny events](docs/screenshots/integrations/wazuh-tawny-events.png) + +![Wazuh Tawny event fields](docs/screenshots/integrations/wazuh-tawny-event-detail.png) + +
+ Generate README-ready product screenshots from the running Docker stack: ```bash @@ -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 `` 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. diff --git a/backend/src/Tawny.Api/Controllers/TelemetryController.cs b/backend/src/Tawny.Api/Controllers/TelemetryController.cs index ec03edb..e313007 100644 --- a/backend/src/Tawny.Api/Controllers/TelemetryController.cs +++ b/backend/src/Tawny.Api/Controllers/TelemetryController.cs @@ -19,7 +19,8 @@ public class TelemetryController( TawnyDbContext db, AuditLogger audit, IValidator validator, - AlertRuleEvaluator alertRules) : ControllerBase + AlertRuleEvaluator alertRules, + IAlertSink alertSink) : ControllerBase { private const int MaxRequestBytes = 1024 * 1024; private const int DefaultLimit = 50; @@ -84,6 +85,11 @@ public async Task Ingest( received_at = receivedAt, }); await db.SaveChangesAsync(ct); + await alertSink.PublishAsync( + agent, + alerts, + events.ToDictionary(e => e.Id), + ct); } return Accepted(); diff --git a/backend/src/Tawny.Api/Program.cs b/backend/src/Tawny.Api/Program.cs index a06825b..2e32263 100644 --- a/backend/src/Tawny.Api/Program.cs +++ b/backend/src/Tawny.Api/Program.cs @@ -24,6 +24,7 @@ builder.Services.Configure(builder.Configuration.GetSection("Tawny:Enrollment")); 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(TawnyAuthSchemes.WebUser, opt => { opt.HmacSecret = builder.Configuration["Tawny:WebUserHmacSecret"] ?? ""; @@ -36,6 +37,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); 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 new file mode 100644 index 0000000..ad6425b --- /dev/null +++ b/backend/src/Tawny.Api/Services/AlertSink.cs @@ -0,0 +1,21 @@ +using Tawny.Domain.Entities; + +namespace Tawny.Api.Services; + +public interface IAlertSink +{ + Task PublishAsync( + Agent agent, + IReadOnlyList alerts, + IReadOnlyDictionary telemetryEvents, + CancellationToken ct); +} + +public sealed class NoopAlertSink : IAlertSink +{ + public Task PublishAsync( + Agent agent, + IReadOnlyList alerts, + IReadOnlyDictionary telemetryEvents, + CancellationToken ct) => Task.CompletedTask; +} diff --git a/backend/src/Tawny.Api/Services/WazuhAlertSink.cs b/backend/src/Tawny.Api/Services/WazuhAlertSink.cs new file mode 100644 index 0000000..ebdfe57 --- /dev/null +++ b/backend/src/Tawny.Api/Services/WazuhAlertSink.cs @@ -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 options, + ILogger log) : IAlertSink +{ + private readonly WazuhSinkOptions _options = options.Value; + + public async Task PublishAsync( + Agent agent, + IReadOnlyList alerts, + IReadOnlyDictionary 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(); + } +} diff --git a/backend/src/Tawny.Api/appsettings.json b/backend/src/Tawny.Api/appsettings.json index 894ef7d..ece2c39 100644 --- a/backend/src/Tawny.Api/appsettings.json +++ b/backend/src/Tawny.Api/appsettings.json @@ -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": { diff --git a/backend/tests/Tawny.Api.Tests/WazuhSyslogFormatterTests.cs b/backend/tests/Tawny.Api.Tests/WazuhSyslogFormatterTests.cs new file mode 100644 index 0000000..c8ed22b --- /dev/null +++ b/backend/tests/Tawny.Api.Tests/WazuhSyslogFormatterTests.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using FluentAssertions; +using Tawny.Api.Services; +using Tawny.Domain; +using Tawny.Domain.Entities; +using Xunit; + +namespace Tawny.Api.Tests; + +public class WazuhSyslogFormatterTests +{ + [Fact] + public void Format_EmitsSyslogWrappedJsonAlert() + { + var agentId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var alertRuleId = Guid.NewGuid(); + var options = new WazuhSinkOptions + { + Facility = 16, + Hostname = "tawny-api", + AppName = "tawny", + }; + var agent = new Agent + { + Id = agentId, + TenantId = tenantId, + Hostname = "linux-host-01", + OperatingSystem = AgentPlatform.Linux, + OsVersion = "6.12", + Architecture = AgentArchitecture.Arm64, + AgentVersion = "0.1.0", + EnrolledAt = DateTimeOffset.UtcNow, + }; + var telemetryEvent = new TelemetryEvent + { + Id = 42, + TenantId = tenantId, + AgentId = agentId, + EventType = TelemetryEventType.ProcessSnapshot, + OccurredAt = DateTimeOffset.Parse("2026-05-14T08:00:00Z"), + ReceivedAt = DateTimeOffset.Parse("2026-05-14T08:00:01Z"), + Payload = """{"processes":[{"name":"suspicious.exe","pid":4242}]}""", + }; + var alert = new Alert + { + Id = 7, + AgentId = agentId, + AlertRuleId = alertRuleId, + TelemetryEventId = telemetryEvent.Id, + Severity = AlertSeverity.High, + Title = "Suspicious process on linux-host-01", + Description = "Matched processes Contains suspicious.exe.", + CreatedAt = DateTimeOffset.Parse("2026-05-14T08:00:02Z"), + }; + + var message = WazuhSyslogFormatter.Format(options, agent, alert, telemetryEvent); + + message.Should().StartWith("<131>May 14 08:00:02 tawny-api tawny: "); + var jsonStart = message.IndexOf('{', StringComparison.Ordinal); + using var doc = JsonDocument.Parse(message[jsonStart..]); + var root = doc.RootElement; + root.GetProperty("integration").GetString().Should().Be("tawny"); + root.GetProperty("event_kind").GetString().Should().Be("alert"); + root.GetProperty("alert_id").GetInt64().Should().Be(7); + root.GetProperty("tenant_id").GetGuid().Should().Be(tenantId); + root.GetProperty("telemetry_type").GetString().Should().Be("process_snapshot"); + var payloadJson = root.GetProperty("telemetry_payload_json").GetString(); + payloadJson.Should().NotBeNullOrWhiteSpace(); + using var payloadDoc = JsonDocument.Parse(payloadJson!); + payloadDoc.RootElement + .GetProperty("processes")[0] + .GetProperty("pid") + .GetInt32() + .Should().Be(4242); + } + + [Fact] + public void Format_OmitsTelemetryPayloadWhenMessageWouldExceedLimit() + { + var agent = new Agent + { + Id = Guid.NewGuid(), + TenantId = Guid.NewGuid(), + Hostname = "host-01", + OperatingSystem = AgentPlatform.Windows, + OsVersion = "11", + Architecture = AgentArchitecture.X64, + AgentVersion = "0.1.0", + EnrolledAt = DateTimeOffset.UtcNow, + }; + var telemetryEvent = new TelemetryEvent + { + Id = 99, + TenantId = agent.TenantId, + AgentId = agent.Id, + EventType = TelemetryEventType.FileIntegrity, + OccurredAt = DateTimeOffset.UtcNow, + ReceivedAt = DateTimeOffset.UtcNow, + Payload = $$"""{"blob":"{{new string('x', 12000)}}"}""", + }; + var alert = new Alert + { + Id = 1, + AgentId = agent.Id, + AlertRuleId = Guid.NewGuid(), + TelemetryEventId = telemetryEvent.Id, + Severity = AlertSeverity.Low, + Title = "Large payload", + CreatedAt = DateTimeOffset.Parse("2026-05-14T08:00:02Z"), + }; + + var message = WazuhSyslogFormatter.Format(new WazuhSinkOptions { MaxMessageBytes = 1024 }, agent, alert, telemetryEvent); + + var jsonStart = message.IndexOf('{', StringComparison.Ordinal); + using var doc = JsonDocument.Parse(message[jsonStart..]); + var root = doc.RootElement; + root.GetProperty("telemetry_payload_omitted").GetBoolean().Should().BeTrue(); + root.GetProperty("telemetry_payload_json").ValueKind.Should().Be(JsonValueKind.Null); + } +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 76540d9..049577c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -36,6 +36,14 @@ services: Tawny__AgentJwt__Audience: "tawny-agents" Tawny__AgentJwt__SigningKeyPem: "/run/secrets/tawny-jwt-key" Tawny__AgentJwt__RequireConfiguredSigningKey: "true" + Tawny__Wazuh__Enabled: "${TAWNY_WAZUH_ENABLED:-false}" + Tawny__Wazuh__Host: "${TAWNY_WAZUH_HOST:-}" + Tawny__Wazuh__Port: "${TAWNY_WAZUH_PORT:-514}" + Tawny__Wazuh__Protocol: "${TAWNY_WAZUH_PROTOCOL:-udp}" + Tawny__Wazuh__Facility: "${TAWNY_WAZUH_FACILITY:-16}" + Tawny__Wazuh__AppName: "${TAWNY_WAZUH_APP_NAME:-tawny}" + Tawny__Wazuh__Hostname: "${TAWNY_WAZUH_HOSTNAME:-}" + Tawny__Wazuh__MaxMessageBytes: "${TAWNY_WAZUH_MAX_MESSAGE_BYTES:-8192}" ports: - "${TAWNY_API_PORT:-5080}:5080" volumes: diff --git a/docs/production.md b/docs/production.md index 98f1d2d..c3088e4 100644 --- a/docs/production.md +++ b/docs/production.md @@ -34,3 +34,121 @@ If a host is rebuilt or the service account changes, re-enroll the agent or rest `POST /api/agents/events` is rate limited with a per-agent token bucket. The API returns `429` and a JSON error body when an agent exceeds the ingest budget. State-changing endpoints write to `AuditLog`, including enrollment token creation/revocation, agent enrollment, heartbeat updates, and telemetry ingest batches. Ship this table to your operational log store if database access is tightly restricted. + +## Wazuh SIEM sink + +Tawny can publish generated alerts to Wazuh using syslog. The sink is disabled by default and emits one syslog message per Tawny alert. The syslog body is JSON with stable top-level fields: + +- `integration`: always `tawny` +- `event_kind`: always `alert` +- `alert_id`, `alert_title`, `alert_description`, `alert_severity`, `alert_status`, `alert_created_at`, and `rule_id` +- `agent_id`, `tenant_id`, `agent_hostname`, `agent_os`, `agent_architecture`, and `agent_version` +- `telemetry_id`, `telemetry_type`, `telemetry_occurred_at`, `telemetry_received_at`, and `telemetry_payload_json` + +The JSON is intentionally flat for Wazuh compatibility. Wazuh's JSON decoder can extract arrays, but not arrays of objects, so Tawny sends the matched telemetry payload as an escaped JSON string in `telemetry_payload_json`. If the event would exceed `MaxMessageBytes`, Tawny omits that field and sets `telemetry_payload_omitted=true`. + +Configure the API: + +```bash +Tawny__Wazuh__Enabled=true +Tawny__Wazuh__Host=wazuh-manager.example.com +Tawny__Wazuh__Port=514 +Tawny__Wazuh__Protocol=udp +Tawny__Wazuh__Facility=16 +Tawny__Wazuh__AppName=tawny +``` + +The Docker stack exposes the same settings with `TAWNY_WAZUH_*` variables in `docker/.env`: + +```bash +TAWNY_WAZUH_ENABLED=true +TAWNY_WAZUH_HOST=wazuh-manager.example.com +TAWNY_WAZUH_PORT=514 +TAWNY_WAZUH_PROTOCOL=udp +``` + +Configure the Wazuh manager to listen for syslog from the Tawny API host. Example manager `ossec.conf` block: + +```xml + + syslog + 514 + udp + 10.0.0.25 + +``` + +Use `tcp` instead of `udp` on both sides if you want connection-oriented delivery. When Tawny runs in Docker Desktop or crosses NAT, Wazuh may see a translated source IP rather than the Tawny container IP or the desktop LAN IP. Put the IP that Wazuh actually reports in `allowed-ips`. + +If the Wazuh manager log contains a message like this: + +```text +wazuh-remoted: WARNING: (1213): Message from '172.67.157.37' not allowed. Cannot find the ID of the agent. +``` + +then Wazuh received the Tawny packet but rejected it. Add that exact source to the syslog block: + +```xml + + syslog + 514 + udp + 10.0.0.25 + 172.67.157.37 + +``` + +For Wazuh running in Docker, confirm the manager publishes UDP 514 on the host: + +```bash +MANAGER=$(docker ps --format '{{.Names}}' | grep -Ei 'wazuh.*manager|manager' | head -1) +docker port "$MANAGER" | grep '514/udp' +``` + +Install the bundled decoder and rules so Tawny events become Wazuh alerts: + +```bash +sudo cp integrations/wazuh/tawny_decoder.xml /var/ossec/etc/decoders/tawny_decoder.xml +sudo cp integrations/wazuh/tawny_rules.xml /var/ossec/etc/rules/tawny_rules.xml +sudo chown wazuh:wazuh /var/ossec/etc/decoders/tawny_decoder.xml /var/ossec/etc/rules/tawny_rules.xml +sudo chmod 660 /var/ossec/etc/decoders/tawny_decoder.xml /var/ossec/etc/rules/tawny_rules.xml +sudo systemctl restart wazuh-manager +``` + +For a Docker-based Wazuh manager, copy the same files into the manager container and restart it: + +```bash +MANAGER=$(docker ps --format '{{.Names}}' | grep -Ei 'wazuh.*manager|manager' | head -1) +docker cp integrations/wazuh/tawny_decoder.xml "$MANAGER":/var/ossec/etc/decoders/tawny_decoder.xml +docker cp integrations/wazuh/tawny_rules.xml "$MANAGER":/var/ossec/etc/rules/tawny_rules.xml +docker exec "$MANAGER" chown wazuh:wazuh /var/ossec/etc/decoders/tawny_decoder.xml /var/ossec/etc/rules/tawny_rules.xml +docker exec "$MANAGER" chmod 660 /var/ossec/etc/decoders/tawny_decoder.xml /var/ossec/etc/rules/tawny_rules.xml +docker restart "$MANAGER" +``` + +Test the decoder/rule on the Wazuh manager: + +```bash +sudo /var/ossec/bin/wazuh-logtest +``` + +Paste a Tawny syslog line such as: + +```text +May 14 08:59:11 tawny-api-local tawny: {"integration":"tawny","event_kind":"alert","alert_id":7,"alert_title":"Linux Download To Temp Path","alert_severity":"medium","alert_status":"open","rule_id":"8b47c9e6-9928-4a87-8d40-beddd733ed34","agent_id":"dcb05d83-ba08-4eca-9b50-a6f434e30486","tenant_id":"00000000-0000-0000-0000-000000000001","agent_hostname":"linux-agent","agent_os":"linux","agent_architecture":"arm64","agent_version":"0.1.0","telemetry_id":1722,"telemetry_type":"process_snapshot","telemetry_payload_json":"{\"processes\":[{\"name\":\"tail\",\"command_line\":\"tail -f /tmp/tawny-wazuh-trigger\"}]}","telemetry_payload_omitted":false} +``` + +The expected result is a rule match on `110500` and group `tawny_alert`. + +After sending live Tawny alerts, confirm Wazuh accepted them: + +```bash +docker exec "$MANAGER" sh -c 'grep -R "Linux Download To Temp Path\\|tawny" /var/ossec/logs/archives/ /var/ossec/logs/alerts/ 2>/dev/null | tail -20' +docker exec "$MANAGER" sh -c 'tail -200 /var/ossec/logs/ossec.log | grep -iE "tawny|syslog|remote|514|not allowed|error"' +``` + +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" +``` diff --git a/docs/screenshots/agent-detail-fim.png b/docs/screenshots/agent-detail-fim.png index 48a8d3c..510ce00 100644 Binary files a/docs/screenshots/agent-detail-fim.png and b/docs/screenshots/agent-detail-fim.png differ diff --git a/docs/screenshots/agent-detail-network.png b/docs/screenshots/agent-detail-network.png index d97d4a1..413ba1b 100644 Binary files a/docs/screenshots/agent-detail-network.png and b/docs/screenshots/agent-detail-network.png differ diff --git a/docs/screenshots/agent-detail-processes.png b/docs/screenshots/agent-detail-processes.png index 9de4bef..29b981a 100644 Binary files a/docs/screenshots/agent-detail-processes.png and b/docs/screenshots/agent-detail-processes.png differ diff --git a/docs/screenshots/agent-detail-raw-events.png b/docs/screenshots/agent-detail-raw-events.png index 72e80bc..8786760 100644 Binary files a/docs/screenshots/agent-detail-raw-events.png and b/docs/screenshots/agent-detail-raw-events.png differ diff --git a/docs/screenshots/agent-detail-sessions.png b/docs/screenshots/agent-detail-sessions.png index 5365f74..abeded2 100644 Binary files a/docs/screenshots/agent-detail-sessions.png and b/docs/screenshots/agent-detail-sessions.png differ diff --git a/docs/screenshots/agents.png b/docs/screenshots/agents.png index b58c006..3211992 100644 Binary files a/docs/screenshots/agents.png and b/docs/screenshots/agents.png differ diff --git a/docs/screenshots/alerts.png b/docs/screenshots/alerts.png new file mode 100644 index 0000000..dbd95f7 Binary files /dev/null and b/docs/screenshots/alerts.png differ diff --git a/docs/screenshots/command-palette.png b/docs/screenshots/command-palette.png index d1784e7..04a1263 100644 Binary files a/docs/screenshots/command-palette.png and b/docs/screenshots/command-palette.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index 3e71542..6d68b98 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/detections.png b/docs/screenshots/detections.png new file mode 100644 index 0000000..f9c4a2c Binary files /dev/null and b/docs/screenshots/detections.png differ diff --git a/docs/screenshots/enrollment.png b/docs/screenshots/enrollment.png index a9976b0..16721c6 100644 Binary files a/docs/screenshots/enrollment.png and b/docs/screenshots/enrollment.png differ diff --git a/docs/screenshots/integrations/wazuh-tawny-event-detail.png b/docs/screenshots/integrations/wazuh-tawny-event-detail.png new file mode 100644 index 0000000..58f6d84 Binary files /dev/null and b/docs/screenshots/integrations/wazuh-tawny-event-detail.png differ diff --git a/docs/screenshots/integrations/wazuh-tawny-events.png b/docs/screenshots/integrations/wazuh-tawny-events.png new file mode 100644 index 0000000..05266aa Binary files /dev/null and b/docs/screenshots/integrations/wazuh-tawny-events.png differ diff --git a/docs/screenshots/light/agent-detail-fim.png b/docs/screenshots/light/agent-detail-fim.png index f4ee05f..6ac7958 100644 Binary files a/docs/screenshots/light/agent-detail-fim.png and b/docs/screenshots/light/agent-detail-fim.png differ diff --git a/docs/screenshots/light/agent-detail-network.png b/docs/screenshots/light/agent-detail-network.png index 2a1d2c1..1e26b58 100644 Binary files a/docs/screenshots/light/agent-detail-network.png and b/docs/screenshots/light/agent-detail-network.png differ diff --git a/docs/screenshots/light/agent-detail-processes.png b/docs/screenshots/light/agent-detail-processes.png index 1a27e3a..fd74760 100644 Binary files a/docs/screenshots/light/agent-detail-processes.png and b/docs/screenshots/light/agent-detail-processes.png differ diff --git a/docs/screenshots/light/agent-detail-raw-events.png b/docs/screenshots/light/agent-detail-raw-events.png index 30c68ac..0bf6307 100644 Binary files a/docs/screenshots/light/agent-detail-raw-events.png and b/docs/screenshots/light/agent-detail-raw-events.png differ diff --git a/docs/screenshots/light/agent-detail-sessions.png b/docs/screenshots/light/agent-detail-sessions.png index 7bd6beb..57b8a08 100644 Binary files a/docs/screenshots/light/agent-detail-sessions.png and b/docs/screenshots/light/agent-detail-sessions.png differ diff --git a/docs/screenshots/light/agents.png b/docs/screenshots/light/agents.png index eaee237..77dd722 100644 Binary files a/docs/screenshots/light/agents.png and b/docs/screenshots/light/agents.png differ diff --git a/docs/screenshots/light/alerts.png b/docs/screenshots/light/alerts.png new file mode 100644 index 0000000..1f6f98d Binary files /dev/null and b/docs/screenshots/light/alerts.png differ diff --git a/docs/screenshots/light/command-palette.png b/docs/screenshots/light/command-palette.png index d4fc8a7..74a1c0f 100644 Binary files a/docs/screenshots/light/command-palette.png and b/docs/screenshots/light/command-palette.png differ diff --git a/docs/screenshots/light/dashboard.png b/docs/screenshots/light/dashboard.png index 4033829..8ac0d27 100644 Binary files a/docs/screenshots/light/dashboard.png and b/docs/screenshots/light/dashboard.png differ diff --git a/docs/screenshots/light/detections.png b/docs/screenshots/light/detections.png new file mode 100644 index 0000000..f296b76 Binary files /dev/null and b/docs/screenshots/light/detections.png differ diff --git a/docs/screenshots/light/enrollment.png b/docs/screenshots/light/enrollment.png index 14af786..97a4dea 100644 Binary files a/docs/screenshots/light/enrollment.png and b/docs/screenshots/light/enrollment.png differ diff --git a/integrations/wazuh/tawny_decoder.xml b/integrations/wazuh/tawny_decoder.xml new file mode 100644 index 0000000..fe7e59c --- /dev/null +++ b/integrations/wazuh/tawny_decoder.xml @@ -0,0 +1,9 @@ + + + ^tawny$ + "integration":"tawny" + JSON_Decoder + diff --git a/integrations/wazuh/tawny_rules.xml b/integrations/wazuh/tawny_rules.xml new file mode 100644 index 0000000..7769a23 --- /dev/null +++ b/integrations/wazuh/tawny_rules.xml @@ -0,0 +1,29 @@ + + + + tawny-json + ^tawny$ + ^alert$ + Tawny EDR alert: $(alert_title) + tawny_alert, + + + + 110500 + ^high$ + Tawny EDR high severity alert: $(alert_title) + tawny_alert,tawny_high, + + + + 110500 + ^critical$ + Tawny EDR critical severity alert: $(alert_title) + tawny_alert,tawny_critical, + + diff --git a/web/scripts/capture-readme-screenshots.mjs b/web/scripts/capture-readme-screenshots.mjs index 696b95e..0d9104a 100644 --- a/web/scripts/capture-readme-screenshots.mjs +++ b/web/scripts/capture-readme-screenshots.mjs @@ -40,6 +40,8 @@ try { await login(page); await captureDashboard(page); + await captureDetections(page); + await captureAlerts(page); const agentHref = await captureAgents(page); if (agentHref) { await captureAgentDetail(page, agentHref); @@ -79,6 +81,20 @@ async function captureAgents(page) { return await page.locator('tbody a[href^="/agents/"]').first().getAttribute("href").catch(() => null); } +async function captureDetections(page) { + await page.goto(`${baseUrl}/detections`, { waitUntil: "networkidle" }); + await waitForChrome(page); + await page.getByText("Imported rules").waitFor({ state: "visible", timeout: 10000 }); + await screenshot(page, "detections.png"); +} + +async function captureAlerts(page) { + await page.goto(`${baseUrl}/alerts`, { waitUntil: "networkidle" }); + await waitForChrome(page); + await page.getByText("Detection matches").waitFor({ state: "visible", timeout: 10000 }); + await screenshot(page, "alerts.png"); +} + async function captureAgentDetail(page, href) { await page.goto(`${baseUrl}${href}`, { waitUntil: "networkidle" }); await waitForChrome(page); @@ -94,7 +110,9 @@ async function captureAgentDetail(page, href) { for (const [tab, fileName, expectedType] of tabs) { await selectEventTab(page, tab); - await waitForEventType(page, expectedType); + await waitForEventType(page, expectedType).catch(() => { + console.warn(`No ${expectedType} event visible while capturing ${fileName}; capturing current tab state.`); + }); await screenshot(page, fileName); } }