Skip to content

feat(threat-intel): ask Brolga for reputation on extracted indicators - #48

Merged
jusso-dev merged 1 commit into
mainfrom
feat/brolga-reputation
Jul 29, 2026
Merged

feat(threat-intel): ask Brolga for reputation on extracted indicators#48
jusso-dev merged 1 commit into
mainfrom
feat/brolga-reputation

Conversation

@jusso-dev

@jusso-dev jusso-dev commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Adds Brolga as a ReputationProvider alongside VirusTotal, AbuseIPDB, and GreyNoise, so a detection can be checked against the operator's own intelligence before it becomes a case.

Brolga is asked about every indicator kind Tawny extracts — not just IPs — because it is the operator's own store rather than a third party's rate-limited API.

Follows the KelpieAlertSink template: typed HttpClient, bearer token from IOptions, absolute-URL validation, per-call timeout from the existing ReputationOptions.

The disposition mapping is the part worth reviewing

A wrong answer here changes whether an alert fires.

Brolga Tawny Why
malicious Malicious
suspicious Suspicious
benign Clean
allow_listed AllowListed (new) See below
unknown Unknownnever Clean Brolga has not heard of it. That is not a clearance.
anything unrecognised Unknown So a later Brolga adding a disposition cannot silently start suppressing alerts
HTTP failure Error "Did not answer" and "has not heard of this" are different facts

allow_listed gets its own verdict rather than being folded into Clean. 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.

ReputationVerdict.AllowListed = 5 — existing values keep their numbers, because ReputationCacheEntry rows are keyed by them and renumbering would silently reinterpret cached verdicts.

Evidence is carried through

The pack's evidence, entities, and gaps go into the cached detail. A verdict an analyst cannot trace back to a source is one they cannot act on with confidence.

Misconfiguration is not asked

A configured base URL with no token means Brolga is not offered at all. Brolga refuses to serve a reachable address without one, so asking would only produce a 401 and a cached Error.

A test-harness note

RecordingHandler records every request, not the last one. GreyNoise needs no API key and always runs for an IPv4 indicator, so a handler keeping only the most recent request would have made these assertions depend on the order providers happen to run in. The first version of the test did exactly that and failed for that reason.

Verification

dotnet test Tawny.sln103 passed, 0 failed. 11 of those are new.

Config

Tawny:Reputation:BrolgaBaseUrl / BrolgaApiToken, with TAWNY_BROLGA_BASE_URL / TAWNY_BROLGA_API_TOKEN in compose. Origin only — the /api/v1 prefix is added when the request is built.

Summary by CodeRabbit

  • New Features

    • Added Brolga reputation lookups for supported indicators.
    • Reputation results now include verdicts, evidence, gaps, and related entities.
    • Added configuration options for the Brolga service, authentication, and cache duration.
    • Added explicit support for “Allow listed” reputation results.
  • Bug Fixes

    • Unknown or unrecognized reputation responses no longer appear as clean.
    • Failed reputation requests are reported as errors.

Adds Brolga as a ReputationProvider alongside VirusTotal, AbuseIPDB, and
GreyNoise. Brolga is the operator's own intelligence store rather than a third
party's, so it is asked about every indicator kind Tawny extracts whenever it is
configured, not just IPs.

Follows the KelpieAlertSink template: typed HttpClient, bearer token from
IOptions, absolute-URL validation, per-call timeout from the existing
ReputationOptions.

The disposition mapping is the part worth reviewing, because a wrong answer here
changes whether an alert fires:

- unknown  -> Unknown, never Clean. Brolga's "unknown" means it has not heard of
  the indicator. Reading that as clean would suppress an alert that nothing has
  actually cleared.
- Anything unrecognised also falls back to Unknown, so a later Brolga adding a
  disposition cannot silently start suppressing alerts.
- allow_listed gets its own verdict rather than being folded into Clean. Clean
  is a finding about the indicator; allow-listed is a decision about how it is
  treated regardless of the finding, and collapsing them would let a feed's
  opinion override an operator's decision. ReputationVerdict.AllowListed = 5;
  existing values keep their numbers because cached rows are keyed by them.
- A failed lookup is Error, not Unknown. "Brolga did not answer" and "Brolga has
  not heard of this" are different facts and only one says anything about the
  indicator.

The pack's evidence, entities, and gaps are carried into the cached detail. A
verdict an analyst cannot trace to a source is one they cannot act on with
confidence.

A configured base URL with no token means Brolga is not offered at all: Brolga
refuses to serve a reachable address without one, so asking would only produce a
401 and a cached Error.

