diff --git a/backend/src/Tawny.Api/appsettings.json b/backend/src/Tawny.Api/appsettings.json index 96b7412..c860e50 100644 --- a/backend/src/Tawny.Api/appsettings.json +++ b/backend/src/Tawny.Api/appsettings.json @@ -40,6 +40,13 @@ "IconEmoji": ":rotating_light:", "TimeoutSeconds": 5 }, + "Reputation": { + "BrolgaBaseUrl": "", + "BrolgaApiToken": "", + "CacheTtlHours": 24, + "TimeoutSeconds": 10, + "EnrichAlertsAutomatically": true + }, "Kelpie": { "Enabled": false, "BaseUrl": "", diff --git a/backend/src/Tawny.Domain/Enums.cs b/backend/src/Tawny.Domain/Enums.cs index 7243164..618132a 100644 --- a/backend/src/Tawny.Domain/Enums.cs +++ b/backend/src/Tawny.Domain/Enums.cs @@ -151,6 +151,7 @@ public enum ReputationProvider VirusTotal = 0, AbuseIpDb = 1, GreyNoise = 2, + Brolga = 3, } public enum ReputationVerdict @@ -160,6 +161,16 @@ public enum ReputationVerdict Suspicious = 2, Malicious = 3, Error = 4, + + /// + /// Deliberately excluded from detection, for example known-good infrastructure. + /// + /// + /// Distinct from : clean is a finding about the indicator, allow-listed is + /// a decision about how it is treated regardless of the finding. Collapsing them would let a + /// feed's opinion override an operator's decision. + /// + AllowListed = 5, } public enum CloudProvider diff --git a/backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs b/backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs index 7384662..f71e263 100644 --- a/backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs +++ b/backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -13,6 +14,19 @@ public class ReputationOptions public string? VirusTotalApiKey { get; set; } public string? AbuseIpDbApiKey { get; set; } public string? GreyNoiseApiKey { get; set; } + + /// Origin of a Brolga instance, for example http://brolga:8787. + /// + /// Origin only: the /api/v1 prefix is added when the request is built, so a base URL + /// carrying a path would produce a doubled one. + /// + public string? BrolgaBaseUrl { get; set; } + + /// + /// Bearer token for Brolga. Required whenever is set, because + /// Brolga refuses to serve a reachable address without one. + /// + public string? BrolgaApiToken { get; set; } public int CacheTtlHours { get; set; } = 24; public int TimeoutSeconds { get; set; } = 10; public bool EnrichAlertsAutomatically { get; set; } = true; @@ -111,6 +125,7 @@ await db.ReputationCache.AddAsync(new ReputationCacheEntry ReputationProvider.VirusTotal => await ProbeVirusTotalAsync(kind, value, timeoutCts.Token), ReputationProvider.AbuseIpDb => await ProbeAbuseIpDbAsync(kind, value, timeoutCts.Token), ReputationProvider.GreyNoise => await ProbeGreyNoiseAsync(kind, value, timeoutCts.Token), + ReputationProvider.Brolga => await ProbeBrolgaAsync(kind, value, timeoutCts.Token), _ => null, }; } @@ -249,5 +264,113 @@ private IEnumerable ProvidersForKind(string kind) { yield return ReputationProvider.GreyNoise; } + // Brolga answers about every indicator kind Tawny extracts, and it is the operator's own + // intelligence rather than a third party's, so it is asked whenever it is configured. + if (!string.IsNullOrWhiteSpace(_opts.BrolgaBaseUrl) + && !string.IsNullOrWhiteSpace(_opts.BrolgaApiToken) + && BrolgaSubjectKind(kind) is not null) + { + yield return ReputationProvider.Brolga; + } + } + + /// + /// Tawny's indicator kind as Brolga spells it. + /// + /// + /// Returns null for a kind Brolga does not accept, so the provider is not offered at + /// all rather than asked and refused. Brolga normalises the value itself — case, whitespace, + /// IPv6 abbreviation — so only the kind needs translating. + /// + private static string? BrolgaSubjectKind(string kind) => kind switch + { + "sha256" => "sha256", + "sha1" => "sha1", + "md5" => "md5", + "ipv4" => "ipv4", + "ipv6" => "ipv6", + "domain" => "domain", + "url" => "url", + _ => null, + }; + + /// + /// Asks Brolga what is known about the indicator. + /// + /// + /// Brolga's unknown maps to and never to + /// . It means Brolga has not heard of the indicator, and + /// reading that as "clean" would suppress an alert that nothing has actually cleared. + /// + private async Task ProbeBrolgaAsync(string kind, string value, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_opts.BrolgaBaseUrl)) return null; + if (string.IsNullOrWhiteSpace(_opts.BrolgaApiToken)) return null; + + var subjectKind = BrolgaSubjectKind(kind); + if (subjectKind is null) return null; + + if (!Uri.TryCreate(_opts.BrolgaBaseUrl.TrimEnd('/') + "/api/v1/context", UriKind.Absolute, out var endpoint) + || (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps)) + { + log.LogWarning("Brolga base URL is not a usable absolute http(s) URL; skipping lookup"); + return null; + } + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _opts.BrolgaApiToken.Trim()); + request.Content = JsonContent.Create( + new + { + subject = new { kind = subjectKind, value }, + purpose = "incident_triage", + }, + options: JsonOptions); + + using var response = await http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + return new ReputationLookup( + ReputationProvider.Brolga, + 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 disposition = root.TryGetProperty("disposition", out var d) ? d.GetString() : null; + var verdict = disposition switch + { + "malicious" => ReputationVerdict.Malicious, + "suspicious" => ReputationVerdict.Suspicious, + "benign" => ReputationVerdict.Clean, + "allow_listed" => ReputationVerdict.AllowListed, + // Covers "unknown" and any disposition a later Brolga adds. An unrecognised + // disposition must not be guessed at: treating it as clean would suppress an alert. + _ => ReputationVerdict.Unknown, + }; + + // Carried through so an analyst can see where the verdict came from. A verdict with + // nothing to cite is one nobody can act on with confidence. + var evidence = root.TryGetProperty("evidence", out var e) ? e.GetRawText() : "[]"; + var gaps = root.TryGetProperty("gaps", out var g) ? g.GetRawText() : "[]"; + var entities = root.TryGetProperty("entities", out var n) ? n.GetRawText() : "[]"; + + return new ReputationLookup( + ReputationProvider.Brolga, + verdict, + null, + new + { + disposition, + observable_id = root.TryGetProperty("observable_id", out var o) ? o.GetString() : null, + schema_version = root.TryGetProperty("schema_version", out var s) ? s.GetString() : null, + entities = JsonDocument.Parse(entities).RootElement.Clone(), + evidence = JsonDocument.Parse(evidence).RootElement.Clone(), + gaps = JsonDocument.Parse(gaps).RootElement.Clone(), + }); } } diff --git a/backend/tests/Tawny.Api.Tests/BrolgaReputationTests.cs b/backend/tests/Tawny.Api.Tests/BrolgaReputationTests.cs new file mode 100644 index 0000000..c865004 --- /dev/null +++ b/backend/tests/Tawny.Api.Tests/BrolgaReputationTests.cs @@ -0,0 +1,221 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Tawny.Domain; +using Tawny.Infrastructure; +using Tawny.Infrastructure.ThreatIntel; +using Xunit; + +namespace Tawny.Api.Tests; + +/// +/// Brolga is the operator's own intelligence store, so its verdict carries further than a third +/// party's. These cover the mapping from its disposition onto Tawny's verdict, which is where a +/// wrong answer would quietly change whether an alert fires. +/// +public class BrolgaReputationTests +{ + private static ReputationEnricher Enricher(RecordingHandler handler, string? baseUrl = "http://brolga.test:8787") + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"brolga-{Guid.NewGuid()}") + .Options; + + return new ReputationEnricher( + new TawnyDbContext(options), + new HttpClient(handler), + Options.Create(new ReputationOptions + { + BrolgaBaseUrl = baseUrl, + BrolgaApiToken = "0123456789abcdef0123456789abcdef", + }), + TimeProvider.System, + NullLogger.Instance); + } + + private static string Pack(string disposition) => $$""" + { + "schema_version": "brolga.context_pack/1.0", + "subject": { "kind": "ipv4_address", "value": "203.0.113.42" }, + "observable_id": "observable:7168327b", + "disposition": "{{disposition}}", + "entities": [{ "id": "entity:9c8e", "kind": "report", "name": "C2 infrastructure" }], + "claims": [], + "relationships": [], + "evidence": [{ "source_object_id": "source:12fc" }], + "gaps": [], + "exclusions": [] + } + """; + + [Theory] + [InlineData("malicious", ReputationVerdict.Malicious)] + [InlineData("suspicious", ReputationVerdict.Suspicious)] + [InlineData("benign", ReputationVerdict.Clean)] + [InlineData("allow_listed", ReputationVerdict.AllowListed)] + public async Task Disposition_MapsOntoTheMatchingVerdict(string disposition, ReputationVerdict expected) + { + var handler = new RecordingHandler(Pack(disposition)); + var results = await Enricher(handler).LookupAsync(Guid.NewGuid(), "ipv4", "203.0.113.42", CancellationToken.None); + + results.Should().ContainSingle(r => r.Provider == ReputationProvider.Brolga) + .Which.Verdict.Should().Be(expected); + } + + /// + /// The mapping that matters most. Brolga's "unknown" means it has not heard of the indicator. + /// Reading that as Clean would suppress an alert that nothing has actually cleared. + /// + [Fact] + public async Task Unknown_IsNeverReadAsClean() + { + var handler = new RecordingHandler(Pack("unknown")); + var results = await Enricher(handler).LookupAsync(Guid.NewGuid(), "ipv4", "203.0.113.42", CancellationToken.None); + + var brolga = results.Single(r => r.Provider == ReputationProvider.Brolga); + brolga.Verdict.Should().Be(ReputationVerdict.Unknown); + brolga.Verdict.Should().NotBe(ReputationVerdict.Clean); + } + + /// + /// A disposition from a later Brolga must not be guessed at. Defaulting to Clean would mean a + /// Brolga upgrade could silently start suppressing alerts. + /// + [Fact] + public async Task AnUnrecognisedDisposition_FallsBackToUnknownRatherThanClean() + { + var handler = new RecordingHandler(Pack("something_brolga_added_later")); + var results = await Enricher(handler).LookupAsync(Guid.NewGuid(), "ipv4", "203.0.113.42", CancellationToken.None); + + results.Single(r => r.Provider == ReputationProvider.Brolga) + .Verdict.Should().Be(ReputationVerdict.Unknown); + } + + [Fact] + public async Task TheRequestGoesToTheVersionedRouteWithABearerToken() + { + var handler = new RecordingHandler(Pack("malicious")); + await Enricher(handler).LookupAsync(Guid.NewGuid(), "ipv4", "203.0.113.42", CancellationToken.None); + + var request = handler.Brolga.Should().NotBeNull().And.Subject.As(); + request.Uri.Should().Be("http://brolga.test:8787/api/v1/context"); + request.Method.Should().Be(HttpMethod.Post); + request.Authorization.Should().Be("Bearer 0123456789abcdef0123456789abcdef"); + request.Body.Should().Contain("\"kind\":\"ipv4\"").And.Contain("203.0.113.42"); + } + + /// + /// A base URL that already carries a path would otherwise produce `/api/v1/api/v1/context`. + /// + [Fact] + public async Task ATrailingSlashOnTheBaseUrlDoesNotDoubleTheSeparator() + { + var handler = new RecordingHandler(Pack("malicious")); + await Enricher(handler, "http://brolga.test:8787/") + .LookupAsync(Guid.NewGuid(), "ipv4", "203.0.113.42", CancellationToken.None); + + handler.Brolga.Should().NotBeNull(); + handler.Brolga!.Uri.Should().Be("http://brolga.test:8787/api/v1/context"); + } + + /// + /// The evidence has to survive into the cached detail, or an analyst reading the verdict has + /// nothing to cite for it. + /// + [Fact] + public async Task TheEvidenceAndEntitiesSurviveIntoTheDetail() + { + var handler = new RecordingHandler(Pack("malicious")); + var results = await Enricher(handler).LookupAsync(Guid.NewGuid(), "ipv4", "203.0.113.42", CancellationToken.None); + + var detail = System.Text.Json.JsonSerializer.Serialize( + results.Single(r => r.Provider == ReputationProvider.Brolga).Detail); + + detail.Should().Contain("source:12fc"); + detail.Should().Contain("C2 infrastructure"); + } + + /// + /// Brolga refuses to serve a reachable address without a token, so a configured base URL with + /// no token is a misconfiguration. It must not be asked at all rather than asked and refused. + /// + [Fact] + public async Task WithoutATokenBrolgaIsNotAsked() + { + var handler = new RecordingHandler(Pack("malicious")); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"brolga-{Guid.NewGuid()}") + .Options; + + var enricher = new ReputationEnricher( + new TawnyDbContext(options), + new HttpClient(handler), + Options.Create(new ReputationOptions + { + BrolgaBaseUrl = "http://brolga.test:8787", + BrolgaApiToken = null, + }), + TimeProvider.System, + NullLogger.Instance); + + var results = await enricher.LookupAsync(Guid.NewGuid(), "ipv4", "203.0.113.42", CancellationToken.None); + + results.Should().NotContain(r => r.Provider == ReputationProvider.Brolga); + // Other providers still run for an IPv4 indicator, so the assertion is that *Brolga* was + // not reached — not that nothing was. + handler.Brolga.Should().BeNull(); + } + + /// + /// A failed lookup is Error, not Unknown. "Brolga did not answer" and "Brolga has not heard of + /// this" are different facts, and only one of them says anything about the indicator. + /// + [Fact] + public async Task AFailedLookupIsAnErrorRatherThanAnAbsenceOfKnowledge() + { + var handler = new RecordingHandler("upstream is unwell", HttpStatusCode.ServiceUnavailable); + var results = await Enricher(handler).LookupAsync(Guid.NewGuid(), "ipv4", "203.0.113.42", CancellationToken.None); + + results.Single(r => r.Provider == ReputationProvider.Brolga) + .Verdict.Should().Be(ReputationVerdict.Error); + } + + private sealed record Recorded(string Uri, HttpMethod Method, string? Authorization, string? Body); + + /// + /// Records every request, not just the last one. GreyNoise needs no API key and so is always + /// asked about an IPv4 indicator; a handler that kept only the most recent request would make + /// these assertions depend on the order providers happen to run in. + /// + private sealed class RecordingHandler(string body, HttpStatusCode status = HttpStatusCode.OK) + : HttpMessageHandler + { + private readonly List _requests = []; + + public IReadOnlyList Requests => _requests; + + public Recorded? Brolga => + _requests.SingleOrDefault(request => request.Uri.Contains("/api/v1/context")); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + _requests.Add(new Recorded( + request.RequestUri?.ToString() ?? string.Empty, + request.Method, + request.Headers.Authorization?.ToString(), + request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken))); + + return new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + } + } +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c81d8fc..88314c1 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -50,6 +50,9 @@ services: Tawny__Slack__Username: "${TAWNY_SLACK_USERNAME:-Tawny}" Tawny__Slack__IconEmoji: "${TAWNY_SLACK_ICON_EMOJI:-:rotating_light:}" Tawny__Slack__TimeoutSeconds: "${TAWNY_SLACK_TIMEOUT_SECONDS:-5}" + Tawny__Reputation__BrolgaBaseUrl: "${TAWNY_BROLGA_BASE_URL:-}" + Tawny__Reputation__BrolgaApiToken: "${TAWNY_BROLGA_API_TOKEN:-}" + Tawny__Reputation__CacheTtlHours: "${TAWNY_REPUTATION_CACHE_TTL_HOURS:-24}" Tawny__Kelpie__Enabled: "${TAWNY_KELPIE_ENABLED:-false}" Tawny__Kelpie__BaseUrl: "${TAWNY_KELPIE_BASE_URL:-}" Tawny__Kelpie__ApiToken: "${TAWNY_KELPIE_API_TOKEN:-}"