Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,11 @@ CLUSTERS_JSON=[{"name":"primary","elasticsearchUrl":"https://your-cluster.es.clo
# verifying dashboards or working on the MCP App's analytics locally.
# See docs/telemetry.md for the full event catalog and opt-out story.
# MCP_APP_TELEMETRY_ENV=staging

# CentinelaCI threat-intel bridge (optional) — powers the cross-reference-intel
# tool. Leave unset to disable; the tool stays registered and explains what to
# configure rather than failing. The client logs in (POST /auth) to get a JWT.
# Treat the password as a secret.
# CENTINELA_API_URL=https://threats.infrasecuritycode.com/api
# CENTINELA_API_PASSWORD=your-centinelaci-password
# CENTINELA_API_USERNAME=optional-defaults-to-admin
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ This project provides six interactive security operations tools, each with a ric
| **Threat Hunt** | ES\|QL workbench with clickable entities and a D3 investigation graph |
| **Sample Data** | Generate ECS security events for demos across 4 attack chain scenarios |

A seventh tool, **Cross-reference Threat Intel**, runs headless (no inline UI): it pulls live prioritised CVEs and indicators of compromise from [CentinelaCI](https://threats.infrasecuritycode.com) and checks each indicator against your own Elasticsearch data via ES|QL, so Claude can answer "have these threats been seen in my environment?". It stays registered but inert until you set `CENTINELA_API_URL` and `CENTINELA_API_PASSWORD` (see [`.env.example`](.env.example)).

See [docs/features.md](docs/features.md) for a full breakdown of each tool's capabilities.

## Quick Start
Expand Down
11 changes: 11 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,14 @@ Rule management dashboard:
Generate ECS-compliant security events:
- Windows Credential Theft, AWS Privilege Escalation, Okta Identity Takeover, Ransomware Kill Chain
- All data tagged for safe cleanup

## Cross-reference Threat Intel

Headless bridge tool (no inline UI — `model`-only visibility) that grounds external threat intelligence in your own data:

- **Live intel pull**: fetches prioritised CVEs (`/vulns/top`) and indicators of compromise (`/iocs`) from the [CentinelaCI](https://threats.infrasecuritycode.com) REST API, authenticating with a cached JWT (lazy login, one-shot 401 re-auth)
- **Indicator matching**: each IOC is checked against the ECS fields where it would appear — `ip` → `source.ip`/`destination.ip`, `domain` → `dns.question.name`/`url.domain`/`destination.domain`, `url` → `url.original`/`url.full` — via the same ES|QL engine the Threat Hunt tool uses
- **"Have I seen this?"**: returns which indicators actually appear in your index pattern, with hit counts and the linked threat actor when known
- **Graceful degradation**: a field missing from the mapping is treated as "not seen here" rather than an error; CVE-type indicators (nothing to match against logs) are skipped
- **Inert until configured**: stays registered and explains what to set when `CENTINELA_API_URL` / `CENTINELA_API_PASSWORD` are unset, instead of failing the build
- **Parameters**: `indexPattern` (default `logs-*`), `iocLimit` (default 20), `vulnLimit` (default 10)
295 changes: 295 additions & 0 deletions src/intel/centinela-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
createCentinelaClient,
createCentinelaClientFromEnv,
} from "./centinela-client.js";

const BASE = "https://intel.test/api";

/** Build a minimal `Response`-like object for the fetch mock. */
function jsonResponse(body: unknown, init?: { status?: number }): Response {
const status = init?.status ?? 200;
return {
ok: status >= 200 && status < 300,
status,
statusText: `HTTP ${status}`,
json: async () => body,
} as unknown as Response;
}

function url(call: unknown[]): string {
return String(call[0]);
}

describe("createCentinelaClient", () => {
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
});

const client = () => createCentinelaClient({ baseUrl: BASE, password: "pw" });

it("logs in once then sends the JWT as a Bearer token on data calls", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "jwt-123" })) // POST /auth
.mockResolvedValueOnce(jsonResponse({ iocs: [] })); // GET /iocs

await client().listIocs();

expect(fetchMock).toHaveBeenCalledTimes(2);
const [authCall, iocCall] = fetchMock.mock.calls;
expect(url(authCall)).toBe(`${BASE}/auth`);
expect((authCall[1] as RequestInit).method).toBe("POST");
expect(JSON.parse((authCall[1] as RequestInit).body as string)).toEqual({
password: "pw",
});
expect(url(iocCall)).toBe(`${BASE}/iocs?limit=20`);
expect(
(iocCall[1] as RequestInit).headers as Record<string, string>,
).toMatchObject({ Authorization: "Bearer jwt-123" });
});

it("caches the token across calls — only one login for multiple requests", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "jwt-1" }))
.mockResolvedValueOnce(jsonResponse({ iocs: [] }))
.mockResolvedValueOnce(jsonResponse({ vulns: [] }));

const c = client();
await c.listIocs();
await c.topVulns();

const authCalls = fetchMock.mock.calls.filter(
(call) => url(call) === `${BASE}/auth`,
);
expect(authCalls).toHaveLength(1);
});

