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 diff --git a/scripts/live-brolga-check.ts b/scripts/live-brolga-check.ts new file mode 100644 index 0000000..8109435 --- /dev/null +++ b/scripts/live-brolga-check.ts @@ -0,0 +1,69 @@ +/** + * 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 { brolgaUrl, configurationFromSettings } from "@/lib/brolga"; +import { safeFetch } from "@/lib/outbound-request"; + +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",