From 2c42d147f514d2e0ee2527a4075de2b9d63b9d81 Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Wed, 29 Jul 2026 16:18:02 +1000 Subject: [PATCH 1/3] feat(brolga): point the client at Brolga's shipped API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brolga's HTTP API now exists and serves the context pack this client was written against. It versions routes in the path under `/api/v1`, not `/v1`, so the two path constants move. `normaliseBaseUrl` reduces the configured URL to an origin on purpose, so the prefix belongs here rather than in the base URL. Verified against a real instance: health 200, a context lookup for an ingested address returning `disposition: "malicious"` with a source object to cite, and an unauthenticated request refused with 401. Driven through `safeFetch`, not bare `fetch`, so the SSRF guard and DNS pinning are on the tested path. Two notes for whoever deploys this: - A self-hosted Brolga on a tailnet or LAN resolves to a non-public address and the outbound guard blocks it. `KELPIE_ALLOW_PRIVATE_NETWORKS` must be the literal string "true" — "1" silently does nothing. Documented in .env.example. - Brolga serves detail level L1 and reports the level it actually served, with the shortfall in `exclusions`. A pack never claims more depth than it has. `disposition: "unknown"` means Brolga has not heard of the subject. It is not benign and must not be rendered as one. `scripts/live-brolga-check.ts` is deliberately not wired into npm test: it needs a reachable instance and a token, which CI has neither of. Co-Authored-By: Claude Opus 5 --- scripts/live-brolga-check.ts | 70 ++++++++++++++++++++++++++ scripts/test-brolga-contract.ts | 4 +- src/lib/brolga/client.ts | 2 +- src/lib/brolga/config.ts | 6 ++- src/lib/brolga/types.ts | 19 ++++--- src/lib/enrichment/providers/brolga.ts | 2 +- 6 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 scripts/live-brolga-check.ts diff --git a/scripts/live-brolga-check.ts b/scripts/live-brolga-check.ts new file mode 100644 index 0000000..2733d52 --- /dev/null +++ b/scripts/live-brolga-check.ts @@ -0,0 +1,70 @@ +/** + * Ad-hoc check against a real Brolga. Not part of the test suite: it needs a + * reachable instance and a token, which CI has neither of. + */ +import assert from "node:assert/strict"; +import { configurationFromSettings } from "../src/lib/brolga/config.ts"; +import { brolgaUrl } from "../src/lib/brolga/client.ts"; +import { safeFetch } from "../src/lib/outbound-request.ts"; + +async function main() { + const base = process.env.BROLGA_BASE_URL; + const token = process.env.BROLGA_API_TOKEN; + assert.ok(base && token, "set BROLGA_BASE_URL and BROLGA_API_TOKEN"); + + const config = configurationFromSettings({ + brolga_base_url: base, + brolga_api_token: token, + brolga_enabled: true, + }); + + const healthUrl = brolgaUrl(config, config.healthPath); + const contextUrl = brolgaUrl(config, config.contextPath); + console.log("health url :", healthUrl); + console.log("context url:", contextUrl); + + const health = await safeFetch(healthUrl); + assert.equal(health.status, 200, `health returned ${health.status}`); + console.log("health :", await health.text()); + + const res = await safeFetch(contextUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + schema_version: "kelpie.brolga.context_request/1.0", + subject: { kind: "ip", value: "203.0.113.42" }, + purpose: "case_enrichment", + detail_level: "L1", + case_id: "case-demo-1", + }), + }); + assert.equal(res.status, 200, `context returned ${res.status}`); + const pack = await res.json(); + + assert.equal(pack.schema_version, "brolga.context_pack/1.0"); + assert.equal(pack.disposition, "malicious"); + assert.ok(Array.isArray(pack.evidence) && pack.evidence.length > 0, "no evidence"); + console.log("disposition:", pack.disposition); + console.log("entities :", pack.entities.map((e: {name: string}) => e.name)); + console.log("evidence :", pack.evidence.length, "source object(s)"); + console.log("gaps :", pack.gaps); + + // An unauthenticated request must be refused. + const anon = await safeFetch(contextUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subject: { kind: "ip", value: "203.0.113.42" } }), + }); + assert.equal(anon.status, 401, `unauthenticated request returned ${anon.status}`); + console.log("no token : 401 as expected"); + + console.log("\nKelpie -> Brolga live check passed"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/test-brolga-contract.ts b/scripts/test-brolga-contract.ts index 02c4f01..7353376 100644 --- a/scripts/test-brolga-contract.ts +++ b/scripts/test-brolga-contract.ts @@ -59,8 +59,8 @@ function testConfig() { assert.equal(enabled.baseUrl, "https://brolga.example"); assert.equal(enabled.hasToken, true); assert.equal(enabled.timeoutMs, 12000); - assert.equal(enabled.contextPath, "/v1/context"); - assert.equal(enabled.healthPath, "/v1/health"); + assert.equal(enabled.contextPath, "/api/v1/context"); + assert.equal(enabled.healthPath, "/api/v1/health"); assert.equal( BROLGA_CONTEXT_REQUEST_SCHEMA, diff --git a/src/lib/brolga/client.ts b/src/lib/brolga/client.ts index be2ce44..814700d 100644 --- a/src/lib/brolga/client.ts +++ b/src/lib/brolga/client.ts @@ -80,7 +80,7 @@ export async function testBrolgaConnection( return { ok: false, message: - "Reached host but /v1/health is not implemented yet (expected until Brolga v0.5). URL is reachable.", + "Reached host but /api/v1/health is not there. Check the URL points at Brolga's origin, not a path.", httpStatus: 404, }; } diff --git a/src/lib/brolga/config.ts b/src/lib/brolga/config.ts index bbd4b73..340fbcb 100644 --- a/src/lib/brolga/config.ts +++ b/src/lib/brolga/config.ts @@ -103,8 +103,10 @@ export function configurationFromSettings( tokenSource, urlSource, timeoutMs: clampTimeout(settings.brolga_timeout_ms), - healthPath: "/v1/health", - contextPath: "/v1/context", + // Brolga versions its routes in the path and serves them under `/api/v1` — see its + // docs/API.md. The base URL is reduced to an origin above, so the full prefix belongs here. + healthPath: "/api/v1/health", + contextPath: "/api/v1/context", }; } diff --git a/src/lib/brolga/types.ts b/src/lib/brolga/types.ts index 2f75f90..562601d 100644 --- a/src/lib/brolga/types.ts +++ b/src/lib/brolga/types.ts @@ -5,15 +5,20 @@ * graph, and context packs. Kelpie consumes compact packs for case * enrichment — it does not re-implement MISP/TAXII/AbuseIPDB pipelines. * - * These types are Kelpie's *consumer* contract. They deliberately track the - * Brolga roadmap (context packs, progressive disclosure, evidence handles) - * without requiring a running Brolga instance yet. When Brolga's HTTP API - * ships (milestone v0.5), adjust paths/fields only if the wire format drifts; - * keep this file as the single import surface for UI and enrichment. + * These types are Kelpie's *consumer* contract, and Brolga now serves it. * - * Wire path (planned): `POST {baseUrl}/v1/context` - * Auth (planned): `Authorization: Bearer ` + * Wire path: `POST {baseUrl}/api/v1/context` + * Auth: `Authorization: Bearer ` — Brolga refuses to bind a reachable + * address without a token, so one is always required off loopback. * Content-Type: `application/json` + * + * Brolga serves detail level L1 today. It reports the level it *actually* + * served and notes the shortfall in `exclusions`, so a pack never claims more + * depth than it has. Progressive disclosure beyond L1 and `expansion_handles` + * remain roadmap. + * + * `disposition: "unknown"` means Brolga has not heard of the subject. It does + * not mean benign, and must not be rendered as one. */ /** Schema id for the request body Kelpie sends. */ diff --git a/src/lib/enrichment/providers/brolga.ts b/src/lib/enrichment/providers/brolga.ts index 0e9b3fa..fc14be9 100644 --- a/src/lib/enrichment/providers/brolga.ts +++ b/src/lib/enrichment/providers/brolga.ts @@ -5,7 +5,7 @@ import { packDispositionSummary } from "@/lib/brolga/client"; /** * Optional enrichment via Brolga context packs. - * Inactive until an admin enables Brolga and the engine exposes /v1/context. + * Inactive until an admin enables Brolga and configures its base URL. */ export const brolgaProvider: EnrichmentProvider = { name: "brolga", From 516eeb853d1ca8a1279043cc4b3afb44ab75005b Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Wed, 29 Jul 2026 16:18:33 +1000 Subject: [PATCH 2/3] docs: correct the Brolga env notes The comment said Brolga's HTTP API was planned and that calls report "unavailable" until then. It ships, and they do not. Also records that KELPIE_ALLOW_PRIVATE_NETWORKS must be the literal "true": "1" leaves the guard on and the call fails with a message about a non-public address, which reads like a network fault rather than a config one. Co-Authored-By: Claude Opus 5 --- .env.example | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 83067b9..926fb97 100644 --- a/.env.example +++ b/.env.example @@ -51,8 +51,9 @@ VIRUSTOTAL_API_KEY= # Optional Brolga threat-intelligence context engine (https://github.com/jusso-dev/Brolga). # Org-level settings under Settings → Integrations take precedence. -# Brolga HTTP API is planned for Brolga v0.5; until then health/context calls -# report "unavailable" without breaking case enrichment. +# Set BROLGA_BASE_URL to Brolga's origin only — the /api/v1 prefix is added by +# the client. A self-hosted Brolga on a tailnet or LAN is a non-public address, +# so KELPIE_ALLOW_PRIVATE_NETWORKS must also be "true" or the call is blocked. BROLGA_BASE_URL= BROLGA_API_TOKEN= BROLGA_ENABLED=false @@ -72,6 +73,8 @@ CLAMAV_HOST= CLAMAV_PORT=3310 # Set only when Kelpie must call intentionally private, self-hosted integrations. +# Must be the literal string "true". Any other value, including "1", leaves the +# outbound guard on and the call fails with "resolves to a non-public address". KELPIE_ALLOW_PRIVATE_NETWORKS=false S3_BUCKET= S3_REGION=ap-southeast-2 From 86ff64796c0c30db2c1628f439946a62b2097563 Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Wed, 29 Jul 2026 16:31:15 +1000 Subject: [PATCH 3/3] fix(scripts): import through the path alias, not a relative .ts path The repo's tsconfig does not set allowImportingTsExtensions, so the .ts suffixes failed the typecheck job even though tsx ran the script fine. Uses @/lib/brolga like the other scripts do. Co-Authored-By: Claude Opus 5 --- scripts/live-brolga-check.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/live-brolga-check.ts b/scripts/live-brolga-check.ts index 2733d52..8109435 100644 --- a/scripts/live-brolga-check.ts +++ b/scripts/live-brolga-check.ts @@ -3,9 +3,8 @@ * reachable instance and a token, which CI has neither of. */ import assert from "node:assert/strict"; -import { configurationFromSettings } from "../src/lib/brolga/config.ts"; -import { brolgaUrl } from "../src/lib/brolga/client.ts"; -import { safeFetch } from "../src/lib/outbound-request.ts"; +import { brolgaUrl, configurationFromSettings } from "@/lib/brolga"; +import { safeFetch } from "@/lib/outbound-request"; async function main() { const base = process.env.BROLGA_BASE_URL;