The test handler records every request rather than the last one. GreyNoise needs
no API key and always runs for an IPv4 indicator, so a handler keeping only the
most recent request would have made these assertions depend on provider order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Brolga as a configurable reputation provider, sends authenticated context requests for supported indicators, maps dispositions to Tawny verdicts, preserves response details, and validates enrichment behavior through HTTP-backed tests.

Changes

Brolga reputation integration

Layer / File(s) Summary
Provider contracts and configuration
backend/src/Tawny.Domain/Enums.cs, backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs, backend/src/Tawny.Api/appsettings.json, docker/docker-compose.yml
Adds Brolga provider and allow-listed verdict values, reputation settings, and container environment variables.
Brolga probing and enrichment
backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs
Routes supported indicators to Brolga, posts authenticated JSON to /api/v1/context, maps dispositions, and stores response evidence, gaps, and entities.
Brolga behavior validation
backend/tests/Tawny.Api.Tests/BrolgaReputationTests.cs
Tests verdict mapping, request shape, URL normalization, detail preservation, missing-token behavior, and HTTP failures.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: claude

Sequence Diagram(s)

sequenceDiagram
  participant LookupAsync
  participant ReputationEnricher
  participant BrolgaAPI
  participant TawnyDbContext
  LookupAsync->>ReputationEnricher: request indicator reputation
  ReputationEnricher->>BrolgaAPI: POST /api/v1/context with bearer token and subject
  BrolgaAPI-->>ReputationEnricher: return disposition and context details
  ReputationEnricher->>TawnyDbContext: cache verdict and enriched detail
  TawnyDbContext-->>LookupAsync: return reputation result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding Brolga-based reputation checks for extracted indicators.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/brolga-reputation

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs (1)

356-374: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid double-parsing and leaking JsonDocument instances for evidence/entities/gaps.

GetRawText() is called on e/g/n (already-parsed JsonElements from doc), then the resulting strings are re-parsed via JsonDocument.Parse(...) — three more JsonDocument instances per Brolga response that are never disposed. JsonDocument rents its backing arrays from ArrayPool<T> and must be disposed to return them; skipping that increases GC pressure on every reputation lookup that hits Brolga. Simplify by cloning the elements directly from the already-open doc before it's disposed.

♻️ Proposed fix
-        // 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() : "[]";
+        // 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. Clone directly out of `doc`
+        // (rather than round-tripping through GetRawText + a second JsonDocument.Parse) so no
+        // extra, undisposed JsonDocument instances are left renting from the ArrayPool.
+        static JsonElement CloneOrEmptyArray(JsonElement root, string property) =>
+            root.TryGetProperty(property, out var value)
+                ? value.Clone()
+                : JsonDocument.Parse("[]").RootElement;
+
+        var evidence = CloneOrEmptyArray(root, "evidence");
+        var gaps = CloneOrEmptyArray(root, "gaps");
+        var entities = CloneOrEmptyArray(root, "entities");

         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(),
+                entities,
+                evidence,
+                gaps,
             });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs` around
lines 356 - 374, In the Brolga response mapping, replace the GetRawText and
JsonDocument.Parse flow for evidence, gaps, and entities with direct Clone calls
on the corresponding JsonElements from root. Preserve the existing empty-array
defaults when those properties are absent, and keep the cloned elements in the
returned ReputationLookup metadata without creating additional undisposed
JsonDocument instances.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs`:
- Around line 356-374: In the Brolga response mapping, replace the GetRawText
and JsonDocument.Parse flow for evidence, gaps, and entities with direct Clone
calls on the corresponding JsonElements from root. Preserve the existing
empty-array defaults when those properties are absent, and keep the cloned
elements in the returned ReputationLookup metadata without creating additional
undisposed JsonDocument instances.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 74899021-6925-4dfc-b361-fe28973e6b6d

📥 Commits

Reviewing files that changed from the base of the PR and between dee3006 and ade89ff.

📒 Files selected for processing (5)
  • backend/src/Tawny.Api/appsettings.json
  • backend/src/Tawny.Domain/Enums.cs
  • backend/src/Tawny.Infrastructure/ThreatIntel/ReputationEnricher.cs
  • backend/tests/Tawny.Api.Tests/BrolgaReputationTests.cs
  • docker/docker-compose.yml

@jusso-dev
jusso-dev merged commit a09a7c8 into main Jul 29, 2026
10 checks passed
@jusso-dev
jusso-dev deleted the feat/brolga-reputation branch July 29, 2026 08:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant