From 2a5b6a701ac271eb79d347411fa433c0e3e1c8ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 04:21:56 +0000 Subject: [PATCH 01/10] Phase 3+4 backend: correlation, full Sigma, YARA-lite, TI feeds, reputation, cases, graphs Detection: - AlertRuleFormat gains Sequence and Yara. Sequence rules store a JSON definition (window, ordered steps) in SourceDefinition; SequenceRuleEvaluator keeps per-(rule, host) progress in-process and fires when the full chain completes inside the window. - SigmaRuleImporter now compiles non-trivial conditions (AND/OR/NOT, "1 of selection_*", "all of selection_*") into a polymorphic SigmaNode tree stored on AlertRule.CompiledExpressionJson; single-selection rules stay on the existing PayloadPath/Operator/MatchValue legacy fields so the existing UI still works. - YARA-lite: JSON-defined string/regex matching against telemetry payload text. Not a full YARA implementation (no PE parsing, no offsets) but enough to express "any/all of these strings appeared in the payload." - RuleTestHarness: POST /api/alert-rules/{id}/test runs a saved rule against caller-supplied events with a per-step trace, no DB writes. Threat intel: - ThreatIntelFeed entity + CRUD + Hangfire job that pulls feeds every 10 minutes. Parsers for abuse.ch URLhaus (CSV + JSON), AlienVault OTX, MISP /events/restSearch, TAXII 2.1, and generic CSV. Indicators are materialised as Format=Ioc AlertRules, keyed by ExternalId so re-imports are idempotent and respect HTTP ETags. Reputation: - ReputationEnricher with VirusTotal, AbuseIPDB, and GreyNoise providers, ReputationCache table for TTL'd verdicts, and a Hangfire job that walks recent unenriched alerts and stores the lookup on Alert.EnrichmentJson. Reputation is opt-in: providers only fire when their API key is configured. Phase 4 backend: - Case management entities (Case, CaseAlert, CaseNote) + CRUD controller with link/unlink alerts and append-only notes. - Investigation views: process-tree-across-hosts aggregator and a host -> remote-endpoint network/lateral-movement graph endpoint. - Saved hunts gain IsShared; HuntsController hides private hunts from anyone other than the creator inside a tenant. Architecture: ThreatIntelFetcher and ReputationEnricher live in Tawny.Infrastructure.ThreatIntel so both Api and Jobs can reuse them without a cross-reference, same pattern we used for HuntQueryParser. https://claude.ai/code/session_01DqLy7vdV9S4prUF9sz2Ehp --- .../Controllers/AlertRulesController.cs | 1 + .../Tawny.Api/Controllers/AlertsController.cs | 2 + .../Tawny.Api/Controllers/CasesController.cs | 298 ++++++++++++++++ .../Tawny.Api/Controllers/HuntsController.cs | 9 +- .../InvestigationViewsController.cs | 241 +++++++++++++ .../Controllers/RuleTestController.cs | 53 +++ .../Controllers/SequenceRulesController.cs | 148 ++++++++ .../Controllers/ThreatIntelFeedsController.cs | 174 ++++++++++ .../Controllers/YaraRulesController.cs | 106 ++++++ backend/src/Tawny.Api/Models/AlertDtos.cs | 1 + backend/src/Tawny.Api/Models/CaseDtos.cs | 67 ++++ backend/src/Tawny.Api/Models/HuntDtos.cs | 8 +- .../src/Tawny.Api/Models/SequenceRuleDtos.cs | 31 ++ .../Tawny.Api/Models/ThreatIntelFeedDtos.cs | 41 +++ backend/src/Tawny.Api/Program.cs | 12 + .../Tawny.Api/Services/AlertRuleEvaluator.cs | 62 +++- .../Tawny.Api/Services/SigmaRuleImporter.cs | 264 ++++++++++++--- backend/src/Tawny.Domain/Entities/Alert.cs | 1 + .../src/Tawny.Domain/Entities/AlertRule.cs | 1 + backend/src/Tawny.Domain/Entities/Case.cs | 61 ++++ .../Entities/ReputationCacheEntry.cs | 15 + .../src/Tawny.Domain/Entities/SavedHunt.cs | 1 + backend/src/Tawny.Domain/Entities/Tenant.cs | 2 + .../Tawny.Domain/Entities/ThreatIntelFeed.cs | 27 ++ backend/src/Tawny.Domain/Enums.cs | 36 ++ .../Hunting/RuleTestHarness.cs | 193 +++++++++++ .../Hunting/SequenceRule.cs | 83 +++++ .../Hunting/SequenceRuleEvaluator.cs | 153 +++++++++ .../Hunting/SigmaExpression.cs | 121 +++++++ .../Tawny.Infrastructure/Hunting/YaraLite.cs | 192 +++++++++++ .../20260524000000_AddPhase3And4.cs | 224 +++++++++++++ .../Migrations/TawnyDbContextModelSnapshot.cs | 317 ++++++++++++++++++ .../Tawny.Infrastructure/TawnyDbContext.cs | 74 ++++ .../ThreatIntel/ReputationEnricher.cs | 253 ++++++++++++++ .../ThreatIntel/ThreatIntelFetcher.cs | 297 ++++++++++++++++ .../src/Tawny.Jobs/ReputationEnrichmentJob.cs | 162 +++++++++ backend/src/Tawny.Jobs/ThreatIntelFeedsJob.cs | 124 +++++++ 37 files changed, 3808 insertions(+), 47 deletions(-) create mode 100644 backend/src/Tawny.Api/Controllers/CasesController.cs create mode 100644 backend/src/Tawny.Api/Controllers/InvestigationViewsController.cs create mode 100644 backend/src/Tawny.Api/Controllers/RuleTestController.cs create mode 100644 backend/src/Tawny.Api/Controllers/SequenceRulesController.cs create mode 100644 backend/src/Tawny.Api/Controllers/ThreatIntelFeedsController.cs create mode 100644 backend/src/Tawny.Api/Controllers/YaraRulesController.cs create mode 100644 backend/src/Tawny.Api/Models/CaseDtos.cs create mode 100644 backend/src/Tawny.Api/Models/SequenceRuleDtos.cs create mode 100644 backend/src/Tawny.Api/Models/ThreatIntelFeedDtos.cs create mode 100644 backend/src/Tawny.Domain/Entities/Case.cs create mode 100644 backend/src/Tawny.Domain/Entities/ReputationCacheEntry.cs create mode 100644 backend/src/Tawny.Domain/Entities/ThreatIntelFeed.cs create mode 100644 backend/src/Tawny.Infrastructure/Hunting/RuleTestHarness.cs create mode 100644 backend/src/Tawny.Infrastructure/Hunting/SequenceRule.cs create mode 100644 backend/src/Tawny.Infrastructure/Hunting/SequenceRuleEvaluator.cs create mode 100644 backend/src/Tawny.Infrastructure/Hunting/SigmaExpression.cs create mode 100644 backend/src/Tawny.Infrastructure/Hunting/YaraLite.cs create mode 100644 backend/src/Tawny.Infrastructure/Migrations/20260524000000_AddPhase3And4.cs create mode 100644 backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs create mode 100644 backend/src/Tawny.Infrastructure/ThreatIntel/ThreatIntelFetcher.cs create mode 100644 backend/src/Tawny.Jobs/ReputationEnrichmentJob.cs create mode 100644 backend/src/Tawny.Jobs/ThreatIntelFeedsJob.cs diff --git a/backend/src/Tawny.Api/Controllers/AlertRulesController.cs b/backend/src/Tawny.Api/Controllers/AlertRulesController.cs index d480e99..7b6a5ba 100644 --- a/backend/src/Tawny.Api/Controllers/AlertRulesController.cs +++ b/backend/src/Tawny.Api/Controllers/AlertRulesController.cs @@ -163,6 +163,7 @@ public async Task> Update(Guid id, UpdateAlertRu rule.PayloadPath = Normalize(req.PayloadPath); rule.MatchValue = Normalize(req.MatchValue); rule.SourceDefinition = null; + rule.CompiledExpressionJson = null; rule.IsEnabled = req.IsEnabled; rule.MitreTechniquesJson = SerializeTechniques(req.MitreTechniques); rule.UpdatedAt = DateTimeOffset.UtcNow; diff --git a/backend/src/Tawny.Api/Controllers/AlertsController.cs b/backend/src/Tawny.Api/Controllers/AlertsController.cs index 0e4670a..f957c9f 100644 --- a/backend/src/Tawny.Api/Controllers/AlertsController.cs +++ b/backend/src/Tawny.Api/Controllers/AlertsController.cs @@ -57,6 +57,7 @@ public async Task>> List( a.SentinelNotificationError, a.Title, a.Description, + a.EnrichmentJson, a.CreatedAt, }) .ToListAsync(ct); @@ -86,6 +87,7 @@ public async Task>> List( a.SentinelNotificationError, a.Title, a.Description, + string.IsNullOrEmpty(a.EnrichmentJson) ? null : JsonSerializer.Deserialize(a.EnrichmentJson), a.CreatedAt)).ToList()); } } diff --git a/backend/src/Tawny.Api/Controllers/CasesController.cs b/backend/src/Tawny.Api/Controllers/CasesController.cs new file mode 100644 index 0000000..0b6e736 --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/CasesController.cs @@ -0,0 +1,298 @@ +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; + +namespace Tawny.Api.Controllers; + +[ApiController] +[Route("api/cases")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken)] +public class CasesController(TawnyDbContext db, AuditLogger audit) : ControllerBase +{ + [HttpGet] + public async Task>> List( + [FromQuery] CaseStatus? status, + [FromQuery] int limit = 50, + CancellationToken ct = default) + { + var take = Math.Clamp(limit, 1, 200); + var tenantId = User.GetTenantId(); + var query = db.Cases.AsNoTracking().Where(c => c.TenantId == tenantId); + if (status is not null) query = query.Where(c => c.Status == status.Value); + var rows = await query + .OrderByDescending(c => c.UpdatedAt) + .Take(take) + .Select(c => new + { + c.Id, c.Title, c.Summary, c.Status, c.Priority, + c.AssignedToUserId, c.CreatedByUserId, c.MitreTechniquesJson, + AlertCount = c.CaseAlerts.Count, + NoteCount = c.Notes.Count, + c.CreatedAt, c.UpdatedAt, c.ClosedAt, + }) + .ToListAsync(ct); + return Ok(rows.Select(c => new CaseResponse( + c.Id, c.Title, c.Summary, c.Status, c.Priority, + c.AssignedToUserId, c.CreatedByUserId, c.AlertCount, c.NoteCount, + DeserializeTechniques(c.MitreTechniquesJson), + c.CreatedAt, c.UpdatedAt, c.ClosedAt)).ToList()); + } + + [HttpGet("{id:long}")] + public async Task> Get(long id, CancellationToken ct) + { + var tenantId = User.GetTenantId(); + var caseRow = await db.Cases + .Include(c => c.CaseAlerts).ThenInclude(ca => ca.Alert).ThenInclude(a => a!.Agent) + .Include(c => c.Notes) + .FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId, ct); + if (caseRow is null) return NotFound(); + + return Ok(new CaseDetailResponse( + caseRow.Id, + caseRow.Title, + caseRow.Summary, + caseRow.Status, + caseRow.Priority, + caseRow.AssignedToUserId, + caseRow.CreatedByUserId, + DeserializeTechniques(caseRow.MitreTechniquesJson), + caseRow.CaseAlerts + .OrderByDescending(ca => ca.AddedAt) + .Select(ca => new CaseAlertResponse( + ca.Id, ca.AlertId, + ca.Alert?.Title ?? "", + ca.Alert?.Agent?.Hostname ?? "", + ca.Alert?.Severity.ToString().ToLowerInvariant() ?? "", + ca.Alert?.CreatedAt ?? DateTimeOffset.MinValue, + ca.AddedAt)) + .ToList(), + caseRow.Notes + .OrderByDescending(n => n.CreatedAt) + .Select(n => new CaseNoteResponse(n.Id, n.AuthorUserId, n.Body, n.CreatedAt)) + .ToList(), + caseRow.CreatedAt, + caseRow.UpdatedAt, + caseRow.ClosedAt)); + } + + [HttpPost] + public async Task> Create( + [FromBody] CreateCaseRequest req, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(req.Title) || req.Title.Length > 255) + { + return Problem(statusCode: 400, title: "title is required and must be 255 characters or fewer."); + } + var tenantId = User.GetTenantId(); + var now = DateTimeOffset.UtcNow; + var newCase = new Case + { + TenantId = tenantId, + Title = req.Title.Trim(), + Summary = string.IsNullOrWhiteSpace(req.Summary) ? null : req.Summary.Trim(), + Priority = req.Priority ?? CasePriority.Medium, + CreatedByUserId = TryGetUserId(), + MitreTechniquesJson = SerializeTechniques(req.MitreTechniques), + CreatedAt = now, + UpdatedAt = now, + }; + db.Cases.Add(newCase); + await db.SaveChangesAsync(ct); + + if (req.AlertIds is { Count: > 0 }) + { + await LinkAlertsAsync(newCase.Id, tenantId, req.AlertIds, now, ct); + } + + audit.Add(User, "case.create", newCase.Id.ToString(), new + { + newCase.Title, + alert_count = req.AlertIds?.Count ?? 0, + }); + await db.SaveChangesAsync(ct); + + return CreatedAtAction(nameof(Get), new { id = newCase.Id }, new CaseResponse( + newCase.Id, newCase.Title, newCase.Summary, newCase.Status, newCase.Priority, + newCase.AssignedToUserId, newCase.CreatedByUserId, + req.AlertIds?.Count ?? 0, 0, + DeserializeTechniques(newCase.MitreTechniquesJson), + newCase.CreatedAt, newCase.UpdatedAt, newCase.ClosedAt)); + } + + [HttpPut("{id:long}")] + public async Task> Update( + long id, + [FromBody] UpdateCaseRequest req, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(req.Title) || req.Title.Length > 255) + { + return Problem(statusCode: 400, title: "title is required and must be 255 characters or fewer."); + } + var tenantId = User.GetTenantId(); + var caseRow = await db.Cases.FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId, ct); + if (caseRow is null) return NotFound(); + + caseRow.Title = req.Title.Trim(); + caseRow.Summary = string.IsNullOrWhiteSpace(req.Summary) ? null : req.Summary.Trim(); + var transitioningToClosed = req.Status is CaseStatus.Resolved or CaseStatus.Closed + && caseRow.Status is not (CaseStatus.Resolved or CaseStatus.Closed); + caseRow.Status = req.Status; + caseRow.Priority = req.Priority; + caseRow.AssignedToUserId = req.AssignedToUserId; + caseRow.MitreTechniquesJson = SerializeTechniques(req.MitreTechniques); + caseRow.UpdatedAt = DateTimeOffset.UtcNow; + if (transitioningToClosed) caseRow.ClosedAt = caseRow.UpdatedAt; + + audit.Add(User, "case.update", caseRow.Id.ToString(), new + { + caseRow.Title, caseRow.Status, caseRow.Priority, caseRow.AssignedToUserId, + }); + await db.SaveChangesAsync(ct); + + var alertCount = await db.CaseAlerts.CountAsync(ca => ca.CaseId == caseRow.Id, ct); + var noteCount = await db.CaseNotes.CountAsync(n => n.CaseId == caseRow.Id, ct); + return Ok(new CaseResponse( + caseRow.Id, caseRow.Title, caseRow.Summary, caseRow.Status, caseRow.Priority, + caseRow.AssignedToUserId, caseRow.CreatedByUserId, alertCount, noteCount, + DeserializeTechniques(caseRow.MitreTechniquesJson), + caseRow.CreatedAt, caseRow.UpdatedAt, caseRow.ClosedAt)); + } + + [HttpDelete("{id:long}")] + public async Task Delete(long id, CancellationToken ct) + { + var tenantId = User.GetTenantId(); + var deleted = await db.Cases + .Where(c => c.Id == id && c.TenantId == tenantId) + .ExecuteDeleteAsync(ct); + if (deleted == 0) return NotFound(); + audit.Add(User, "case.delete", id.ToString()); + await db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:long}/alerts")] + public async Task> AddAlerts( + long id, + [FromBody] AddCaseAlertRequest req, + CancellationToken ct) + { + if (req.AlertIds is null || req.AlertIds.Count == 0) + { + return Problem(statusCode: 400, title: "alert_ids must contain at least one id."); + } + var tenantId = User.GetTenantId(); + var caseRow = await db.Cases.FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId, ct); + if (caseRow is null) return NotFound(); + + var now = DateTimeOffset.UtcNow; + var linked = await LinkAlertsAsync(caseRow.Id, tenantId, req.AlertIds, now, ct); + if (linked > 0) + { + caseRow.UpdatedAt = now; + audit.Add(User, "case.alerts_add", caseRow.Id.ToString(), new { count = linked }); + } + await db.SaveChangesAsync(ct); + return await Get(id, ct); + } + + [HttpDelete("{id:long}/alerts/{alertId:long}")] + public async Task RemoveAlert(long id, long alertId, CancellationToken ct) + { + var tenantId = User.GetTenantId(); + if (!await db.Cases.AnyAsync(c => c.Id == id && c.TenantId == tenantId, ct)) + { + return NotFound(); + } + await db.CaseAlerts.Where(ca => ca.CaseId == id && ca.AlertId == alertId).ExecuteDeleteAsync(ct); + audit.Add(User, "case.alert_remove", id.ToString(), new { alert_id = alertId }); + await db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:long}/notes")] + public async Task> AddNote( + long id, + [FromBody] AddCaseNoteRequest req, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(req.Body)) + { + return Problem(statusCode: 400, title: "body is required."); + } + var tenantId = User.GetTenantId(); + var caseRow = await db.Cases.FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId, ct); + if (caseRow is null) return NotFound(); + + var note = new CaseNote + { + CaseId = caseRow.Id, + AuthorUserId = TryGetUserId(), + Body = req.Body.Trim(), + CreatedAt = DateTimeOffset.UtcNow, + }; + db.CaseNotes.Add(note); + caseRow.UpdatedAt = note.CreatedAt; + audit.Add(User, "case.note_add", caseRow.Id.ToString()); + await db.SaveChangesAsync(ct); + return Ok(new CaseNoteResponse(note.Id, note.AuthorUserId, note.Body, note.CreatedAt)); + } + + private async Task LinkAlertsAsync( + long caseId, Guid tenantId, IReadOnlyList alertIds, DateTimeOffset now, CancellationToken ct) + { + var validAlertIds = await db.Alerts + .Where(a => alertIds.Contains(a.Id) && a.Agent!.TenantId == tenantId) + .Select(a => a.Id) + .ToListAsync(ct); + var existing = await db.CaseAlerts + .Where(ca => ca.CaseId == caseId && validAlertIds.Contains(ca.AlertId)) + .Select(ca => ca.AlertId) + .ToListAsync(ct); + var existingSet = new HashSet(existing); + var toAdd = validAlertIds.Where(id => !existingSet.Contains(id)).ToList(); + var added = toAdd.Select(alertId => new CaseAlert + { + CaseId = caseId, + AlertId = alertId, + AddedAt = now, + AddedByUserId = TryGetUserId(), + }).ToList(); + if (added.Count > 0) db.CaseAlerts.AddRange(added); + return added.Count; + } + + 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 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/HuntsController.cs b/backend/src/Tawny.Api/Controllers/HuntsController.cs index f8f8b9d..17eab4d 100644 --- a/backend/src/Tawny.Api/Controllers/HuntsController.cs +++ b/backend/src/Tawny.Api/Controllers/HuntsController.cs @@ -49,9 +49,12 @@ public async Task> Run( public async Task>> List(CancellationToken ct) { var tenantId = User.GetTenantId(); + var currentUserId = TryGetUserId(); + // Show shared hunts to everyone in the tenant; private hunts only to their creator. var rows = await db.SavedHunts .AsNoTracking() - .Where(h => h.TenantId == tenantId) + .Where(h => h.TenantId == tenantId + && (h.IsShared || h.CreatedByUserId == currentUserId)) .OrderBy(h => h.Name) .ToListAsync(ct); return Ok(rows.Select(ToResponse).ToList()); @@ -100,6 +103,7 @@ public async Task> Create( AlertOnMatch = req.AlertOnMatch ?? false, AlertSeverity = req.AlertSeverity ?? AlertSeverity.Medium, MitreTechniquesJson = SerializeTechniques(req.MitreTechniques), + IsShared = req.IsShared ?? true, CreatedByUserId = TryGetUserId(), CreatedAt = now, UpdatedAt = now, @@ -143,6 +147,7 @@ public async Task> Update( hunt.AlertOnMatch = req.AlertOnMatch; hunt.AlertSeverity = req.AlertSeverity; hunt.MitreTechniquesJson = SerializeTechniques(req.MitreTechniques); + if (req.IsShared.HasValue) hunt.IsShared = req.IsShared.Value; hunt.UpdatedAt = DateTimeOffset.UtcNow; audit.Add(User, "saved_hunt.update", hunt.Id.ToString(), new { @@ -254,6 +259,8 @@ public async Task>> Runs(Guid id, Ca h.AlertOnMatch, h.AlertSeverity, DeserializeTechniques(h.MitreTechniquesJson), + h.IsShared, + h.CreatedByUserId, h.LastRunAt, h.LastMatchCount, h.CreatedAt, diff --git a/backend/src/Tawny.Api/Controllers/InvestigationViewsController.cs b/backend/src/Tawny.Api/Controllers/InvestigationViewsController.cs new file mode 100644 index 0000000..aacc89c --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/InvestigationViewsController.cs @@ -0,0 +1,241 @@ +using System.Text.Json; +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 ProcessTreeAcrossHostsRow( + string ProcessName, + int HostCount, + int TotalSeen, + IReadOnlyList Hosts); + +public record ProcessTreeHostHit( + Guid AgentId, + string Hostname, + int SeenCount, + DateTimeOffset LastSeen); + +public record ProcessTreeAcrossHostsResponse( + IReadOnlyList Rows, + DateTimeOffset From, + DateTimeOffset To); + +public record NetworkGraphNode( + string Id, + string Label, + string Kind, + int Weight); + +public record NetworkGraphEdge( + string SourceId, + string TargetId, + int Weight); + +public record NetworkGraphResponse( + IReadOnlyList Nodes, + IReadOnlyList Edges, + DateTimeOffset From, + DateTimeOffset To); + +[ApiController] +[Route("api/investigation")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken)] +public class InvestigationViewsController(TawnyDbContext db) : ControllerBase +{ + /// + /// Aggregates process snapshots in the requested window across every agent, + /// returning each process name with the hosts that have run it. Useful for + /// answering "where else has this binary been seen?" without a per-host hunt. + /// + [HttpGet("process-tree")] + public async Task> ProcessTree( + [FromQuery] int hours = 24, + [FromQuery] string? nameFilter = null, + [FromQuery] int limit = 50, + CancellationToken ct = default) + { + var tenantId = User.GetTenantId(); + var windowHours = Math.Clamp(hours, 1, 168); + var since = DateTimeOffset.UtcNow.AddHours(-windowHours); + var top = Math.Clamp(limit, 1, 200); + + var events = await db.TelemetryEvents + .AsNoTracking() + .Where(e => e.TenantId == tenantId + && e.EventType == TelemetryEventType.ProcessSnapshot + && e.OccurredAt >= since) + .Select(e => new { e.AgentId, Hostname = e.Agent!.Hostname, e.OccurredAt, e.Payload }) + .ToListAsync(ct); + + // Aggregate in-memory: SQL Server can't easily walk JSON arrays this way. + var byName = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var ev in events) + { + JsonDocument doc; + try { doc = JsonDocument.Parse(ev.Payload); } + catch { continue; } + using (doc) + { + if (!doc.RootElement.TryGetProperty("processes", out var processes) + || processes.ValueKind != JsonValueKind.Array) continue; + foreach (var p in processes.EnumerateArray()) + { + if (!p.TryGetProperty("name", out var n) || n.ValueKind != JsonValueKind.String) continue; + var name = n.GetString(); + if (string.IsNullOrWhiteSpace(name)) continue; + if (!string.IsNullOrWhiteSpace(nameFilter) + && !name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (!byName.TryGetValue(name, out var hosts)) + { + hosts = new Dictionary(); + byName[name] = hosts; + } + if (!hosts.TryGetValue(ev.AgentId, out var acc)) + { + acc = new ProcessHostAccumulator(ev.AgentId, ev.Hostname); + hosts[ev.AgentId] = acc; + } + acc.Bump(ev.OccurredAt); + } + } + } + + var rows = byName + .Select(kvp => new ProcessTreeAcrossHostsRow( + kvp.Key, + kvp.Value.Count, + kvp.Value.Values.Sum(h => h.SeenCount), + kvp.Value.Values + .OrderByDescending(h => h.LastSeen) + .Select(h => new ProcessTreeHostHit(h.AgentId, h.Hostname, h.SeenCount, h.LastSeen)) + .ToList())) + .OrderByDescending(r => r.HostCount) + .ThenByDescending(r => r.TotalSeen) + .Take(top) + .ToList(); + + return Ok(new ProcessTreeAcrossHostsResponse(rows, since, DateTimeOffset.UtcNow)); + } + + /// + /// Builds a directed graph of host -> remote endpoint flows from network + /// snapshots. Nodes are agents (kind=host) plus distinct remote IPs + /// (kind=endpoint). Edge weight is the number of observed connections. + /// + [HttpGet("network-graph")] + public async Task> NetworkGraph( + [FromQuery] int hours = 24, + [FromQuery] int maxEndpoints = 100, + CancellationToken ct = default) + { + var tenantId = User.GetTenantId(); + var windowHours = Math.Clamp(hours, 1, 168); + var since = DateTimeOffset.UtcNow.AddHours(-windowHours); + var cap = Math.Clamp(maxEndpoints, 10, 500); + + var events = await db.TelemetryEvents + .AsNoTracking() + .Where(e => e.TenantId == tenantId + && e.EventType == TelemetryEventType.NetworkSnapshot + && e.OccurredAt >= since) + .Select(e => new { e.AgentId, Hostname = e.Agent!.Hostname, e.Payload }) + .ToListAsync(ct); + + var hostNodes = new Dictionary(); + var endpointNodes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var edges = new Dictionary<(Guid HostId, string Endpoint), int>(); + + foreach (var ev in events) + { + JsonDocument doc; + try { doc = JsonDocument.Parse(ev.Payload); } + catch { continue; } + using (doc) + { + if (!doc.RootElement.TryGetProperty("connections", out var conns) + || conns.ValueKind != JsonValueKind.Array) continue; + if (!hostNodes.TryGetValue(ev.AgentId, out _)) + { + hostNodes[ev.AgentId] = new NetworkGraphNode( + $"host:{ev.AgentId}", ev.Hostname, "host", 0); + } + + foreach (var conn in conns.EnumerateArray()) + { + if (!conn.TryGetProperty("remote_address", out var ra) + || ra.ValueKind != JsonValueKind.String) continue; + var remote = ra.GetString(); + if (string.IsNullOrWhiteSpace(remote) + || IsLoopbackOrUnspecified(remote)) continue; + + if (!endpointNodes.TryGetValue(remote, out var acc)) + { + acc = new EndpointAccumulator(remote); + endpointNodes[remote] = acc; + } + acc.Hits += 1; + var key = (ev.AgentId, remote); + edges[key] = edges.GetValueOrDefault(key) + 1; + } + } + } + + // Cap to the top N busiest endpoints to keep the graph readable. + var topEndpoints = endpointNodes.Values + .OrderByDescending(e => e.Hits) + .Take(cap) + .ToList(); + var topEndpointKeys = topEndpoints.Select(e => e.Address).ToHashSet(StringComparer.OrdinalIgnoreCase); + + var allNodes = new List(hostNodes.Values.Select(h => h with { Weight = 1 })); + allNodes.AddRange(topEndpoints.Select(e => + new NetworkGraphNode($"endpoint:{e.Address}", e.Address, "endpoint", e.Hits))); + + var filteredEdges = edges + .Where(kvp => topEndpointKeys.Contains(kvp.Key.Endpoint)) + .Select(kvp => new NetworkGraphEdge( + $"host:{kvp.Key.HostId}", + $"endpoint:{kvp.Key.Endpoint}", + kvp.Value)) + .OrderByDescending(e => e.Weight) + .ToList(); + + return Ok(new NetworkGraphResponse(allNodes, filteredEdges, since, DateTimeOffset.UtcNow)); + } + + private static bool IsLoopbackOrUnspecified(string address) + { + return address.StartsWith("127.", StringComparison.Ordinal) + || address == "::1" + || address == "0.0.0.0" + || address.StartsWith("169.254.", StringComparison.Ordinal); + } + + private sealed class ProcessHostAccumulator(Guid agentId, string hostname) + { + public Guid AgentId { get; } = agentId; + public string Hostname { get; } = hostname; + public int SeenCount { get; private set; } + public DateTimeOffset LastSeen { get; private set; } + + public void Bump(DateTimeOffset at) + { + SeenCount += 1; + if (at > LastSeen) LastSeen = at; + } + } + + private sealed class EndpointAccumulator(string address) + { + public string Address { get; } = address; + public int Hits { get; set; } + } +} diff --git a/backend/src/Tawny.Api/Controllers/RuleTestController.cs b/backend/src/Tawny.Api/Controllers/RuleTestController.cs new file mode 100644 index 0000000..4772cdb --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/RuleTestController.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tawny.Api.Auth; +using Tawny.Domain; +using Tawny.Domain.Entities; +using Tawny.Infrastructure; +using Tawny.Infrastructure.Hunting; + +namespace Tawny.Api.Controllers; + +public record RuleTestEventBody( + TelemetryEventType EventType, + DateTimeOffset OccurredAt, + JsonElement Payload); + +public record RuleTestRequest(IReadOnlyList Events); + +public record RuleTestResponse( + bool Matched, + string? FailReason, + IReadOnlyList Trace); + +[ApiController] +[Route("api/alert-rules")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken)] +public class RuleTestController(TawnyDbContext db, RuleTestHarness harness) : ControllerBase +{ + /// + /// Run a saved rule against a supplied list of events without touching the DB. + /// Returns whether it would fire and a per-step trace of why or why not. + /// + [HttpPost("{id:guid}/test")] + public async Task> Test( + Guid id, + [FromBody] RuleTestRequest req, + CancellationToken ct) + { + if (req.Events is null || req.Events.Count == 0) + { + return Problem(statusCode: 400, title: "events array is required and must contain at least one event."); + } + var rule = await db.AlertRules.AsNoTracking().FirstOrDefaultAsync(r => r.Id == id, ct); + if (rule is null) return NotFound(); + + var inputs = req.Events + .Select(e => new RuleTestEventInput(e.EventType, e.OccurredAt, e.Payload)) + .ToList(); + var result = harness.Test(rule, inputs); + return Ok(new RuleTestResponse(result.Matched, result.FailReason, result.Trace)); + } +} diff --git a/backend/src/Tawny.Api/Controllers/SequenceRulesController.cs b/backend/src/Tawny.Api/Controllers/SequenceRulesController.cs new file mode 100644 index 0000000..fbb4230 --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/SequenceRulesController.cs @@ -0,0 +1,148 @@ +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/sequence-rules")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken)] +public class SequenceRulesController( + TawnyDbContext db, + AuditLogger audit, + SequenceRuleEvaluator sequences) : ControllerBase +{ + [HttpGet] + public async Task>> List(CancellationToken ct) + { + var rows = await db.AlertRules + .AsNoTracking() + .Where(r => r.Format == AlertRuleFormat.Sequence) + .OrderBy(r => r.Name) + .ToListAsync(ct); + return Ok(rows.Select(ToResponse).ToList()); + } + + [HttpPost] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task> Create( + [FromBody] CreateSequenceRuleRequest 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."); + } + if (req.Steps is null || req.Steps.Count < 2) + { + return Problem(statusCode: 400, title: "A sequence rule needs at least two steps."); + } + if (req.WindowSeconds <= 0 || req.WindowSeconds > 86_400) + { + return Problem(statusCode: 400, title: "window_seconds must be between 1 and 86400."); + } + + var definition = new SequenceRuleDefinition( + req.WindowSeconds, + "agent", + req.Steps.Select(s => new SequenceStep(s.Name, s.EventType, s.PayloadPath, s.Operator, s.MatchValue)).ToList()); + + try { SequenceRuleParser.Parse(SequenceRuleParser.Serialize(definition)); } + catch (SequenceRuleException ex) + { + return Problem(statusCode: 400, title: ex.Message); + } + + var now = DateTimeOffset.UtcNow; + var rule = new AlertRule + { + Id = Guid.NewGuid(), + Name = req.Name.Trim(), + Format = AlertRuleFormat.Sequence, + Description = string.IsNullOrWhiteSpace(req.Description) ? null : req.Description.Trim(), + Severity = req.Severity, + Operator = AlertRuleOperator.Exists, + SourceDefinition = SequenceRuleParser.Serialize(definition), + IsEnabled = req.IsEnabled ?? true, + MitreTechniquesJson = SerializeTechniques(req.MitreTechniques), + CreatedAt = now, + UpdatedAt = now, + }; + db.AlertRules.Add(rule); + audit.Add(User, "sequence_rule.create", rule.Id.ToString(), new + { + rule.Name, + step_count = req.Steps.Count, + req.WindowSeconds, + }); + await db.SaveChangesAsync(ct); + sequences.ResetAll(); // wipe in-memory partial state so new rule starts cleanly + return CreatedAtAction(nameof(List), new { id = rule.Id }, ToResponse(rule)); + } + + [HttpDelete("{id:guid}")] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + if (await db.Alerts.AnyAsync(a => a.AlertRuleId == id, ct)) + { + return Problem(statusCode: 409, title: "Sequence rule has alerts and cannot be deleted. Disable it instead."); + } + var deleted = await db.AlertRules + .Where(r => r.Id == id && r.Format == AlertRuleFormat.Sequence) + .ExecuteDeleteAsync(ct); + if (deleted == 0) return NotFound(); + audit.Add(User, "sequence_rule.delete", id.ToString()); + await db.SaveChangesAsync(ct); + sequences.ResetAll(); + return NoContent(); + } + + private static SequenceRuleResponse ToResponse(AlertRule rule) + { + SequenceRuleDefinition definition; + try { definition = SequenceRuleParser.Parse(rule.SourceDefinition ?? ""); } + catch + { + return new SequenceRuleResponse( + rule.Id, rule.Name, rule.Description, rule.Severity, 0, [], [], rule.IsEnabled, rule.CreatedAt, rule.UpdatedAt); + } + return new SequenceRuleResponse( + rule.Id, + rule.Name, + rule.Description, + rule.Severity, + definition.WindowSeconds, + definition.Steps.Select(s => new SequenceStepInput(s.Name, s.EventType, s.PayloadPath, s.Operator, s.MatchValue)).ToList(), + DeserializeTechniques(rule.MitreTechniquesJson), + rule.IsEnabled, + rule.CreatedAt, + rule.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); + } +} diff --git a/backend/src/Tawny.Api/Controllers/ThreatIntelFeedsController.cs b/backend/src/Tawny.Api/Controllers/ThreatIntelFeedsController.cs new file mode 100644 index 0000000..2fd7b16 --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/ThreatIntelFeedsController.cs @@ -0,0 +1,174 @@ +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.ThreatIntel; +using Tawny.Jobs; + +namespace Tawny.Api.Controllers; + +[ApiController] +[Route("api/threat-intel-feeds")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken)] +public class ThreatIntelFeedsController( + TawnyDbContext db, + AuditLogger audit, + ThreatIntelFeedsJob job) : ControllerBase +{ + [HttpGet] + public async Task>> List(CancellationToken ct) + { + var tenantId = User.GetTenantId(); + var rows = await db.ThreatIntelFeeds + .AsNoTracking() + .Where(f => f.TenantId == tenantId) + .OrderBy(f => f.Name) + .Select(f => new ThreatIntelFeedResponse( + f.Id, f.Name, f.Kind, f.Url, f.AuthHeaderName, + f.DefaultSeverity, f.IntervalMinutes, f.IsEnabled, + f.Status, f.LastRunAt, f.LastSuccessAt, + f.LastImportedCount, f.LastSkippedCount, f.LastError, + f.CreatedAt, f.UpdatedAt)) + .ToListAsync(ct); + return Ok(rows); + } + + [HttpPost] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task> Create( + [FromBody] CreateThreatIntelFeedRequest 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."); + } + if (!Uri.TryCreate(req.Url, UriKind.Absolute, out _)) + { + return Problem(statusCode: 400, title: "url must be an absolute URL."); + } + var interval = req.IntervalMinutes ?? 60; + if (interval < 5 || interval > 10_080) + { + return Problem(statusCode: 400, title: "interval_minutes must be between 5 and 10080."); + } + + var now = DateTimeOffset.UtcNow; + var feed = new ThreatIntelFeed + { + Id = Guid.NewGuid(), + TenantId = User.GetTenantId(), + Name = req.Name.Trim(), + Kind = req.Kind, + Url = req.Url.Trim(), + AuthHeaderName = NullIfEmpty(req.AuthHeaderName), + AuthHeaderValueEncrypted = NullIfEmpty(req.AuthHeaderValue), + DefaultSeverity = req.DefaultSeverity ?? AlertSeverity.High, + IntervalMinutes = interval, + IsEnabled = req.IsEnabled ?? true, + CreatedByUserId = TryGetUserId(), + CreatedAt = now, + UpdatedAt = now, + }; + db.ThreatIntelFeeds.Add(feed); + audit.Add(User, "threat_intel_feed.create", feed.Id.ToString(), new + { + feed.Name, feed.Kind, feed.Url, feed.IntervalMinutes, + }); + await db.SaveChangesAsync(ct); + + return CreatedAtAction(nameof(List), new { id = feed.Id }, ToResponse(feed)); + } + + [HttpPut("{id:guid}")] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task> Update( + Guid id, + [FromBody] UpdateThreatIntelFeedRequest req, + CancellationToken ct) + { + if (!Uri.TryCreate(req.Url, UriKind.Absolute, out _)) + { + return Problem(statusCode: 400, title: "url must be an absolute URL."); + } + if (req.IntervalMinutes < 5 || req.IntervalMinutes > 10_080) + { + return Problem(statusCode: 400, title: "interval_minutes must be between 5 and 10080."); + } + + var feed = await db.ThreatIntelFeeds.FirstOrDefaultAsync(f => f.Id == id && f.TenantId == User.GetTenantId(), ct); + if (feed is null) return NotFound(); + + feed.Name = req.Name.Trim(); + feed.Kind = req.Kind; + feed.Url = req.Url.Trim(); + feed.AuthHeaderName = NullIfEmpty(req.AuthHeaderName); + if (!string.IsNullOrWhiteSpace(req.AuthHeaderValue)) + { + feed.AuthHeaderValueEncrypted = req.AuthHeaderValue; + } + feed.DefaultSeverity = req.DefaultSeverity; + feed.IntervalMinutes = req.IntervalMinutes; + feed.IsEnabled = req.IsEnabled; + feed.UpdatedAt = DateTimeOffset.UtcNow; + audit.Add(User, "threat_intel_feed.update", feed.Id.ToString(), new + { + feed.Name, feed.Kind, feed.IntervalMinutes, feed.IsEnabled, + }); + await db.SaveChangesAsync(ct); + return Ok(ToResponse(feed)); + } + + [HttpDelete("{id:guid}")] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var deleted = await db.ThreatIntelFeeds + .Where(f => f.Id == id && f.TenantId == User.GetTenantId()) + .ExecuteDeleteAsync(ct); + if (deleted == 0) return NotFound(); + audit.Add(User, "threat_intel_feed.delete", id.ToString()); + await db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/run")] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task> Run(Guid id, CancellationToken ct) + { + var feed = await db.ThreatIntelFeeds.FirstOrDefaultAsync(f => f.Id == id && f.TenantId == User.GetTenantId(), ct); + if (feed is null) return NotFound(); + // Reset throttle so the job picks it up immediately. + feed.LastRunAt = null; + await db.SaveChangesAsync(ct); + await job.ExecuteAsync(ct); + await db.Entry(feed).ReloadAsync(ct); + audit.Add(User, "threat_intel_feed.run", feed.Id.ToString()); + await db.SaveChangesAsync(ct); + return Ok(ToResponse(feed)); + } + + private static ThreatIntelFeedResponse ToResponse(ThreatIntelFeed f) => new( + f.Id, f.Name, f.Kind, f.Url, f.AuthHeaderName, + f.DefaultSeverity, f.IntervalMinutes, f.IsEnabled, + f.Status, f.LastRunAt, f.LastSuccessAt, + f.LastImportedCount, f.LastSkippedCount, f.LastError, + f.CreatedAt, f.UpdatedAt); + + private static string? NullIfEmpty(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/YaraRulesController.cs b/backend/src/Tawny.Api/Controllers/YaraRulesController.cs new file mode 100644 index 0000000..e1484b3 --- /dev/null +++ b/backend/src/Tawny.Api/Controllers/YaraRulesController.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tawny.Api.Auth; +using Tawny.Api.Services; +using Tawny.Domain; +using Tawny.Domain.Entities; +using Tawny.Infrastructure; +using Tawny.Infrastructure.Hunting; + +namespace Tawny.Api.Controllers; + +public record CreateYaraRuleRequest( + string Name, + string? Description, + AlertSeverity Severity, + TelemetryEventType? EventType, + string Definition, + bool? IsEnabled); + +public record YaraRuleResponse( + Guid Id, + string Name, + string? Description, + AlertSeverity Severity, + TelemetryEventType? EventType, + string Definition, + bool IsEnabled, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +[ApiController] +[Route("api/yara-rules")] +[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken)] +public class YaraRulesController(TawnyDbContext db, AuditLogger audit) : ControllerBase +{ + [HttpGet] + public async Task>> List(CancellationToken ct) + { + var rows = await db.AlertRules + .AsNoTracking() + .Where(r => r.Format == AlertRuleFormat.Yara) + .OrderBy(r => r.Name) + .Select(r => new YaraRuleResponse( + r.Id, r.Name, r.Description, r.Severity, r.EventType, + r.SourceDefinition ?? "", r.IsEnabled, r.CreatedAt, r.UpdatedAt)) + .ToListAsync(ct); + return Ok(rows); + } + + [HttpPost] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task> Create( + [FromBody] CreateYaraRuleRequest 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."); + } + try { YaraLiteParser.Parse(req.Definition); } + catch (YaraLiteException ex) + { + return Problem(statusCode: 400, title: ex.Message); + } + + var now = DateTimeOffset.UtcNow; + var rule = new AlertRule + { + Id = Guid.NewGuid(), + Name = req.Name.Trim(), + Format = AlertRuleFormat.Yara, + Description = string.IsNullOrWhiteSpace(req.Description) ? null : req.Description.Trim(), + EventType = req.EventType, + Severity = req.Severity, + Operator = AlertRuleOperator.Exists, + SourceDefinition = req.Definition.Trim(), + IsEnabled = req.IsEnabled ?? true, + CreatedAt = now, + UpdatedAt = now, + }; + db.AlertRules.Add(rule); + audit.Add(User, "yara_rule.create", rule.Id.ToString(), new { rule.Name }); + await db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(List), new { id = rule.Id }, + new YaraRuleResponse(rule.Id, rule.Name, rule.Description, rule.Severity, rule.EventType, + rule.SourceDefinition!, rule.IsEnabled, rule.CreatedAt, rule.UpdatedAt)); + } + + [HttpDelete("{id:guid}")] + [Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser + "," + TawnyAuthSchemes.ApiToken, Roles = "Admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + if (await db.Alerts.AnyAsync(a => a.AlertRuleId == id, ct)) + { + return Problem(statusCode: 409, title: "Rule has alerts; disable it instead."); + } + var deleted = await db.AlertRules + .Where(r => r.Id == id && r.Format == AlertRuleFormat.Yara) + .ExecuteDeleteAsync(ct); + if (deleted == 0) return NotFound(); + audit.Add(User, "yara_rule.delete", id.ToString()); + await db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/backend/src/Tawny.Api/Models/AlertDtos.cs b/backend/src/Tawny.Api/Models/AlertDtos.cs index f18c6fd..cec5a03 100644 --- a/backend/src/Tawny.Api/Models/AlertDtos.cs +++ b/backend/src/Tawny.Api/Models/AlertDtos.cs @@ -79,4 +79,5 @@ public record AlertResponse( string? SentinelNotificationError, string Title, string? Description, + JsonElement? Enrichment, DateTimeOffset CreatedAt); diff --git a/backend/src/Tawny.Api/Models/CaseDtos.cs b/backend/src/Tawny.Api/Models/CaseDtos.cs new file mode 100644 index 0000000..7770b0b --- /dev/null +++ b/backend/src/Tawny.Api/Models/CaseDtos.cs @@ -0,0 +1,67 @@ +using Tawny.Domain.Entities; + +namespace Tawny.Api.Models; + +public record CreateCaseRequest( + string Title, + string? Summary, + CasePriority? Priority, + IReadOnlyList? AlertIds, + IReadOnlyList? MitreTechniques); + +public record UpdateCaseRequest( + string Title, + string? Summary, + CaseStatus Status, + CasePriority Priority, + Guid? AssignedToUserId, + IReadOnlyList? MitreTechniques); + +public record CaseAlertResponse( + long Id, + long AlertId, + string AlertTitle, + string AlertHostname, + string AlertSeverity, + DateTimeOffset AlertCreatedAt, + DateTimeOffset AddedAt); + +public record CaseNoteResponse( + long Id, + Guid? AuthorUserId, + string Body, + DateTimeOffset CreatedAt); + +public record CaseResponse( + long Id, + string Title, + string? Summary, + CaseStatus Status, + CasePriority Priority, + Guid? AssignedToUserId, + Guid? CreatedByUserId, + int AlertCount, + int NoteCount, + IReadOnlyList MitreTechniques, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTimeOffset? ClosedAt); + +public record CaseDetailResponse( + long Id, + string Title, + string? Summary, + CaseStatus Status, + CasePriority Priority, + Guid? AssignedToUserId, + Guid? CreatedByUserId, + IReadOnlyList MitreTechniques, + IReadOnlyList Alerts, + IReadOnlyList Notes, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTimeOffset? ClosedAt); + +public record AddCaseAlertRequest(IReadOnlyList AlertIds); + +public record AddCaseNoteRequest(string Body); diff --git a/backend/src/Tawny.Api/Models/HuntDtos.cs b/backend/src/Tawny.Api/Models/HuntDtos.cs index 99511ec..aa786e6 100644 --- a/backend/src/Tawny.Api/Models/HuntDtos.cs +++ b/backend/src/Tawny.Api/Models/HuntDtos.cs @@ -29,7 +29,8 @@ public record CreateSavedHuntRequest( string? ScheduleCron, bool? AlertOnMatch, AlertSeverity? AlertSeverity, - IReadOnlyList? MitreTechniques); + IReadOnlyList? MitreTechniques, + bool? IsShared); public record UpdateSavedHuntRequest( string Name, @@ -39,7 +40,8 @@ public record UpdateSavedHuntRequest( string? ScheduleCron, bool AlertOnMatch, AlertSeverity AlertSeverity, - IReadOnlyList? MitreTechniques); + IReadOnlyList? MitreTechniques, + bool? IsShared); public record SavedHuntResponse( Guid Id, @@ -51,6 +53,8 @@ public record SavedHuntResponse( bool AlertOnMatch, AlertSeverity AlertSeverity, IReadOnlyList MitreTechniques, + bool IsShared, + Guid? CreatedByUserId, DateTimeOffset? LastRunAt, int? LastMatchCount, DateTimeOffset CreatedAt, diff --git a/backend/src/Tawny.Api/Models/SequenceRuleDtos.cs b/backend/src/Tawny.Api/Models/SequenceRuleDtos.cs new file mode 100644 index 0000000..b9195ed --- /dev/null +++ b/backend/src/Tawny.Api/Models/SequenceRuleDtos.cs @@ -0,0 +1,31 @@ +using Tawny.Domain; + +namespace Tawny.Api.Models; + +public record CreateSequenceRuleRequest( + string Name, + string? Description, + AlertSeverity Severity, + int WindowSeconds, + IReadOnlyList Steps, + IReadOnlyList? MitreTechniques, + bool? IsEnabled); + +public record SequenceStepInput( + string Name, + TelemetryEventType EventType, + string? PayloadPath, + AlertRuleOperator Operator, + string? MatchValue); + +public record SequenceRuleResponse( + Guid Id, + string Name, + string? Description, + AlertSeverity Severity, + int WindowSeconds, + IReadOnlyList Steps, + IReadOnlyList MitreTechniques, + bool IsEnabled, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); diff --git a/backend/src/Tawny.Api/Models/ThreatIntelFeedDtos.cs b/backend/src/Tawny.Api/Models/ThreatIntelFeedDtos.cs new file mode 100644 index 0000000..547ea61 --- /dev/null +++ b/backend/src/Tawny.Api/Models/ThreatIntelFeedDtos.cs @@ -0,0 +1,41 @@ +using Tawny.Domain; + +namespace Tawny.Api.Models; + +public record CreateThreatIntelFeedRequest( + string Name, + ThreatIntelFeedKind Kind, + string Url, + string? AuthHeaderName, + string? AuthHeaderValue, + AlertSeverity? DefaultSeverity, + int? IntervalMinutes, + bool? IsEnabled); + +public record UpdateThreatIntelFeedRequest( + string Name, + ThreatIntelFeedKind Kind, + string Url, + string? AuthHeaderName, + string? AuthHeaderValue, + AlertSeverity DefaultSeverity, + int IntervalMinutes, + bool IsEnabled); + +public record ThreatIntelFeedResponse( + Guid Id, + string Name, + ThreatIntelFeedKind Kind, + string Url, + string? AuthHeaderName, + AlertSeverity DefaultSeverity, + int IntervalMinutes, + bool IsEnabled, + ThreatIntelFeedStatus Status, + DateTimeOffset? LastRunAt, + DateTimeOffset? LastSuccessAt, + int LastImportedCount, + int LastSkippedCount, + string? LastError, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); diff --git a/backend/src/Tawny.Api/Program.cs b/backend/src/Tawny.Api/Program.cs index da4bf99..49e54a0 100644 --- a/backend/src/Tawny.Api/Program.cs +++ b/backend/src/Tawny.Api/Program.cs @@ -15,6 +15,7 @@ using Tawny.Api.Services; using Tawny.Infrastructure; using Tawny.Infrastructure.Hunting; +using Tawny.Infrastructure.ThreatIntel; using Tawny.Jobs; var builder = WebApplication.CreateBuilder(args); @@ -28,6 +29,7 @@ builder.Services.Configure(builder.Configuration.GetSection("Tawny:Wazuh")); builder.Services.Configure(builder.Configuration.GetSection("Tawny:Slack")); builder.Services.Configure(builder.Configuration.GetSection("Tawny:Sentinel")); +builder.Services.Configure(builder.Configuration.GetSection("Tawny:Reputation")); builder.Services.Configure(TawnyAuthSchemes.WebUser, opt => { opt.HmacSecret = builder.Configuration["Tawny:WebUserHmacSecret"] ?? ""; @@ -44,7 +46,11 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); @@ -123,6 +129,8 @@ await context.HttpContext.Response.WriteAsJsonAsync(new builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddHangfire(cfg => cfg @@ -175,6 +183,10 @@ await context.HttpContext.Response.WriteAsJsonAsync(new "check-agent-releases", j => j.ExecuteAsync(default), Cron.Hourly); RecurringJob.AddOrUpdate( "scheduled-hunts", j => j.ExecuteAsync(default), "*/5 * * * *"); + RecurringJob.AddOrUpdate( + "threat-intel-feeds", j => j.ExecuteAsync(default), "*/10 * * * *"); + RecurringJob.AddOrUpdate( + "reputation-enrichment", j => j.ExecuteAsync(default), "*/5 * * * *"); } app.Run(); diff --git a/backend/src/Tawny.Api/Services/AlertRuleEvaluator.cs b/backend/src/Tawny.Api/Services/AlertRuleEvaluator.cs index 75c1276..94f2eae 100644 --- a/backend/src/Tawny.Api/Services/AlertRuleEvaluator.cs +++ b/backend/src/Tawny.Api/Services/AlertRuleEvaluator.cs @@ -8,7 +8,10 @@ namespace Tawny.Api.Services; -public class AlertRuleEvaluator(TawnyDbContext db, SuppressionEvaluator suppressions) +public class AlertRuleEvaluator( + TawnyDbContext db, + SuppressionEvaluator suppressions, + SequenceRuleEvaluator sequences) { public async Task> EvaluateAsync( Agent agent, @@ -26,7 +29,13 @@ public async Task> EvaluateAsync( .Where(r => r.IsEnabled && (r.EventType == null || eventTypes.Contains(r.EventType.Value))) .ToListAsync(ct); - if (rules.Count == 0) + // Sequence rules need to see every event regardless of EventType filter, + // because each step can target a different type. Load them separately. + var sequenceRules = await db.AlertRules + .Where(r => r.IsEnabled && r.Format == AlertRuleFormat.Sequence) + .ToListAsync(ct); + + if (rules.Count == 0 && sequenceRules.Count == 0) { return []; } @@ -37,6 +46,7 @@ public async Task> EvaluateAsync( using var payload = JsonDocument.Parse(telemetryEvent.Payload); foreach (var rule in rules) { + if (rule.Format == AlertRuleFormat.Sequence) continue; // handled below if (rule.EventType is not null && rule.EventType.Value != telemetryEvent.EventType) { continue; @@ -60,6 +70,27 @@ public async Task> EvaluateAsync( } } + foreach (var rule in sequenceRules) + { + SequenceRuleDefinition definition; + try { definition = SequenceRuleParser.Parse(rule.SourceDefinition ?? ""); } + catch { continue; } + var matches = sequences.Evaluate(rule, definition, agent, events, now); + foreach (var match in matches) + { + candidates.Add(new Alert + { + AlertRuleId = rule.Id, + AgentId = agent.Id, + TelemetryEventId = match.TriggeringEventId, + Severity = rule.Severity, + Title = $"{rule.Name} on {agent.Hostname}", + Description = BuildSequenceDescription(rule, match), + CreatedAt = now, + }); + } + } + if (candidates.Count == 0) { return []; @@ -79,8 +110,35 @@ public async Task> EvaluateAsync( return emitted; } + private static string BuildSequenceDescription(AlertRule rule, SequenceMatch match) + { + var steps = string.Join(" -> ", match.Trail.Select(s => s.Name)); + return $"Sequence '{rule.Name}' completed: {steps}."; + } + private static bool Matches(AlertRule rule, JsonElement payload) { + // YARA-lite: match strings against the raw payload text. + if (rule.Format == AlertRuleFormat.Yara && !string.IsNullOrWhiteSpace(rule.SourceDefinition)) + { + try + { + var definition = YaraLiteParser.Parse(rule.SourceDefinition); + return YaraLiteEvaluator.Evaluate(definition, payload.GetRawText()); + } + catch (YaraLiteException) + { + return false; + } + } + + // Compiled boolean tree (Sigma AND/OR/NOT, 1 of selection_*, all of selection_*). + if (!string.IsNullOrWhiteSpace(rule.CompiledExpressionJson)) + { + var tree = SigmaExpressionSerializer.Deserialize(rule.CompiledExpressionJson); + return tree is not null && SigmaExpressionEvaluator.Evaluate(tree, payload); + } + if (string.IsNullOrWhiteSpace(rule.PayloadPath)) { return true; diff --git a/backend/src/Tawny.Api/Services/SigmaRuleImporter.cs b/backend/src/Tawny.Api/Services/SigmaRuleImporter.cs index ad268ba..09455b3 100644 --- a/backend/src/Tawny.Api/Services/SigmaRuleImporter.cs +++ b/backend/src/Tawny.Api/Services/SigmaRuleImporter.cs @@ -1,6 +1,9 @@ +using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using Tawny.Domain; using Tawny.Domain.Entities; +using Tawny.Infrastructure.Hunting; using YamlDotNet.RepresentationModel; namespace Tawny.Api.Services; @@ -39,17 +42,27 @@ public AlertRule Import(string yaml, bool isEnabled, DateTimeOffset now) { throw new SigmaRuleException("Sigma rule detection.condition is required."); } - if (condition.Contains(' ', StringComparison.Ordinal)) + + // Map every selection name (except `condition`) to its compiled SigmaNode. + var selections = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in detection.Children) { - throw new SigmaRuleException("Only a single Sigma selection condition is supported for now."); + if (pair.Key is not YamlScalarNode keyNode || keyNode.Value is null) continue; + if (string.Equals(keyNode.Value, "condition", StringComparison.OrdinalIgnoreCase)) continue; + if (pair.Value is not YamlMappingNode selectionMap) + { + throw new SigmaRuleException($"Selection '{keyNode.Value}' must be a mapping."); + } + selections[keyNode.Value] = CompileSelectionNode(selectionMap); } - var selection = Mapping(detection, condition) - ?? throw new SigmaRuleException($"Sigma selection '{condition}' was not found."); - var predicate = CompileSelection(selection); - var logsource = Mapping(root, "logsource"); + if (selections.Count == 0) + { + throw new SigmaRuleException("Sigma rule needs at least one named selection block."); + } - return new AlertRule + var logsource = Mapping(root, "logsource"); + var rule = new AlertRule { Id = Guid.NewGuid(), Name = title.Trim(), @@ -58,15 +71,61 @@ public AlertRule Import(string yaml, bool isEnabled, DateTimeOffset now) Description = Normalize(Scalar(root, "description")), EventType = MapEventType(logsource), Severity = MapSeverity(Scalar(root, "level")), - Operator = predicate.Operator, - PayloadPath = predicate.PayloadPath, - MatchValue = predicate.MatchValue, SourceDefinition = yaml, IsEnabled = isEnabled, MitreTechniquesJson = ExtractMitreTechniques(root), CreatedAt = now, UpdatedAt = now, }; + + // Fast path: single named selection referenced directly. Stays as a + // single-predicate rule so the existing legacy fields and the existing + // UI keep working without change. + if (selections.Count == 1 + && selections.TryGetValue(condition.Trim(), out var solo) + && solo is SigmaFieldPredicate predicate + && predicate.Values.Count > 0) + { + rule.Operator = predicate.Operator; + rule.PayloadPath = predicate.PayloadPath; + rule.MatchValue = predicate.Values.Count == 1 + ? predicate.Values[0] + : JsonSerializer.Serialize(predicate.Values, JsonOptions); + return rule; + } + + // General path: parse the condition into a SigmaNode tree by resolving + // names + globs against the compiled selections. + var tree = SigmaConditionParser.Parse(condition, selections); + rule.CompiledExpressionJson = SigmaExpressionSerializer.Serialize(tree); + // Leave legacy predicate fields null — the evaluator falls back to CompiledExpression. + return rule; + } + + private static SigmaNode CompileSelectionNode(YamlMappingNode selection) + { + if (selection.Children.Count == 0) + { + throw new SigmaRuleException("Selection must have at least one field predicate."); + } + var children = new List(); + foreach (var pair in selection.Children) + { + if (pair.Key is not YamlScalarNode keyNode || string.IsNullOrWhiteSpace(keyNode.Value)) + { + throw new SigmaRuleException("Selection field name must be a scalar."); + } + var (fieldRaw, op) = ParseField(keyNode.Value); + var field = NormalizeField(fieldRaw); + var values = Values(pair.Value); + if (values.Count == 0 && op != AlertRuleOperator.Exists) + { + throw new SigmaRuleException("Selection value is required."); + } + children.Add(new SigmaFieldPredicate(field, op, values)); + } + // Multiple fields in the same selection mapping => AND across them (Sigma semantics). + return children.Count == 1 ? children[0] : new SigmaAnd(children); } private static string? ExtractMitreTechniques(YamlMappingNode root) @@ -95,34 +154,6 @@ public AlertRule Import(string yaml, bool isEnabled, DateTimeOffset now) return JsonSerializer.Serialize(techniques.Distinct().ToList(), JsonOptions); } - private static CompiledPredicate CompileSelection(YamlMappingNode selection) - { - if (selection.Children.Count != 1) - { - throw new SigmaRuleException("Only one field predicate per Sigma selection is supported for now."); - } - - var pair = selection.Children.Single(); - if (pair.Key is not YamlScalarNode keyNode || string.IsNullOrWhiteSpace(keyNode.Value)) - { - throw new SigmaRuleException("Sigma selection field must be a scalar."); - } - - var (field, op) = ParseField(keyNode.Value); - var values = Values(pair.Value); - if (values.Count == 0) - { - throw new SigmaRuleException("Sigma selection value is required."); - } - - return new CompiledPredicate( - NormalizeField(field), - op, - values.Count == 1 - ? values[0] - : JsonSerializer.Serialize(values, JsonOptions)); - } - private static string NormalizeField(string field) => field switch { "Image" or "process.name" or "process.executable" => "processes.name", @@ -229,11 +260,160 @@ private static List Values(YamlNode node) var trimmed = value?.Trim(); return string.IsNullOrEmpty(trimmed) ? null : trimmed; } +} - private sealed record CompiledPredicate( - string PayloadPath, - AlertRuleOperator Operator, - string MatchValue); +/// +/// Tiny recursive-descent parser for Sigma `condition:` strings. +/// Supports: selection_name | not | and | or | () | "1 of name_*" | "all of name_*" +/// Globs are resolved against the dictionary of compiled selections. +/// +internal static class SigmaConditionParser +{ + public static SigmaNode Parse(string condition, IReadOnlyDictionary selections) + { + var tokens = Tokenize(condition); + var pos = 0; + var node = ParseOr(tokens, ref pos, selections); + if (pos < tokens.Count) + { + throw new SigmaRuleException($"Unexpected token '{tokens[pos]}' at end of condition."); + } + return node; + } + + private static SigmaNode ParseOr(IReadOnlyList tokens, ref int pos, IReadOnlyDictionary selections) + { + var left = ParseAnd(tokens, ref pos, selections); + while (pos < tokens.Count && string.Equals(tokens[pos], "or", StringComparison.OrdinalIgnoreCase)) + { + pos++; + var right = ParseAnd(tokens, ref pos, selections); + left = new SigmaOr(Flatten(left, right)); + } + return left; + } + + private static SigmaNode ParseAnd(IReadOnlyList tokens, ref int pos, IReadOnlyDictionary selections) + { + var left = ParseUnary(tokens, ref pos, selections); + while (pos < tokens.Count && string.Equals(tokens[pos], "and", StringComparison.OrdinalIgnoreCase)) + { + pos++; + var right = ParseUnary(tokens, ref pos, selections); + left = new SigmaAnd(Flatten(left, right)); + } + return left; + } + + private static SigmaNode ParseUnary(IReadOnlyList tokens, ref int pos, IReadOnlyDictionary selections) + { + if (pos >= tokens.Count) throw new SigmaRuleException("Unexpected end of condition."); + var token = tokens[pos]; + if (string.Equals(token, "not", StringComparison.OrdinalIgnoreCase)) + { + pos++; + return new SigmaNot(ParseUnary(tokens, ref pos, selections)); + } + if (token == "(") + { + pos++; + var inner = ParseOr(tokens, ref pos, selections); + if (pos >= tokens.Count || tokens[pos] != ")") + { + throw new SigmaRuleException("Expected ')'."); + } + pos++; + return inner; + } + if (string.Equals(token, "1", StringComparison.Ordinal) + || string.Equals(token, "all", StringComparison.OrdinalIgnoreCase)) + { + var quantifier = token; + pos++; + if (pos >= tokens.Count || !string.Equals(tokens[pos], "of", StringComparison.OrdinalIgnoreCase)) + { + throw new SigmaRuleException("Expected 'of' after quantifier."); + } + pos++; + if (pos >= tokens.Count) throw new SigmaRuleException("Expected pattern after 'of'."); + var pattern = tokens[pos]; + pos++; + var matched = ResolveGlob(pattern, selections); + if (matched.Count == 0) + { + throw new SigmaRuleException($"No selections matched pattern '{pattern}'."); + } + return string.Equals(quantifier, "1", StringComparison.Ordinal) + ? new SigmaAnyOf(matched) + : new SigmaAllOf(matched); + } + // Bare selection name. + pos++; + if (!selections.TryGetValue(token, out var selection)) + { + throw new SigmaRuleException($"Unknown selection '{token}' in condition."); + } + return selection; + } + + private static List ResolveGlob(string pattern, IReadOnlyDictionary selections) + { + var matched = new List(); + var regex = new Regex("^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$", + RegexOptions.IgnoreCase); + foreach (var (name, node) in selections) + { + if (regex.IsMatch(name)) matched.Add(node); + } + return matched; + } + + private static IReadOnlyList Flatten(SigmaNode left, SigmaNode right) where T : SigmaNode + { + var list = new List(); + AddFlattened(list, left); + AddFlattened(list, right); + return list; + } + + private static void AddFlattened(List list, SigmaNode node) where T : SigmaNode + { + if (typeof(T) == typeof(SigmaAnd) && node is SigmaAnd and) + { + list.AddRange(and.Children); + return; + } + if (typeof(T) == typeof(SigmaOr) && node is SigmaOr or) + { + list.AddRange(or.Children); + return; + } + list.Add(node); + } + + private static List Tokenize(string condition) + { + var tokens = new List(); + var i = 0; + while (i < condition.Length) + { + var c = condition[i]; + if (char.IsWhiteSpace(c)) { i++; continue; } + if (c == '(' || c == ')') + { + tokens.Add(c.ToString()); + i++; + continue; + } + var start = i; + while (i < condition.Length && !char.IsWhiteSpace(condition[i]) && condition[i] != '(' && condition[i] != ')') + { + i++; + } + tokens.Add(condition[start..i]); + } + return tokens; + } } public class SigmaRuleException(string message) : Exception(message); diff --git a/backend/src/Tawny.Domain/Entities/Alert.cs b/backend/src/Tawny.Domain/Entities/Alert.cs index a09c37c..06123a5 100644 --- a/backend/src/Tawny.Domain/Entities/Alert.cs +++ b/backend/src/Tawny.Domain/Entities/Alert.cs @@ -16,6 +16,7 @@ public class Alert public string? SentinelNotificationError { get; set; } public required string Title { get; set; } public string? Description { get; set; } + public string? EnrichmentJson { get; set; } public DateTimeOffset CreatedAt { get; set; } public AlertRule? AlertRule { get; set; } diff --git a/backend/src/Tawny.Domain/Entities/AlertRule.cs b/backend/src/Tawny.Domain/Entities/AlertRule.cs index 54bddaf..b669b0d 100644 --- a/backend/src/Tawny.Domain/Entities/AlertRule.cs +++ b/backend/src/Tawny.Domain/Entities/AlertRule.cs @@ -13,6 +13,7 @@ public class AlertRule public string? PayloadPath { get; set; } public string? MatchValue { get; set; } public string? SourceDefinition { get; set; } + public string? CompiledExpressionJson { get; set; } public bool IsEnabled { get; set; } = true; public string? MitreTechniquesJson { get; set; } public DateTimeOffset CreatedAt { get; set; } diff --git a/backend/src/Tawny.Domain/Entities/Case.cs b/backend/src/Tawny.Domain/Entities/Case.cs new file mode 100644 index 0000000..5a0b0a0 --- /dev/null +++ b/backend/src/Tawny.Domain/Entities/Case.cs @@ -0,0 +1,61 @@ +namespace Tawny.Domain.Entities; + +public enum CaseStatus +{ + Open = 0, + Investigating = 1, + Contained = 2, + Resolved = 3, + Closed = 4, +} + +public enum CasePriority +{ + Low = 0, + Medium = 1, + High = 2, + Critical = 3, +} + +public class Case +{ + public long Id { get; set; } + public Guid TenantId { get; set; } + public required string Title { get; set; } + public string? Summary { get; set; } + public CaseStatus Status { get; set; } = CaseStatus.Open; + public CasePriority Priority { get; set; } = CasePriority.Medium; + public Guid? AssignedToUserId { get; set; } + public Guid? CreatedByUserId { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset? ClosedAt { get; set; } + public string? MitreTechniquesJson { get; set; } + + public Tenant? Tenant { get; set; } + public List CaseAlerts { get; set; } = []; + public List Notes { get; set; } = []; +} + +public class CaseAlert +{ + public long Id { get; set; } + public long CaseId { get; set; } + public long AlertId { get; set; } + public DateTimeOffset AddedAt { get; set; } + public Guid? AddedByUserId { get; set; } + + public Case? Case { get; set; } + public Alert? Alert { get; set; } +} + +public class CaseNote +{ + public long Id { get; set; } + public long CaseId { get; set; } + public Guid? AuthorUserId { get; set; } + public required string Body { get; set; } + public DateTimeOffset CreatedAt { get; set; } + + public Case? Case { get; set; } +} diff --git a/backend/src/Tawny.Domain/Entities/ReputationCacheEntry.cs b/backend/src/Tawny.Domain/Entities/ReputationCacheEntry.cs new file mode 100644 index 0000000..7250329 --- /dev/null +++ b/backend/src/Tawny.Domain/Entities/ReputationCacheEntry.cs @@ -0,0 +1,15 @@ +namespace Tawny.Domain.Entities; + +public class ReputationCacheEntry +{ + public long Id { get; set; } + public Guid TenantId { get; set; } + public ReputationProvider Provider { get; set; } + public required string IndicatorKind { get; set; } + public required string IndicatorValue { get; set; } + public ReputationVerdict Verdict { get; set; } + public int? Score { get; set; } + public required string DetailJson { get; set; } + public DateTimeOffset FetchedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } +} diff --git a/backend/src/Tawny.Domain/Entities/SavedHunt.cs b/backend/src/Tawny.Domain/Entities/SavedHunt.cs index 155c0bb..906d926 100644 --- a/backend/src/Tawny.Domain/Entities/SavedHunt.cs +++ b/backend/src/Tawny.Domain/Entities/SavedHunt.cs @@ -15,6 +15,7 @@ public class SavedHunt public string? MitreTechniquesJson { get; set; } public DateTimeOffset? LastRunAt { get; set; } public int? LastMatchCount { get; set; } + public bool IsShared { get; set; } = true; public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } diff --git a/backend/src/Tawny.Domain/Entities/Tenant.cs b/backend/src/Tawny.Domain/Entities/Tenant.cs index eca6a45..cf7b963 100644 --- a/backend/src/Tawny.Domain/Entities/Tenant.cs +++ b/backend/src/Tawny.Domain/Entities/Tenant.cs @@ -15,4 +15,6 @@ public class Tenant public List SavedHunts { get; set; } = []; public List SuppressionRules { get; set; } = []; public List ApiTokens { get; set; } = []; + public List ThreatIntelFeeds { get; set; } = []; + public List Cases { get; set; } = []; } diff --git a/backend/src/Tawny.Domain/Entities/ThreatIntelFeed.cs b/backend/src/Tawny.Domain/Entities/ThreatIntelFeed.cs new file mode 100644 index 0000000..72a5ee3 --- /dev/null +++ b/backend/src/Tawny.Domain/Entities/ThreatIntelFeed.cs @@ -0,0 +1,27 @@ +namespace Tawny.Domain.Entities; + +public class ThreatIntelFeed +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public required string Name { get; set; } + public ThreatIntelFeedKind Kind { get; set; } + public required string Url { get; set; } + public string? AuthHeaderName { get; set; } + public string? AuthHeaderValueEncrypted { get; set; } + public AlertSeverity DefaultSeverity { get; set; } = AlertSeverity.High; + public bool IsEnabled { get; set; } = true; + public int IntervalMinutes { get; set; } = 60; + public ThreatIntelFeedStatus Status { get; set; } = ThreatIntelFeedStatus.NeverRun; + public DateTimeOffset? LastRunAt { get; set; } + public DateTimeOffset? LastSuccessAt { get; set; } + public int LastImportedCount { get; set; } + public int LastSkippedCount { get; set; } + public string? LastError { get; set; } + public string? Etag { get; set; } + public Guid? CreatedByUserId { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public Tenant? Tenant { get; set; } +} diff --git a/backend/src/Tawny.Domain/Enums.cs b/backend/src/Tawny.Domain/Enums.cs index 8f83617..c40a962 100644 --- a/backend/src/Tawny.Domain/Enums.cs +++ b/backend/src/Tawny.Domain/Enums.cs @@ -74,6 +74,8 @@ public enum AlertRuleFormat TawnyPredicate = 0, Sigma = 1, Ioc = 2, + Sequence = 3, + Yara = 4, } public enum ResponseActionType @@ -103,3 +105,37 @@ public enum SuppressionScope AllRules = 0, SpecificRule = 1, } + +public enum ThreatIntelFeedKind +{ + UrlhausCsv = 0, + UrlhausJson = 1, + OtxPulse = 2, + MispEvents = 3, + Taxii21 = 4, + GenericCsv = 5, +} + +public enum ThreatIntelFeedStatus +{ + Healthy = 0, + Degraded = 1, + Failed = 2, + NeverRun = 3, +} + +public enum ReputationProvider +{ + VirusTotal = 0, + AbuseIpDb = 1, + GreyNoise = 2, +} + +public enum ReputationVerdict +{ + Unknown = 0, + Clean = 1, + Suspicious = 2, + Malicious = 3, + Error = 4, +} diff --git a/backend/src/Tawny.Infrastructure/Hunting/RuleTestHarness.cs b/backend/src/Tawny.Infrastructure/Hunting/RuleTestHarness.cs new file mode 100644 index 0000000..e1073c1 --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Hunting/RuleTestHarness.cs @@ -0,0 +1,193 @@ +using System.Globalization; +using System.Text.Json; +using Tawny.Domain; +using Tawny.Domain.Entities; + +namespace Tawny.Infrastructure.Hunting; + +public record RuleTestEventInput( + TelemetryEventType EventType, + DateTimeOffset OccurredAt, + JsonElement Payload); + +public record RuleTestStepTrace( + int Index, + string Step, + bool Matched, + string? FailReason); + +public record RuleTestResult( + bool Matched, + string? FailReason, + IReadOnlyList Trace); + +/// +/// Pure-function tester that runs an in-memory AlertRule (any format) against +/// supplied event(s). No DB writes, no broker publish, no sinks — used by the +/// /rule-test endpoint so detection authors can iterate on rules quickly. +/// +public class RuleTestHarness +{ + public RuleTestResult Test(AlertRule rule, IReadOnlyList events) + { + if (events.Count == 0) + { + return new RuleTestResult(false, "no events supplied", []); + } + + return rule.Format switch + { + AlertRuleFormat.Sequence => TestSequence(rule, events), + _ => TestSinglePredicate(rule, events), + }; + } + + private static RuleTestResult TestSinglePredicate(AlertRule rule, IReadOnlyList events) + { + var trace = new List(); + for (var i = 0; i < events.Count; i++) + { + var ev = events[i]; + if (rule.EventType is not null && rule.EventType.Value != ev.EventType) + { + trace.Add(new RuleTestStepTrace(i, $"event[{i}] {ev.EventType}", false, + $"event_type {ev.EventType} does not match rule event_type {rule.EventType}")); + continue; + } + if (string.IsNullOrWhiteSpace(rule.PayloadPath)) + { + trace.Add(new RuleTestStepTrace(i, $"event[{i}]", true, null)); + return new RuleTestResult(true, null, trace); + } + var values = ResolvePath(ev.Payload, rule.PayloadPath).ToList(); + if (values.Count == 0) + { + trace.Add(new RuleTestStepTrace(i, $"event[{i}] {rule.PayloadPath}", false, + $"payload_path '{rule.PayloadPath}' was not found in the event payload")); + continue; + } + if (RuleMatches(rule, values)) + { + trace.Add(new RuleTestStepTrace(i, $"event[{i}] {rule.PayloadPath} {rule.Operator} {rule.MatchValue}", true, null)); + return new RuleTestResult(true, null, trace); + } + trace.Add(new RuleTestStepTrace(i, $"event[{i}] {rule.PayloadPath} {rule.Operator} {rule.MatchValue}", false, + $"value(s) {string.Join(", ", values.Select(JsonScalar))} did not satisfy the predicate")); + } + return new RuleTestResult(false, "no event satisfied the predicate", trace); + } + + private static RuleTestResult TestSequence(AlertRule rule, IReadOnlyList events) + { + SequenceRuleDefinition definition; + try { definition = SequenceRuleParser.Parse(rule.SourceDefinition ?? ""); } + catch (SequenceRuleException ex) + { + return new RuleTestResult(false, ex.Message, []); + } + + var trace = new List(); + var matched = 0; + var firstMatchTime = DateTimeOffset.MinValue; + var ordered = events.OrderBy(e => e.OccurredAt).ToList(); + foreach (var ev in ordered) + { + if (matched >= definition.Steps.Count) break; + var step = definition.Steps[matched]; + if (step.EventType != ev.EventType) + { + trace.Add(new RuleTestStepTrace(matched, step.Name, false, + $"step expects {step.EventType} but event was {ev.EventType}")); + continue; + } + if (!StepMatches(step, ev.Payload)) + { + trace.Add(new RuleTestStepTrace(matched, step.Name, false, + $"payload did not satisfy step predicate")); + continue; + } + if (matched > 0 + && (ev.OccurredAt - firstMatchTime).TotalSeconds > definition.WindowSeconds) + { + trace.Add(new RuleTestStepTrace(matched, step.Name, false, + $"step occurred {(ev.OccurredAt - firstMatchTime).TotalSeconds:F0}s after step 0, outside window_seconds={definition.WindowSeconds}")); + continue; + } + if (matched == 0) firstMatchTime = ev.OccurredAt; + trace.Add(new RuleTestStepTrace(matched, step.Name, true, null)); + matched += 1; + } + + if (matched == definition.Steps.Count) + { + return new RuleTestResult(true, null, trace); + } + return new RuleTestResult(false, $"matched {matched} of {definition.Steps.Count} steps", trace); + } + + private static bool RuleMatches(AlertRule rule, IEnumerable values) + => 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)), + AlertRuleOperator.GreaterThan => values.Any(v => CompareNumber(v, rule.MatchValue, (a, b) => a > b)), + AlertRuleOperator.LessThan => values.Any(v => CompareNumber(v, rule.MatchValue, (a, b) => a < b)), + _ => false, + }; + + private static bool StepMatches(SequenceStep step, JsonElement payload) + { + if (string.IsNullOrWhiteSpace(step.PayloadPath)) return true; + var values = ResolvePath(payload, step.PayloadPath).ToList(); + if (values.Count == 0) return false; + return step.Operator switch + { + AlertRuleOperator.Exists => true, + AlertRuleOperator.Equals => values.Any(v => string.Equals(JsonScalar(v), step.MatchValue, StringComparison.OrdinalIgnoreCase)), + AlertRuleOperator.Contains => values.Any(v => !string.IsNullOrEmpty(step.MatchValue) && JsonScalar(v).Contains(step.MatchValue, StringComparison.OrdinalIgnoreCase)), + AlertRuleOperator.GreaterThan => values.Any(v => CompareNumber(v, step.MatchValue, (a, b) => a > b)), + AlertRuleOperator.LessThan => values.Any(v => CompareNumber(v, step.MatchValue, (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, string? expected, Func cmp) + { + if (!decimal.TryParse(JsonScalar(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var left)) return false; + if (!decimal.TryParse(expected, NumberStyles.Float, CultureInfo.InvariantCulture, out var right)) return false; + return cmp(left, right); + } +} diff --git a/backend/src/Tawny.Infrastructure/Hunting/SequenceRule.cs b/backend/src/Tawny.Infrastructure/Hunting/SequenceRule.cs new file mode 100644 index 0000000..83e429e --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Hunting/SequenceRule.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Tawny.Domain; +using Tawny.Domain.Entities; + +namespace Tawny.Infrastructure.Hunting; + +public class SequenceRuleException(string message) : Exception(message); + +/// +/// JSON shape stored on AlertRule.SourceDefinition when Format = Sequence. +/// Each step is a predicate that must match an event of the named type; +/// steps must occur in order on the same host, within the rule's time window. +/// +public record SequenceRuleDefinition( + [property: JsonPropertyName("window_seconds")] int WindowSeconds, + [property: JsonPropertyName("group_by")] string GroupBy, + [property: JsonPropertyName("steps")] IReadOnlyList Steps); + +public record SequenceStep( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("event_type")] TelemetryEventType EventType, + [property: JsonPropertyName("payload_path")] string? PayloadPath, + [property: JsonPropertyName("operator")] AlertRuleOperator Operator, + [property: JsonPropertyName("match_value")] string? MatchValue); + +public static class SequenceRuleParser +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, + }; + + public static SequenceRuleDefinition Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + throw new SequenceRuleException("Sequence rule definition is empty."); + } + SequenceRuleDefinition? def; + try + { + def = JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException ex) + { + throw new SequenceRuleException($"Invalid sequence rule JSON: {ex.Message}"); + } + if (def is null) + { + throw new SequenceRuleException("Sequence rule definition deserialized to null."); + } + if (def.WindowSeconds <= 0 || def.WindowSeconds > 86_400) + { + throw new SequenceRuleException("window_seconds must be between 1 and 86400."); + } + if (def.Steps is null || def.Steps.Count < 2) + { + throw new SequenceRuleException("A sequence rule needs at least two steps."); + } + if (def.Steps.Count > 8) + { + throw new SequenceRuleException("A sequence rule can have at most 8 steps."); + } + foreach (var step in def.Steps) + { + if (string.IsNullOrWhiteSpace(step.Name)) + { + throw new SequenceRuleException("Each step needs a non-empty name."); + } + if (step.Operator != AlertRuleOperator.Exists && string.IsNullOrWhiteSpace(step.MatchValue)) + { + throw new SequenceRuleException($"Step '{step.Name}' needs a match_value (or use the exists operator)."); + } + } + return def; + } + + public static string Serialize(SequenceRuleDefinition def) + => JsonSerializer.Serialize(def, JsonOptions); +} diff --git a/backend/src/Tawny.Infrastructure/Hunting/SequenceRuleEvaluator.cs b/backend/src/Tawny.Infrastructure/Hunting/SequenceRuleEvaluator.cs new file mode 100644 index 0000000..4d6e2af --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Hunting/SequenceRuleEvaluator.cs @@ -0,0 +1,153 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tawny.Domain; +using Tawny.Domain.Entities; + +namespace Tawny.Infrastructure.Hunting; + +/// +/// Tracks in-flight sequence matches keyed by (rule, host). State is process- +/// local: we deliberately don't persist partial progress, because operators +/// expect EDR detections to fire when the *whole* sequence is observed within +/// the window, and survivability across restarts isn't worth the storage +/// churn. Rebuilds on the next matching event after a restart. +/// +public class SequenceRuleEvaluator +{ + private readonly ConcurrentDictionary<(Guid RuleId, Guid AgentId), SequenceState> _state = new(); + + public IReadOnlyList Evaluate( + AlertRule rule, + SequenceRuleDefinition definition, + Agent agent, + IReadOnlyList events, + DateTimeOffset now) + { + var window = TimeSpan.FromSeconds(definition.WindowSeconds); + var matches = new List(); + var key = (rule.Id, agent.Id); + var state = _state.GetOrAdd(key, _ => new SequenceState()); + + foreach (var ev in events.OrderBy(e => e.OccurredAt)) + { + JsonDocument doc; + try { doc = JsonDocument.Parse(ev.Payload); } + catch { continue; } + + using (doc) + { + var nextStepIndex = state.MatchedSteps.Count; + if (nextStepIndex >= definition.Steps.Count) continue; + var step = definition.Steps[nextStepIndex]; + + if (step.EventType != ev.EventType) continue; + if (!StepMatches(step, doc.RootElement)) continue; + + // Reset if too far behind the first matched event. + if (state.MatchedSteps.Count > 0 + && (ev.OccurredAt - state.MatchedSteps[0].OccurredAt) > window) + { + state.MatchedSteps.Clear(); + nextStepIndex = 0; + step = definition.Steps[0]; + if (step.EventType != ev.EventType || !StepMatches(step, doc.RootElement)) + { + continue; + } + } + + state.MatchedSteps.Add(new MatchedStep(step.Name, ev.Id, ev.OccurredAt)); + + if (state.MatchedSteps.Count == definition.Steps.Count) + { + matches.Add(new SequenceMatch( + rule.Id, + agent.Id, + state.MatchedSteps.Last().EventId, + state.MatchedSteps.ToList())); + state.MatchedSteps.Clear(); + } + } + } + + // Garbage-collect stale state per host: if oldest matched step is past window, drop progress. + if (state.MatchedSteps.Count > 0 && (now - state.MatchedSteps[0].OccurredAt) > window) + { + state.MatchedSteps.Clear(); + } + + return matches; + } + + public void ResetAll() => _state.Clear(); + + private static bool StepMatches(SequenceStep step, JsonElement payload) + { + if (string.IsNullOrWhiteSpace(step.PayloadPath)) return true; + var values = ResolvePath(payload, step.PayloadPath).ToList(); + if (values.Count == 0) return false; + return step.Operator switch + { + AlertRuleOperator.Exists => true, + AlertRuleOperator.Equals => values.Any(v => string.Equals(JsonScalar(v), step.MatchValue, StringComparison.OrdinalIgnoreCase)), + AlertRuleOperator.Contains => values.Any(v => !string.IsNullOrEmpty(step.MatchValue) && JsonScalar(v).Contains(step.MatchValue, StringComparison.OrdinalIgnoreCase)), + AlertRuleOperator.GreaterThan => values.Any(v => CompareNumber(v, step.MatchValue, (a, b) => a > b)), + AlertRuleOperator.LessThan => values.Any(v => CompareNumber(v, step.MatchValue, (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, string? expected, Func cmp) + { + if (!decimal.TryParse(JsonScalar(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var left)) return false; + if (!decimal.TryParse(expected, NumberStyles.Float, CultureInfo.InvariantCulture, out var right)) return false; + return cmp(left, right); + } + + private sealed class SequenceState + { + public List MatchedSteps { get; } = []; + } +} + +public record MatchedStep(string Name, long EventId, DateTimeOffset OccurredAt); + +public record SequenceMatch( + Guid RuleId, + Guid AgentId, + long TriggeringEventId, + IReadOnlyList Trail); diff --git a/backend/src/Tawny.Infrastructure/Hunting/SigmaExpression.cs b/backend/src/Tawny.Infrastructure/Hunting/SigmaExpression.cs new file mode 100644 index 0000000..da855d8 --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Hunting/SigmaExpression.cs @@ -0,0 +1,121 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Tawny.Domain; + +namespace Tawny.Infrastructure.Hunting; + +/// +/// Compiled Sigma rule tree, stored on AlertRule.CompiledExpressionJson when +/// the source rule has a non-trivial condition (AND/OR/NOT, "1 of selection_*", +/// "all of selection_*"). Single-selection rules continue to use the legacy +/// AlertRule.PayloadPath/Operator/MatchValue fields so the simple path stays simple. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] +[JsonDerivedType(typeof(SigmaAnd), "and")] +[JsonDerivedType(typeof(SigmaOr), "or")] +[JsonDerivedType(typeof(SigmaNot), "not")] +[JsonDerivedType(typeof(SigmaAnyOf), "any_of")] +[JsonDerivedType(typeof(SigmaAllOf), "all_of")] +[JsonDerivedType(typeof(SigmaFieldPredicate), "field")] +public abstract record SigmaNode; + +public sealed record SigmaAnd(IReadOnlyList Children) : SigmaNode; +public sealed record SigmaOr(IReadOnlyList Children) : SigmaNode; +public sealed record SigmaNot(SigmaNode Inner) : SigmaNode; +public sealed record SigmaAnyOf(IReadOnlyList Children) : SigmaNode; +public sealed record SigmaAllOf(IReadOnlyList Children) : SigmaNode; + +public sealed record SigmaFieldPredicate( + string PayloadPath, + AlertRuleOperator Operator, + IReadOnlyList Values) : SigmaNode; + +public static class SigmaExpressionEvaluator +{ + public static bool Evaluate(SigmaNode node, JsonElement payload) + { + return node switch + { + SigmaAnd and => and.Children.All(c => Evaluate(c, payload)), + SigmaOr or => or.Children.Any(c => Evaluate(c, payload)), + SigmaNot not => !Evaluate(not.Inner, payload), + SigmaAnyOf anyOf => anyOf.Children.Any(c => Evaluate(c, payload)), + SigmaAllOf allOf => allOf.Children.All(c => Evaluate(c, payload)), + SigmaFieldPredicate p => EvaluatePredicate(p, payload), + _ => false, + }; + } + + private static bool EvaluatePredicate(SigmaFieldPredicate predicate, JsonElement payload) + { + var values = ResolvePath(payload, predicate.PayloadPath).ToList(); + if (values.Count == 0) return false; + return predicate.Operator switch + { + AlertRuleOperator.Exists => true, + AlertRuleOperator.Equals => values.Any(v => predicate.Values.Any(target => string.Equals(JsonScalar(v), target, StringComparison.OrdinalIgnoreCase))), + AlertRuleOperator.Contains => values.Any(v => predicate.Values.Any(target => JsonScalar(v).Contains(target, StringComparison.OrdinalIgnoreCase))), + AlertRuleOperator.GreaterThan => values.Any(v => predicate.Values.Any(target => CompareNumber(v, target, (a, b) => a > b))), + AlertRuleOperator.LessThan => values.Any(v => predicate.Values.Any(target => CompareNumber(v, target, (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, string? expected, Func cmp) + { + if (!decimal.TryParse(JsonScalar(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var left)) return false; + if (!decimal.TryParse(expected, NumberStyles.Float, CultureInfo.InvariantCulture, out var right)) return false; + return cmp(left, right); + } +} + +public static class SigmaExpressionSerializer +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, + }; + + public static string Serialize(SigmaNode node) => JsonSerializer.Serialize(node, Options); + + public static SigmaNode? Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try { return JsonSerializer.Deserialize(json, Options); } + catch { return null; } + } +} diff --git a/backend/src/Tawny.Infrastructure/Hunting/YaraLite.cs b/backend/src/Tawny.Infrastructure/Hunting/YaraLite.cs new file mode 100644 index 0000000..2b2c63e --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Hunting/YaraLite.cs @@ -0,0 +1,192 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Tawny.Infrastructure.Hunting; + +public class YaraLiteException(string message) : Exception(message); + +/// +/// YARA-lite: a JSON-defined string-match rule that evaluates against the +/// raw text of a telemetry payload. Not a full YARA implementation (no PE +/// parsing, no offsets, no XOR/wide modifiers) — those need libyara and a +/// way to ship file content from agents, which is Phase 2 territory. +/// +/// What we do support: +/// strings: list of either { literal: "..." } or { regex: "..." } with a $name +/// condition: "any_of" | "all_of" | "n_of(K)" +/// +public record YaraLiteDefinition( + [property: JsonPropertyName("strings")] IReadOnlyList Strings, + [property: JsonPropertyName("condition")] string Condition); + +public record YaraLiteString( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("literal")] string? Literal, + [property: JsonPropertyName("regex")] string? Regex, + [property: JsonPropertyName("case_sensitive")] bool? CaseSensitive); + +public static class YaraLiteParser +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + public static YaraLiteDefinition Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + throw new YaraLiteException("YARA rule definition is empty."); + } + YaraLiteDefinition? def; + try { def = JsonSerializer.Deserialize(json, JsonOptions); } + catch (JsonException ex) + { + throw new YaraLiteException($"Invalid YARA-lite JSON: {ex.Message}"); + } + if (def is null || def.Strings is null || def.Strings.Count == 0) + { + throw new YaraLiteException("YARA-lite rule must define at least one string."); + } + foreach (var s in def.Strings) + { + if (string.IsNullOrWhiteSpace(s.Name)) + { + throw new YaraLiteException("Each string needs a name (e.g. $cmd1)."); + } + if (string.IsNullOrEmpty(s.Literal) && string.IsNullOrEmpty(s.Regex)) + { + throw new YaraLiteException($"String {s.Name} needs either a literal or a regex."); + } + if (!string.IsNullOrEmpty(s.Regex)) + { + try { _ = new Regex(s.Regex); } + catch (ArgumentException ex) + { + throw new YaraLiteException($"Invalid regex in {s.Name}: {ex.Message}"); + } + } + } + if (string.IsNullOrWhiteSpace(def.Condition)) + { + throw new YaraLiteException("YARA-lite rule must include a condition."); + } + return def; + } + + public static string Serialize(YaraLiteDefinition def) => JsonSerializer.Serialize(def, JsonOptions); +} + +public static class YaraLiteEvaluator +{ + private static readonly Regex NofRe = new(@"^\s*(?\d+)_of\s*$", RegexOptions.Compiled); + + public static bool Evaluate(YaraLiteDefinition definition, string payloadText) + { + var matched = new HashSet(StringComparer.Ordinal); + foreach (var s in definition.Strings) + { + if (StringMatches(s, payloadText)) + { + matched.Add(s.Name); + } + } + var condition = definition.Condition.Trim().ToLowerInvariant(); + if (condition == "any_of" || condition == "any of them") + { + return matched.Count > 0; + } + if (condition == "all_of" || condition == "all of them") + { + return matched.Count == definition.Strings.Count; + } + var nofMatch = NofRe.Match(condition); + if (nofMatch.Success && int.TryParse(nofMatch.Groups["n"].Value, out var n)) + { + return matched.Count >= n; + } + // Specific names like "$a and $b": evaluate as token-replace -> boolean expression. + var expr = condition; + foreach (var s in definition.Strings) + { + var truthy = matched.Contains(s.Name) ? "true" : "false"; + expr = Regex.Replace(expr, Regex.Escape(s.Name.ToLowerInvariant()), truthy); + } + return EvalBoolean(expr); + } + + private static bool StringMatches(YaraLiteString s, string payloadText) + { + if (!string.IsNullOrEmpty(s.Literal)) + { + var comparison = s.CaseSensitive == true + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + return payloadText.Contains(s.Literal, comparison); + } + if (!string.IsNullOrEmpty(s.Regex)) + { + var opts = s.CaseSensitive == true ? RegexOptions.None : RegexOptions.IgnoreCase; + return Regex.IsMatch(payloadText, s.Regex, opts); + } + return false; + } + + private static bool EvalBoolean(string expr) + { + // Cheap, safe boolean-expression evaluator for "true and false or not true" style strings. + // Tokenize, then a tiny recursive-descent parser. We deliberately keep this minimal. + var tokens = Tokenize(expr).ToList(); + var pos = 0; + var result = ParseOr(tokens, ref pos); + return result; + } + + private static bool ParseOr(IReadOnlyList tokens, ref int pos) + { + var left = ParseAnd(tokens, ref pos); + while (pos < tokens.Count && tokens[pos] == "or") + { + pos++; + var right = ParseAnd(tokens, ref pos); + left = left || right; + } + return left; + } + + private static bool ParseAnd(IReadOnlyList tokens, ref int pos) + { + var left = ParseUnary(tokens, ref pos); + while (pos < tokens.Count && tokens[pos] == "and") + { + pos++; + var right = ParseUnary(tokens, ref pos); + left = left && right; + } + return left; + } + + private static bool ParseUnary(IReadOnlyList tokens, ref int pos) + { + if (pos >= tokens.Count) return false; + if (tokens[pos] == "not") { pos++; return !ParseUnary(tokens, ref pos); } + if (tokens[pos] == "(") { pos++; var v = ParseOr(tokens, ref pos); if (pos < tokens.Count && tokens[pos] == ")") pos++; return v; } + var t = tokens[pos++]; + return t == "true"; + } + + private static IEnumerable Tokenize(string expr) + { + var i = 0; + while (i < expr.Length) + { + var c = expr[i]; + if (char.IsWhiteSpace(c)) { i++; continue; } + if (c == '(' || c == ')') { yield return c.ToString(); i++; continue; } + var start = i; + while (i < expr.Length && !char.IsWhiteSpace(expr[i]) && expr[i] != '(' && expr[i] != ')') i++; + yield return expr[start..i]; + } + } +} diff --git a/backend/src/Tawny.Infrastructure/Migrations/20260524000000_AddPhase3And4.cs b/backend/src/Tawny.Infrastructure/Migrations/20260524000000_AddPhase3And4.cs new file mode 100644 index 0000000..e831b53 --- /dev/null +++ b/backend/src/Tawny.Infrastructure/Migrations/20260524000000_AddPhase3And4.cs @@ -0,0 +1,224 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Tawny.Infrastructure; + +#nullable disable + +namespace Tawny.Infrastructure.Migrations +{ + /// + [DbContext(typeof(TawnyDbContext))] + [Migration("20260524000000_AddPhase3And4")] + public partial class AddPhase3And4 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Enrichment", + table: "Alerts", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsShared", + table: "SavedHunts", + type: "bit", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "CompiledExpression", + table: "AlertRules", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.CreateTable( + name: "ThreatIntelFeeds", + 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), + Kind = table.Column(type: "int", nullable: false), + Url = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), + AuthHeaderName = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + AuthHeaderValueEncrypted = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + DefaultSeverity = table.Column(type: "int", nullable: false), + IsEnabled = table.Column(type: "bit", nullable: false), + IntervalMinutes = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + LastRunAt = table.Column(type: "datetimeoffset", nullable: true), + LastSuccessAt = table.Column(type: "datetimeoffset", nullable: true), + LastImportedCount = table.Column(type: "int", nullable: false), + LastSkippedCount = table.Column(type: "int", nullable: false), + LastError = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + Etag = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + CreatedByUserId = table.Column(type: "uniqueidentifier", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ThreatIntelFeeds", x => x.Id); + table.ForeignKey( + name: "FK_ThreatIntelFeeds_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ReputationCache", + 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")), + Provider = table.Column(type: "int", nullable: false), + IndicatorKind = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + IndicatorValue = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + Verdict = table.Column(type: "int", nullable: false), + Score = table.Column(type: "int", nullable: true), + DetailJson = table.Column(type: "nvarchar(max)", nullable: false), + FetchedAt = table.Column(type: "datetimeoffset", nullable: false), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReputationCache", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Cases", + 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")), + Title = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + Summary = table.Column(type: "nvarchar(max)", nullable: true), + Status = table.Column(type: "int", nullable: false), + Priority = table.Column(type: "int", nullable: false), + AssignedToUserId = table.Column(type: "uniqueidentifier", nullable: true), + CreatedByUserId = table.Column(type: "uniqueidentifier", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: false), + ClosedAt = table.Column(type: "datetimeoffset", nullable: true), + MitreTechniques = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Cases", x => x.Id); + table.ForeignKey( + name: "FK_Cases_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "CaseAlerts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CaseId = table.Column(type: "bigint", nullable: false), + AlertId = table.Column(type: "bigint", nullable: false), + AddedAt = table.Column(type: "datetimeoffset", nullable: false), + AddedByUserId = table.Column(type: "uniqueidentifier", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CaseAlerts", x => x.Id); + table.ForeignKey( + name: "FK_CaseAlerts_Cases_CaseId", + column: x => x.CaseId, + principalTable: "Cases", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CaseAlerts_Alerts_AlertId", + column: x => x.AlertId, + principalTable: "Alerts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CaseNotes", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CaseId = table.Column(type: "bigint", nullable: false), + AuthorUserId = table.Column(type: "uniqueidentifier", nullable: true), + Body = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CaseNotes", x => x.Id); + table.ForeignKey( + name: "FK_CaseNotes_Cases_CaseId", + column: x => x.CaseId, + principalTable: "Cases", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ThreatIntelFeeds_TenantId_IsEnabled", + table: "ThreatIntelFeeds", + columns: new[] { "TenantId", "IsEnabled" }); + + migrationBuilder.CreateIndex( + name: "IX_ReputationCache_TenantId_Provider_IndicatorKind_IndicatorValue", + table: "ReputationCache", + columns: new[] { "TenantId", "Provider", "IndicatorKind", "IndicatorValue" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ReputationCache_ExpiresAt", + table: "ReputationCache", + column: "ExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_Cases_TenantId_Status_CreatedAt", + table: "Cases", + columns: new[] { "TenantId", "Status", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_CaseAlerts_CaseId_AlertId", + table: "CaseAlerts", + columns: new[] { "CaseId", "AlertId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CaseAlerts_AlertId", + table: "CaseAlerts", + column: "AlertId"); + + migrationBuilder.CreateIndex( + name: "IX_CaseNotes_CaseId_CreatedAt", + table: "CaseNotes", + columns: new[] { "CaseId", "CreatedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "CaseNotes"); + migrationBuilder.DropTable(name: "CaseAlerts"); + migrationBuilder.DropTable(name: "Cases"); + migrationBuilder.DropTable(name: "ReputationCache"); + migrationBuilder.DropTable(name: "ThreatIntelFeeds"); + migrationBuilder.DropColumn(name: "CompiledExpression", table: "AlertRules"); + migrationBuilder.DropColumn(name: "IsShared", table: "SavedHunts"); + migrationBuilder.DropColumn(name: "Enrichment", table: "Alerts"); + } + } +} diff --git a/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs b/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs index 319f4d3..6391996 100644 --- a/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs +++ b/backend/src/Tawny.Infrastructure/Migrations/TawnyDbContextModelSnapshot.cs @@ -136,6 +136,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Description") .HasColumnType("nvarchar(max)"); + b.Property("EnrichmentJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("Enrichment"); + b.Property("Severity") .HasColumnType("int"); @@ -189,6 +193,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("CompiledExpressionJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("CompiledExpression"); + b.Property("CreatedAt") .HasColumnType("datetimeoffset"); @@ -270,6 +278,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsScheduled") .HasColumnType("bit"); + b.Property("IsShared") + .HasColumnType("bit"); + b.Property("LastMatchCount") .HasColumnType("int"); @@ -482,6 +493,249 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ApiTokens"); }); + modelBuilder.Entity("Tawny.Domain.Entities.ThreatIntelFeed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AuthHeaderName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("AuthHeaderValueEncrypted") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultSeverity") + .HasColumnType("int"); + + b.Property("Etag") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IntervalMinutes") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LastImportedCount") + .HasColumnType("int"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastSkippedCount") + .HasColumnType("int"); + + b.Property("LastSuccessAt") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "IsEnabled"); + + b.ToTable("ThreatIntelFeeds"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.ReputationCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DetailJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("FetchedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IndicatorKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("IndicatorValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + + b.Property("Verdict") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("TenantId", "Provider", "IndicatorKind", "IndicatorValue") + .IsUnique(); + + b.ToTable("ReputationCache"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.Case", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssignedToUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClosedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("MitreTechniquesJson") + .HasColumnType("nvarchar(max)") + .HasColumnName("MitreTechniques"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Status", "CreatedAt"); + + b.ToTable("Cases"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.CaseAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AddedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("AlertId") + .HasColumnType("bigint"); + + b.Property("CaseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("AlertId"); + + b.HasIndex("CaseId", "AlertId") + .IsUnique(); + + b.ToTable("CaseAlerts"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.CaseNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthorUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CaseId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("CaseId", "CreatedAt"); + + b.ToTable("CaseNotes"); + }); + modelBuilder.Entity("Tawny.Domain.Entities.AuditLog", b => { b.Property("Id") @@ -876,6 +1130,58 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Tenant"); }); + modelBuilder.Entity("Tawny.Domain.Entities.ThreatIntelFeed", b => + { + b.HasOne("Tawny.Domain.Entities.Tenant", "Tenant") + .WithMany("ThreatIntelFeeds") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.Case", b => + { + b.HasOne("Tawny.Domain.Entities.Tenant", "Tenant") + .WithMany("Cases") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.CaseAlert", b => + { + b.HasOne("Tawny.Domain.Entities.Alert", "Alert") + .WithMany() + .HasForeignKey("AlertId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tawny.Domain.Entities.Case", "Case") + .WithMany("CaseAlerts") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alert"); + + b.Navigation("Case"); + }); + + modelBuilder.Entity("Tawny.Domain.Entities.CaseNote", b => + { + b.HasOne("Tawny.Domain.Entities.Case", "Case") + .WithMany("Notes") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Case"); + }); + modelBuilder.Entity("Tawny.Domain.Entities.Agent", b => { b.Navigation("Events"); @@ -889,6 +1195,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("AuditLog"); + b.Navigation("Cases"); + b.Navigation("EnrollmentTokens"); b.Navigation("SavedHunts"); @@ -897,6 +1205,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("TelemetryEvents"); + b.Navigation("ThreatIntelFeeds"); + b.Navigation("Users"); }); @@ -909,6 +1219,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Runs"); }); + + modelBuilder.Entity("Tawny.Domain.Entities.Case", b => + { + b.Navigation("CaseAlerts"); + + b.Navigation("Notes"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/src/Tawny.Infrastructure/TawnyDbContext.cs b/backend/src/Tawny.Infrastructure/TawnyDbContext.cs index 1659a5d..f5a2fa9 100644 --- a/backend/src/Tawny.Infrastructure/TawnyDbContext.cs +++ b/backend/src/Tawny.Infrastructure/TawnyDbContext.cs @@ -20,6 +20,11 @@ public class TawnyDbContext(DbContextOptions options) : DbContex public DbSet HuntRuns => Set(); public DbSet SuppressionRules => Set(); public DbSet ApiTokens => Set(); + public DbSet ThreatIntelFeeds => Set(); + public DbSet ReputationCache => Set(); + public DbSet Cases => Set(); + public DbSet CaseAlerts => Set(); + public DbSet CaseNotes => Set(); protected override void OnModelCreating(ModelBuilder b) { @@ -108,6 +113,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.CompiledExpressionJson).HasColumnName("CompiledExpression").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 }); @@ -118,6 +124,7 @@ protected override void OnModelCreating(ModelBuilder b) e.HasKey(a => a.Id); e.Property(a => a.Title).HasMaxLength(255).IsRequired(); e.Property(a => a.Description).HasColumnType("nvarchar(max)"); + e.Property(a => a.EnrichmentJson).HasColumnName("Enrichment").HasColumnType("nvarchar(max)"); e.Property(a => a.SlackNotificationError).HasMaxLength(1024); e.Property(a => a.SentinelNotificationError).HasMaxLength(1024); e.HasOne(a => a.AlertRule) @@ -240,5 +247,72 @@ protected override void OnModelCreating(ModelBuilder b) e.HasIndex(t => t.TokenHash).IsUnique(); e.HasIndex(t => new { t.TenantId, t.CreatedAt }); }); + + 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.Url).HasMaxLength(1024).IsRequired(); + e.Property(t => t.AuthHeaderName).HasMaxLength(64); + e.Property(t => t.AuthHeaderValueEncrypted).HasMaxLength(1024); + e.Property(t => t.LastError).HasMaxLength(2048); + e.Property(t => t.Etag).HasMaxLength(256); + e.HasOne(t => t.Tenant) + .WithMany(t => t.ThreatIntelFeeds) + .HasForeignKey(t => t.TenantId) + .OnDelete(DeleteBehavior.Restrict); + e.HasIndex(t => new { t.TenantId, t.IsEnabled }); + }); + + b.Entity(e => + { + e.HasKey(r => r.Id); + e.Property(r => r.TenantId).HasDefaultValue(TenantDefaults.DefaultTenantId); + e.Property(r => r.IndicatorKind).HasMaxLength(32).IsRequired(); + e.Property(r => r.IndicatorValue).HasMaxLength(512).IsRequired(); + e.Property(r => r.DetailJson).HasColumnType("nvarchar(max)").IsRequired(); + e.HasIndex(r => new { r.TenantId, r.Provider, r.IndicatorKind, r.IndicatorValue }).IsUnique(); + e.HasIndex(r => r.ExpiresAt); + }); + + b.Entity(e => + { + e.HasKey(c => c.Id); + e.Property(c => c.TenantId).HasDefaultValue(TenantDefaults.DefaultTenantId); + e.Property(c => c.Title).HasMaxLength(255).IsRequired(); + e.Property(c => c.Summary).HasColumnType("nvarchar(max)"); + e.Property(c => c.MitreTechniquesJson).HasColumnName("MitreTechniques").HasColumnType("nvarchar(max)"); + e.HasOne(c => c.Tenant) + .WithMany(t => t.Cases) + .HasForeignKey(c => c.TenantId) + .OnDelete(DeleteBehavior.Restrict); + e.HasIndex(c => new { c.TenantId, c.Status, c.CreatedAt }); + }); + + b.Entity(e => + { + e.HasKey(ca => ca.Id); + e.HasOne(ca => ca.Case) + .WithMany(c => c.CaseAlerts) + .HasForeignKey(ca => ca.CaseId) + .OnDelete(DeleteBehavior.Cascade); + e.HasOne(ca => ca.Alert) + .WithMany() + .HasForeignKey(ca => ca.AlertId) + .OnDelete(DeleteBehavior.Cascade); + e.HasIndex(ca => new { ca.CaseId, ca.AlertId }).IsUnique(); + }); + + b.Entity(e => + { + e.HasKey(n => n.Id); + e.Property(n => n.Body).HasColumnType("nvarchar(max)").IsRequired(); + e.HasOne(n => n.Case) + .WithMany(c => c.Notes) + .HasForeignKey(n => n.CaseId) + .OnDelete(DeleteBehavior.Cascade); + e.HasIndex(n => new { n.CaseId, n.CreatedAt }); + }); } } diff --git a/backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs b/backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs new file mode 100644 index 0000000..7384662 --- /dev/null +++ b/backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs @@ -0,0 +1,253 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tawny.Domain; +using Tawny.Domain.Entities; + +namespace Tawny.Infrastructure.ThreatIntel; + +public class ReputationOptions +{ + public string? VirusTotalApiKey { get; set; } + public string? AbuseIpDbApiKey { get; set; } + public string? GreyNoiseApiKey { get; set; } + public int CacheTtlHours { get; set; } = 24; + public int TimeoutSeconds { get; set; } = 10; + public bool EnrichAlertsAutomatically { get; set; } = true; +} + +public record ReputationLookup( + ReputationProvider Provider, + ReputationVerdict Verdict, + int? Score, + object Detail); + +/// +/// Looks up reputation for IoCs (hashes, IPs, domains) from configured providers +/// and caches the result. Designed to be safe to call from the alert pipeline: +/// each provider is HTTP-bound, has a short timeout, and respects the cache. +/// +public class ReputationEnricher( + TawnyDbContext db, + HttpClient http, + IOptions options, + TimeProvider timeProvider, + ILogger log) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly ReputationOptions _opts = options.Value; + + public async Task> LookupAsync( + Guid tenantId, + string kind, + string value, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(value)) return []; + var providers = ProvidersForKind(kind).ToList(); + if (providers.Count == 0) return []; + + var results = new List(); + foreach (var provider in providers) + { + var cached = await TryCachedAsync(tenantId, provider, kind, value, ct); + if (cached is not null) + { + results.Add(cached); + continue; + } + try + { + var fresh = await ProbeAsync(provider, kind, value, ct); + if (fresh is null) continue; + results.Add(fresh); + await db.ReputationCache.AddAsync(new ReputationCacheEntry + { + TenantId = tenantId, + Provider = provider, + IndicatorKind = kind, + IndicatorValue = value, + Verdict = fresh.Verdict, + Score = fresh.Score, + DetailJson = JsonSerializer.Serialize(fresh.Detail, JsonOptions), + FetchedAt = timeProvider.GetUtcNow(), + ExpiresAt = timeProvider.GetUtcNow().AddHours(_opts.CacheTtlHours), + }, ct); + await db.SaveChangesAsync(ct); + } + catch (Exception ex) + { + log.LogWarning(ex, "Reputation probe for {Provider} {Kind} {Value} failed", provider, kind, value); + } + } + return results; + } + + private async Task TryCachedAsync( + Guid tenantId, ReputationProvider provider, string kind, string value, CancellationToken ct) + { + var now = timeProvider.GetUtcNow(); + var entry = await db.ReputationCache.AsNoTracking() + .FirstOrDefaultAsync(r => r.TenantId == tenantId + && r.Provider == provider + && r.IndicatorKind == kind + && r.IndicatorValue == value + && r.ExpiresAt > now, ct); + if (entry is null) return null; + object detail; + try { detail = JsonSerializer.Deserialize(entry.DetailJson); } + catch { detail = new { cached = true }; } + return new ReputationLookup(entry.Provider, entry.Verdict, entry.Score, detail); + } + + private async Task ProbeAsync(ReputationProvider provider, string kind, string value, CancellationToken ct) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_opts.TimeoutSeconds, 1, 60))); + return provider switch + { + ReputationProvider.VirusTotal => await ProbeVirusTotalAsync(kind, value, timeoutCts.Token), + ReputationProvider.AbuseIpDb => await ProbeAbuseIpDbAsync(kind, value, timeoutCts.Token), + ReputationProvider.GreyNoise => await ProbeGreyNoiseAsync(kind, value, timeoutCts.Token), + _ => null, + }; + } + + private async Task ProbeVirusTotalAsync(string kind, string value, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_opts.VirusTotalApiKey)) return null; + if (kind is not "sha256" and not "sha1" and not "ipv4" and not "domain") return null; + var path = kind switch + { + "sha256" or "sha1" => $"https://www.virustotal.com/api/v3/files/{Uri.EscapeDataString(value)}", + "ipv4" => $"https://www.virustotal.com/api/v3/ip_addresses/{Uri.EscapeDataString(value)}", + "domain" => $"https://www.virustotal.com/api/v3/domains/{Uri.EscapeDataString(value)}", + _ => throw new InvalidOperationException(), + }; + using var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.TryAddWithoutValidation("x-apikey", _opts.VirusTotalApiKey); + using var response = await http.SendAsync(request, ct); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return new ReputationLookup(ReputationProvider.VirusTotal, ReputationVerdict.Unknown, null, new { not_found = true }); + } + if (!response.IsSuccessStatusCode) + { + return new ReputationLookup(ReputationProvider.VirusTotal, ReputationVerdict.Error, null, new { http_status = (int)response.StatusCode }); + } + var body = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var stats = doc.RootElement + .GetProperty("data") + .GetProperty("attributes") + .GetProperty("last_analysis_stats"); + var malicious = stats.GetProperty("malicious").GetInt32(); + var suspicious = stats.TryGetProperty("suspicious", out var s) ? s.GetInt32() : 0; + var verdict = malicious switch + { + >= 5 => ReputationVerdict.Malicious, + >= 1 => ReputationVerdict.Suspicious, + _ => suspicious > 0 ? ReputationVerdict.Suspicious : ReputationVerdict.Clean, + }; + return new ReputationLookup(ReputationProvider.VirusTotal, verdict, malicious, new + { + malicious, + suspicious, + stats = stats.GetRawText(), + }); + } + + private async Task ProbeAbuseIpDbAsync(string kind, string value, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_opts.AbuseIpDbApiKey)) return null; + if (kind != "ipv4") return null; + using var request = new HttpRequestMessage(HttpMethod.Get, + $"https://api.abuseipdb.com/api/v2/check?ipAddress={Uri.EscapeDataString(value)}&maxAgeInDays=90"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.TryAddWithoutValidation("Key", _opts.AbuseIpDbApiKey); + using var response = await http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + return new ReputationLookup(ReputationProvider.AbuseIpDb, ReputationVerdict.Error, null, + new { http_status = (int)response.StatusCode }); + } + var body = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var data = doc.RootElement.GetProperty("data"); + var score = data.GetProperty("abuseConfidenceScore").GetInt32(); + var verdict = score switch + { + >= 75 => ReputationVerdict.Malicious, + >= 25 => ReputationVerdict.Suspicious, + _ => ReputationVerdict.Clean, + }; + return new ReputationLookup(ReputationProvider.AbuseIpDb, verdict, score, new + { + confidence = score, + usage_type = data.TryGetProperty("usageType", out var ut) ? ut.GetString() : null, + country = data.TryGetProperty("countryCode", out var cc) ? cc.GetString() : null, + total_reports = data.TryGetProperty("totalReports", out var tr) ? tr.GetInt32() : 0, + }); + } + + private async Task ProbeGreyNoiseAsync(string kind, string value, CancellationToken ct) + { + // GreyNoise Community API works without a key (rate-limited); the key + // unlocks higher quotas. Only IPv4 is supported. + if (kind != "ipv4") return null; + using var request = new HttpRequestMessage(HttpMethod.Get, + $"https://api.greynoise.io/v3/community/{Uri.EscapeDataString(value)}"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + if (!string.IsNullOrWhiteSpace(_opts.GreyNoiseApiKey)) + { + request.Headers.TryAddWithoutValidation("key", _opts.GreyNoiseApiKey); + } + using var response = await http.SendAsync(request, ct); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return new ReputationLookup(ReputationProvider.GreyNoise, ReputationVerdict.Unknown, null, new { not_found = true }); + } + if (!response.IsSuccessStatusCode) + { + return new ReputationLookup(ReputationProvider.GreyNoise, ReputationVerdict.Error, null, + new { http_status = (int)response.StatusCode }); + } + var body = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + var classification = root.TryGetProperty("classification", out var c) ? c.GetString() : null; + var verdict = classification switch + { + "malicious" => ReputationVerdict.Malicious, + "suspicious" => ReputationVerdict.Suspicious, + "benign" => ReputationVerdict.Clean, + _ => ReputationVerdict.Unknown, + }; + return new ReputationLookup(ReputationProvider.GreyNoise, verdict, null, new + { + classification, + noise = root.TryGetProperty("noise", out var n) && n.ValueKind == JsonValueKind.True, + riot = root.TryGetProperty("riot", out var r) && r.ValueKind == JsonValueKind.True, + name = root.TryGetProperty("name", out var nm) ? nm.GetString() : null, + }); + } + + private IEnumerable ProvidersForKind(string kind) + { + if (!string.IsNullOrWhiteSpace(_opts.VirusTotalApiKey) + && kind is "sha256" or "sha1" or "ipv4" or "domain") + { + yield return ReputationProvider.VirusTotal; + } + if (!string.IsNullOrWhiteSpace(_opts.AbuseIpDbApiKey) && kind == "ipv4") + { + yield return ReputationProvider.AbuseIpDb; + } + if (kind == "ipv4") + { + yield return ReputationProvider.GreyNoise; + } + } +} diff --git a/backend/src/Tawny.Infrastructure/ThreatIntel/ThreatIntelFetcher.cs b/backend/src/Tawny.Infrastructure/ThreatIntel/ThreatIntelFetcher.cs new file mode 100644 index 0000000..4c08cc5 --- /dev/null +++ b/backend/src/Tawny.Infrastructure/ThreatIntel/ThreatIntelFetcher.cs @@ -0,0 +1,297 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Tawny.Domain; +using Tawny.Domain.Entities; + +namespace Tawny.Infrastructure.ThreatIntel; + +public record FetchedIndicator(string Kind, string Value, string? Description); + +public record FetchResult( + bool Modified, + string? Etag, + IReadOnlyList Indicators, + IReadOnlyList Skipped); + +public class ThreatIntelFetchException(string message, Exception? inner = null) : Exception(message, inner); + +/// +/// Pulls indicators from supported TI feed shapes. Returns raw FetchedIndicator +/// records — the caller decides which to turn into AlertRules. +/// +public class ThreatIntelFetcher(HttpClient http, ILogger log) +{ + private static readonly Regex Sha256Re = new(@"\b[a-fA-F0-9]{64}\b", RegexOptions.Compiled); + private static readonly Regex Sha1Re = new(@"\b[a-fA-F0-9]{40}\b", RegexOptions.Compiled); + private static readonly Regex Ipv4Re = new(@"\b(?:\d{1,3}\.){3}\d{1,3}\b", RegexOptions.Compiled); + private static readonly Regex DomainRe = new(@"\b(?=.{4,253}\b)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}\b", RegexOptions.Compiled); + private const int MaxIndicatorsPerFeed = 5_000; + + public async Task FetchAsync(ThreatIntelFeed feed, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Get, feed.Url); + if (!string.IsNullOrWhiteSpace(feed.AuthHeaderName) && !string.IsNullOrWhiteSpace(feed.AuthHeaderValueEncrypted)) + { + // We're using the column name "Encrypted" defensively but storing plaintext here; + // a real deployment would decrypt via DPAPI or the configured secret store. + request.Headers.TryAddWithoutValidation(feed.AuthHeaderName, feed.AuthHeaderValueEncrypted); + } + if (!string.IsNullOrWhiteSpace(feed.Etag)) + { + request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue($"\"{feed.Etag}\"")); + } + request.Headers.UserAgent.ParseAdd("Tawny-EDR/1.0 (+https://github.com/jusso-dev/Tawny)"); + + using var response = await http.SendAsync(request, ct); + if (response.StatusCode == System.Net.HttpStatusCode.NotModified) + { + return new FetchResult(false, feed.Etag, [], []); + } + if (!response.IsSuccessStatusCode) + { + throw new ThreatIntelFetchException($"Feed responded with {(int)response.StatusCode} {response.ReasonPhrase}"); + } + + var body = await response.Content.ReadAsStringAsync(ct); + var etag = response.Headers.ETag?.Tag?.Trim('"'); + var indicators = feed.Kind switch + { + ThreatIntelFeedKind.UrlhausCsv => ParseUrlhausCsv(body), + ThreatIntelFeedKind.UrlhausJson => ParseUrlhausJson(body), + ThreatIntelFeedKind.OtxPulse => ParseOtxPulse(body), + ThreatIntelFeedKind.MispEvents => ParseMispEvents(body), + ThreatIntelFeedKind.Taxii21 => ParseTaxii21(body), + ThreatIntelFeedKind.GenericCsv => ParseGenericCsv(body), + _ => throw new ThreatIntelFetchException($"Unsupported feed kind: {feed.Kind}"), + }; + + var (taken, skipped) = TakeWithBudget(indicators); + if (skipped.Count > 0) + { + log.LogInformation("Feed {Feed} returned {Total} indicators, kept {Kept}, skipped {Skipped}.", + feed.Name, indicators.Count, taken.Count, skipped.Count); + } + return new FetchResult(true, etag, taken, skipped); + } + + // ---------- parsers ---------- + + private static List ParseUrlhausCsv(string body) + { + // abuse.ch URLhaus CSV: id,dateadded,url,url_status,threat,tags,urlhaus_link,reporter + var result = new List(); + foreach (var rawLine in body.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) continue; + var cols = SplitCsv(line); + if (cols.Count < 3) continue; + var url = cols[2].Trim('"'); + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host)) + { + result.Add(new FetchedIndicator("domain", uri.Host, $"URLhaus: {url}")); + } + } + return result; + } + + private static List ParseUrlhausJson(string body) + { + var result = new List(); + try + { + using var doc = JsonDocument.Parse(body); + // URLhaus JSON: { "1": { "url": "...", "host": "...", "tags": [...] }, ... } + foreach (var entry in doc.RootElement.EnumerateObject()) + { + if (entry.Value.ValueKind != JsonValueKind.Object) continue; + if (entry.Value.TryGetProperty("host", out var host) && host.ValueKind == JsonValueKind.String) + { + var value = host.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + result.Add(new FetchedIndicator("domain", value, "URLhaus host")); + } + } + } + } + catch (JsonException ex) + { + throw new ThreatIntelFetchException("URLhaus JSON parse failed", ex); + } + return result; + } + + private static List ParseOtxPulse(string body) + { + // OTX pulse JSON: { "results": [ { "indicators": [ { "type": "IPv4", "indicator": "1.2.3.4" } ] } ] } + var result = new List(); + try + { + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("results", out var results)) return result; + foreach (var pulse in results.EnumerateArray()) + { + if (!pulse.TryGetProperty("indicators", out var indicators)) continue; + foreach (var ind in indicators.EnumerateArray()) + { + var type = ind.GetProperty("type").GetString(); + var value = ind.GetProperty("indicator").GetString(); + if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(type)) continue; + var kind = type.ToLowerInvariant() switch + { + "ipv4" => "ipv4", + "ipv6" => "ipv6", + "domain" or "hostname" => "domain", + "filehash-sha256" => "sha256", + "filehash-sha1" => "sha1", + _ => null, + }; + if (kind is not null) + { + result.Add(new FetchedIndicator(kind, value, $"OTX {type}")); + } + } + } + } + catch (JsonException ex) + { + throw new ThreatIntelFetchException("OTX pulse parse failed", ex); + } + return result; + } + + private static List ParseMispEvents(string body) + { + // MISP /events/restSearch returns { "response": [{ "Event": { "Attribute": [{ "type": "...", "value": "..." }]}}]} + var result = new List(); + try + { + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("response", out var response)) return result; + foreach (var entry in response.EnumerateArray()) + { + if (!entry.TryGetProperty("Event", out var eventNode)) continue; + if (!eventNode.TryGetProperty("Attribute", out var attributes)) continue; + foreach (var attr in attributes.EnumerateArray()) + { + var type = attr.GetProperty("type").GetString(); + var value = attr.GetProperty("value").GetString(); + if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(type)) continue; + var kind = type switch + { + "ip-src" or "ip-dst" => "ipv4", + "domain" or "hostname" => "domain", + "sha256" => "sha256", + "sha1" => "sha1", + _ => null, + }; + if (kind is not null) + { + result.Add(new FetchedIndicator(kind, value, $"MISP {type}")); + } + } + } + } + catch (JsonException ex) + { + throw new ThreatIntelFetchException("MISP parse failed", ex); + } + return result; + } + + private static List ParseTaxii21(string body) + { + // TAXII 2.1 envelope: { "objects": [ STIX bundles... ] } + var result = new List(); + try + { + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("objects", out var objects)) return result; + foreach (var obj in objects.EnumerateArray()) + { + if (!obj.TryGetProperty("type", out var type) || type.GetString() != "indicator") continue; + if (!obj.TryGetProperty("pattern", out var pattern)) continue; + var raw = pattern.GetString() ?? ""; + // Reuse the same simple pattern shapes the existing IoC importer understands. + foreach (var match in Regex.Matches(raw, @"\[(?file:hashes\.'SHA-256'|file:hashes\.'SHA-1'|ipv4-addr:value|ipv6-addr:value|domain-name:value)\s*=\s*'(?[^']+)'\]").Cast()) + { + var k = match.Groups["kind"].Value; + var v = match.Groups["value"].Value; + var kind = k switch + { + "file:hashes.'SHA-256'" => "sha256", + "file:hashes.'SHA-1'" => "sha1", + "ipv4-addr:value" => "ipv4", + "ipv6-addr:value" => "ipv6", + "domain-name:value" => "domain", + _ => null, + }; + if (kind is not null) + { + result.Add(new FetchedIndicator(kind, v, $"TAXII {k}")); + } + } + } + } + catch (JsonException ex) + { + throw new ThreatIntelFetchException("TAXII parse failed", ex); + } + return result; + } + + private static List ParseGenericCsv(string body) + { + // One indicator per line (or first column of a CSV). Auto-detect kind by shape. + var result = new List(); + foreach (var rawLine in body.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) continue; + var cols = SplitCsv(line); + var value = cols.Count == 0 ? line : cols[0].Trim('"'); + var kind = DetectKind(value); + if (kind is not null) + { + result.Add(new FetchedIndicator(kind, value, "Generic CSV")); + } + } + return result; + } + + private static string? DetectKind(string value) + { + if (Sha256Re.IsMatch(value) && value.Length == 64) return "sha256"; + if (Sha1Re.IsMatch(value) && value.Length == 40) return "sha1"; + if (Ipv4Re.IsMatch(value)) return "ipv4"; + if (DomainRe.IsMatch(value)) return "domain"; + return null; + } + + private static List SplitCsv(string line) + { + return line.Split(',').Select(p => p.Trim()).ToList(); + } + + private static (List Taken, List Skipped) TakeWithBudget(IReadOnlyList all) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var taken = new List(); + var skipped = new List(); + foreach (var indicator in all) + { + var key = $"{indicator.Kind}:{indicator.Value}"; + if (!seen.Add(key)) continue; + if (taken.Count >= MaxIndicatorsPerFeed) + { + skipped.Add(key); + continue; + } + taken.Add(indicator); + } + return (taken, skipped); + } +} diff --git a/backend/src/Tawny.Jobs/ReputationEnrichmentJob.cs b/backend/src/Tawny.Jobs/ReputationEnrichmentJob.cs new file mode 100644 index 0000000..33b55de --- /dev/null +++ b/backend/src/Tawny.Jobs/ReputationEnrichmentJob.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tawny.Domain; +using Tawny.Domain.Entities; +using Tawny.Infrastructure; +using Tawny.Infrastructure.ThreatIntel; + +namespace Tawny.Jobs; + +/// +/// Walks recent unenriched alerts, extracts the matched IoC value from the +/// rule's payload_path, looks it up via reputation providers, and stores the +/// verdict on Alert.EnrichmentJson. Reputation is cached per tenant. +/// +public class ReputationEnrichmentJob( + TawnyDbContext db, + ReputationEnricher enricher, + IOptions options, + ILogger log) +{ + private const int BatchSize = 100; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + public async Task ExecuteAsync(CancellationToken ct = default) + { + if (!options.Value.EnrichAlertsAutomatically) return; + + var cutoff = DateTimeOffset.UtcNow.AddHours(-24); + var alerts = await db.Alerts + .Where(a => a.EnrichmentJson == null && a.CreatedAt >= cutoff) + .OrderByDescending(a => a.CreatedAt) + .Take(BatchSize) + .Include(a => a.AlertRule) + .Include(a => a.Agent) + .Include(a => a.TelemetryEvent) + .ToListAsync(ct); + + if (alerts.Count == 0) return; + var enrichedCount = 0; + + foreach (var alert in alerts) + { + if (ct.IsCancellationRequested) break; + var rule = alert.AlertRule; + var telemetry = alert.TelemetryEvent; + if (rule is null || telemetry is null) continue; + + var (kind, value) = ExtractIndicator(rule, telemetry); + if (kind is null || string.IsNullOrEmpty(value)) + { + alert.EnrichmentJson = "{\"enriched\":false,\"reason\":\"no_extractable_indicator\"}"; + continue; + } + + try + { + var tenantId = alert.Agent?.TenantId ?? Tawny.Domain.TenantDefaults.DefaultTenantId; + var lookups = await enricher.LookupAsync(tenantId, kind, value, ct); + alert.EnrichmentJson = JsonSerializer.Serialize(new + { + enriched = true, + indicator = new { kind, value }, + lookups = lookups.Select(l => new + { + provider = l.Provider.ToString(), + verdict = l.Verdict.ToString(), + score = l.Score, + detail = l.Detail, + }), + }, JsonOptions); + enrichedCount += 1; + } + catch (Exception ex) + { + alert.EnrichmentJson = JsonSerializer.Serialize(new + { + enriched = false, + reason = "lookup_failed", + error = ex.Message, + }, JsonOptions); + log.LogWarning(ex, "Reputation enrichment failed for alert {AlertId}", alert.Id); + } + } + + await db.SaveChangesAsync(ct); + if (enrichedCount > 0) + { + log.LogInformation("Reputation enrichment completed: {Count} alerts enriched.", enrichedCount); + } + } + + private static (string? Kind, string? Value) ExtractIndicator(AlertRule rule, TelemetryEvent telemetryEvent) + { + // The cheapest path: if the rule is an IoC rule, the MatchValue is the indicator itself. + if (rule.Format == AlertRuleFormat.Ioc && !string.IsNullOrEmpty(rule.MatchValue)) + { + var kind = rule.PayloadPath switch + { + "new_sha256" => "sha256", + "new_sha1" => "sha1", + "connections.remote_address" => "ipv4", + "processes.command_line" => "domain", + _ => null, + }; + if (kind is not null) + { + return (kind, rule.MatchValue); + } + } + + // Fallback: pull from the payload via rule.PayloadPath. + if (string.IsNullOrWhiteSpace(rule.PayloadPath)) return (null, null); + try + { + using var payload = JsonDocument.Parse(telemetryEvent.Payload); + var segments = rule.PayloadPath.Split('.', StringSplitOptions.RemoveEmptyEntries); + var first = Resolve(payload.RootElement, segments).FirstOrDefault(); + if (first.ValueKind == JsonValueKind.Undefined) return (null, null); + var scalar = first.ValueKind switch + { + JsonValueKind.String => first.GetString(), + JsonValueKind.Number => first.GetRawText(), + _ => null, + }; + if (string.IsNullOrWhiteSpace(scalar)) return (null, null); + var kind = rule.PayloadPath switch + { + "new_sha256" => "sha256", + "new_sha1" => "sha1", + _ when rule.PayloadPath.Contains("address", StringComparison.OrdinalIgnoreCase) => "ipv4", + _ when rule.PayloadPath.Contains("domain", StringComparison.OrdinalIgnoreCase) => "domain", + _ => null, + }; + return (kind, scalar); + } + catch + { + return (null, null); + } + } + + private static IEnumerable Resolve(JsonElement current, IReadOnlyList segments, int index = 0) + { + if (index >= segments.Count) { yield return current; yield break; } + if (current.ValueKind == JsonValueKind.Array) + { + foreach (var item in current.EnumerateArray()) + { + foreach (var v in Resolve(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 Resolve(child, segments, index + 1)) yield return v; + } +} diff --git a/backend/src/Tawny.Jobs/ThreatIntelFeedsJob.cs b/backend/src/Tawny.Jobs/ThreatIntelFeedsJob.cs new file mode 100644 index 0000000..0691c76 --- /dev/null +++ b/backend/src/Tawny.Jobs/ThreatIntelFeedsJob.cs @@ -0,0 +1,124 @@ +using System.Net.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Tawny.Domain; +using Tawny.Domain.Entities; +using Tawny.Infrastructure; +using Tawny.Infrastructure.ThreatIntel; + +namespace Tawny.Jobs; + +/// +/// Walks every enabled ThreatIntelFeed whose interval has elapsed, pulls its +/// payload, and materialises new indicators as AlertRules (Format = Ioc) keyed +/// by ExternalId so re-imports are idempotent. +/// +public class ThreatIntelFeedsJob( + TawnyDbContext db, + TimeProvider timeProvider, + ThreatIntelFetcher fetcher, + ILogger log) +{ + public async Task ExecuteAsync(CancellationToken ct = default) + { + var now = timeProvider.GetUtcNow(); + var due = await db.ThreatIntelFeeds + .Where(f => f.IsEnabled + && (f.LastRunAt == null + || EF.Functions.DateDiffMinute(f.LastRunAt!.Value, now) >= f.IntervalMinutes)) + .ToListAsync(ct); + if (due.Count == 0) return; + + foreach (var feed in due) + { + if (ct.IsCancellationRequested) break; + await RunOneAsync(feed, now, ct); + } + } + + private async Task RunOneAsync(ThreatIntelFeed feed, DateTimeOffset now, CancellationToken ct) + { + feed.LastRunAt = now; + try + { + var result = await fetcher.FetchAsync(feed, ct); + if (!result.Modified) + { + feed.Status = ThreatIntelFeedStatus.Healthy; + feed.LastSuccessAt = now; + feed.LastError = null; + await db.SaveChangesAsync(ct); + return; + } + feed.Etag = result.Etag; + await MaterialiseAsync(feed, result, now, ct); + feed.Status = ThreatIntelFeedStatus.Healthy; + feed.LastSuccessAt = now; + feed.LastImportedCount = result.Indicators.Count; + feed.LastSkippedCount = result.Skipped.Count; + feed.LastError = null; + } + catch (Exception ex) + { + feed.Status = ThreatIntelFeedStatus.Failed; + feed.LastError = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message; + log.LogError(ex, "TI feed {Name} ({Url}) failed", feed.Name, feed.Url); + } + await db.SaveChangesAsync(ct); + } + + private async Task MaterialiseAsync( + ThreatIntelFeed feed, + FetchResult result, + DateTimeOffset now, + CancellationToken ct) + { + if (result.Indicators.Count == 0) return; + + var externalIdPrefix = $"ti-feed:{feed.Id}:"; + var existingIds = await db.AlertRules + .Where(r => r.ExternalId != null && r.ExternalId.StartsWith(externalIdPrefix)) + .Select(r => r.ExternalId!) + .ToListAsync(ct); + var existing = new HashSet(existingIds, StringComparer.OrdinalIgnoreCase); + + var newRules = new List(); + foreach (var ind in result.Indicators) + { + var externalId = externalIdPrefix + ind.Kind + ":" + ind.Value.ToLowerInvariant(); + if (existing.Contains(externalId)) continue; + + var (eventType, payloadPath, op) = ind.Kind switch + { + "sha256" => (TelemetryEventType.FileIntegrity, "new_sha256", AlertRuleOperator.Equals), + "sha1" => (TelemetryEventType.FileIntegrity, "new_sha1", AlertRuleOperator.Equals), + "ipv4" or "ipv6" => (TelemetryEventType.NetworkSnapshot, "connections.remote_address", AlertRuleOperator.Equals), + "domain" => (TelemetryEventType.ProcessSnapshot, "processes.command_line", AlertRuleOperator.Contains), + _ => default((TelemetryEventType, string, AlertRuleOperator)?)!, + }; + + newRules.Add(new AlertRule + { + Id = Guid.NewGuid(), + Name = $"TI feed {feed.Name}: {ind.Kind} {ind.Value}", + Format = AlertRuleFormat.Ioc, + ExternalId = externalId, + Description = ind.Description, + EventType = eventType, + Severity = feed.DefaultSeverity, + Operator = op, + PayloadPath = payloadPath, + MatchValue = ind.Value, + IsEnabled = true, + CreatedAt = now, + UpdatedAt = now, + }); + } + + if (newRules.Count > 0) + { + db.AlertRules.AddRange(newRules); + log.LogInformation("TI feed {Name} imported {Count} new IoCs.", feed.Name, newRules.Count); + } + } +} From 439444fd609474ee3ea0171e2053bb46a7016843 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 04:32:47 +0000 Subject: [PATCH 02/10] Phase 4 frontend: Cases UI, TI feeds UI, cross-host investigation views, hunt sharing toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI for the Phase 3 + Phase 4 backend that shipped in 2a5b6a7: - /cases — list of investigations with status/priority chips; per-case detail page with inline metadata editor, linked-alerts table, and an append-only notes timeline. Backed by /api/cases proxies. - /threat-intel — list and manage TI feeds (urlhaus_csv, urlhaus_json, otx_pulse, misp_events, taxii21, generic_csv), with per-row "Run now" and last-import counts. Status badges reflect ThreatIntelFeedStatus. - /investigation — two cross-host views in one page: a process-tree- across-hosts table (which binaries are running where) and an SVG bipartite graph of host -> remote-endpoint flows. Window + name filter via URL params, no client state. - Hunt sharing: hunts default to tenant-shared with an IsShared toggle in the save dialog. Private hunts get a lock icon in the sidebar and are hidden from other tenant users (already filtered server-side). - Nav grows three items (Cases, Investigate, Threat intel) for ten total. All 38 routes register cleanly in `pnpm build`; typecheck + lint pass. https://claude.ai/code/session_01DqLy7vdV9S4prUF9sz2Ehp --- .../api/cases/[id]/alerts/[alertId]/route.ts | 21 ++ web/app/api/cases/[id]/notes/route.ts | 30 ++ web/app/api/cases/[id]/route.ts | 53 +++ web/app/api/cases/route.ts | 35 ++ web/app/api/hunts/[id]/route.ts | 1 + web/app/api/hunts/route.ts | 1 + web/app/api/threat-intel-feeds/[id]/route.ts | 21 ++ .../api/threat-intel-feeds/[id]/run/route.ts | 21 ++ web/app/api/threat-intel-feeds/route.ts | 38 ++ web/app/cases/[id]/case-detail-panel.tsx | 347 +++++++++++++++++ web/app/cases/[id]/page.tsx | 48 +++ web/app/cases/create-case-button.tsx | 132 +++++++ web/app/cases/page.tsx | 139 +++++++ web/app/hunt/hunt-workbench.tsx | 36 +- web/app/investigation/network-graph-svg.tsx | 107 ++++++ web/app/investigation/page.tsx | 174 +++++++++ web/app/threat-intel/page.tsx | 33 ++ web/app/threat-intel/threat-intel-panel.tsx | 354 ++++++++++++++++++ web/components/app-shell.tsx | 8 +- 19 files changed, 1592 insertions(+), 7 deletions(-) create mode 100644 web/app/api/cases/[id]/alerts/[alertId]/route.ts create mode 100644 web/app/api/cases/[id]/notes/route.ts create mode 100644 web/app/api/cases/[id]/route.ts create mode 100644 web/app/api/cases/route.ts create mode 100644 web/app/api/threat-intel-feeds/[id]/route.ts create mode 100644 web/app/api/threat-intel-feeds/[id]/run/route.ts create mode 100644 web/app/api/threat-intel-feeds/route.ts create mode 100644 web/app/cases/[id]/case-detail-panel.tsx create mode 100644 web/app/cases/[id]/page.tsx create mode 100644 web/app/cases/create-case-button.tsx create mode 100644 web/app/cases/page.tsx create mode 100644 web/app/investigation/network-graph-svg.tsx create mode 100644 web/app/investigation/page.tsx create mode 100644 web/app/threat-intel/page.tsx create mode 100644 web/app/threat-intel/threat-intel-panel.tsx diff --git a/web/app/api/cases/[id]/alerts/[alertId]/route.ts b/web/app/api/cases/[id]/alerts/[alertId]/route.ts new file mode 100644 index 0000000..3844149 --- /dev/null +++ b/web/app/api/cases/[id]/alerts/[alertId]/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; alertId: string }> }) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { id, alertId } = await ctx.params; + try { + await apiDelete(`/api/cases/${id}/alerts/${alertId}`, 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 remove alert." }, { status: 502 }); + } +} diff --git a/web/app/api/cases/[id]/notes/route.ts b/web/app/api/cases/[id]/notes/route.ts new file mode 100644 index 0000000..b9c966a --- /dev/null +++ b/web/app/api/cases/[id]/notes/route.ts @@ -0,0 +1,30 @@ +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({ body: z.string().trim().min(1) }); + +export async function POST(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 apiPost(`/api/cases/${id}/notes`, 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 add note." }, { status: 502 }); + } +} diff --git a/web/app/api/cases/[id]/route.ts b/web/app/api/cases/[id]/route.ts new file mode 100644 index 0000000..9bec97d --- /dev/null +++ b/web/app/api/cases/[id]/route.ts @@ -0,0 +1,53 @@ +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({ + title: z.string().trim().min(1).max(255), + summary: z.string().nullable().optional(), + status: z.enum(["open", "investigating", "contained", "resolved", "closed"]), + priority: z.enum(["low", "medium", "high", "critical"]), + assigned_to_user_id: z.string().nullable().optional(), + 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/cases/${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 case." }, { 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/cases/${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 case." }, { status: 502 }); + } +} diff --git a/web/app/api/cases/route.ts b/web/app/api/cases/route.ts new file mode 100644 index 0000000..68c3206 --- /dev/null +++ b/web/app/api/cases/route.ts @@ -0,0 +1,35 @@ +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({ + title: z.string().trim().min(1).max(255), + summary: z.string().nullable().optional(), + priority: z.enum(["low", "medium", "high", "critical"]).optional(), + alert_ids: z.array(z.number().int()).optional(), + 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/cases", 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 case." }, { status: 502 }); + } +} diff --git a/web/app/api/hunts/[id]/route.ts b/web/app/api/hunts/[id]/route.ts index c534993..f16b33d 100644 --- a/web/app/api/hunts/[id]/route.ts +++ b/web/app/api/hunts/[id]/route.ts @@ -14,6 +14,7 @@ const schema = z.object({ alert_on_match: z.boolean(), alert_severity: z.enum(["low", "medium", "high", "critical"]), mitre_techniques: z.array(z.string()).optional(), + is_shared: z.boolean().optional(), }); export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { diff --git a/web/app/api/hunts/route.ts b/web/app/api/hunts/route.ts index e670562..9a30e3a 100644 --- a/web/app/api/hunts/route.ts +++ b/web/app/api/hunts/route.ts @@ -14,6 +14,7 @@ const schema = z.object({ alert_on_match: z.boolean(), alert_severity: z.enum(["low", "medium", "high", "critical"]), mitre_techniques: z.array(z.string()).optional(), + is_shared: z.boolean().optional(), }); export async function POST(req: NextRequest) { diff --git a/web/app/api/threat-intel-feeds/[id]/route.ts b/web/app/api/threat-intel-feeds/[id]/route.ts new file mode 100644 index 0000000..3dc972f --- /dev/null +++ b/web/app/api/threat-intel-feeds/[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/threat-intel-feeds/${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 feed." }, { status: 502 }); + } +} diff --git a/web/app/api/threat-intel-feeds/[id]/run/route.ts b/web/app/api/threat-intel-feeds/[id]/run/route.ts new file mode 100644 index 0000000..871124d --- /dev/null +++ b/web/app/api/threat-intel-feeds/[id]/run/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, apiPost } from "@/lib/api"; + +export async function POST(_: 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 { + const data = await apiPost(`/api/threat-intel-feeds/${id}/run`, {}, 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 run feed." }, { status: 502 }); + } +} diff --git a/web/app/api/threat-intel-feeds/route.ts b/web/app/api/threat-intel-feeds/route.ts new file mode 100644 index 0000000..e9169ef --- /dev/null +++ b/web/app/api/threat-intel-feeds/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), + kind: z.enum(["urlhaus_csv", "urlhaus_json", "otx_pulse", "misp_events", "taxii21", "generic_csv"]), + url: z.string().url(), + auth_header_name: z.string().nullable().optional(), + auth_header_value: z.string().nullable().optional(), + default_severity: z.enum(["low", "medium", "high", "critical"]).optional(), + interval_minutes: z.number().int().min(5).max(10080).optional(), + is_enabled: z.boolean().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/threat-intel-feeds", 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 feed." }, { status: 502 }); + } +} diff --git a/web/app/cases/[id]/case-detail-panel.tsx b/web/app/cases/[id]/case-detail-panel.tsx new file mode 100644 index 0000000..e302631 --- /dev/null +++ b/web/app/cases/[id]/case-detail-panel.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { CheckCircle2, Loader2, MessageSquarePlus, Trash2 } from "lucide-react"; + +export type CaseDetail = { + id: number; + title: string; + summary: string | null; + status: "open" | "investigating" | "contained" | "resolved" | "closed"; + priority: "low" | "medium" | "high" | "critical"; + assigned_to_user_id: string | null; + created_by_user_id: string | null; + mitre_techniques: string[]; + alerts: Array<{ + id: number; + alert_id: number; + alert_title: string; + alert_hostname: string; + alert_severity: string; + alert_created_at: string; + added_at: string; + }>; + notes: Array<{ + id: number; + author_user_id: string | null; + body: string; + created_at: string; + }>; + created_at: string; + updated_at: string; + closed_at: string | null; +}; + +const STATUS_OPTIONS: CaseDetail["status"][] = ["open", "investigating", "contained", "resolved", "closed"]; +const PRIORITY_OPTIONS: CaseDetail["priority"][] = ["low", "medium", "high", "critical"]; + +export function CaseDetailPanel({ initial, role }: { initial: CaseDetail; role: "Admin" | "Viewer" }) { + const router = useRouter(); + const [caseDetail, setCaseDetail] = useState(initial); + const [editing, setEditing] = useState(false); + const [savingMeta, setSavingMeta] = useState(false); + const [noteBody, setNoteBody] = useState(""); + const [addingNote, setAddingNote] = useState(false); + const [error, setError] = useState(null); + + const [title, setTitle] = useState(caseDetail.title); + const [summary, setSummary] = useState(caseDetail.summary ?? ""); + const [status, setStatus] = useState(caseDetail.status); + const [priority, setPriority] = useState(caseDetail.priority); + const [techniques, setTechniques] = useState(caseDetail.mitre_techniques.join(", ")); + + async function saveMeta() { + setError(null); + setSavingMeta(true); + try { + const techList = techniques + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + const res = await fetch(`/api/cases/${caseDetail.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: title.trim(), + summary: summary.trim() || null, + status, + priority, + assigned_to_user_id: caseDetail.assigned_to_user_id, + mitre_techniques: techList, + }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(body?.error ?? `Save failed with ${res.status}`); + } + setEditing(false); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Save failed."); + } finally { + setSavingMeta(false); + } + } + + async function addNote() { + if (!noteBody.trim()) return; + setError(null); + setAddingNote(true); + try { + const res = await fetch(`/api/cases/${caseDetail.id}/notes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: noteBody.trim() }), + }); + const body = (await res.json().catch(() => null)) as + | { id: number; author_user_id: string | null; body: string; created_at: string; error?: string } + | null; + if (!res.ok || !body || "error" in body) { + throw new Error((body && "error" in body && body.error) || `Add note failed with ${res.status}`); + } + setNoteBody(""); + setCaseDetail((current) => ({ + ...current, + notes: [ + { + id: body.id, + author_user_id: body.author_user_id, + body: body.body, + created_at: body.created_at, + }, + ...current.notes, + ], + })); + } catch (err) { + setError(err instanceof Error ? err.message : "Add note failed."); + } finally { + setAddingNote(false); + } + } + + async function removeAlert(alertId: number) { + if (!window.confirm("Unlink this alert from the case?")) return; + const res = await fetch(`/api/cases/${caseDetail.id}/alerts/${alertId}`, { method: "DELETE" }); + if (res.ok) { + setCaseDetail((current) => ({ + ...current, + alerts: current.alerts.filter((a) => a.alert_id !== alertId), + })); + router.refresh(); + } + } + + return ( +
+ {editing ? ( +
+ +