From 2bbdb02076dff4fb2c0b2ce8c4a43d3f5e624c98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:26:24 +0000 Subject: [PATCH] Add threat hunting, suppressions, audit UI, API tokens, SSE, MITRE tagging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships eight user-mode EDR features that close the largest gaps below kernel work: - Threat hunting (/hunt) with a small KQL-style DSL, saved queries that can be scheduled to emit alerts, and execution history. HuntQueryParser and HuntExecutor live in Tawny.Infrastructure so both Api and Jobs can reuse them; ScheduledHuntsJob runs every 5 minutes. - MITRE ATT&CK tagging on alert rules and saved hunts. SigmaRuleImporter now extracts `attack.tNNNN` tags. Dashboard renders a 7-day technique heatmap built from alerts attached to tagged rules. - Alert suppression rules (/suppressions). SuppressionEvaluator runs inside AlertRuleEvaluator after a candidate alert is built, before it's persisted; counters track how often each rule fires. - Server-Sent Events for live agent telemetry (replaces 2s dashboard polling). AgentEventBroker is an in-process pub/sub fed by the telemetry ingest path; subscribers get one frame per event plus 15s keep-alives. - Cross-host pivot (/pivot): paste a hash, IP, or domain and find every agent whose telemetry references it in the last N days. - Process tree view on the agent detail page, built from existing PPID data — collapsible per-process lineage, with name/pid/command filter. - Audit log UI (/audit) with substring filter on the action column. - Programmatic API tokens (/api-tokens) — bearer tokens prefixed `twny_` authed by a new ApiToken scheme; admin/viewer roles, optional expiry, one-time display on creation. Schema: one new migration (`AddHuntingAndGovernance`) adds SavedHunts, HuntRuns, SuppressionRules, ApiTokens and a MitreTechniques column on AlertRules. Snapshot kept in lockstep. https://claude.ai/code/session_01DqLy7vdV9S4prUF9sz2Ehp --- .../src/Tawny.Api/Auth/ApiTokenAuthHandler.cs | 91 +++ .../src/Tawny.Api/Auth/TawnyAuthSchemes.cs | 1 + .../Controllers/AgentEventStreamController.cs | 101 ++++ .../Controllers/AlertRulesController.cs | 25 +- .../Controllers/ApiTokensController.cs | 96 ++++ .../Controllers/AuditLogController.cs | 49 ++ .../Controllers/DashboardController.cs | 56 +- .../Tawny.Api/Controllers/HuntsController.cs | 308 ++++++++++ .../Tawny.Api/Controllers/PivotController.cs | 65 +++ .../Controllers/SuppressionRulesController.cs | 178 ++++++ .../Controllers/TelemetryController.cs | 11 +- backend/src/Tawny.Api/Models/AlertDtos.cs | 7 +- backend/src/Tawny.Api/Models/ApiTokenDtos.cs | 27 + backend/src/Tawny.Api/Models/AuditLogDtos.cs | 11 + backend/src/Tawny.Api/Models/DashboardDtos.cs | 7 +- backend/src/Tawny.Api/Models/HuntDtos.cs | 67 +++ .../src/Tawny.Api/Models/SuppressionDtos.cs | 46 ++ backend/src/Tawny.Api/Program.cs | 11 +- .../Tawny.Api/Services/AgentEventBroker.cs | 81 +++ .../Tawny.Api/Services/AlertRuleEvaluator.cs | 26 +- .../Tawny.Api/Services/SigmaRuleImporter.cs | 27 + .../src/Tawny.Domain/Entities/AlertRule.cs | 1 + backend/src/Tawny.Domain/Entities/ApiToken.cs | 18 + backend/src/Tawny.Domain/Entities/HuntRun.cs | 17 + .../src/Tawny.Domain/Entities/SavedHunt.cs | 23 + .../Tawny.Domain/Entities/SuppressionRule.cs | 26 + backend/src/Tawny.Domain/Entities/Tenant.cs | 3 + backend/src/Tawny.Domain/Enums.cs | 13 + .../Hunting/HuntExecutor.cs | 196 +++++++ .../Tawny.Infrastructure/Hunting/HuntQuery.cs | 481 ++++++++++++++++ .../Hunting/SuppressionEvaluator.cs | 119 ++++ .../20260523000000_AddHuntingAndGovernance.cs | 208 +++++++ .../Migrations/TawnyDbContextModelSnapshot.cs | 309 ++++++++++ .../Tawny.Infrastructure/TawnyDbContext.cs | 74 +++ backend/src/Tawny.Jobs/ScheduledHuntsJob.cs | 149 +++++ web/app/agents/[id]/events-panel.tsx | 72 ++- web/app/agents/[id]/process-tree.tsx | 145 +++++ web/app/api-tokens/api-tokens-panel.tsx | 297 ++++++++++ web/app/api-tokens/page.tsx | 51 ++ web/app/api/api-tokens/[id]/route.ts | 21 + web/app/api/api-tokens/route.ts | 33 ++ web/app/api/hunts/[id]/route.ts | 55 ++ web/app/api/hunts/route.ts | 38 ++ web/app/api/hunts/run/route.ts | 37 ++ web/app/api/suppression-rules/[id]/route.ts | 21 + web/app/api/suppression-rules/route.ts | 40 ++ web/app/audit/page.tsx | 114 ++++ web/app/hunt/hunt-workbench.tsx | 530 ++++++++++++++++++ web/app/hunt/page.tsx | 37 ++ web/app/page.tsx | 45 ++ web/app/pivot/page.tsx | 158 ++++++ web/app/suppressions/page.tsx | 48 ++ web/app/suppressions/suppressions-panel.tsx | 375 +++++++++++++ web/components/app-shell.tsx | 18 +- web/lib/api.ts | 29 + 55 files changed, 5069 insertions(+), 23 deletions(-) create mode 100644 backend/src/Tawny.Api/Auth/ApiTokenAuthHandler.cs create mode 100644 backend/src/Tawny.Api/Controllers/AgentEventStreamController.cs create mode 100644 backend/src/Tawny.Api/Controllers/ApiTokensController.cs create mode 100644 backend/src/Tawny.Api/Controllers/AuditLogController.cs create mode 100644 backend/src/Tawny.Api/Controllers/HuntsController.cs create mode 100644 backend/src/Tawny.Api/Controllers/PivotController.cs create mode 100644 backend/src/Tawny.Api/Controllers/SuppressionRulesController.cs create mode 100644 backend/src/Tawny.Api/Models/ApiTokenDtos.cs create mode 100644 backend/src/Tawny.Api/Models/AuditLogDtos.cs create mode 100644 backend/src/Tawny.Api/Models/HuntDtos.cs create mode 100644 backend/src/Tawny.Api/Models/SuppressionDtos.cs create mode 100644 backend/src/Tawny.Api/Services/AgentEventBroker.cs create mode 100644 backend/src/Tawny.Domain/Entities/ApiToken.cs create mode 100644 backend/src/Tawny.Domain/Entities/HuntRun.cs create mode 100644 backend/src/Tawny.Domain/Entities/SavedHunt.cs create mode 100644 backend/src/Tawny.Domain/Entities/SuppressionRule.cs create mode 100644 backend/src/Tawny.Infrastructure/Hunting/HuntExecutor.cs create mode 100644 backend/src/Tawny.Infrastructure/Hunting/HuntQuery.cs create mode 100644 backend/src/Tawny.Infrastructure/Hunting/SuppressionEvaluator.cs create mode 100644 backend/src/Tawny.Infrastructure/Migrations/20260523000000_AddHuntingAndGovernance.cs create mode 100644 backend/src/Tawny.Jobs/ScheduledHuntsJob.cs create mode 100644 web/app/agents/[id]/process-tree.tsx create mode 100644 web/app/api-tokens/api-tokens-panel.tsx create mode 100644 web/app/api-tokens/page.tsx create mode 100644 web/app/api/api-tokens/[id]/route.ts create mode 100644 web/app/api/api-tokens/route.ts create mode 100644 web/app/api/hunts/[id]/route.ts create mode 100644 web/app/api/hunts/route.ts create mode 100644 web/app/api/hunts/run/route.ts create mode 100644 web/app/api/suppression-rules/[id]/route.ts create mode 100644 web/app/api/suppression-rules/route.ts create mode 100644 web/app/audit/page.tsx create mode 100644 web/app/hunt/hunt-workbench.tsx create mode 100644 web/app/hunt/page.tsx create mode 100644 web/app/pivot/page.tsx create mode 100644 web/app/suppressions/page.tsx create mode 100644 web/app/suppressions/suppressions-panel.tsx diff --git a/backend/src/Tawny.Api/Auth/ApiTokenAuthHandler.cs b/backend/src/Tawny.Api/Auth/ApiTokenAuthHandler.cs new file mode 100644 index 0000000..3e3bfae --- /dev/null +++ b/backend/src/Tawny.Api/Auth/ApiTokenAuthHandler.cs @@ -0,0 +1,91 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tawny.Domain; +using Tawny.Infrastructure; + +namespace Tawny.Api.Auth; + +public class ApiTokenAuthOptions : AuthenticationSchemeOptions +{ +} + +public class ApiTokenAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + TawnyDbContext db) + : AuthenticationHandler(options, logger, encoder) +{ + public const string TokenPrefix = "twny_"; + + protected override async Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue("Authorization", out var authHeader)) + { + return AuthenticateResult.NoResult(); + } + + var raw = authHeader.ToString(); + if (string.IsNullOrEmpty(raw) || !raw.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return AuthenticateResult.NoResult(); + } + + var token = raw["Bearer ".Length..].Trim(); + if (!token.StartsWith(TokenPrefix, StringComparison.Ordinal)) + { + // Not one of our API tokens — let the JWT scheme handle it. + return AuthenticateResult.NoResult(); + } + + var hash = HashToken(token); + var record = await db.ApiTokens + .FirstOrDefaultAsync(t => t.TokenHash == hash); + + if (record is null || record.RevokedAt is not null) + { + return AuthenticateResult.Fail("Unknown or revoked API token."); + } + + if (record.ExpiresAt is not null && record.ExpiresAt.Value <= DateTimeOffset.UtcNow) + { + return AuthenticateResult.Fail("API token expired."); + } + + record.LastUsedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + var identity = new ClaimsIdentity(TawnyAuthSchemes.ApiToken); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, record.CreatedByUserId?.ToString() ?? Guid.Empty.ToString())); + identity.AddClaim(new Claim(ClaimTypes.Role, record.Role.ToString())); + identity.AddClaim(new Claim(TenantClaimExtensions.TenantIdClaim, record.TenantId.ToString())); + identity.AddClaim(new Claim("api_token_id", record.Id.ToString())); + + return AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(identity), TawnyAuthSchemes.ApiToken)); + } + + public static string HashToken(string token) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(); + } + + public static (string Token, string Prefix) Generate() + { + // 32 random bytes, base64url-encoded — enough entropy to resist guessing. + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + var secret = Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); + var token = $"{TokenPrefix}{secret}"; + return (token, token[..12]); + } +} diff --git a/backend/src/Tawny.Api/Auth/TawnyAuthSchemes.cs b/backend/src/Tawny.Api/Auth/TawnyAuthSchemes.cs index 95a2660..1c54887 100644 --- a/backend/src/Tawny.Api/Auth/TawnyAuthSchemes.cs +++ b/backend/src/Tawny.Api/Auth/TawnyAuthSchemes.cs @@ -4,4 +4,5 @@ public static class TawnyAuthSchemes { public const string AgentJwt = "AgentJwt"; public const string WebUser = "WebUser"; + public const string ApiToken = "ApiToken"; } diff --git a/backend/src/Tawny.Api/Controllers/AgentEventStreamController.cs b/backend/src/Tawny.Api/Controllers/AgentEventStreamController.cs new file mode 100644 index 0000000..4668344 --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/AgentEventStreamController.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tawny.Api.Auth; +using Tawny.Api.Services; +using Tawny.Infrastructure; + +namespace Tawny.Api.Controllers; + +[ApiController] +[Route("api/agents")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser)] +public class AgentEventStreamController( + TawnyDbContext db, + AgentEventBroker broker) : ControllerBase +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + /// + /// Server-Sent Events stream of new telemetry for a single agent. + /// The client receives one JSON-encoded event per `data:` frame and a + /// `: keep-alive` comment every 15s to keep proxies happy. + /// + [HttpGet("{id:guid}/events/stream")] + public async Task Stream(Guid id, CancellationToken ct) + { + var tenantId = User.GetTenantId(); + if (!await db.Agents.AnyAsync(a => a.Id == id && a.TenantId == tenantId, ct)) + { + Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + Response.Headers["Content-Type"] = "text/event-stream"; + Response.Headers["Cache-Control"] = "no-cache, no-transform"; + Response.Headers["X-Accel-Buffering"] = "no"; + + await Response.WriteAsync("retry: 5000\n\n", ct); + await Response.Body.FlushAsync(ct); + + using var sub = broker.Subscribe(tenantId, id, out var channel); + var reader = channel.Reader; + var keepAlive = TimeSpan.FromSeconds(15); + var lastWrite = DateTimeOffset.UtcNow; + + try + { + while (!ct.IsCancellationRequested) + { + using var heartbeatCts = new CancellationTokenSource(keepAlive); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, heartbeatCts.Token); + + StreamedEvent next; + try + { + next = await reader.ReadAsync(linked.Token); + } + catch (OperationCanceledException) when (heartbeatCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + await Response.WriteAsync(": keep-alive\n\n", ct); + await Response.Body.FlushAsync(ct); + lastWrite = DateTimeOffset.UtcNow; + continue; + } + catch (OperationCanceledException) + { + break; + } + + var payload = JsonSerializer.Serialize(new + { + id = next.Id, + agent_id = next.AgentId, + type = WireName(next.EventType), + occurred_at = next.OccurredAt, + received_at = next.ReceivedAt, + payload = next.Payload, + }, JsonOpts); + await Response.WriteAsync($"data: {payload}\n\n", ct); + await Response.Body.FlushAsync(ct); + lastWrite = DateTimeOffset.UtcNow; + } + } + catch (OperationCanceledException) { } + } + + private static string WireName(Domain.TelemetryEventType t) => t switch + { + Domain.TelemetryEventType.ProcessSnapshot => "process_snapshot", + Domain.TelemetryEventType.NetworkSnapshot => "network_snapshot", + Domain.TelemetryEventType.UserSession => "user_session", + Domain.TelemetryEventType.SystemInfo => "system_info", + Domain.TelemetryEventType.FileIntegrity => "file_integrity", + Domain.TelemetryEventType.Heartbeat => "heartbeat", + _ => t.ToString().ToLowerInvariant(), + }; +} diff --git a/backend/src/Tawny.Api/Controllers/AlertRulesController.cs b/backend/src/Tawny.Api/Controllers/AlertRulesController.cs index 6bc2062..d480e99 100644 --- a/backend/src/Tawny.Api/Controllers/AlertRulesController.cs +++ b/backend/src/Tawny.Api/Controllers/AlertRulesController.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -25,9 +26,8 @@ public async Task>> List(Cancellat var rows = await db.AlertRules .AsNoTracking() .OrderBy(r => r.Name) - .Select(r => ToResponse(r)) .ToListAsync(ct); - return Ok(rows); + return Ok(rows.Select(ToResponse).ToList()); } [HttpPost] @@ -52,6 +52,7 @@ public async Task> Create(CreateAlertRuleRequest PayloadPath = Normalize(req.PayloadPath), MatchValue = Normalize(req.MatchValue), IsEnabled = req.IsEnabled ?? true, + MitreTechniquesJson = SerializeTechniques(req.MitreTechniques), CreatedAt = now, UpdatedAt = now, }; @@ -163,6 +164,7 @@ public async Task> Update(Guid id, UpdateAlertRu rule.MatchValue = Normalize(req.MatchValue); rule.SourceDefinition = null; rule.IsEnabled = req.IsEnabled; + rule.MitreTechniquesJson = SerializeTechniques(req.MitreTechniques); rule.UpdatedAt = DateTimeOffset.UtcNow; audit.Add(User, "alert_rule.update", rule.Id.ToString(), new { @@ -213,9 +215,28 @@ public async Task Delete(Guid id, CancellationToken ct) r.MatchValue, r.SourceDefinition, r.IsEnabled, + DeserializeTechniques(r.MitreTechniquesJson), r.CreatedAt, r.UpdatedAt); + private static IReadOnlyList DeserializeTechniques(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try { return JsonSerializer.Deserialize>(json) ?? []; } + catch { return []; } + } + + private static string? SerializeTechniques(IReadOnlyList? techniques) + { + if (techniques is null || techniques.Count == 0) return null; + var normalized = techniques + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim().ToUpperInvariant()) + .Distinct() + .ToList(); + return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized); + } + private ActionResult? ValidateRule( string name, AlertRuleOperator op, diff --git a/backend/src/Tawny.Api/Controllers/ApiTokensController.cs b/backend/src/Tawny.Api/Controllers/ApiTokensController.cs new file mode 100644 index 0000000..0ec8d32 --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/ApiTokensController.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tawny.Api.Auth; +using Tawny.Api.Models; +using Tawny.Api.Services; +using Tawny.Domain; +using Tawny.Domain.Entities; +using Tawny.Infrastructure; + +namespace Tawny.Api.Controllers; + +[ApiController] +[Route("api/api-tokens")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser, Roles = "Admin")] +public class ApiTokensController( + TawnyDbContext db, + AuditLogger audit) : ControllerBase +{ + [HttpGet] + public async Task>> List(CancellationToken ct) + { + var tenantId = User.GetTenantId(); + var rows = await db.ApiTokens + .AsNoTracking() + .Where(t => t.TenantId == tenantId) + .OrderByDescending(t => t.CreatedAt) + .Select(t => new ApiTokenResponse( + t.Id, t.Name, t.TokenPrefix, t.Role, + t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.RevokedAt)) + .ToListAsync(ct); + return Ok(rows); + } + + [HttpPost] + public async Task> Create( + [FromBody] CreateApiTokenRequest req, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 160) + { + return Problem(statusCode: 400, title: "name is required and must be 160 characters or fewer."); + } + + var (token, prefix) = ApiTokenAuthHandler.Generate(); + var tenantId = User.GetTenantId(); + var record = new ApiToken + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Name = req.Name.Trim(), + TokenHash = ApiTokenAuthHandler.HashToken(token), + TokenPrefix = prefix, + Role = req.Role ?? UserRole.Viewer, + CreatedByUserId = TryGetUserId(), + CreatedAt = DateTimeOffset.UtcNow, + ExpiresAt = req.ExpiresAt, + }; + db.ApiTokens.Add(record); + audit.Add(User, "api_token.create", record.Id.ToString(), new + { + record.Name, record.Role, record.ExpiresAt, + }); + await db.SaveChangesAsync(ct); + + return CreatedAtAction(nameof(List), new { id = record.Id }, + new CreatedApiTokenResponse( + record.Id, + record.Name, + token, + record.TokenPrefix, + record.Role, + record.CreatedAt, + record.ExpiresAt)); + } + + [HttpDelete("{id:guid}")] + public async Task Revoke(Guid id, CancellationToken ct) + { + var tenantId = User.GetTenantId(); + var record = await db.ApiTokens.FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId, ct); + if (record is null) return NotFound(); + if (record.RevokedAt is not null) return NoContent(); + + record.RevokedAt = DateTimeOffset.UtcNow; + audit.Add(User, "api_token.revoke", id.ToString()); + await db.SaveChangesAsync(ct); + return NoContent(); + } + + private Guid? TryGetUserId() + { + var raw = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(raw, out var id) ? id : null; + } +} diff --git a/backend/src/Tawny.Api/Controllers/AuditLogController.cs b/backend/src/Tawny.Api/Controllers/AuditLogController.cs new file mode 100644 index 0000000..6c0dab3 --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/AuditLogController.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tawny.Api.Auth; +using Tawny.Api.Models; +using Tawny.Infrastructure; + +namespace Tawny.Api.Controllers; + +[ApiController] +[Route("api/audit-logs")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken)] +public class AuditLogController(TawnyDbContext db) : ControllerBase +{ + [HttpGet] + public async Task>> List( + [FromQuery] string? action, + [FromQuery] DateTimeOffset? before, + [FromQuery] int limit = 100, + CancellationToken ct = default) + { + var take = Math.Clamp(limit, 1, 500); + var tenantId = User.GetTenantId(); + var query = db.AuditLog.AsNoTracking().Where(a => a.TenantId == tenantId); + if (!string.IsNullOrWhiteSpace(action)) + { + var like = $"%{action.Trim()}%"; + query = query.Where(a => EF.Functions.Like(a.Action, like)); + } + if (before is not null) + { + query = query.Where(a => a.OccurredAt < before.Value); + } + var rows = await query + .OrderByDescending(a => a.OccurredAt) + .ThenByDescending(a => a.Id) + .Take(take) + .ToListAsync(ct); + + return Ok(rows.Select(a => new AuditLogResponse( + a.Id, + a.UserId, + a.Action, + a.Target, + string.IsNullOrEmpty(a.MetadataJson) ? null : JsonSerializer.Deserialize(a.MetadataJson!), + a.OccurredAt)).ToList()); + } +} diff --git a/backend/src/Tawny.Api/Controllers/DashboardController.cs b/backend/src/Tawny.Api/Controllers/DashboardController.cs index 14f636d..79240aa 100644 --- a/backend/src/Tawny.Api/Controllers/DashboardController.cs +++ b/backend/src/Tawny.Api/Controllers/DashboardController.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -62,6 +63,51 @@ public async Task> Summary(CancellationTo }) .ToList(); + var sevenDaysAgo = now.AddDays(-7); + var taggedRules = await db.AlertRules + .AsNoTracking() + .Where(r => r.MitreTechniquesJson != null) + .Select(r => new { r.Id, r.MitreTechniquesJson }) + .ToListAsync(ct); + var techniqueByRule = new Dictionary>(); + foreach (var row in taggedRules) + { + var techniques = ParseTechniques(row.MitreTechniquesJson); + if (techniques.Count > 0) + { + techniqueByRule[row.Id] = techniques; + } + } + + var heatmap = new List(); + if (techniqueByRule.Count > 0) + { + var ruleIds = techniqueByRule.Keys.ToList(); + var counts = await db.Alerts + .AsNoTracking() + .Where(a => a.CreatedAt >= sevenDaysAgo + && ruleIds.Contains(a.AlertRuleId) + && db.Agents.Any(ag => ag.Id == a.AgentId && ag.TenantId == tenantId)) + .GroupBy(a => a.AlertRuleId) + .Select(g => new { RuleId = g.Key, Count = g.Count() }) + .ToListAsync(ct); + + var perTechnique = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var c in counts) + { + if (!techniqueByRule.TryGetValue(c.RuleId, out var techniques)) continue; + foreach (var t in techniques) + { + perTechnique[t] = perTechnique.GetValueOrDefault(t) + c.Count; + } + } + heatmap = perTechnique + .OrderByDescending(p => p.Value) + .Take(20) + .Select(p => new DashboardMitreHeatmapEntry(p.Key, p.Value)) + .ToList(); + } + return Ok(new DashboardSummaryResponse( totalAgents, onlineAgents, @@ -69,7 +115,15 @@ public async Task> Summary(CancellationTo staleAgents, unknownAgents, recentEvents, - buckets)); + buckets, + heatmap)); + } + + private static List ParseTechniques(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try { return JsonSerializer.Deserialize>(json) ?? []; } + catch { return []; } } private static DateTimeOffset HourBucket(DateTimeOffset value) diff --git a/backend/src/Tawny.Api/Controllers/HuntsController.cs b/backend/src/Tawny.Api/Controllers/HuntsController.cs new file mode 100644 index 0000000..f8f8b9d --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/HuntsController.cs @@ -0,0 +1,308 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tawny.Api.Auth; +using Tawny.Api.Models; +using Tawny.Api.Services; +using Tawny.Domain; +using Tawny.Domain.Entities; +using Tawny.Infrastructure; +using Tawny.Infrastructure.Hunting; + +namespace Tawny.Api.Controllers; + +[ApiController] +[Route("api/hunts")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken)] +public class HuntsController( + TawnyDbContext db, + AuditLogger audit, + HuntQueryParser parser, + HuntExecutor executor) : ControllerBase +{ + [HttpPost("run")] + public async Task> Run( + [FromBody] RunHuntRequest req, + CancellationToken ct) + { + var tenantId = User.GetTenantId(); + HuntQueryPlan plan; + try + { + plan = parser.Parse(req.Query, req.Limit); + } + catch (HuntQueryException ex) + { + return Problem(statusCode: 400, title: "Could not parse hunt query.", detail: ex.Message); + } + + var result = await executor.ExecuteAsync(tenantId, plan, ct); + return Ok(new RunHuntResponse( + result.MatchCount, + result.Matches.Select(m => new HuntMatchResponse( + m.EventId, m.AgentId, m.Hostname, m.EventType, m.OccurredAt, m.ReceivedAt, m.Payload)).ToList(), + result.Warnings)); + } + + [HttpGet] + public async Task>> List(CancellationToken ct) + { + var tenantId = User.GetTenantId(); + var rows = await db.SavedHunts + .AsNoTracking() + .Where(h => h.TenantId == tenantId) + .OrderBy(h => h.Name) + .ToListAsync(ct); + return Ok(rows.Select(ToResponse).ToList()); + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var hunt = await db.SavedHunts.AsNoTracking() + .FirstOrDefaultAsync(h => h.Id == id && h.TenantId == User.GetTenantId(), ct); + if (hunt is null) return NotFound(); + return Ok(ToResponse(hunt)); + } + + [HttpPost] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task> Create( + [FromBody] CreateSavedHuntRequest req, + CancellationToken ct) + { + var validation = ValidateRequest(req.Name, req.Query, req.ScheduleCron); + if (validation is not null) return validation; + + try { parser.Parse(req.Query); } + catch (HuntQueryException ex) + { + return Problem(statusCode: 400, title: "Saved hunt query did not parse.", detail: ex.Message); + } + + var tenantId = User.GetTenantId(); + if (await db.SavedHunts.AnyAsync(h => h.TenantId == tenantId && h.Name == req.Name.Trim(), ct)) + { + return Problem(statusCode: 409, title: "A saved hunt with this name already exists."); + } + + var now = DateTimeOffset.UtcNow; + var hunt = new SavedHunt + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Name = req.Name.Trim(), + Description = string.IsNullOrWhiteSpace(req.Description) ? null : req.Description.Trim(), + Query = req.Query.Trim(), + IsScheduled = req.IsScheduled ?? false, + ScheduleCron = NormalizeCron(req.ScheduleCron), + AlertOnMatch = req.AlertOnMatch ?? false, + AlertSeverity = req.AlertSeverity ?? AlertSeverity.Medium, + MitreTechniquesJson = SerializeTechniques(req.MitreTechniques), + CreatedByUserId = TryGetUserId(), + CreatedAt = now, + UpdatedAt = now, + }; + db.SavedHunts.Add(hunt); + audit.Add(User, "saved_hunt.create", hunt.Id.ToString(), new + { + hunt.Name, + hunt.IsScheduled, + hunt.AlertOnMatch, + }); + await db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(Get), new { id = hunt.Id }, ToResponse(hunt)); + } + + [HttpPut("{id:guid}")] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task> Update( + Guid id, + [FromBody] UpdateSavedHuntRequest req, + CancellationToken ct) + { + var validation = ValidateRequest(req.Name, req.Query, req.ScheduleCron); + if (validation is not null) return validation; + + try { parser.Parse(req.Query); } + catch (HuntQueryException ex) + { + return Problem(statusCode: 400, title: "Saved hunt query did not parse.", detail: ex.Message); + } + + var tenantId = User.GetTenantId(); + var hunt = await db.SavedHunts.FirstOrDefaultAsync(h => h.Id == id && h.TenantId == tenantId, ct); + if (hunt is null) return NotFound(); + + hunt.Name = req.Name.Trim(); + hunt.Description = string.IsNullOrWhiteSpace(req.Description) ? null : req.Description.Trim(); + hunt.Query = req.Query.Trim(); + hunt.IsScheduled = req.IsScheduled; + hunt.ScheduleCron = NormalizeCron(req.ScheduleCron); + hunt.AlertOnMatch = req.AlertOnMatch; + hunt.AlertSeverity = req.AlertSeverity; + hunt.MitreTechniquesJson = SerializeTechniques(req.MitreTechniques); + hunt.UpdatedAt = DateTimeOffset.UtcNow; + audit.Add(User, "saved_hunt.update", hunt.Id.ToString(), new + { + hunt.Name, + hunt.IsScheduled, + hunt.AlertOnMatch, + }); + await db.SaveChangesAsync(ct); + return Ok(ToResponse(hunt)); + } + + [HttpDelete("{id:guid}")] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var tenantId = User.GetTenantId(); + var deleted = await db.SavedHunts + .Where(h => h.Id == id && h.TenantId == tenantId) + .ExecuteDeleteAsync(ct); + if (deleted == 0) return NotFound(); + audit.Add(User, "saved_hunt.delete", id.ToString()); + await db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/run")] + public async Task> RunSaved(Guid id, CancellationToken ct) + { + var tenantId = User.GetTenantId(); + var hunt = await db.SavedHunts.FirstOrDefaultAsync(h => h.Id == id && h.TenantId == tenantId, ct); + if (hunt is null) return NotFound(); + + HuntQueryPlan plan; + try { plan = parser.Parse(hunt.Query); } + catch (HuntQueryException ex) + { + return Problem(statusCode: 400, title: "Saved hunt query did not parse.", detail: ex.Message); + } + + var run = new HuntRun + { + TenantId = tenantId, + SavedHuntId = hunt.Id, + TriggeredByUserId = TryGetUserId(), + StartedAt = DateTimeOffset.UtcNow, + Status = HuntRunStatus.Running, + }; + db.HuntRuns.Add(run); + await db.SaveChangesAsync(ct); + + try + { + var result = await executor.ExecuteAsync(tenantId, plan, ct); + run.MatchCount = result.MatchCount; + run.CompletedAt = DateTimeOffset.UtcNow; + run.Status = HuntRunStatus.Succeeded; + hunt.LastRunAt = run.CompletedAt; + hunt.LastMatchCount = result.MatchCount; + audit.Add(User, "saved_hunt.run", hunt.Id.ToString(), new + { + match_count = result.MatchCount, + }); + await db.SaveChangesAsync(ct); + + return Ok(new RunHuntResponse( + result.MatchCount, + result.Matches.Select(m => new HuntMatchResponse( + m.EventId, m.AgentId, m.Hostname, m.EventType, m.OccurredAt, m.ReceivedAt, m.Payload)).ToList(), + result.Warnings)); + } + catch (Exception ex) + { + run.Status = HuntRunStatus.Failed; + run.CompletedAt = DateTimeOffset.UtcNow; + run.ErrorMessage = ex.Message.Length > 1000 ? ex.Message[..1000] : ex.Message; + await db.SaveChangesAsync(ct); + return Problem(statusCode: 500, title: "Hunt execution failed.", detail: ex.Message); + } + } + + [HttpGet("{id:guid}/runs")] + public async Task>> Runs(Guid id, CancellationToken ct) + { + var tenantId = User.GetTenantId(); + if (!await db.SavedHunts.AnyAsync(h => h.Id == id && h.TenantId == tenantId, ct)) + { + return NotFound(); + } + + var rows = await db.HuntRuns + .AsNoTracking() + .Where(r => r.TenantId == tenantId && r.SavedHuntId == id) + .OrderByDescending(r => r.StartedAt) + .Take(50) + .Select(r => new HuntRunResponse( + r.Id, r.SavedHuntId, r.Status, r.StartedAt, r.CompletedAt, + r.MatchCount, r.AlertsCreated, r.ErrorMessage)) + .ToListAsync(ct); + return Ok(rows); + } + + private static SavedHuntResponse ToResponse(SavedHunt h) => new( + h.Id, + h.Name, + h.Description, + h.Query, + h.IsScheduled, + h.ScheduleCron, + h.AlertOnMatch, + h.AlertSeverity, + DeserializeTechniques(h.MitreTechniquesJson), + h.LastRunAt, + h.LastMatchCount, + h.CreatedAt, + h.UpdatedAt); + + private static IReadOnlyList DeserializeTechniques(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try { return JsonSerializer.Deserialize>(json) ?? []; } + catch { return []; } + } + + private static string? SerializeTechniques(IReadOnlyList? techniques) + { + if (techniques is null || techniques.Count == 0) return null; + var normalized = techniques + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim().ToUpperInvariant()) + .Distinct() + .ToList(); + return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized); + } + + private static string? NormalizeCron(string? cron) + { + var trimmed = cron?.Trim(); + return string.IsNullOrEmpty(trimmed) ? null : trimmed; + } + + private ActionResult? ValidateRequest(string name, string query, string? scheduleCron) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 160) + { + return Problem(statusCode: 400, title: "name is required and must be 160 characters or fewer."); + } + if (string.IsNullOrWhiteSpace(query)) + { + return Problem(statusCode: 400, title: "query is required."); + } + if (!string.IsNullOrWhiteSpace(scheduleCron) && scheduleCron.Length > 64) + { + return Problem(statusCode: 400, title: "schedule_cron must be 64 characters or fewer."); + } + return null; + } + + private Guid? TryGetUserId() + { + var raw = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(raw, out var id) ? id : null; + } +} diff --git a/backend/src/Tawny.Api/Controllers/PivotController.cs b/backend/src/Tawny.Api/Controllers/PivotController.cs new file mode 100644 index 0000000..581694f --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/PivotController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tawny.Api.Auth; +using Tawny.Domain; +using Tawny.Infrastructure; + +namespace Tawny.Api.Controllers; + +public record PivotHostHit( + Guid AgentId, + string Hostname, + int EventCount, + DateTimeOffset FirstSeen, + DateTimeOffset LastSeen); + +public record PivotResponse(string Kind, string Value, int HostCount, IReadOnlyList Hosts); + +[ApiController] +[Route("api/pivot")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken)] +public class PivotController(TawnyDbContext db) : ControllerBase +{ + /// + /// Find every host that has telemetry referencing the given indicator + /// (sha256, ipv4/ipv6, or domain). Defaults to the last 30 days. + /// + [HttpGet] + public async Task> Pivot( + [FromQuery] string kind, + [FromQuery] string value, + [FromQuery] int days = 30, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Problem(statusCode: 400, title: "value is required."); + } + + var normalizedKind = kind?.Trim().ToLowerInvariant() ?? "any"; + var needle = value.Trim(); + var clampedDays = Math.Clamp(days, 1, 365); + var since = DateTimeOffset.UtcNow.AddDays(-clampedDays); + var tenantId = User.GetTenantId(); + var like = $"%{needle}%"; + + // Coarse JSON-payload LIKE filter — the payload is searchable text in SQL Server. + // Cheap enough at 30d, and the result set is small (host aggregation). + var rows = await db.TelemetryEvents + .AsNoTracking() + .Where(e => e.TenantId == tenantId && e.OccurredAt >= since && EF.Functions.Like(e.Payload, like)) + .GroupBy(e => new { e.AgentId, Hostname = e.Agent!.Hostname }) + .Select(g => new PivotHostHit( + g.Key.AgentId, + g.Key.Hostname, + g.Count(), + g.Min(e => e.OccurredAt), + g.Max(e => e.OccurredAt))) + .OrderByDescending(h => h.LastSeen) + .Take(200) + .ToListAsync(ct); + + return Ok(new PivotResponse(normalizedKind, needle, rows.Count, rows)); + } +} diff --git a/backend/src/Tawny.Api/Controllers/SuppressionRulesController.cs b/backend/src/Tawny.Api/Controllers/SuppressionRulesController.cs new file mode 100644 index 0000000..3d5fd84 --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/SuppressionRulesController.cs @@ -0,0 +1,178 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tawny.Api.Auth; +using Tawny.Api.Models; +using Tawny.Api.Services; +using Tawny.Domain; +using Tawny.Domain.Entities; +using Tawny.Infrastructure; + +namespace Tawny.Api.Controllers; + +[ApiController] +[Route("api/suppression-rules")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken)] +public class SuppressionRulesController( + TawnyDbContext db, + AuditLogger audit) : ControllerBase +{ + [HttpGet] + public async Task>> List(CancellationToken ct) + { + var tenantId = User.GetTenantId(); + var rows = await db.SuppressionRules + .AsNoTracking() + .Where(s => s.TenantId == tenantId) + .OrderByDescending(s => s.CreatedAt) + .Select(s => new + { + s.Id, s.Name, s.Reason, s.Scope, s.AlertRuleId, + AlertRuleName = s.AlertRule != null ? s.AlertRule.Name : null, + s.AgentId, + AgentHostname = s.Agent != null ? s.Agent.Hostname : null, + s.PayloadPath, s.Operator, s.MatchValue, s.IsEnabled, + s.ExpiresAt, s.SuppressedCount, s.LastSuppressedAt, + s.CreatedAt, s.UpdatedAt, + }) + .ToListAsync(ct); + return Ok(rows.Select(r => new SuppressionRuleResponse( + r.Id, r.Name, r.Reason, r.Scope, r.AlertRuleId, r.AlertRuleName, + r.AgentId, r.AgentHostname, r.PayloadPath, r.Operator, r.MatchValue, + r.IsEnabled, r.ExpiresAt, r.SuppressedCount, r.LastSuppressedAt, + r.CreatedAt, r.UpdatedAt)).ToList()); + } + + [HttpPost] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task> Create( + [FromBody] CreateSuppressionRuleRequest req, + CancellationToken ct) + { + var validation = Validate(req.Name, req.Scope, req.AlertRuleId, req.Operator, req.MatchValue); + if (validation is not null) return validation; + + var tenantId = User.GetTenantId(); + var now = DateTimeOffset.UtcNow; + var rule = new SuppressionRule + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Name = req.Name.Trim(), + Reason = string.IsNullOrWhiteSpace(req.Reason) ? null : req.Reason.Trim(), + Scope = req.Scope, + AlertRuleId = req.Scope == SuppressionScope.SpecificRule ? req.AlertRuleId : null, + AgentId = req.AgentId, + PayloadPath = Normalize(req.PayloadPath), + Operator = req.Operator, + MatchValue = Normalize(req.MatchValue), + IsEnabled = req.IsEnabled ?? true, + ExpiresAt = req.ExpiresAt, + CreatedByUserId = TryGetUserId(), + CreatedAt = now, + UpdatedAt = now, + }; + db.SuppressionRules.Add(rule); + audit.Add(User, "suppression_rule.create", rule.Id.ToString(), new + { + rule.Name, rule.Scope, rule.AlertRuleId, rule.AgentId, rule.IsEnabled, + }); + await db.SaveChangesAsync(ct); + + return CreatedAtAction(nameof(List), new { id = rule.Id }, await BuildResponse(rule.Id, ct)); + } + + [HttpPut("{id:guid}")] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task> Update( + Guid id, + [FromBody] UpdateSuppressionRuleRequest req, + CancellationToken ct) + { + var validation = Validate(req.Name, req.Scope, req.AlertRuleId, req.Operator, req.MatchValue); + if (validation is not null) return validation; + + var tenantId = User.GetTenantId(); + var rule = await db.SuppressionRules.FirstOrDefaultAsync(s => s.Id == id && s.TenantId == tenantId, ct); + if (rule is null) return NotFound(); + + rule.Name = req.Name.Trim(); + rule.Reason = string.IsNullOrWhiteSpace(req.Reason) ? null : req.Reason.Trim(); + rule.Scope = req.Scope; + rule.AlertRuleId = req.Scope == SuppressionScope.SpecificRule ? req.AlertRuleId : null; + rule.AgentId = req.AgentId; + rule.PayloadPath = Normalize(req.PayloadPath); + rule.Operator = req.Operator; + rule.MatchValue = Normalize(req.MatchValue); + rule.IsEnabled = req.IsEnabled; + rule.ExpiresAt = req.ExpiresAt; + rule.UpdatedAt = DateTimeOffset.UtcNow; + audit.Add(User, "suppression_rule.update", rule.Id.ToString(), new + { + rule.Name, rule.Scope, rule.IsEnabled, + }); + await db.SaveChangesAsync(ct); + return Ok(await BuildResponse(rule.Id, ct)); + } + + [HttpDelete("{id:guid}")] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var tenantId = User.GetTenantId(); + var deleted = await db.SuppressionRules + .Where(s => s.Id == id && s.TenantId == tenantId) + .ExecuteDeleteAsync(ct); + if (deleted == 0) return NotFound(); + audit.Add(User, "suppression_rule.delete", id.ToString()); + await db.SaveChangesAsync(ct); + return NoContent(); + } + + private ActionResult? Validate( + string name, + SuppressionScope scope, + Guid? alertRuleId, + AlertRuleOperator op, + string? matchValue) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 160) + { + return Problem(statusCode: 400, title: "name is required and must be 160 characters or fewer."); + } + if (scope == SuppressionScope.SpecificRule && alertRuleId is null) + { + return Problem(statusCode: 400, title: "alert_rule_id is required when scope is specific_rule."); + } + if (op != AlertRuleOperator.Exists && string.IsNullOrWhiteSpace(matchValue)) + { + return Problem(statusCode: 400, title: "match_value is required unless the operator is exists."); + } + return null; + } + + private async Task BuildResponse(Guid id, CancellationToken ct) + { + var r = await db.SuppressionRules + .Include(s => s.AlertRule) + .Include(s => s.Agent) + .FirstAsync(s => s.Id == id, ct); + return new SuppressionRuleResponse( + r.Id, r.Name, r.Reason, r.Scope, r.AlertRuleId, r.AlertRule?.Name, + r.AgentId, r.Agent?.Hostname, r.PayloadPath, r.Operator, r.MatchValue, + r.IsEnabled, r.ExpiresAt, r.SuppressedCount, r.LastSuppressedAt, + r.CreatedAt, r.UpdatedAt); + } + + private static string? Normalize(string? value) + { + var t = value?.Trim(); + return string.IsNullOrEmpty(t) ? null : t; + } + + private Guid? TryGetUserId() + { + var raw = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(raw, out var id) ? id : null; + } +} diff --git a/backend/src/Tawny.Api/Controllers/TelemetryController.cs b/backend/src/Tawny.Api/Controllers/TelemetryController.cs index 011df8b..2a291ae 100644 --- a/backend/src/Tawny.Api/Controllers/TelemetryController.cs +++ b/backend/src/Tawny.Api/Controllers/TelemetryController.cs @@ -21,7 +21,8 @@ public class TelemetryController( IValidator validator, AlertRuleEvaluator alertRules, ITelemetrySink telemetrySink, - IAlertSink alertSink) : ControllerBase + IAlertSink alertSink, + AgentEventBroker eventBroker) : ControllerBase { private const int MaxRequestBytes = 1024 * 1024; private const int DefaultLimit = 50; @@ -75,6 +76,7 @@ public async Task Ingest( received_at = receivedAt, }); await db.SaveChangesAsync(ct); + eventBroker.Publish(agent, events); await telemetrySink.PublishAsync(agent, events, ct); var alerts = await alertRules.EvaluateAsync(agent, events, receivedAt, ct); @@ -86,7 +88,12 @@ public async Task Ingest( event_count = events.Count, received_at = receivedAt, }); - await db.SaveChangesAsync(ct); + } + // Always save: even if alerts.Count == 0, the evaluator may have + // touched suppression counters (SuppressedCount, LastSuppressedAt). + await db.SaveChangesAsync(ct); + if (alerts.Count > 0) + { await alertSink.PublishAsync( agent, alerts, diff --git a/backend/src/Tawny.Api/Models/AlertDtos.cs b/backend/src/Tawny.Api/Models/AlertDtos.cs index 2e28a42..f18c6fd 100644 --- a/backend/src/Tawny.Api/Models/AlertDtos.cs +++ b/backend/src/Tawny.Api/Models/AlertDtos.cs @@ -16,6 +16,7 @@ public record AlertRuleResponse( string? MatchValue, string? SourceDefinition, bool IsEnabled, + IReadOnlyList MitreTechniques, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); @@ -26,7 +27,8 @@ public record CreateAlertRuleRequest( AlertRuleOperator Operator, string? PayloadPath, string? MatchValue, - bool? IsEnabled); + bool? IsEnabled, + IReadOnlyList? MitreTechniques); public record UpdateAlertRuleRequest( string Name, @@ -35,7 +37,8 @@ public record UpdateAlertRuleRequest( AlertRuleOperator Operator, string? PayloadPath, string? MatchValue, - bool IsEnabled); + bool IsEnabled, + IReadOnlyList? MitreTechniques); public record ImportSigmaRuleRequest( string RuleYaml, diff --git a/backend/src/Tawny.Api/Models/ApiTokenDtos.cs b/backend/src/Tawny.Api/Models/ApiTokenDtos.cs new file mode 100644 index 0000000..b522c77 --- /dev/null +++ b/backend/src/Tawny.Api/Models/ApiTokenDtos.cs @@ -0,0 +1,27 @@ +using Tawny.Domain; + +namespace Tawny.Api.Models; + +public record CreateApiTokenRequest( + string Name, + UserRole? Role, + DateTimeOffset? ExpiresAt); + +public record CreatedApiTokenResponse( + Guid Id, + string Name, + string Token, + string TokenPrefix, + UserRole Role, + DateTimeOffset CreatedAt, + DateTimeOffset? ExpiresAt); + +public record ApiTokenResponse( + Guid Id, + string Name, + string TokenPrefix, + UserRole Role, + DateTimeOffset CreatedAt, + DateTimeOffset? ExpiresAt, + DateTimeOffset? LastUsedAt, + DateTimeOffset? RevokedAt); diff --git a/backend/src/Tawny.Api/Models/AuditLogDtos.cs b/backend/src/Tawny.Api/Models/AuditLogDtos.cs new file mode 100644 index 0000000..0dc7e95 --- /dev/null +++ b/backend/src/Tawny.Api/Models/AuditLogDtos.cs @@ -0,0 +1,11 @@ +using System.Text.Json; + +namespace Tawny.Api.Models; + +public record AuditLogResponse( + long Id, + Guid? UserId, + string Action, + string? Target, + JsonElement? Metadata, + DateTimeOffset OccurredAt); diff --git a/backend/src/Tawny.Api/Models/DashboardDtos.cs b/backend/src/Tawny.Api/Models/DashboardDtos.cs index 694fd8c..c8a5d1f 100644 --- a/backend/src/Tawny.Api/Models/DashboardDtos.cs +++ b/backend/src/Tawny.Api/Models/DashboardDtos.cs @@ -9,7 +9,12 @@ public record DashboardSummaryResponse( int StaleAgents, int UnknownAgents, IReadOnlyList RecentEvents, - IReadOnlyList EventVolume); + IReadOnlyList EventVolume, + IReadOnlyList MitreHeatmap); + +public record DashboardMitreHeatmapEntry( + string TechniqueId, + int AlertCount); public record DashboardRecentEvent( long Id, diff --git a/backend/src/Tawny.Api/Models/HuntDtos.cs b/backend/src/Tawny.Api/Models/HuntDtos.cs new file mode 100644 index 0000000..99511ec --- /dev/null +++ b/backend/src/Tawny.Api/Models/HuntDtos.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using Tawny.Domain; + +namespace Tawny.Api.Models; + +public record RunHuntRequest( + string Query, + int? Limit); + +public record HuntMatchResponse( + long EventId, + Guid AgentId, + string Hostname, + TelemetryEventType EventType, + DateTimeOffset OccurredAt, + DateTimeOffset ReceivedAt, + JsonElement Payload); + +public record RunHuntResponse( + int MatchCount, + IReadOnlyList Matches, + IReadOnlyList Warnings); + +public record CreateSavedHuntRequest( + string Name, + string? Description, + string Query, + bool? IsScheduled, + string? ScheduleCron, + bool? AlertOnMatch, + AlertSeverity? AlertSeverity, + IReadOnlyList? MitreTechniques); + +public record UpdateSavedHuntRequest( + string Name, + string? Description, + string Query, + bool IsScheduled, + string? ScheduleCron, + bool AlertOnMatch, + AlertSeverity AlertSeverity, + IReadOnlyList? MitreTechniques); + +public record SavedHuntResponse( + Guid Id, + string Name, + string? Description, + string Query, + bool IsScheduled, + string? ScheduleCron, + bool AlertOnMatch, + AlertSeverity AlertSeverity, + IReadOnlyList MitreTechniques, + DateTimeOffset? LastRunAt, + int? LastMatchCount, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public record HuntRunResponse( + long Id, + Guid SavedHuntId, + HuntRunStatus Status, + DateTimeOffset StartedAt, + DateTimeOffset? CompletedAt, + int MatchCount, + int AlertsCreated, + string? ErrorMessage); diff --git a/backend/src/Tawny.Api/Models/SuppressionDtos.cs b/backend/src/Tawny.Api/Models/SuppressionDtos.cs new file mode 100644 index 0000000..3565183 --- /dev/null +++ b/backend/src/Tawny.Api/Models/SuppressionDtos.cs @@ -0,0 +1,46 @@ +using Tawny.Domain; + +namespace Tawny.Api.Models; + +public record CreateSuppressionRuleRequest( + string Name, + string? Reason, + SuppressionScope Scope, + Guid? AlertRuleId, + Guid? AgentId, + string? PayloadPath, + AlertRuleOperator Operator, + string? MatchValue, + bool? IsEnabled, + DateTimeOffset? ExpiresAt); + +public record UpdateSuppressionRuleRequest( + string Name, + string? Reason, + SuppressionScope Scope, + Guid? AlertRuleId, + Guid? AgentId, + string? PayloadPath, + AlertRuleOperator Operator, + string? MatchValue, + bool IsEnabled, + DateTimeOffset? ExpiresAt); + +public record SuppressionRuleResponse( + Guid Id, + string Name, + string? Reason, + SuppressionScope Scope, + Guid? AlertRuleId, + string? AlertRuleName, + Guid? AgentId, + string? AgentHostname, + string? PayloadPath, + AlertRuleOperator Operator, + string? MatchValue, + bool IsEnabled, + DateTimeOffset? ExpiresAt, + int SuppressedCount, + DateTimeOffset? LastSuppressedAt, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); diff --git a/backend/src/Tawny.Api/Program.cs b/backend/src/Tawny.Api/Program.cs index 944a647..da4bf99 100644 --- a/backend/src/Tawny.Api/Program.cs +++ b/backend/src/Tawny.Api/Program.cs @@ -14,6 +14,7 @@ using Tawny.Api.Controllers; using Tawny.Api.Services; using Tawny.Infrastructure; +using Tawny.Infrastructure.Hunting; using Tawny.Jobs; var builder = WebApplication.CreateBuilder(args); @@ -40,6 +41,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); @@ -88,7 +93,8 @@ await context.HttpContext.Response.WriteAsJsonAsync(new builder.Services .AddAuthentication() .AddJwtBearer(TawnyAuthSchemes.AgentJwt, _ => { }) - .AddScheme(TawnyAuthSchemes.WebUser, _ => { }); + .AddScheme(TawnyAuthSchemes.WebUser, _ => { }) + .AddScheme(TawnyAuthSchemes.ApiToken, _ => { }); builder.Services .AddOptions(TawnyAuthSchemes.AgentJwt) @@ -116,6 +122,7 @@ await context.HttpContext.Response.WriteAsJsonAsync(new { builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddHangfire(cfg => cfg @@ -166,6 +173,8 @@ await context.HttpContext.Response.WriteAsJsonAsync(new "backup-telemetry", j => j.ExecuteAsync(default), "0 3 * * *"); RecurringJob.AddOrUpdate( "check-agent-releases", j => j.ExecuteAsync(default), Cron.Hourly); + RecurringJob.AddOrUpdate( + "scheduled-hunts", j => j.ExecuteAsync(default), "*/5 * * * *"); } app.Run(); diff --git a/backend/src/Tawny.Api/Services/AgentEventBroker.cs b/backend/src/Tawny.Api/Services/AgentEventBroker.cs new file mode 100644 index 0000000..91b147d --- /dev/null +++ b/backend/src/Tawny.Api/Services/AgentEventBroker.cs @@ -0,0 +1,81 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Threading.Channels; +using Tawny.Domain; +using Tawny.Domain.Entities; + +namespace Tawny.Api.Services; + +public record StreamedEvent( + long Id, + Guid TenantId, + Guid AgentId, + TelemetryEventType EventType, + DateTimeOffset OccurredAt, + DateTimeOffset ReceivedAt, + JsonElement Payload); + +/// +/// In-process pub/sub for live telemetry events. Each subscriber gets a bounded +/// channel; slow consumers are dropped rather than backpressuring the publisher. +/// This is intentionally not durable — clients reconnect and resume via the +/// existing polling endpoint if they miss events. +/// +public class AgentEventBroker +{ + private readonly ConcurrentDictionary>> _subscribers = new(); + + public IDisposable Subscribe(Guid tenantId, Guid agentId, out Channel channel) + { + channel = Channel.CreateBounded(new BoundedChannelOptions(256) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + + var perTenant = _subscribers.GetOrAdd(tenantId, _ => new ConcurrentDictionary>()); + var subscriberId = Guid.NewGuid(); + // Key by (subscriberId XOR agentId) so multiple subscribers on same agent coexist. + // We store the filter agentId alongside via the channel writer's queue items being already filtered. + // Implementation detail: store as a list of (agentId, channel) under tenant for routing. + perTenant.TryAdd(subscriberId, channel); + _filters[subscriberId] = agentId; + + return new Subscription(() => + { + perTenant.TryRemove(subscriberId, out _); + _filters.TryRemove(subscriberId, out _); + channel.Writer.TryComplete(); + }); + } + + private readonly ConcurrentDictionary _filters = new(); + + public void Publish(Agent agent, IReadOnlyList events) + { + if (!_subscribers.TryGetValue(agent.TenantId, out var perTenant) || perTenant.IsEmpty) return; + + foreach (var (subscriberId, channel) in perTenant) + { + if (!_filters.TryGetValue(subscriberId, out var filterAgent) || filterAgent != agent.Id) + { + continue; + } + + foreach (var ev in events) + { + JsonElement payload; + try { payload = JsonSerializer.Deserialize(ev.Payload); } + catch { continue; } + channel.Writer.TryWrite(new StreamedEvent( + ev.Id, ev.TenantId, ev.AgentId, ev.EventType, ev.OccurredAt, ev.ReceivedAt, payload)); + } + } + } + + private sealed class Subscription(Action onDispose) : IDisposable + { + public void Dispose() => onDispose(); + } +} diff --git a/backend/src/Tawny.Api/Services/AlertRuleEvaluator.cs b/backend/src/Tawny.Api/Services/AlertRuleEvaluator.cs index c9fd7f6..75c1276 100644 --- a/backend/src/Tawny.Api/Services/AlertRuleEvaluator.cs +++ b/backend/src/Tawny.Api/Services/AlertRuleEvaluator.cs @@ -4,10 +4,11 @@ using Tawny.Domain; using Tawny.Domain.Entities; using Tawny.Infrastructure; +using Tawny.Infrastructure.Hunting; namespace Tawny.Api.Services; -public class AlertRuleEvaluator(TawnyDbContext db) +public class AlertRuleEvaluator(TawnyDbContext db, SuppressionEvaluator suppressions) { public async Task> EvaluateAsync( Agent agent, @@ -30,7 +31,7 @@ public async Task> EvaluateAsync( return []; } - var alerts = new List(); + var candidates = new List(); foreach (var telemetryEvent in events) { using var payload = JsonDocument.Parse(telemetryEvent.Payload); @@ -46,7 +47,7 @@ public async Task> EvaluateAsync( continue; } - alerts.Add(new Alert + candidates.Add(new Alert { AlertRuleId = rule.Id, AgentId = agent.Id, @@ -59,8 +60,23 @@ public async Task> EvaluateAsync( } } - db.Alerts.AddRange(alerts); - return alerts; + if (candidates.Count == 0) + { + return []; + } + + var eventsById = events.ToDictionary(e => e.Id); + var suppressed = await suppressions.ApplyAsync(agent.TenantId, candidates, eventsById, now, ct); + if (suppressed.Count == 0) + { + db.Alerts.AddRange(candidates); + return candidates; + } + + var suppressedAlerts = new HashSet(suppressed.Select(s => s.Alert)); + var emitted = candidates.Where(c => !suppressedAlerts.Contains(c)).ToList(); + db.Alerts.AddRange(emitted); + return emitted; } private static bool Matches(AlertRule rule, JsonElement payload) diff --git a/backend/src/Tawny.Api/Services/SigmaRuleImporter.cs b/backend/src/Tawny.Api/Services/SigmaRuleImporter.cs index 0c3ce20..ad268ba 100644 --- a/backend/src/Tawny.Api/Services/SigmaRuleImporter.cs +++ b/backend/src/Tawny.Api/Services/SigmaRuleImporter.cs @@ -63,11 +63,38 @@ public AlertRule Import(string yaml, bool isEnabled, DateTimeOffset now) MatchValue = predicate.MatchValue, SourceDefinition = yaml, IsEnabled = isEnabled, + MitreTechniquesJson = ExtractMitreTechniques(root), CreatedAt = now, UpdatedAt = now, }; } + private static string? ExtractMitreTechniques(YamlMappingNode root) + { + // Sigma rules surface techniques via `tags:` like `attack.t1059.003`. + // We extract everything that matches `attack.tNNNN(.NNN)?` and + // uppercase to the canonical ATT&CK form (e.g. T1059.003). + if (!root.Children.TryGetValue(new YamlScalarNode("tags"), out var tagsNode) + || tagsNode is not YamlSequenceNode sequence) + { + return null; + } + + var techniques = new List(); + foreach (var item in sequence.Children.OfType()) + { + var raw = item.Value; + if (string.IsNullOrWhiteSpace(raw)) continue; + var trimmed = raw.Trim(); + if (!trimmed.StartsWith("attack.t", StringComparison.OrdinalIgnoreCase)) continue; + var id = trimmed[(trimmed.IndexOf('.') + 1)..]; + techniques.Add(id.ToUpperInvariant()); + } + + if (techniques.Count == 0) return null; + return JsonSerializer.Serialize(techniques.Distinct().ToList(), JsonOptions); + } + private static CompiledPredicate CompileSelection(YamlMappingNode selection) { if (selection.Children.Count != 1) diff --git a/backend/src/Tawny.Domain/Entities/AlertRule.cs b/backend/src/Tawny.Domain/Entities/AlertRule.cs index 9ea1f53..54bddaf 100644 --- a/backend/src/Tawny.Domain/Entities/AlertRule.cs +++ b/backend/src/Tawny.Domain/Entities/AlertRule.cs @@ -14,6 +14,7 @@ public class AlertRule public string? MatchValue { get; set; } public string? SourceDefinition { get; set; } public bool IsEnabled { get; set; } = true; + public string? MitreTechniquesJson { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } diff --git a/backend/src/Tawny.Domain/Entities/ApiToken.cs b/backend/src/Tawny.Domain/Entities/ApiToken.cs new file mode 100644 index 0000000..fab93f7 --- /dev/null +++ b/backend/src/Tawny.Domain/Entities/ApiToken.cs @@ -0,0 +1,18 @@ +namespace Tawny.Domain.Entities; + +public class ApiToken +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public required string Name { get; set; } + public required string TokenHash { get; set; } + public required string TokenPrefix { get; set; } + public Guid? CreatedByUserId { get; set; } + public UserRole Role { get; set; } = UserRole.Viewer; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + public DateTimeOffset? LastUsedAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public Tenant? Tenant { get; set; } +} diff --git a/backend/src/Tawny.Domain/Entities/HuntRun.cs b/backend/src/Tawny.Domain/Entities/HuntRun.cs new file mode 100644 index 0000000..88d5bf0 --- /dev/null +++ b/backend/src/Tawny.Domain/Entities/HuntRun.cs @@ -0,0 +1,17 @@ +namespace Tawny.Domain.Entities; + +public class HuntRun +{ + public long Id { get; set; } + public Guid TenantId { get; set; } + public Guid SavedHuntId { get; set; } + public Guid? TriggeredByUserId { get; set; } + public HuntRunStatus Status { get; set; } = HuntRunStatus.Running; + public DateTimeOffset StartedAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public int MatchCount { get; set; } + public int AlertsCreated { get; set; } + public string? ErrorMessage { get; set; } + + public SavedHunt? SavedHunt { get; set; } +} diff --git a/backend/src/Tawny.Domain/Entities/SavedHunt.cs b/backend/src/Tawny.Domain/Entities/SavedHunt.cs new file mode 100644 index 0000000..155c0bb --- /dev/null +++ b/backend/src/Tawny.Domain/Entities/SavedHunt.cs @@ -0,0 +1,23 @@ +namespace Tawny.Domain.Entities; + +public class SavedHunt +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public required string Query { get; set; } + public Guid? CreatedByUserId { get; set; } + public bool IsScheduled { get; set; } + public string? ScheduleCron { get; set; } + public bool AlertOnMatch { get; set; } + public AlertSeverity AlertSeverity { get; set; } = AlertSeverity.Medium; + public string? MitreTechniquesJson { get; set; } + public DateTimeOffset? LastRunAt { get; set; } + public int? LastMatchCount { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public Tenant? Tenant { get; set; } + public List Runs { get; set; } = []; +} diff --git a/backend/src/Tawny.Domain/Entities/SuppressionRule.cs b/backend/src/Tawny.Domain/Entities/SuppressionRule.cs new file mode 100644 index 0000000..3968bc4 --- /dev/null +++ b/backend/src/Tawny.Domain/Entities/SuppressionRule.cs @@ -0,0 +1,26 @@ +namespace Tawny.Domain.Entities; + +public class SuppressionRule +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public required string Name { get; set; } + public string? Reason { get; set; } + public SuppressionScope Scope { get; set; } = SuppressionScope.SpecificRule; + public Guid? AlertRuleId { get; set; } + public Guid? AgentId { get; set; } + public string? PayloadPath { get; set; } + public AlertRuleOperator Operator { get; set; } = AlertRuleOperator.Contains; + public string? MatchValue { get; set; } + public bool IsEnabled { get; set; } = true; + public Guid? CreatedByUserId { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + public int SuppressedCount { get; set; } + public DateTimeOffset? LastSuppressedAt { get; set; } + + public Tenant? Tenant { get; set; } + public AlertRule? AlertRule { get; set; } + public Agent? Agent { get; set; } +} diff --git a/backend/src/Tawny.Domain/Entities/Tenant.cs b/backend/src/Tawny.Domain/Entities/Tenant.cs index 613d598..eca6a45 100644 --- a/backend/src/Tawny.Domain/Entities/Tenant.cs +++ b/backend/src/Tawny.Domain/Entities/Tenant.cs @@ -12,4 +12,7 @@ public class Tenant public List TelemetryEvents { get; set; } = []; public List Users { get; set; } = []; public List AuditLog { get; set; } = []; + public List SavedHunts { get; set; } = []; + public List SuppressionRules { get; set; } = []; + public List ApiTokens { get; set; } = []; } diff --git a/backend/src/Tawny.Domain/Enums.cs b/backend/src/Tawny.Domain/Enums.cs index fb1f534..8f83617 100644 --- a/backend/src/Tawny.Domain/Enums.cs +++ b/backend/src/Tawny.Domain/Enums.cs @@ -90,3 +90,16 @@ public enum ResponseActionStatus Failed = 3, Cancelled = 4, } + +public enum HuntRunStatus +{ + Running = 0, + Succeeded = 1, + Failed = 2, +} + +public enum SuppressionScope +{ + AllRules = 0, + SpecificRule = 1, +} diff --git a/backend/src/Tawny.Infrastructure/Hunting/HuntExecutor.cs b/backend/src/Tawny.Infrastructure/Hunting/HuntExecutor.cs new file mode 100644 index 0000000..e43eba2 --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Hunting/HuntExecutor.cs @@ -0,0 +1,196 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tawny.Domain; +using Tawny.Domain.Entities; + +namespace Tawny.Infrastructure.Hunting; + +public record HuntMatch( + long EventId, + Guid AgentId, + string Hostname, + TelemetryEventType EventType, + DateTimeOffset OccurredAt, + DateTimeOffset ReceivedAt, + JsonElement Payload); + +public record HuntResult(int MatchCount, IReadOnlyList Matches, IReadOnlyList Warnings); + +public class HuntExecutor(TawnyDbContext db) +{ + // Hard cap on the prefilter pull from SQL Server so a wide-open query + // cannot drag the whole telemetry table back to memory. + private const int PrefilterCap = 5_000; + + public async Task ExecuteAsync( + Guid tenantId, + HuntQueryPlan plan, + CancellationToken ct) + { + var warnings = new List(); + var query = db.TelemetryEvents + .AsNoTracking() + .Where(e => e.TenantId == tenantId); + + if (plan.EventType is not null) + { + query = query.Where(e => e.EventType == plan.EventType.Value); + } + if (plan.AgentId is not null) + { + query = query.Where(e => e.AgentId == plan.AgentId.Value); + } + if (plan.From is not null) + { + query = query.Where(e => e.OccurredAt >= plan.From.Value); + } + if (plan.To is not null) + { + query = query.Where(e => e.OccurredAt <= plan.To.Value); + } + + if (plan.AgentHostnameLike is { Length: > 0 } host) + { + var like = $"%{host}%"; + query = query.Where(e => EF.Functions.Like(e.Agent!.Hostname, like)); + } + + // Default to last 24h when no time bound is set, to keep wide-open queries cheap. + if (plan.From is null && plan.To is null && plan.EventType is null && plan.AgentId is null) + { + var cutoff = DateTimeOffset.UtcNow.AddHours(-24); + query = query.Where(e => e.OccurredAt >= cutoff); + warnings.Add("No time window specified — restricted to the last 24h. Add 'last:7d' or 'from:...' to widen."); + } + + var rows = await query + .OrderByDescending(e => e.OccurredAt) + .Take(PrefilterCap) + .Select(e => new + { + e.Id, + e.AgentId, + Hostname = e.Agent!.Hostname, + e.EventType, + e.OccurredAt, + e.ReceivedAt, + e.Payload, + }) + .ToListAsync(ct); + + if (rows.Count == PrefilterCap) + { + warnings.Add($"Hit prefilter cap of {PrefilterCap} events. Narrow the query with event_type, agent, or a tighter time window."); + } + + var matches = new List(Math.Min(rows.Count, plan.Limit)); + foreach (var row in rows) + { + using var doc = JsonDocument.Parse(row.Payload); + if (plan.Filter is null || Evaluate(plan.Filter, doc.RootElement)) + { + matches.Add(new HuntMatch( + row.Id, + row.AgentId, + row.Hostname, + row.EventType, + row.OccurredAt, + row.ReceivedAt, + JsonSerializer.Deserialize(row.Payload))); + if (matches.Count >= plan.Limit) break; + } + } + + return new HuntResult(matches.Count, matches, warnings); + } + + public static bool Evaluate(HuntNode node, JsonElement payload) + { + return node switch + { + HuntAnd and => Evaluate(and.Left, payload) && Evaluate(and.Right, payload), + HuntOr or => Evaluate(or.Left, payload) || Evaluate(or.Right, payload), + HuntNot not => !Evaluate(not.Inner, payload), + HuntPredicate p => EvaluatePredicate(p, payload), + _ => false, + }; + } + + private static bool EvaluatePredicate(HuntPredicate predicate, JsonElement payload) + { + var path = predicate.Field; + if (path.StartsWith("payload.", StringComparison.OrdinalIgnoreCase)) + { + path = path[8..]; + } + + var values = ResolvePath(payload, path).ToList(); + if (values.Count == 0) + { + return predicate.Operator == HuntOperator.NotEquals; + } + + return predicate.Operator switch + { + HuntOperator.Equals => values.Any(v => predicate.Values.Any(target => string.Equals(JsonScalar(v), target, StringComparison.OrdinalIgnoreCase))), + HuntOperator.NotEquals => !values.Any(v => predicate.Values.Any(target => string.Equals(JsonScalar(v), target, StringComparison.OrdinalIgnoreCase))), + HuntOperator.Contains => values.Any(v => predicate.Values.Any(target => JsonScalar(v).Contains(target, StringComparison.OrdinalIgnoreCase))), + HuntOperator.In => values.Any(v => predicate.Values.Any(target => string.Equals(JsonScalar(v), target, StringComparison.OrdinalIgnoreCase))), + HuntOperator.GreaterThan => values.Any(v => CompareNumber(v, predicate.Values, (a, b) => a > b)), + HuntOperator.LessThan => values.Any(v => CompareNumber(v, predicate.Values, (a, b) => a < b)), + HuntOperator.GreaterThanOrEqual => values.Any(v => CompareNumber(v, predicate.Values, (a, b) => a >= b)), + HuntOperator.LessThanOrEqual => values.Any(v => CompareNumber(v, predicate.Values, (a, b) => a <= b)), + _ => false, + }; + } + + private static IEnumerable ResolvePath(JsonElement root, string path) + { + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return ResolvePath(root, segments, 0); + } + + private static IEnumerable ResolvePath(JsonElement current, IReadOnlyList segments, int index) + { + if (index >= segments.Count) + { + yield return current; + yield break; + } + if (current.ValueKind == JsonValueKind.Array) + { + foreach (var item in current.EnumerateArray()) + { + foreach (var v in ResolvePath(item, segments, index)) yield return v; + } + yield break; + } + if (current.ValueKind != JsonValueKind.Object) yield break; + if (!current.TryGetProperty(segments[index], out var child)) yield break; + foreach (var v in ResolvePath(child, segments, index + 1)) yield return v; + } + + private static string JsonScalar(JsonElement value) => value.ValueKind switch + { + JsonValueKind.String => value.GetString() ?? "", + JsonValueKind.Number => value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "", + _ => value.GetRawText(), + }; + + private static bool CompareNumber(JsonElement value, IReadOnlyList targets, Func cmp) + { + if (!decimal.TryParse(JsonScalar(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var a)) return false; + foreach (var raw in targets) + { + if (decimal.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var b) && cmp(a, b)) + { + return true; + } + } + return false; + } +} diff --git a/backend/src/Tawny.Infrastructure/Hunting/HuntQuery.cs b/backend/src/Tawny.Infrastructure/Hunting/HuntQuery.cs new file mode 100644 index 0000000..7bb8c90 --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Hunting/HuntQuery.cs @@ -0,0 +1,481 @@ +using System.Globalization; +using System.Text; +using Tawny.Domain; + +namespace Tawny.Infrastructure.Hunting; + +public class HuntQueryException(string message) : Exception(message); + +public enum HuntOperator +{ + Equals, + NotEquals, + Contains, + GreaterThan, + LessThan, + GreaterThanOrEqual, + LessThanOrEqual, + In, +} + +public abstract record HuntNode; + +public sealed record HuntAnd(HuntNode Left, HuntNode Right) : HuntNode; +public sealed record HuntOr(HuntNode Left, HuntNode Right) : HuntNode; +public sealed record HuntNot(HuntNode Inner) : HuntNode; +public sealed record HuntPredicate(string Field, HuntOperator Operator, IReadOnlyList Values) : HuntNode; + +public sealed record HuntQueryPlan( + HuntNode? Filter, + DateTimeOffset? From, + DateTimeOffset? To, + TelemetryEventType? EventType, + Guid? AgentId, + string? AgentHostnameLike, + int Limit); + +/// +/// Tiny KQL-style DSL for threat hunting across telemetry events. +/// Grammar (informal): +/// query := orExpr +/// orExpr := andExpr (OR andExpr)* +/// andExpr := unaryExpr (AND unaryExpr)* +/// unaryExpr := NOT unaryExpr | '(' orExpr ')' | predicate +/// predicate := field op value +/// field := identifier ('.' identifier)* +/// op := ':' | '=' | '!=' | '>' | '<' | '>=' | '<=' +/// value := quoted-string | bareword | '[' value (',' value)* ']' +/// +/// Reserved shortcut fields: +/// event_type:<snake_case> -> event type filter +/// agent:<hostname-substring> -> agent hostname like +/// agent_id:<guid> -> specific agent +/// from:<iso-datetime> -> start of time window +/// to:<iso-datetime> -> end of time window +/// last:<duration> -> e.g. "1h", "30m", "7d" +/// +/// All other fields are evaluated against the JSON payload via JSON_VALUE(Payload, '$.path'). +/// +public class HuntQueryParser +{ + private const int DefaultLimit = 200; + private const int MaxLimit = 1000; + + public HuntQueryPlan Parse(string source, int? limit = null) + { + var tokens = Tokenize(source); + var pos = 0; + + DateTimeOffset? from = null; + DateTimeOffset? to = null; + TelemetryEventType? eventType = null; + Guid? agentId = null; + string? agentHostnameLike = null; + + // Extract shortcut filters first so they don't pollute the predicate tree. + var residual = new List(); + for (var i = 0; i < tokens.Count; i++) + { + if (i + 2 >= tokens.Count + || tokens[i].Kind != TokenKind.Identifier + || tokens[i + 1].Kind != TokenKind.Colon) + { + residual.Add(tokens[i]); + continue; + } + + var field = tokens[i].Value.ToLowerInvariant(); + var valueToken = tokens[i + 2]; + if (valueToken.Kind is not (TokenKind.Identifier or TokenKind.String or TokenKind.Number)) + { + residual.Add(tokens[i]); + continue; + } + + switch (field) + { + case "from": + from = ParseDateTime(valueToken.Value); + i += 2; + continue; + case "to": + to = ParseDateTime(valueToken.Value); + i += 2; + continue; + case "last": + var delta = ParseDuration(valueToken.Value); + from = DateTimeOffset.UtcNow - delta; + i += 2; + continue; + case "event_type": + eventType = ParseEventType(valueToken.Value); + i += 2; + continue; + case "agent": + agentHostnameLike = valueToken.Value; + i += 2; + continue; + case "agent_id": + if (!Guid.TryParse(valueToken.Value, out var aid)) + { + throw new HuntQueryException($"agent_id must be a GUID, got '{valueToken.Value}'."); + } + agentId = aid; + i += 2; + continue; + } + + residual.Add(tokens[i]); + } + + HuntNode? filter = null; + if (residual.Count > 0) + { + pos = 0; + filter = ParseOr(residual, ref pos); + if (pos < residual.Count) + { + throw new HuntQueryException($"Unexpected token '{residual[pos].Value}' at position {pos}."); + } + } + + var resolvedLimit = Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); + return new HuntQueryPlan(filter, from, to, eventType, agentId, agentHostnameLike, resolvedLimit); + } + + private static HuntNode ParseOr(IReadOnlyList tokens, ref int pos) + { + var left = ParseAnd(tokens, ref pos); + while (pos < tokens.Count + && tokens[pos].Kind == TokenKind.Identifier + && string.Equals(tokens[pos].Value, "or", StringComparison.OrdinalIgnoreCase)) + { + pos++; + var right = ParseAnd(tokens, ref pos); + left = new HuntOr(left, right); + } + return left; + } + + private static HuntNode ParseAnd(IReadOnlyList tokens, ref int pos) + { + var left = ParseUnary(tokens, ref pos); + while (pos < tokens.Count + && tokens[pos].Kind == TokenKind.Identifier + && string.Equals(tokens[pos].Value, "and", StringComparison.OrdinalIgnoreCase)) + { + pos++; + var right = ParseUnary(tokens, ref pos); + left = new HuntAnd(left, right); + } + return left; + } + + private static HuntNode ParseUnary(IReadOnlyList tokens, ref int pos) + { + if (pos >= tokens.Count) + { + throw new HuntQueryException("Unexpected end of query."); + } + + var token = tokens[pos]; + if (token.Kind == TokenKind.Identifier && string.Equals(token.Value, "not", StringComparison.OrdinalIgnoreCase)) + { + pos++; + return new HuntNot(ParseUnary(tokens, ref pos)); + } + + if (token.Kind == TokenKind.LeftParen) + { + pos++; + var inner = ParseOr(tokens, ref pos); + if (pos >= tokens.Count || tokens[pos].Kind != TokenKind.RightParen) + { + throw new HuntQueryException("Expected ')'."); + } + pos++; + return inner; + } + + return ParsePredicate(tokens, ref pos); + } + + private static HuntNode ParsePredicate(IReadOnlyList tokens, ref int pos) + { + if (pos >= tokens.Count || tokens[pos].Kind != TokenKind.Identifier) + { + throw new HuntQueryException( + pos < tokens.Count + ? $"Expected a field name, got '{tokens[pos].Value}'." + : "Expected a field name."); + } + + var fieldBuilder = new StringBuilder(tokens[pos].Value); + pos++; + while (pos < tokens.Count && tokens[pos].Kind == TokenKind.Dot) + { + pos++; + if (pos >= tokens.Count || tokens[pos].Kind != TokenKind.Identifier) + { + throw new HuntQueryException("Expected identifier after '.'."); + } + fieldBuilder.Append('.').Append(tokens[pos].Value); + pos++; + } + + if (pos >= tokens.Count) + { + throw new HuntQueryException($"Expected operator after field '{fieldBuilder}'."); + } + + var op = tokens[pos].Kind switch + { + TokenKind.Colon or TokenKind.Equals => HuntOperator.Contains, + TokenKind.NotEquals => HuntOperator.NotEquals, + TokenKind.Greater => HuntOperator.GreaterThan, + TokenKind.Less => HuntOperator.LessThan, + TokenKind.GreaterOrEqual => HuntOperator.GreaterThanOrEqual, + TokenKind.LessOrEqual => HuntOperator.LessThanOrEqual, + _ => throw new HuntQueryException($"Expected operator after '{fieldBuilder}', got '{tokens[pos].Value}'.") + }; + pos++; + + if (pos >= tokens.Count) + { + throw new HuntQueryException($"Expected value after operator on '{fieldBuilder}'."); + } + + var values = new List(); + if (tokens[pos].Kind == TokenKind.LeftBracket) + { + op = HuntOperator.In; + pos++; + while (pos < tokens.Count && tokens[pos].Kind != TokenKind.RightBracket) + { + values.Add(tokens[pos].Value); + pos++; + if (pos < tokens.Count && tokens[pos].Kind == TokenKind.Comma) + { + pos++; + } + } + if (pos >= tokens.Count) + { + throw new HuntQueryException("Unterminated list, expected ']'."); + } + pos++; + } + else + { + var raw = tokens[pos].Value; + // `:` with a bareword that contains '*' becomes Contains-with-wildcard, + // a quoted value with `:` becomes Equals semantics (full string match). + if (op == HuntOperator.Contains && tokens[pos].Kind == TokenKind.String) + { + op = HuntOperator.Equals; + } + values.Add(raw.Trim('*')); + pos++; + } + + return new HuntPredicate(fieldBuilder.ToString(), op, values); + } + + private static DateTimeOffset ParseDateTime(string raw) + { + if (!DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt)) + { + throw new HuntQueryException($"Could not parse '{raw}' as an ISO-8601 datetime."); + } + return dt; + } + + private static TimeSpan ParseDuration(string raw) + { + if (raw.Length < 2) + { + throw new HuntQueryException($"Could not parse duration '{raw}'. Use e.g. '15m', '2h', '7d'."); + } + var suffix = raw[^1]; + if (!int.TryParse(raw[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var amount) || amount <= 0) + { + throw new HuntQueryException($"Could not parse duration '{raw}'. Use e.g. '15m', '2h', '7d'."); + } + return char.ToLowerInvariant(suffix) switch + { + 's' => TimeSpan.FromSeconds(amount), + 'm' => TimeSpan.FromMinutes(amount), + 'h' => TimeSpan.FromHours(amount), + 'd' => TimeSpan.FromDays(amount), + _ => throw new HuntQueryException($"Unknown duration suffix '{suffix}'. Use s, m, h or d."), + }; + } + + private static TelemetryEventType ParseEventType(string raw) + { + return raw.ToLowerInvariant().Replace("-", "_") switch + { + "process_snapshot" => TelemetryEventType.ProcessSnapshot, + "network_snapshot" => TelemetryEventType.NetworkSnapshot, + "user_session" => TelemetryEventType.UserSession, + "system_info" => TelemetryEventType.SystemInfo, + "file_integrity" => TelemetryEventType.FileIntegrity, + "heartbeat" => TelemetryEventType.Heartbeat, + _ => throw new HuntQueryException($"Unknown event_type '{raw}'.") + }; + } + + private enum TokenKind + { + Identifier, + String, + Number, + Colon, + Equals, + NotEquals, + Greater, + Less, + GreaterOrEqual, + LessOrEqual, + LeftParen, + RightParen, + LeftBracket, + RightBracket, + Comma, + Dot, + } + + private readonly record struct Token(TokenKind Kind, string Value); + + private static List Tokenize(string source) + { + var tokens = new List(); + var i = 0; + while (i < source.Length) + { + var c = source[i]; + if (char.IsWhiteSpace(c)) + { + i++; + continue; + } + + switch (c) + { + case '(': + tokens.Add(new Token(TokenKind.LeftParen, "(")); + i++; + continue; + case ')': + tokens.Add(new Token(TokenKind.RightParen, ")")); + i++; + continue; + case '[': + tokens.Add(new Token(TokenKind.LeftBracket, "[")); + i++; + continue; + case ']': + tokens.Add(new Token(TokenKind.RightBracket, "]")); + i++; + continue; + case ',': + tokens.Add(new Token(TokenKind.Comma, ",")); + i++; + continue; + case ':': + tokens.Add(new Token(TokenKind.Colon, ":")); + i++; + continue; + case '.': + tokens.Add(new Token(TokenKind.Dot, ".")); + i++; + continue; + case '=': + tokens.Add(new Token(TokenKind.Equals, "=")); + i++; + continue; + case '!': + if (i + 1 < source.Length && source[i + 1] == '=') + { + tokens.Add(new Token(TokenKind.NotEquals, "!=")); + i += 2; + continue; + } + throw new HuntQueryException("Expected '=' after '!'."); + case '>': + if (i + 1 < source.Length && source[i + 1] == '=') + { + tokens.Add(new Token(TokenKind.GreaterOrEqual, ">=")); + i += 2; + } + else + { + tokens.Add(new Token(TokenKind.Greater, ">")); + i++; + } + continue; + case '<': + if (i + 1 < source.Length && source[i + 1] == '=') + { + tokens.Add(new Token(TokenKind.LessOrEqual, "<=")); + i += 2; + } + else + { + tokens.Add(new Token(TokenKind.Less, "<")); + i++; + } + continue; + case '"': + case '\'': + { + var quote = c; + i++; + var start = i; + while (i < source.Length && source[i] != quote) + { + if (source[i] == '\\' && i + 1 < source.Length) + { + i += 2; + continue; + } + i++; + } + if (i >= source.Length) + { + throw new HuntQueryException("Unterminated string literal."); + } + tokens.Add(new Token(TokenKind.String, source[start..i].Replace("\\\"", "\"").Replace("\\'", "'"))); + i++; + continue; + } + } + + if (char.IsDigit(c) || (c == '-' && i + 1 < source.Length && char.IsDigit(source[i + 1]))) + { + var start = i; + if (c == '-') i++; + while (i < source.Length && (char.IsDigit(source[i]) || source[i] == '.')) + { + i++; + } + tokens.Add(new Token(TokenKind.Number, source[start..i])); + continue; + } + + if (char.IsLetter(c) || c == '_' || c == '*') + { + var start = i; + while (i < source.Length && (char.IsLetterOrDigit(source[i]) || source[i] == '_' || source[i] == '-' || source[i] == '*')) + { + i++; + } + tokens.Add(new Token(TokenKind.Identifier, source[start..i])); + continue; + } + + throw new HuntQueryException($"Unexpected character '{c}' at position {i}."); + } + return tokens; + } +} diff --git a/backend/src/Tawny.Infrastructure/Hunting/SuppressionEvaluator.cs b/backend/src/Tawny.Infrastructure/Hunting/SuppressionEvaluator.cs new file mode 100644 index 0000000..47a0936 --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Hunting/SuppressionEvaluator.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tawny.Domain; +using Tawny.Domain.Entities; + +namespace Tawny.Infrastructure.Hunting; + +/// +/// Checks whether a candidate alert should be suppressed based on per-tenant +/// suppression rules. A suppression rule matches when: +/// - its scope is AllRules, or its AlertRuleId matches the candidate's rule, AND +/// - its AgentId is null, or it matches the candidate's agent, AND +/// - its PayloadPath/MatchValue predicate matches the telemetry payload. +/// Expired or disabled suppressions are skipped. +/// +public class SuppressionEvaluator(TawnyDbContext db) +{ + public async Task> ApplyAsync( + Guid tenantId, + IReadOnlyList candidates, + IReadOnlyDictionary eventsById, + DateTimeOffset now, + CancellationToken ct) + { + if (candidates.Count == 0) return []; + + var rules = await db.SuppressionRules + .Where(s => s.TenantId == tenantId && s.IsEnabled && (s.ExpiresAt == null || s.ExpiresAt > now)) + .ToListAsync(ct); + if (rules.Count == 0) return []; + + var suppressed = new List<(Alert, SuppressionRule)>(); + foreach (var alert in candidates) + { + if (!eventsById.TryGetValue(alert.TelemetryEventId, out var telemetryEvent)) + { + continue; + } + + using var payload = JsonDocument.Parse(telemetryEvent.Payload); + foreach (var rule in rules) + { + if (rule.Scope == SuppressionScope.SpecificRule && rule.AlertRuleId != alert.AlertRuleId) + { + continue; + } + if (rule.AgentId is not null && rule.AgentId.Value != alert.AgentId) + { + continue; + } + if (!MatchesPredicate(rule, payload.RootElement)) + { + continue; + } + + suppressed.Add((alert, rule)); + rule.SuppressedCount += 1; + rule.LastSuppressedAt = now; + break; + } + } + + return suppressed; + } + + private static bool MatchesPredicate(SuppressionRule rule, JsonElement payload) + { + if (string.IsNullOrWhiteSpace(rule.PayloadPath)) + { + return true; + } + + var values = ResolvePath(payload, rule.PayloadPath).ToList(); + if (values.Count == 0) + { + return false; + } + + return rule.Operator switch + { + AlertRuleOperator.Exists => true, + AlertRuleOperator.Equals => values.Any(v => string.Equals(JsonScalar(v), rule.MatchValue, StringComparison.OrdinalIgnoreCase)), + AlertRuleOperator.Contains => values.Any(v => !string.IsNullOrEmpty(rule.MatchValue) && JsonScalar(v).Contains(rule.MatchValue, StringComparison.OrdinalIgnoreCase)), + _ => false, + }; + } + + private static IEnumerable ResolvePath(JsonElement root, string path) + { + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return ResolvePath(root, segments, 0); + } + + private static IEnumerable ResolvePath(JsonElement current, IReadOnlyList segments, int index) + { + if (index >= segments.Count) { yield return current; yield break; } + if (current.ValueKind == JsonValueKind.Array) + { + foreach (var item in current.EnumerateArray()) + { + foreach (var v in ResolvePath(item, segments, index)) yield return v; + } + yield break; + } + if (current.ValueKind != JsonValueKind.Object) yield break; + if (!current.TryGetProperty(segments[index], out var child)) yield break; + foreach (var v in ResolvePath(child, segments, index + 1)) yield return v; + } + + private static string JsonScalar(JsonElement value) => value.ValueKind switch + { + JsonValueKind.String => value.GetString() ?? "", + JsonValueKind.Number => value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "", + _ => value.GetRawText(), + }; +} diff --git a/backend/src/Tawny.Infrastructure/Migrations/20260523000000_AddHuntingAndGovernance.cs b/backend/src/Tawny.Infrastructure/Migrations/20260523000000_AddHuntingAndGovernance.cs new file mode 100644 index 0000000..3d8338a --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Migrations/20260523000000_AddHuntingAndGovernance.cs @@ -0,0 +1,208 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Tawny.Infrastructure; + +#nullable disable + +namespace Tawny.Infrastructure.Migrations +{ + /// + [DbContext(typeof(TawnyDbContext))] + [Migration("20260523000000_AddHuntingAndGovernance")] + public partial class AddHuntingAndGovernance : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MitreTechniques", + table: "AlertRules", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.CreateTable( + name: "SavedHunts", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000001")), + Name = table.Column(type: "nvarchar(160)", maxLength: 160, nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + Query = table.Column(type: "nvarchar(max)", nullable: false), + CreatedByUserId = table.Column(type: "uniqueidentifier", nullable: true), + IsScheduled = table.Column(type: "bit", nullable: false), + ScheduleCron = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + AlertOnMatch = table.Column(type: "bit", nullable: false), + AlertSeverity = table.Column(type: "int", nullable: false), + MitreTechniques = table.Column(type: "nvarchar(max)", nullable: true), + LastRunAt = table.Column(type: "datetimeoffset", nullable: true), + LastMatchCount = table.Column(type: "int", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SavedHunts", x => x.Id); + table.ForeignKey( + name: "FK_SavedHunts_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "HuntRuns", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TenantId = table.Column(type: "uniqueidentifier", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000001")), + SavedHuntId = table.Column(type: "uniqueidentifier", nullable: false), + TriggeredByUserId = table.Column(type: "uniqueidentifier", nullable: true), + Status = table.Column(type: "int", nullable: false), + StartedAt = table.Column(type: "datetimeoffset", nullable: false), + CompletedAt = table.Column(type: "datetimeoffset", nullable: true), + MatchCount = table.Column(type: "int", nullable: false), + AlertsCreated = table.Column(type: "int", nullable: false), + ErrorMessage = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_HuntRuns", x => x.Id); + table.ForeignKey( + name: "FK_HuntRuns_SavedHunts_SavedHuntId", + column: x => x.SavedHuntId, + principalTable: "SavedHunts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SuppressionRules", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000001")), + Name = table.Column(type: "nvarchar(160)", maxLength: 160, nullable: false), + Reason = table.Column(type: "nvarchar(max)", nullable: true), + Scope = table.Column(type: "int", nullable: false), + AlertRuleId = table.Column(type: "uniqueidentifier", nullable: true), + AgentId = table.Column(type: "uniqueidentifier", nullable: true), + PayloadPath = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Operator = table.Column(type: "int", nullable: false), + MatchValue = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + IsEnabled = table.Column(type: "bit", nullable: false), + CreatedByUserId = table.Column(type: "uniqueidentifier", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: false), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: true), + SuppressedCount = table.Column(type: "int", nullable: false), + LastSuppressedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SuppressionRules", x => x.Id); + table.ForeignKey( + name: "FK_SuppressionRules_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SuppressionRules_AlertRules_AlertRuleId", + column: x => x.AlertRuleId, + principalTable: "AlertRules", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SuppressionRules_Agents_AgentId", + column: x => x.AgentId, + principalTable: "Agents", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "ApiTokens", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false, defaultValue: new Guid("00000000-0000-0000-0000-000000000001")), + Name = table.Column(type: "nvarchar(160)", maxLength: 160, nullable: false), + TokenHash = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + TokenPrefix = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + CreatedByUserId = table.Column(type: "uniqueidentifier", nullable: true), + Role = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: true), + LastUsedAt = table.Column(type: "datetimeoffset", nullable: true), + RevokedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiTokens", x => x.Id); + table.ForeignKey( + name: "FK_ApiTokens_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_SavedHunts_TenantId_Name", + table: "SavedHunts", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SavedHunts_TenantId_IsScheduled", + table: "SavedHunts", + columns: new[] { "TenantId", "IsScheduled" }); + + migrationBuilder.CreateIndex( + name: "IX_HuntRuns_TenantId_SavedHuntId_StartedAt", + table: "HuntRuns", + columns: new[] { "TenantId", "SavedHuntId", "StartedAt" }, + descending: new[] { false, false, true }); + + migrationBuilder.CreateIndex( + name: "IX_SuppressionRules_TenantId_IsEnabled", + table: "SuppressionRules", + columns: new[] { "TenantId", "IsEnabled" }); + + migrationBuilder.CreateIndex( + name: "IX_SuppressionRules_AlertRuleId", + table: "SuppressionRules", + column: "AlertRuleId"); + + migrationBuilder.CreateIndex( + name: "IX_SuppressionRules_AgentId", + table: "SuppressionRules", + column: "AgentId"); + + migrationBuilder.CreateIndex( + name: "IX_ApiTokens_TokenHash", + table: "ApiTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ApiTokens_TenantId_CreatedAt", + table: "ApiTokens", + columns: new[] { "TenantId", "CreatedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "HuntRuns"); + migrationBuilder.DropTable(name: "SavedHunts"); + migrationBuilder.DropTable(name: "SuppressionRules"); + migrationBuilder.DropTable(name: "ApiTokens"); + migrationBuilder.DropColumn(name: "MitreTechniques", table: "AlertRules"); + } + } +} diff --git a/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs b/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs index 56bb64d..319f4d3 100644 --- a/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs +++ b/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs @@ -212,6 +212,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(512) .HasColumnType("nvarchar(512)"); + b.Property("MitreTechniquesJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("MitreTechniques"); + b.Property("Name") .IsRequired() .HasMaxLength(160) @@ -242,6 +246,242 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AlertRules"); }); + modelBuilder.Entity("Tawny.Domain.Entities.SavedHunt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AlertOnMatch") + .HasColumnType("bit"); + + b.Property("AlertSeverity") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsScheduled") + .HasColumnType("bit"); + + b.Property("LastMatchCount") + .HasColumnType("int"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("MitreTechniquesJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("MitreTechniques"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("Query") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleCron") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "IsScheduled"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("SavedHunts"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.HuntRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsCreated") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("MatchCount") + .HasColumnType("int"); + + b.Property("SavedHuntId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + + b.Property("TriggeredByUserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SavedHuntId", "StartedAt") + .IsDescending(false, false, true); + + b.ToTable("HuntRuns"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.SuppressionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentId") + .HasColumnType("uniqueidentifier"); + + b.Property("AlertRuleId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("LastSuppressedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MatchValue") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("Operator") + .HasColumnType("int"); + + b.Property("PayloadPath") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Reason") + .HasColumnType("nvarchar(max)"); + + b.Property("Scope") + .HasColumnType("int"); + + b.Property("SuppressedCount") + .HasColumnType("int"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AgentId"); + + b.HasIndex("AlertRuleId"); + + b.HasIndex("TenantId", "IsEnabled"); + + b.ToTable("SuppressionRules"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastUsedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TokenPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "CreatedAt"); + + b.ToTable("ApiTokens"); + }); + modelBuilder.Entity("Tawny.Domain.Entities.AuditLog", b => { b.Property("Id") @@ -578,6 +818,64 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Tenant"); }); + modelBuilder.Entity("Tawny.Domain.Entities.SavedHunt", b => + { + b.HasOne("Tawny.Domain.Entities.Tenant", "Tenant") + .WithMany("SavedHunts") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.HuntRun", b => + { + b.HasOne("Tawny.Domain.Entities.SavedHunt", "SavedHunt") + .WithMany("Runs") + .HasForeignKey("SavedHuntId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SavedHunt"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.SuppressionRule", b => + { + b.HasOne("Tawny.Domain.Entities.Agent", "Agent") + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Tawny.Domain.Entities.AlertRule", "AlertRule") + .WithMany() + .HasForeignKey("AlertRuleId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tawny.Domain.Entities.Tenant", "Tenant") + .WithMany("SuppressionRules") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Agent"); + + b.Navigation("AlertRule"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.ApiToken", b => + { + b.HasOne("Tawny.Domain.Entities.Tenant", "Tenant") + .WithMany("ApiTokens") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Tenant"); + }); + modelBuilder.Entity("Tawny.Domain.Entities.Agent", b => { b.Navigation("Events"); @@ -587,10 +885,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Agents"); + b.Navigation("ApiTokens"); + b.Navigation("AuditLog"); b.Navigation("EnrollmentTokens"); + b.Navigation("SavedHunts"); + + b.Navigation("SuppressionRules"); + b.Navigation("TelemetryEvents"); b.Navigation("Users"); @@ -600,6 +904,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Alerts"); }); + + modelBuilder.Entity("Tawny.Domain.Entities.SavedHunt", b => + { + b.Navigation("Runs"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/src/Tawny.Infrastructure/TawnyDbContext.cs b/backend/src/Tawny.Infrastructure/TawnyDbContext.cs index 9a76b1c..1659a5d 100644 --- a/backend/src/Tawny.Infrastructure/TawnyDbContext.cs +++ b/backend/src/Tawny.Infrastructure/TawnyDbContext.cs @@ -16,6 +16,10 @@ public class TawnyDbContext(DbContextOptions options) : DbContex public DbSet ResponseActions => Set(); public DbSet AgentReleases => Set(); public DbSet AuditLog => Set(); + public DbSet SavedHunts => Set(); + public DbSet HuntRuns => Set(); + public DbSet SuppressionRules => Set(); + public DbSet ApiTokens => Set(); protected override void OnModelCreating(ModelBuilder b) { @@ -104,6 +108,7 @@ protected override void OnModelCreating(ModelBuilder b) e.Property(r => r.PayloadPath).HasMaxLength(256); e.Property(r => r.MatchValue).HasMaxLength(512); e.Property(r => r.SourceDefinition).HasColumnType("nvarchar(max)"); + e.Property(r => r.MitreTechniquesJson).HasColumnName("MitreTechniques").HasColumnType("nvarchar(max)"); e.HasIndex(r => new { r.IsEnabled, r.EventType }); e.HasIndex(r => new { r.Format, r.ExternalId }); }); @@ -166,5 +171,74 @@ protected override void OnModelCreating(ModelBuilder b) .OnDelete(DeleteBehavior.Restrict); e.HasIndex(a => new { a.TenantId, a.OccurredAt }); }); + + b.Entity(e => + { + e.HasKey(h => h.Id); + e.Property(h => h.TenantId).HasDefaultValue(TenantDefaults.DefaultTenantId); + e.Property(h => h.Name).HasMaxLength(160).IsRequired(); + e.Property(h => h.Description).HasColumnType("nvarchar(max)"); + e.Property(h => h.Query).HasColumnType("nvarchar(max)").IsRequired(); + e.Property(h => h.ScheduleCron).HasMaxLength(64); + e.Property(h => h.MitreTechniquesJson).HasColumnName("MitreTechniques").HasColumnType("nvarchar(max)"); + e.HasOne(h => h.Tenant) + .WithMany(t => t.SavedHunts) + .HasForeignKey(h => h.TenantId) + .OnDelete(DeleteBehavior.Restrict); + e.HasIndex(h => new { h.TenantId, h.Name }).IsUnique(); + e.HasIndex(h => new { h.TenantId, h.IsScheduled }); + }); + + b.Entity(e => + { + e.HasKey(r => r.Id); + e.Property(r => r.TenantId).HasDefaultValue(TenantDefaults.DefaultTenantId); + e.Property(r => r.ErrorMessage).HasMaxLength(1024); + e.HasOne(r => r.SavedHunt) + .WithMany(h => h.Runs) + .HasForeignKey(r => r.SavedHuntId) + .OnDelete(DeleteBehavior.Cascade); + e.HasIndex(r => new { r.TenantId, r.SavedHuntId, r.StartedAt }) + .IsDescending(false, false, true); + }); + + b.Entity(e => + { + e.HasKey(s => s.Id); + e.Property(s => s.TenantId).HasDefaultValue(TenantDefaults.DefaultTenantId); + e.Property(s => s.Name).HasMaxLength(160).IsRequired(); + e.Property(s => s.Reason).HasColumnType("nvarchar(max)"); + e.Property(s => s.PayloadPath).HasMaxLength(256); + e.Property(s => s.MatchValue).HasMaxLength(512); + e.HasOne(s => s.Tenant) + .WithMany(t => t.SuppressionRules) + .HasForeignKey(s => s.TenantId) + .OnDelete(DeleteBehavior.Restrict); + e.HasOne(s => s.AlertRule) + .WithMany() + .HasForeignKey(s => s.AlertRuleId) + .OnDelete(DeleteBehavior.Cascade); + e.HasOne(s => s.Agent) + .WithMany() + .HasForeignKey(s => s.AgentId) + .OnDelete(DeleteBehavior.SetNull); + e.HasIndex(s => new { s.TenantId, s.IsEnabled }); + e.HasIndex(s => new { s.AlertRuleId }); + }); + + b.Entity(e => + { + e.HasKey(t => t.Id); + e.Property(t => t.TenantId).HasDefaultValue(TenantDefaults.DefaultTenantId); + e.Property(t => t.Name).HasMaxLength(160).IsRequired(); + e.Property(t => t.TokenHash).HasMaxLength(128).IsRequired(); + e.Property(t => t.TokenPrefix).HasMaxLength(16).IsRequired(); + e.HasOne(t => t.Tenant) + .WithMany(t => t.ApiTokens) + .HasForeignKey(t => t.TenantId) + .OnDelete(DeleteBehavior.Restrict); + e.HasIndex(t => t.TokenHash).IsUnique(); + e.HasIndex(t => new { t.TenantId, t.CreatedAt }); + }); } } diff --git a/backend/src/Tawny.Jobs/ScheduledHuntsJob.cs b/backend/src/Tawny.Jobs/ScheduledHuntsJob.cs new file mode 100644 index 0000000..894dea8 --- /dev/null +++ b/backend/src/Tawny.Jobs/ScheduledHuntsJob.cs @@ -0,0 +1,149 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Tawny.Domain; +using Tawny.Domain.Entities; +using Tawny.Infrastructure; +using Tawny.Infrastructure.Hunting; + +namespace Tawny.Jobs; + +/// +/// Runs every 5 minutes. For each enabled scheduled saved hunt whose last run +/// is older than its cadence (parsed from a simple "Nm" / "Nh" cron-ish string), +/// executes the query and, when AlertOnMatch is true and there are matches, +/// emits alerts attached to the matched telemetry events. +/// +public class ScheduledHuntsJob( + TawnyDbContext db, + TimeProvider timeProvider, + HuntQueryParser parser, + HuntExecutor executor, + ILogger log) +{ + public async Task ExecuteAsync(CancellationToken ct = default) + { + var now = timeProvider.GetUtcNow(); + var due = await db.SavedHunts + .Where(h => h.IsScheduled && h.ScheduleCron != null) + .ToListAsync(ct); + + if (due.Count == 0) return; + + foreach (var hunt in due) + { + if (ct.IsCancellationRequested) break; + if (!IsDue(hunt, now)) continue; + + var run = new HuntRun + { + TenantId = hunt.TenantId, + SavedHuntId = hunt.Id, + StartedAt = now, + Status = HuntRunStatus.Running, + }; + db.HuntRuns.Add(run); + await db.SaveChangesAsync(ct); + + try + { + var plan = parser.Parse(hunt.Query); + var result = await executor.ExecuteAsync(hunt.TenantId, plan, ct); + + var alertsCreated = 0; + if (hunt.AlertOnMatch && result.Matches.Count > 0) + { + // Manufacture a synthetic alert rule placeholder so the alert FK is satisfiable. + // We use an "always-on" rule per saved hunt, created on the fly. + var ruleId = await EnsureHuntRuleAsync(hunt, ct); + foreach (var match in result.Matches) + { + db.Alerts.Add(new Alert + { + AlertRuleId = ruleId, + AgentId = match.AgentId, + TelemetryEventId = match.EventId, + Severity = hunt.AlertSeverity, + Title = $"Scheduled hunt: {hunt.Name}", + Description = $"Matched by saved hunt '{hunt.Name}'.", + CreatedAt = now, + }); + alertsCreated += 1; + } + } + + run.Status = HuntRunStatus.Succeeded; + run.CompletedAt = timeProvider.GetUtcNow(); + run.MatchCount = result.MatchCount; + run.AlertsCreated = alertsCreated; + hunt.LastRunAt = run.CompletedAt; + hunt.LastMatchCount = result.MatchCount; + + await db.SaveChangesAsync(ct); + log.LogInformation("Scheduled hunt {HuntId} ran with {Matches} matches, {Alerts} alerts created.", + hunt.Id, result.MatchCount, alertsCreated); + } + catch (Exception ex) + { + run.Status = HuntRunStatus.Failed; + run.CompletedAt = timeProvider.GetUtcNow(); + run.ErrorMessage = ex.Message.Length > 1000 ? ex.Message[..1000] : ex.Message; + await db.SaveChangesAsync(ct); + log.LogError(ex, "Scheduled hunt {HuntId} failed", hunt.Id); + } + } + } + + private async Task EnsureHuntRuleAsync(SavedHunt hunt, CancellationToken ct) + { + var externalId = $"saved-hunt:{hunt.Id}"; + var existing = await db.AlertRules.FirstOrDefaultAsync(r => r.ExternalId == externalId, ct); + if (existing is not null) return existing.Id; + + var now = DateTimeOffset.UtcNow; + var rule = new AlertRule + { + Id = Guid.NewGuid(), + Name = $"Hunt: {hunt.Name}", + Format = AlertRuleFormat.TawnyPredicate, + ExternalId = externalId, + Description = $"Auto-generated rule backing saved hunt {hunt.Id}.", + Severity = hunt.AlertSeverity, + Operator = AlertRuleOperator.Exists, + IsEnabled = false, // we only emit via scheduled hunt, not via ingest evaluation + MitreTechniquesJson = hunt.MitreTechniquesJson, + CreatedAt = now, + UpdatedAt = now, + }; + db.AlertRules.Add(rule); + await db.SaveChangesAsync(ct); + return rule.Id; + } + + private static bool IsDue(SavedHunt hunt, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(hunt.ScheduleCron)) return false; + + // Accept the simple "Nm" / "Nh" / "Nd" form so users don't need full cron semantics. + var trimmed = hunt.ScheduleCron.Trim(); + if (trimmed.Length >= 2 + && int.TryParse(trimmed[..^1], out var amount) + && amount > 0) + { + var span = char.ToLowerInvariant(trimmed[^1]) switch + { + 'm' => TimeSpan.FromMinutes(amount), + 'h' => TimeSpan.FromHours(amount), + 'd' => TimeSpan.FromDays(amount), + _ => TimeSpan.Zero, + }; + if (span > TimeSpan.Zero) + { + return hunt.LastRunAt is null || (now - hunt.LastRunAt.Value) >= span; + } + } + + // Anything else (e.g. classic cron) -> run at most every 15 minutes; + // a real cron parser is out of scope here. + return hunt.LastRunAt is null || (now - hunt.LastRunAt.Value) >= TimeSpan.FromMinutes(15); + } +} diff --git a/web/app/agents/[id]/events-panel.tsx b/web/app/agents/[id]/events-panel.tsx index 574f77a..3a150e5 100644 --- a/web/app/agents/[id]/events-panel.tsx +++ b/web/app/agents/[id]/events-panel.tsx @@ -1,9 +1,10 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Pause, Play, RefreshCcw } from "lucide-react"; -import { QueryClient, QueryClientProvider, keepPreviousData, useQuery } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import { cn } from "@/lib/cn"; +import { ProcessTreeView } from "./process-tree"; type EventType = | "process_snapshot" @@ -30,6 +31,7 @@ type Tab = { const TABS: Tab[] = [ { key: "processes", label: "Processes", type: "process_snapshot" }, + { key: "tree", label: "Process tree", type: "process_snapshot" }, { key: "network", label: "Network", type: "network_snapshot" }, { key: "fim", label: "FIM", type: "file_integrity" }, { key: "sessions", label: "Sessions", type: "user_session" }, @@ -37,7 +39,6 @@ const TABS: Tab[] = [ ]; const EVENT_LIMIT = "12"; -const LIVE_POLL_INTERVAL_MS = 2000; export function AgentEventsPanel({ agentId }: { agentId: string }) { const [queryClient] = useState(() => new QueryClient()); @@ -56,6 +57,7 @@ function AgentEvents({ agentId }: { agentId: string }) { () => TABS.find((tab) => tab.key === activeKey) ?? TABS[0]!, [activeKey], ); + const queryClient = useQueryClient(); const { data, error, isFetching, dataUpdatedAt, refetch } = useQuery({ queryKey: ["agent-events", agentId, activeTab.type ?? "all"], @@ -68,13 +70,50 @@ function AgentEvents({ agentId }: { agentId: string }) { return (await res.json()) as TelemetryEvent[]; }, placeholderData: keepPreviousData, - refetchInterval: isLive ? LIVE_POLL_INTERVAL_MS : false, - refetchIntervalInBackground: true, staleTime: 1000, }); + // Live updates ride on Server-Sent Events. When the agent ingests new + // telemetry the broker pushes one frame per event; we splice it into the + // cached list and prune to the displayed limit. Polling is gone. + const liveRef = useRef(isLive); + useEffect(() => { + liveRef.current = isLive; + }, [isLive]); + + useEffect(() => { + if (!isLive) return; + const source = new EventSource(`/api/agents/${agentId}/events/stream`); + source.addEventListener("message", (event) => { + if (!liveRef.current) return; + try { + const next = JSON.parse(event.data) as TelemetryEvent; + const filterKey: TelemetryEvent["type"] | "all" = activeTab.type ?? "all"; + if (filterKey !== "all" && next.type !== filterKey) { + return; + } + queryClient.setQueryData(["agent-events", agentId, filterKey], (prev) => { + const limit = parseInt(EVENT_LIMIT, 10); + const current = prev ?? []; + if (current.some((e) => e.id === next.id)) return current; + return [next, ...current].slice(0, limit); + }); + } catch { + // ignore malformed frames + } + }); + source.addEventListener("error", () => { + // The browser auto-reconnects per the retry: directive from the server. + }); + return () => source.close(); + }, [agentId, isLive, activeTab.type, queryClient]); + const events = data ?? []; const lastUpdated = dataUpdatedAt ? formatTime(dataUpdatedAt) : "Not loaded"; + const latestProcessSnapshot = activeTab.key === "tree" + ? events.find((e): e is TelemetryEvent & { payload: { processes: ProcessRow[] } } => + e.type === "process_snapshot" && isProcessSnapshot(e.payload)) + : null; return (
@@ -100,7 +139,7 @@ function AgentEvents({ agentId }: { agentId: string }) {
- {isLive ? "Polling every 2s" : `Latest ${EVENT_LIMIT} events`} + {isLive ? "Live stream (SSE)" : `Latest ${EVENT_LIMIT} events`} {isFetching ? ", updating" : ""} @@ -129,6 +168,16 @@ function AgentEvents({ agentId }: { agentId: string }) {
Events could not be loaded.
+ ) : activeTab.key === "tree" ? ( + latestProcessSnapshot ? ( +
+ +
+ ) : ( +
+ No process snapshot has arrived yet — switch to Processes for the table. +
+ ) ) : events.length === 0 && !isFetching ? (
No {activeTab.label.toLowerCase()} telemetry has arrived yet. @@ -201,10 +250,19 @@ function summarizePayload(event: TelemetryEvent) { return JSON.stringify(event.payload, null, 2); } -function isProcessSnapshot(payload: unknown): payload is { processes: Array<{ name: string }> } { +type ProcessRow = { + pid: number; + ppid?: number; + name: string; + command_line?: string; +}; + +function isProcessSnapshot(payload: unknown): payload is { processes: ProcessRow[] } { if (!payload || typeof payload !== "object" || !("processes" in payload)) { return false; } const processes = (payload as { processes: unknown }).processes; return Array.isArray(processes); } + +export type { ProcessRow }; diff --git a/web/app/agents/[id]/process-tree.tsx b/web/app/agents/[id]/process-tree.tsx new file mode 100644 index 0000000..d14c620 --- /dev/null +++ b/web/app/agents/[id]/process-tree.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronDown, ChevronRight, Search } from "lucide-react"; +import type { ProcessRow } from "./events-panel"; + +type TreeNode = ProcessRow & { + children: TreeNode[]; +}; + +export function ProcessTreeView({ processes }: { processes: ProcessRow[] }) { + const [filter, setFilter] = useState(""); + + const { roots, totalNodes } = useMemo(() => buildForest(processes), [processes]); + const filtered = useMemo(() => { + const needle = filter.trim().toLowerCase(); + if (!needle) return roots; + return filterTree(roots, (n) => + n.name.toLowerCase().includes(needle) + || (n.command_line ?? "").toLowerCase().includes(needle) + || String(n.pid).includes(needle)); + }, [roots, filter]); + + return ( +
+
+
+

Process tree

+

+ {totalNodes} processes from the latest snapshot, nested by parent_pid. +

+
+ +
+ + {filtered.length === 0 ? ( +

+ No processes match this filter. +

+ ) : ( +
    + {filtered.map((node) => ( + + ))} +
+ )} +
+ ); +} + +function TreeNodeView({ + node, + depth, + defaultOpen = false, +}: { + node: TreeNode; + depth: number; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen || depth < 2); + const hasChildren = node.children.length > 0; + return ( +
  • +
    + +
    +
    + {node.name} + pid {node.pid} + {typeof node.ppid === "number" ? ( + ppid {node.ppid} + ) : null} +
    + {node.command_line ? ( +

    + {node.command_line} +

    + ) : null} +
    +
    + {hasChildren && open ? ( +
      + {node.children.map((child) => ( + + ))} +
    + ) : null} +
  • + ); +} + +function buildForest(processes: ProcessRow[]): { roots: TreeNode[]; totalNodes: number } { + const byPid = new Map(); + for (const p of processes) { + byPid.set(p.pid, { ...p, children: [] }); + } + const roots: TreeNode[] = []; + for (const node of byPid.values()) { + if (node.ppid && byPid.has(node.ppid) && node.ppid !== node.pid) { + byPid.get(node.ppid)!.children.push(node); + } else { + roots.push(node); + } + } + // Sort each level by name for stable display. + const sortAll = (nodes: TreeNode[]) => { + nodes.sort((a, b) => a.name.localeCompare(b.name) || a.pid - b.pid); + nodes.forEach((n) => sortAll(n.children)); + }; + sortAll(roots); + return { roots, totalNodes: byPid.size }; +} + +function filterTree(nodes: TreeNode[], predicate: (n: TreeNode) => boolean): TreeNode[] { + const out: TreeNode[] = []; + for (const n of nodes) { + const kids = filterTree(n.children, predicate); + if (predicate(n) || kids.length > 0) { + out.push({ ...n, children: kids }); + } + } + return out; +} diff --git a/web/app/api-tokens/api-tokens-panel.tsx b/web/app/api-tokens/api-tokens-panel.tsx new file mode 100644 index 0000000..b4104bb --- /dev/null +++ b/web/app/api-tokens/api-tokens-panel.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { CheckCircle2, Copy, KeyRound, Loader2, Plus, Trash2, X } from "lucide-react"; + +export type ApiToken = { + id: string; + name: string; + token_prefix: string; + role: "admin" | "viewer"; + created_at: string; + expires_at: string | null; + last_used_at: string | null; + revoked_at: string | null; +}; + +type CreatedToken = ApiToken & { token: string }; + +export function ApiTokensPanel({ initialTokens }: { initialTokens: ApiToken[] }) { + const router = useRouter(); + const [tokens, setTokens] = useState(initialTokens); + const [createOpen, setCreateOpen] = useState(false); + const [createdToken, setCreatedToken] = useState(null); + + async function revoke(id: string) { + if (!window.confirm("Revoke this token? Any clients using it will stop working.")) return; + const res = await fetch(`/api/api-tokens/${id}`, { method: "DELETE" }); + if (res.ok) { + setTokens((current) => + current.map((t) => (t.id === id ? { ...t, revoked_at: new Date().toISOString() } : t)), + ); + router.refresh(); + } + } + + function onCreated(token: CreatedToken) { + setTokens((current) => [ + { + id: token.id, + name: token.name, + token_prefix: token.token_prefix, + role: token.role, + created_at: token.created_at, + expires_at: token.expires_at, + last_used_at: null, + revoked_at: null, + }, + ...current, + ]); + setCreateOpen(false); + setCreatedToken(token); + router.refresh(); + } + + return ( +
    +
    + +
    + + {createdToken ? setCreatedToken(null)} /> : null} + +
    + + + + + + + + + + + + + + {tokens.length === 0 ? ( + + + + ) : ( + tokens.map((t) => ( + + + + + + + + + + + )) + )} + +
    NamePrefixRoleCreatedLast usedExpiresState +
    + No API tokens yet. +
    {t.name} + {t.token_prefix}… + {t.role} + {new Date(t.created_at).toLocaleDateString()} + + {t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never"} + + {t.expires_at ? new Date(t.expires_at).toLocaleDateString() : "—"} + + {t.revoked_at ? ( + + Revoked + + ) : ( + + Active + + )} + + {!t.revoked_at && ( + + )} +
    +
    + + {createOpen ? setCreateOpen(false)} onCreated={onCreated} /> : null} +
    + ); +} + +function CreatedTokenBanner({ token, onDismiss }: { token: CreatedToken; onDismiss: () => void }) { + const [copied, setCopied] = useState(false); + async function copy() { + await navigator.clipboard.writeText(token.token); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } + + return ( +
    +
    +
    + +
    +

    Token created — copy it now

    +

    + Tawny only shows this once. Save it in your secrets manager before closing this banner. +

    +
    +
    + +
    +
    + + {token.token} + + +
    +
    + ); +} + +function CreateTokenDialog({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (token: CreatedToken) => void; +}) { + const [name, setName] = useState(""); + const [role, setRole] = useState<"admin" | "viewer">("viewer"); + const [expiresInDays, setExpiresInDays] = useState("90"); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + async function submit() { + setError(null); + if (!name.trim()) { + setError("Name is required."); + return; + } + let expiresAt: string | null = null; + const days = parseInt(expiresInDays, 10); + if (Number.isFinite(days) && days > 0) { + const d = new Date(); + d.setDate(d.getDate() + days); + expiresAt = d.toISOString(); + } + setPending(true); + try { + const res = await fetch("/api/api-tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim(), role, expires_at: expiresAt }), + }); + const data = (await res.json().catch(() => null)) as CreatedToken | { error?: string } | null; + if (!res.ok || !data || "error" in data) { + throw new Error((data && "error" in data && data.error) || `Create failed with ${res.status}`); + } + onCreated(data as CreatedToken); + } catch (err) { + setError(err instanceof Error ? err.message : "Create failed."); + } finally { + setPending(false); + } + } + + return ( +
    +
    +
    +

    Create API token

    + +
    + + + + + + + + {error ? ( +

    + {error} +

    + ) : null} + +
    + + +
    +
    +
    + ); +} diff --git a/web/app/api-tokens/page.tsx b/web/app/api-tokens/page.tsx new file mode 100644 index 0000000..410f09f --- /dev/null +++ b/web/app/api-tokens/page.tsx @@ -0,0 +1,51 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; +import { authRole } from "@/lib/auth-role"; +import { apiGet } from "@/lib/api"; +import { AppShell, PageHeader } from "@/components/app-shell"; +import { ApiTokensPanel, type ApiToken } from "./api-tokens-panel"; + +type Agent = { + id: string; + hostname: string; + status: string; +}; + +export default async function ApiTokensPage() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect("/login"); + + const role = authRole(session.user); + if (role !== "Admin") { + return ( + +
    + +
    +
    + ); + } + + const [tokens, agents] = await Promise.all([ + apiGet("/api/api-tokens", session.user.id, role), + apiGet("/api/agents", session.user.id, role), + ]); + + return ( + +
    + + +
    +
    + ); +} diff --git a/web/app/api/api-tokens/[id]/route.ts b/web/app/api/api-tokens/[id]/route.ts new file mode 100644 index 0000000..64b7974 --- /dev/null +++ b/web/app/api/api-tokens/[id]/route.ts @@ -0,0 +1,21 @@ +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { authRole } from "@/lib/auth-role"; +import { ApiError, apiDelete } from "@/lib/api"; + +export async function DELETE(_: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { id } = await ctx.params; + try { + await apiDelete(`/api/api-tokens/${id}`, session.user.id, authRole(session.user)); + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof ApiError && err.status >= 400 && err.status < 500) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + return NextResponse.json({ error: "Failed to revoke API token." }, { status: 502 }); + } +} diff --git a/web/app/api/api-tokens/route.ts b/web/app/api/api-tokens/route.ts new file mode 100644 index 0000000..30d2e31 --- /dev/null +++ b/web/app/api/api-tokens/route.ts @@ -0,0 +1,33 @@ +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { authRole } from "@/lib/auth-role"; +import { ApiError, apiPost } from "@/lib/api"; + +const schema = z.object({ + name: z.string().trim().min(1).max(160), + role: z.enum(["admin", "viewer"]), + expires_at: z.string().datetime().nullable().optional(), +}); + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await req.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request." }, { status: 400 }); + } + + try { + const data = await apiPost("/api/api-tokens", parsed.data, session.user.id, authRole(session.user)); + return NextResponse.json(data); + } catch (err) { + if (err instanceof ApiError && err.status >= 400 && err.status < 500) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + return NextResponse.json({ error: "Failed to create API token." }, { status: 502 }); + } +} diff --git a/web/app/api/hunts/[id]/route.ts b/web/app/api/hunts/[id]/route.ts new file mode 100644 index 0000000..c534993 --- /dev/null +++ b/web/app/api/hunts/[id]/route.ts @@ -0,0 +1,55 @@ +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { authRole } from "@/lib/auth-role"; +import { ApiError, apiDelete, apiPut } from "@/lib/api"; + +const schema = z.object({ + name: z.string().trim().min(1).max(160), + description: z.string().nullable().optional(), + query: z.string().trim().min(1), + is_scheduled: z.boolean(), + schedule_cron: z.string().nullable().optional(), + alert_on_match: z.boolean(), + alert_severity: z.enum(["low", "medium", "high", "critical"]), + mitre_techniques: z.array(z.string()).optional(), +}); + +export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { id } = await ctx.params; + const body = await req.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request." }, { status: 400 }); + } + + try { + const data = await apiPut(`/api/hunts/${id}`, parsed.data, session.user.id, authRole(session.user)); + return NextResponse.json(data); + } catch (err) { + if (err instanceof ApiError && err.status >= 400 && err.status < 500) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + return NextResponse.json({ error: "Failed to update hunt." }, { status: 502 }); + } +} + +export async function DELETE(_: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { id } = await ctx.params; + try { + await apiDelete(`/api/hunts/${id}`, session.user.id, authRole(session.user)); + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof ApiError && err.status >= 400 && err.status < 500) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + return NextResponse.json({ error: "Failed to delete hunt." }, { status: 502 }); + } +} diff --git a/web/app/api/hunts/route.ts b/web/app/api/hunts/route.ts new file mode 100644 index 0000000..e670562 --- /dev/null +++ b/web/app/api/hunts/route.ts @@ -0,0 +1,38 @@ +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { authRole } from "@/lib/auth-role"; +import { ApiError, apiPost } from "@/lib/api"; + +const schema = z.object({ + name: z.string().trim().min(1).max(160), + description: z.string().nullable().optional(), + query: z.string().trim().min(1), + is_scheduled: z.boolean(), + schedule_cron: z.string().nullable().optional(), + alert_on_match: z.boolean(), + alert_severity: z.enum(["low", "medium", "high", "critical"]), + mitre_techniques: z.array(z.string()).optional(), +}); + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await req.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request." }, { status: 400 }); + } + + try { + const data = await apiPost("/api/hunts", parsed.data, session.user.id, authRole(session.user)); + return NextResponse.json(data); + } catch (err) { + if (err instanceof ApiError && err.status >= 400 && err.status < 500) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + return NextResponse.json({ error: "Failed to save hunt." }, { status: 502 }); + } +} diff --git a/web/app/api/hunts/run/route.ts b/web/app/api/hunts/run/route.ts new file mode 100644 index 0000000..a637b95 --- /dev/null +++ b/web/app/api/hunts/run/route.ts @@ -0,0 +1,37 @@ +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { authRole } from "@/lib/auth-role"; +import { ApiError, apiPost } from "@/lib/api"; + +const schema = z.object({ + query: z.string().trim().min(1, "query is required."), + limit: z.number().int().min(1).max(1000).optional(), +}); + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await req.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request." }, { status: 400 }); + } + + try { + const data = await apiPost( + "/api/hunts/run", + parsed.data, + session.user.id, + authRole(session.user), + ); + return NextResponse.json(data); + } catch (err) { + if (err instanceof ApiError && err.status >= 400 && err.status < 500) { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + return NextResponse.json({ error: "Hunt execution failed." }, { status: 502 }); + } +} diff --git a/web/app/api/suppression-rules/[id]/route.ts b/web/app/api/suppression-rules/[id]/route.ts new file mode 100644 index 0000000..e38e0f9 --- /dev/null +++ b/web/app/api/suppression-rules/[id]/route.ts @@ -0,0 +1,21 @@ +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { authRole } from "@/lib/auth-role"; +import { ApiError, apiDelete } from "@/lib/api"; + +export async function DELETE(_: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { id } = await ctx.params; + try { + await apiDelete(`/api/suppression-rules/${id}`, session.user.id, authRole(session.user)); + return new NextResponse(null, { status: 204 }); + } catch (err) { + if (err instanceof ApiError && err.status >= 400 && err.status < 500) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + return NextResponse.json({ error: "Failed to delete suppression rule." }, { status: 502 }); + } +} diff --git a/web/app/api/suppression-rules/route.ts b/web/app/api/suppression-rules/route.ts new file mode 100644 index 0000000..149709a --- /dev/null +++ b/web/app/api/suppression-rules/route.ts @@ -0,0 +1,40 @@ +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { authRole } from "@/lib/auth-role"; +import { ApiError, apiPost } from "@/lib/api"; + +const schema = z.object({ + name: z.string().trim().min(1).max(160), + reason: z.string().nullable().optional(), + scope: z.enum(["all_rules", "specific_rule"]), + alert_rule_id: z.string().uuid().nullable().optional(), + agent_id: z.string().uuid().nullable().optional(), + payload_path: z.string().nullable().optional(), + operator: z.enum(["exists", "equals", "contains", "greater_than", "less_than"]), + match_value: z.string().nullable().optional(), + is_enabled: z.boolean(), + expires_at: z.string().datetime().nullable().optional(), +}); + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await req.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request." }, { status: 400 }); + } + + try { + const data = await apiPost("/api/suppression-rules", parsed.data, session.user.id, authRole(session.user)); + return NextResponse.json(data); + } catch (err) { + if (err instanceof ApiError && err.status >= 400 && err.status < 500) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + return NextResponse.json({ error: "Failed to create suppression rule." }, { status: 502 }); + } +} diff --git a/web/app/audit/page.tsx b/web/app/audit/page.tsx new file mode 100644 index 0000000..aabf354 --- /dev/null +++ b/web/app/audit/page.tsx @@ -0,0 +1,114 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; +import { authRole } from "@/lib/auth-role"; +import { apiGet } from "@/lib/api"; +import { AppShell, PageHeader } from "@/components/app-shell"; + +type Agent = { + id: string; + hostname: string; + status: string; +}; + +type AuditLogEntry = { + id: number; + user_id: string | null; + action: string; + target: string | null; + metadata: unknown; + occurred_at: string; +}; + +export default async function AuditPage({ + searchParams, +}: { + searchParams: Promise<{ action?: string }>; +}) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect("/login"); + + const role = authRole(session.user); + const { action } = await searchParams; + const qs = new URLSearchParams({ limit: "200" }); + if (action) qs.set("action", action); + + const [entries, agents] = await Promise.all([ + apiGet(`/api/audit-logs?${qs.toString()}`, session.user.id, role), + apiGet("/api/agents", session.user.id, role), + ]); + + return ( + +
    + + +
    + + +
    + +
    + + + + + + + + + + + + {entries.length === 0 ? ( + + + + ) : ( + entries.map((e) => ( + + + + + + + + )) + )} + +
    TimeActionTargetUserMetadata
    + No audit log entries match these filters. +
    + {new Date(e.occurred_at).toLocaleString()} + {e.action} + {e.target ?? "—"} + + {e.user_id ? e.user_id.slice(0, 8) : "system"} + + {e.metadata ? ( +
    +                          {JSON.stringify(e.metadata, null, 2)}
    +                        
    + ) : ( + + )} +
    +
    +
    +
    + ); +} diff --git a/web/app/hunt/hunt-workbench.tsx b/web/app/hunt/hunt-workbench.tsx new file mode 100644 index 0000000..3b7d216 --- /dev/null +++ b/web/app/hunt/hunt-workbench.tsx @@ -0,0 +1,530 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { AlertTriangle, BookmarkPlus, Calendar, Loader2, Pencil, Play, Save, Trash2, X } from "lucide-react"; + +export type SavedHunt = { + id: string; + name: string; + description: string | null; + query: string; + is_scheduled: boolean; + schedule_cron: string | null; + alert_on_match: boolean; + alert_severity: "low" | "medium" | "high" | "critical"; + mitre_techniques: string[]; + last_run_at: string | null; + last_match_count: number | null; + created_at: string; + updated_at: string; +}; + +type HuntMatch = { + event_id: number; + agent_id: string; + hostname: string; + event_type: string; + occurred_at: string; + received_at: string; + payload: unknown; +}; + +type RunResponse = { + match_count: number; + matches: HuntMatch[]; + warnings: string[]; +}; + +const STARTER_QUERIES: { label: string; query: string }[] = [ + { + label: "PowerShell with EncodedCommand", + query: 'event_type:process_snapshot AND processes.command_line:"-EncodedCommand"', + }, + { + label: "Connections to 1.1.1.1 in last 6h", + query: "last:6h AND event_type:network_snapshot AND connections.remote_address:1.1.1.1", + }, + { + label: "Any cmd.exe or powershell.exe lineage", + query: "processes.name:[cmd.exe, powershell.exe]", + }, + { + label: "FIM events on /etc/ in last 24h", + query: "event_type:file_integrity AND path:/etc/", + }, +]; + +const SEVERITY_OPTIONS: SavedHunt["alert_severity"][] = ["low", "medium", "high", "critical"]; + +export function HuntWorkbench({ + initialHunts, + role, +}: { + initialHunts: SavedHunt[]; + role: "Admin" | "Viewer"; +}) { + const router = useRouter(); + const [query, setQuery] = useState(initialHunts[0]?.query ?? STARTER_QUERIES[0]!.query); + const [selectedHuntId, setSelectedHuntId] = useState(initialHunts[0]?.id ?? null); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + const [runError, setRunError] = useState(null); + const [saveOpen, setSaveOpen] = useState(false); + const [hunts, setHunts] = useState(initialHunts); + + const selectedHunt = useMemo( + () => hunts.find((h) => h.id === selectedHuntId) ?? null, + [hunts, selectedHuntId], + ); + + const canEdit = role === "Admin"; + + function selectHunt(hunt: SavedHunt) { + setSelectedHuntId(hunt.id); + setQuery(hunt.query); + setResult(null); + setRunError(null); + } + + async function runQuery() { + setRunning(true); + setRunError(null); + try { + const res = await fetch("/api/hunts/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, limit: 200 }), + }); + const body = (await res.json().catch(() => null)) as RunResponse | { error?: string } | null; + if (!res.ok || !body || "error" in body) { + throw new Error((body && "error" in body && body.error) || `Run failed with ${res.status}`); + } + setResult(body as RunResponse); + } catch (err) { + setRunError(err instanceof Error ? err.message : "Hunt failed."); + setResult(null); + } finally { + setRunning(false); + } + } + + async function deleteHunt(id: string) { + if (!canEdit) return; + if (!window.confirm("Delete this saved hunt?")) return; + const res = await fetch(`/api/hunts/${id}`, { method: "DELETE" }); + if (res.ok) { + setHunts((current) => current.filter((h) => h.id !== id)); + if (selectedHuntId === id) { + setSelectedHuntId(null); + } + router.refresh(); + } + } + + return ( +
    + + +
    +
    +
    +
    +

    Query

    +

    + Fields like event_type, agent, + or any payload.path. Combine with AND, + OR, NOT. +

    +
    +
    + + + {selectedHunt && canEdit ? ( + <> + + + + ) : null} +
    +
    + +
    +