Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
69 changes: 69 additions & 0 deletions scripts/live-brolga-check.ts
Original file line number Diff line number Diff line change
@@ -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);
});
4 changes: 2 additions & 2 deletions scripts/test-brolga-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/brolga/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
6 changes: 4 additions & 2 deletions src/lib/brolga/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
}

Expand Down
19 changes: 12 additions & 7 deletions src/lib/brolga/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`
* Wire path: `POST {baseUrl}/api/v1/context`
* Auth: `Authorization: Bearer <token>` — 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. */
Expand Down
2 changes: 1 addition & 1 deletion src/lib/enrichment/providers/brolga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down