it("re-authenticates exactly once on a 401 and retries", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "stale" })) // login
.mockResolvedValueOnce(jsonResponse({}, { status: 401 })) // /iocs -> expired
.mockResolvedValueOnce(jsonResponse({ token: "fresh" })) // re-login
.mockResolvedValueOnce(jsonResponse({ iocs: [] })); // retry /iocs

await client().listIocs();

expect(fetchMock).toHaveBeenCalledTimes(4);
const retry = fetchMock.mock.calls[3];
expect(
(retry[1] as RequestInit).headers as Record<string, string>,
).toMatchObject({ Authorization: "Bearer fresh" });
});

it("includes the username in the login body when provided", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "t" }))
.mockResolvedValueOnce(jsonResponse({ iocs: [] }));

await createCentinelaClient({
baseUrl: BASE,
password: "pw",
username: "analyst",
}).listIocs();

expect(
JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string),
).toEqual({ password: "pw", username: "analyst" });
});

it("throws when login returns no token", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ notAToken: true }));
await expect(client().listIocs()).rejects.toThrow(/no token/i);
});

it("throws on a non-OK login response", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, { status: 500 }));
await expect(client().listIocs()).rejects.toThrow(/login/i);
});

describe("listIocs parsing", () => {
it("normalises snake_case and camelCase fields and lowercases type", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "t" }))
.mockResolvedValueOnce(
jsonResponse({
iocs: [
{
id: "a1",
type: "IP",
value: "1.2.3.4",
confidence: 0.8,
actor_name: "APT-X",
last_seen: "2026-01-01",
},
],
}),
);

const iocs = await client().listIocs();
expect(iocs).toEqual([
{
id: "a1",
type: "ip",
value: "1.2.3.4",
confidence: 0.8,
actorName: "APT-X",
lastSeen: "2026-01-01",
},
]);
});

it("drops rows missing a type or value", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "t" }))
.mockResolvedValueOnce(
jsonResponse({
iocs: [
{ id: "1", type: "ip" }, // no value
{ id: "2", value: "x" }, // no type
{ id: "3", type: "domain", value: "ok.test" },
],
}),
);

const iocs = await client().listIocs();
expect(iocs).toHaveLength(1);
expect(iocs[0].value).toBe("ok.test");
});

it("falls back to the value as id when none is provided", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "t" }))
.mockResolvedValueOnce(
jsonResponse({ iocs: [{ type: "ip", value: "9.9.9.9" }] }),
);

const iocs = await client().listIocs();
expect(iocs[0].id).toBe("9.9.9.9");
});

it("accepts a bare array response", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "t" }))
.mockResolvedValueOnce(
jsonResponse([{ type: "url", value: "http://x.test" }]),
);

const iocs = await client().listIocs();
expect(iocs).toHaveLength(1);
});

it("forwards the limit to the query string", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "t" }))
.mockResolvedValueOnce(jsonResponse({ iocs: [] }));

await client().listIocs(7);
expect(url(fetchMock.mock.calls[1])).toBe(`${BASE}/iocs?limit=7`);
});
});

describe("topVulns parsing", () => {
it("normalises CVE fields and coerces kevListed to a boolean", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "t" }))
.mockResolvedValueOnce(
jsonResponse({
vulns: [
{
cve_id: "CVE-2026-0001",
title: "Bad bug",
severity: "CRÍTICO",
priority_score: 95,
cvss_score: 9.8,
kev_listed: 1,
},
],
}),
);

const vulns = await client().topVulns();
expect(vulns).toEqual([
{
cveId: "CVE-2026-0001",
title: "Bad bug",
severity: "CRÍTICO",
priorityScore: 95,
cvssScore: 9.8,
kevListed: true,
},
]);
});

it("drops rows with no CVE id", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "t" }))
.mockResolvedValueOnce(
jsonResponse({ vulns: [{ title: "orphan" }] }),
);

expect(await client().topVulns()).toEqual([]);
});

it("uses the /vulns/top endpoint with the given limit", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "t" }))
.mockResolvedValueOnce(jsonResponse({ vulns: [] }));

await client().topVulns(3);
expect(url(fetchMock.mock.calls[1])).toBe(`${BASE}/vulns/top?limit=3`);
});
});

it("trims a trailing slash off the base URL", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ token: "t" }))
.mockResolvedValueOnce(jsonResponse({ iocs: [] }));

await createCentinelaClient({
baseUrl: `${BASE}/`,
password: "pw",
}).listIocs();

expect(url(fetchMock.mock.calls[0])).toBe(`${BASE}/auth`);
});
});

describe("createCentinelaClientFromEnv", () => {
const saved = { ...process.env };

afterEach(() => {
process.env = { ...saved };
});

it("returns null when the API URL is missing", () => {
delete process.env.CENTINELA_API_URL;
process.env.CENTINELA_API_PASSWORD = "pw";
expect(createCentinelaClientFromEnv()).toBeNull();
});

it("returns null when the password is missing", () => {
process.env.CENTINELA_API_URL = BASE;
delete process.env.CENTINELA_API_PASSWORD;
expect(createCentinelaClientFromEnv()).toBeNull();
});

it("builds a client when both URL and password are set", () => {
process.env.CENTINELA_API_URL = BASE;
process.env.CENTINELA_API_PASSWORD = "pw";
expect(createCentinelaClientFromEnv()).not.toBeNull();
});
});
Loading