diff --git a/.env.example b/.env.example index d142ec2..5ce8358 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 70d610a..612e1dd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/features.md b/docs/features.md index 027b5c9..39dce12 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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) diff --git a/src/intel/centinela-client.test.ts b/src/intel/centinela-client.test.ts new file mode 100644 index 0000000..119d94d --- /dev/null +++ b/src/intel/centinela-client.test.ts @@ -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; + + 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, + ).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, + ).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(); + }); +}); diff --git a/src/intel/centinela-client.ts b/src/intel/centinela-client.ts new file mode 100644 index 0000000..59c671e --- /dev/null +++ b/src/intel/centinela-client.ts @@ -0,0 +1,235 @@ +/* + * 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 type { Logger } from "../shared/logger.js"; + +/** + * Minimal client for the CentinelaCI threat-intelligence REST API + * (https://threats.infrasecuritycode.com/api). + * + * This is the "bridge" the cross-reference tool needs: CentinelaCI owns the + * intel (news, actors, CVEs, IOCs) and exposes it over plain HTTP, so the + * Elastic MCP server can pull indicators and then check them against the + * user's own data. We only model the two endpoints the demo uses; the rest + * of the API is intentionally out of scope. + * + * Auth: the API issues a short-lived JWT from `POST /auth` (password, with an + * optional username). That JWT is then sent as a Bearer token on every + * subsequent call. We log in lazily, cache the token, and re-authenticate once + * on a 401 so an expired session self-heals without bubbling up to the tool. + */ + +/** A single indicator of compromise as returned by `GET /iocs`. */ +export interface IntelIoc { + id: string; + /** `ip` | `domain` | `url` | `cve` | … — drives how we query Elastic. */ + type: string; + /** The indicator itself: the IP, domain, URL, or CVE id. */ + value: string; + /** 0..1 source confidence, when the feed provides one. */ + confidence?: number; + /** The threat actor linked to this indicator, when known. */ + actorName?: string; + lastSeen?: string; +} + +/** A prioritised vulnerability as returned by `GET /vulns/top`. */ +export interface IntelVuln { + cveId: string; + title?: string; + /** Feed severity label (Spanish: BAJO/MEDIO/ALTO/CRÍTICO). */ + severity?: string; + /** The feed's own ranking score (0..100). */ + priorityScore?: number; + cvssScore?: number; + /** Whether it's on CISA's Known Exploited Vulnerabilities list. */ + kevListed?: boolean; +} + +export interface CentinelaClient { + /** Pull recent indicators. `limit` caps the page size. */ + listIocs(limit?: number): Promise; + /** Pull the top-N prioritised CVEs. */ + topVulns(limit?: number): Promise; +} + +export interface CentinelaConfig { + /** API base, e.g. `https://threats.infrasecuritycode.com/api`. */ + readonly baseUrl: string; + /** Login password (the deployment's APP_PASSWORD). Treat as a secret. */ + readonly password: string; + /** Optional username; omitted logs in as the default admin user. */ + readonly username?: string; + readonly logger?: Logger; + /** Per-request timeout; defaults to 10s. */ + readonly timeoutMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * Read a value off a loosely-typed JSON object, trying several key spellings. + * The feed mixes camelCase and snake_case depending on the endpoint, so we + * normalise here rather than scattering `?? record.foo_bar` across callers. + */ +function pick(record: Record, ...keys: string[]): unknown { + for (const key of keys) { + if (record[key] !== undefined && record[key] !== null) return record[key]; + } + return undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** + * Unwrap a list response. CentinelaCI wraps lists under a named key + * (`{ iocs: [...] }`, `{ vulns: [...] }`) but we also accept a bare array or + * the generic `data`/`items` shapes to stay robust to API tweaks. + */ +function asList(body: unknown): Record[] { + const raw = Array.isArray(body) + ? body + : pick( + (body ?? {}) as Record, + "iocs", + "vulns", + "data", + "items", + "results", + ); + return Array.isArray(raw) ? (raw as Record[]) : []; +} + +export function createCentinelaClient(config: CentinelaConfig): CentinelaClient { + const base = config.baseUrl.replace(/\/+$/, ""); + const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + let token: string | null = null; + + function withTimeout(run: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + timer.unref?.(); + return run(controller.signal).finally(() => clearTimeout(timer)); + } + + async function login(): Promise { + const body: Record = { password: config.password }; + if (config.username) body.username = config.username; + const json = await withTimeout(async (signal) => { + const res = await fetch(`${base}/auth`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + signal, + }); + if (!res.ok) { + throw new Error(`CentinelaCI login → HTTP ${res.status} ${res.statusText}`); + } + return (await res.json()) as Record; + }); + const fresh = asString(pick(json, "token", "access_token", "accessToken")); + if (!fresh) throw new Error("CentinelaCI login returned no token"); + token = fresh; + return fresh; + } + + /** + * GET a path with the cached JWT, logging in on demand. A 401 (expired or + * missing session) triggers exactly one re-login + retry before giving up. + */ + async function getJson(path: string): Promise { + const call = (bearer: string): Promise => + withTimeout((signal) => + fetch(`${base}${path}`, { + headers: { Authorization: `Bearer ${bearer}`, Accept: "application/json" }, + signal, + }), + ); + + let res = await call(token ?? (await login())); + if (res.status === 401) { + config.logger?.debug("CentinelaCI session expired — re-authenticating"); + res = await call(await login()); + } + if (!res.ok) { + throw new Error(`CentinelaCI ${path} → HTTP ${res.status} ${res.statusText}`); + } + return res.json(); + } + + return { + async listIocs(limit = 20): Promise { + const body = await getJson(`/iocs?limit=${encodeURIComponent(String(limit))}`); + return asList(body).flatMap((row): IntelIoc[] => { + const type = asString(pick(row, "type")); + const value = asString(pick(row, "value")); + if (!type || !value) return []; + return [ + { + id: asString(pick(row, "id")) ?? value, + type: type.toLowerCase(), + value, + confidence: asNumber(pick(row, "confidence")), + actorName: asString(pick(row, "actor_name", "actorName")), + lastSeen: asString(pick(row, "last_seen", "lastSeen")), + }, + ]; + }); + }, + + async topVulns(limit = 10): Promise { + const body = await getJson(`/vulns/top?limit=${encodeURIComponent(String(limit))}`); + return asList(body).flatMap((row): IntelVuln[] => { + const cveId = asString(pick(row, "cve_id", "cveId", "id")); + if (!cveId) return []; + return [ + { + cveId, + title: asString(pick(row, "title")), + severity: asString(pick(row, "severity")), + priorityScore: asNumber(pick(row, "priority_score", "priorityScore")), + cvssScore: asNumber(pick(row, "cvss_score", "cvssScore")), + kevListed: Boolean(pick(row, "kev_listed", "kevListed")), + }, + ]; + }); + }, + }; +} + +/** + * Build a client from the environment, or return `null` when intel is not + * configured. Returning `null` (rather than throwing) keeps the server bootable + * without a CentinelaCI subscription: the tool stays registered and explains + * what to set instead of crashing startup. + * + * CENTINELA_API_URL e.g. https://threats.infrasecuritycode.com/api + * CENTINELA_API_PASSWORD login password (secret) + * CENTINELA_API_USERNAME optional; defaults to the admin user + */ +export function createCentinelaClientFromEnv(logger?: Logger): CentinelaClient | null { + const baseUrl = process.env.CENTINELA_API_URL; + const password = process.env.CENTINELA_API_PASSWORD; + if (!baseUrl || !password) { + logger?.warn( + "CentinelaCI intel not configured — set CENTINELA_API_URL and CENTINELA_API_PASSWORD to enable cross-reference-intel", + ); + return null; + } + return createCentinelaClient({ + baseUrl, + password, + username: process.env.CENTINELA_API_USERNAME, + logger, + }); +} diff --git a/src/server.ts b/src/server.ts index f684b05..8545e47 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,6 +41,8 @@ import { registerCaseManagementTools } from "./tools/case-management.js"; import { registerDetectionRuleTools } from "./tools/detection-rules.js"; import { registerSampleDataTools } from "./tools/sample-data.js"; import { registerThreatHuntTools } from "./tools/threat-hunt.js"; +import { registerCrossReferenceIntelTool } from "./tools/cross-reference-intel.js"; +import { createCentinelaClientFromEnv } from "./intel/centinela-client.js"; import { noopAnalyticsClient, type AnalyticsClient } from "./elastic/analytics/index.js"; export interface CreateServerDeps { @@ -129,5 +131,14 @@ export function createServer(deps: CreateServerDeps = {}): McpServer { }); registerAnalyticsTools(server, { analytics }); + // Bridge to external threat intel (CentinelaCI). `null` when the + // subscription env vars aren't set — the tool stays registered and + // explains what to configure rather than failing the build. + registerCrossReferenceIntelTool(server, { + esqlService, + intelClient: createCentinelaClientFromEnv(), + analytics, + }); + return server; } diff --git a/src/test/integration/server.integration.test.ts b/src/test/integration/server.integration.test.ts index 274233d..de2dfe0 100644 --- a/src/test/integration/server.integration.test.ts +++ b/src/test/integration/server.integration.test.ts @@ -142,6 +142,8 @@ describe("MCP server integration (in-process Client + Server)", () => { "list-ai-connectors", // analytics "report-analytics-event", + // cross-reference-intel + "cross-reference-intel", ].sort() ); } finally { diff --git a/src/tools/cross-reference-intel.test.ts b/src/tools/cross-reference-intel.test.ts new file mode 100644 index 0000000..8dce4b6 --- /dev/null +++ b/src/tools/cross-reference-intel.test.ts @@ -0,0 +1,210 @@ +/* + * 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 } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { registerCrossReferenceIntelTool } from "./cross-reference-intel.js"; +import { + createMockMcpServer, + parseToolText, + type MockMcpServer, +} from "../test/helpers/mockMcpServer.js"; +import { createMockEsqlService } from "../test/helpers/mockServices.js"; +import { noopAnalyticsClient } from "../test/helpers/mockAnalytics.js"; +import type { EsqlService } from "../elastic/service/index.js"; +import type { + CentinelaClient, + IntelIoc, + IntelVuln, +} from "../intel/centinela-client.js"; + +function createMockIntelClient(): { + [K in keyof CentinelaClient]: ReturnType; +} { + return { + listIocs: vi.fn(), + topVulns: vi.fn(), + }; +} + +describe("registerCrossReferenceIntelTool", () => { + let server: MockMcpServer; + let esqlService: EsqlService; + let intelClient: ReturnType; + + function register(client: CentinelaClient | null) { + registerCrossReferenceIntelTool(server as unknown as McpServer, { + esqlService, + intelClient: client, + analytics: noopAnalyticsClient, + }); + } + + beforeEach(() => { + server = createMockMcpServer(); + esqlService = createMockEsqlService(); + intelClient = createMockIntelClient(); + }); + + it("registers the cross-reference-intel tool", () => { + register(intelClient as unknown as CentinelaClient); + expect([...server.tools.keys()]).toEqual(["cross-reference-intel"]); + }); + + describe("when intel is not configured", () => { + it("returns a configuration hint instead of failing", async () => { + register(null); + + const out = await server.tool("cross-reference-intel").callback({}); + + const body = parseToolText<{ error: string }>(out); + expect(body.error).toContain("CENTINELA_API_URL"); + expect(body.error).toContain("CENTINELA_API_PASSWORD"); + }); + }); + + describe("IOC matching", () => { + it("matches an IP indicator against network fields and reports hits", async () => { + const iocs: IntelIoc[] = [ + { + id: "1", + type: "ip", + value: "1.2.3.4", + confidence: 0.9, + actorName: "APT-Test", + }, + ]; + intelClient.topVulns.mockResolvedValueOnce([]); + intelClient.listIocs.mockResolvedValueOnce(iocs); + vi.mocked(esqlService.executeEsql).mockResolvedValueOnce({ + columns: [{ name: "hits", type: "long" }], + values: [[3]], + }); + + register(intelClient as unknown as CentinelaClient); + const out = await server.tool("cross-reference-intel").callback({}); + + expect(esqlService.executeEsql).toHaveBeenCalledWith( + 'FROM logs-* | WHERE source.ip == "1.2.3.4" OR destination.ip == "1.2.3.4" | STATS hits = COUNT(*)', + ); + const body = parseToolText<{ + matchCount: number; + matches: { value: string; hits: number; actorName?: string }[]; + }>(out); + expect(body.matchCount).toBe(1); + expect(body.matches[0]).toMatchObject({ + value: "1.2.3.4", + hits: 3, + actorName: "APT-Test", + }); + }); + + it("reports a zero-hit indicator as unmatched", async () => { + intelClient.topVulns.mockResolvedValueOnce([]); + intelClient.listIocs.mockResolvedValueOnce([ + { id: "1", type: "domain", value: "evil.test" }, + ]); + vi.mocked(esqlService.executeEsql).mockResolvedValueOnce({ + columns: [{ name: "hits", type: "long" }], + values: [[0]], + }); + + register(intelClient as unknown as CentinelaClient); + const out = await server.tool("cross-reference-intel").callback({}); + + const body = parseToolText<{ matchCount: number }>(out); + expect(body.matchCount).toBe(0); + }); + + it("skips indicator types that can't be matched against logs (e.g. cve)", async () => { + intelClient.topVulns.mockResolvedValueOnce([]); + intelClient.listIocs.mockResolvedValueOnce([ + { id: "1", type: "cve", value: "CVE-2024-0001" }, + ]); + + register(intelClient as unknown as CentinelaClient); + const out = await server.tool("cross-reference-intel").callback({}); + + expect(esqlService.executeEsql).not.toHaveBeenCalled(); + const body = parseToolText<{ matchCount: number }>(out); + expect(body.matchCount).toBe(0); + }); + + it("rejects indicator values that would break the ES|QL literal", async () => { + intelClient.topVulns.mockResolvedValueOnce([]); + intelClient.listIocs.mockResolvedValueOnce([ + { id: "1", type: "domain", value: 'evil".test' }, + ]); + + register(intelClient as unknown as CentinelaClient); + const out = await server.tool("cross-reference-intel").callback({}); + + expect(esqlService.executeEsql).not.toHaveBeenCalled(); + const body = parseToolText<{ + intel: { iocs: { checked: number } }; + matchCount: number; + }>(out); + expect(body.intel.iocs.checked).toBe(1); + expect(body.matchCount).toBe(0); + }); + + it("treats an ES|QL failure as 'not seen here' rather than throwing", async () => { + intelClient.topVulns.mockResolvedValueOnce([]); + intelClient.listIocs.mockResolvedValueOnce([ + { id: "1", type: "ip", value: "5.6.7.8" }, + ]); + vi.mocked(esqlService.executeEsql).mockRejectedValueOnce( + new Error("Unknown column [source.ip]"), + ); + + register(intelClient as unknown as CentinelaClient); + const out = await server.tool("cross-reference-intel").callback({}); + + const body = parseToolText<{ matchCount: number }>(out); + expect(body.matchCount).toBe(0); + }); + + it("honours indexPattern, iocLimit and vulnLimit parameters", async () => { + intelClient.topVulns.mockResolvedValueOnce([]); + intelClient.listIocs.mockResolvedValueOnce([]); + + register(intelClient as unknown as CentinelaClient); + const out = await server.tool("cross-reference-intel").callback({ + indexPattern: "filebeat-*", + iocLimit: 5, + vulnLimit: 3, + }); + + expect(intelClient.listIocs).toHaveBeenCalledWith(5); + expect(intelClient.topVulns).toHaveBeenCalledWith(3); + const body = parseToolText<{ indexPattern: string }>(out); + expect(body.indexPattern).toBe("filebeat-*"); + }); + }); + + describe("CVE context layer", () => { + it("includes the prioritised CVEs and a summary line", async () => { + const vulns: IntelVuln[] = [ + { cveId: "CVE-2024-1234", severity: "CRÍTICO", kevListed: true }, + ]; + intelClient.topVulns.mockResolvedValueOnce(vulns); + intelClient.listIocs.mockResolvedValueOnce([]); + + register(intelClient as unknown as CentinelaClient); + const out = await server.tool("cross-reference-intel").callback({}); + + const body = parseToolText<{ + summary: string; + intel: { vulns: { count: number; top: IntelVuln[] } }; + }>(out); + expect(body.intel.vulns.count).toBe(1); + expect(body.intel.vulns.top[0].cveId).toBe("CVE-2024-1234"); + expect(body.summary).toContain("1 prioritised CVEs"); + }); + }); +}); diff --git a/src/tools/cross-reference-intel.ts b/src/tools/cross-reference-intel.ts new file mode 100644 index 0000000..f49e200 --- /dev/null +++ b/src/tools/cross-reference-intel.ts @@ -0,0 +1,188 @@ +/* + * 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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { EsqlService } from "../elastic/service/index.js"; +import type { CentinelaClient, IntelIoc } from "../intel/centinela-client.js"; +import type { AnalyticsClient } from "../elastic/analytics/index.js"; +import { registerTrackedAppTool } from "./tracked-app-tool.js"; + +export interface CrossReferenceIntelDeps { + readonly esqlService: EsqlService; + /** `null` when CentinelaCI is not configured; the handler degrades gracefully. */ + readonly intelClient: CentinelaClient | null; + readonly analytics: AnalyticsClient; +} + +/** + * Map an IOC type to the ECS fields where that indicator would show up in the + * user's Elastic data. This is what makes the match meaningful: an `ip` is + * checked against network fields, a `domain` against DNS/URL fields, etc. + * Types we can't match against logs (e.g. `cve`) return an empty list and are + * skipped. + */ +function fieldsForIocType(type: string): string[] { + switch (type) { + case "ip": + return ["source.ip", "destination.ip"]; + case "domain": + return ["dns.question.name", "url.domain", "destination.domain"]; + case "url": + return ["url.original", "url.full"]; + default: + return []; + } +} + +interface IocMatch { + type: string; + value: string; + confidence?: number; + /** The threat actor linked to this indicator, when known. */ + actorName?: string; + /** How many events in the index pattern mentioned this indicator. */ + hits: number; + matched: boolean; + error?: string; +} + +/** + * Count how many events in `indexPattern` reference an indicator's value. + * + * We use ES|QL (the same engine the threat-hunt tool uses) and a defensive + * try/catch: a field missing from the mapping makes ES|QL throw, which for our + * purposes just means "this indicator wasn't seen here" rather than a failure. + */ +async function matchIoc( + esqlService: EsqlService, + indexPattern: string, + ioc: IntelIoc, +): Promise { + const fields = fieldsForIocType(ioc.type); + const base: IocMatch = { + type: ioc.type, + value: ioc.value, + confidence: ioc.confidence, + actorName: ioc.actorName, + hits: 0, + matched: false, + }; + + // Nothing to match against (e.g. a CVE indicator), or a value we can't + // safely embed in an ES|QL string literal — skip rather than risk a + // malformed query. + if (fields.length === 0) return base; + if (ioc.value.includes('"') || ioc.value.includes("\\")) { + return { ...base, error: "unsupported characters in indicator value" }; + } + + const where = fields.map((field) => `${field} == "${ioc.value}"`).join(" OR "); + const query = `FROM ${indexPattern} | WHERE ${where} | STATS hits = COUNT(*)`; + + try { + const result = await esqlService.executeEsql(query); + const raw = result.values[0]?.[0]; + const hits = typeof raw === "number" ? raw : 0; + return { ...base, hits, matched: hits > 0 }; + } catch (e) { + return { ...base, error: e instanceof Error ? e.message : String(e) }; + } +} + +/** + * Register `cross-reference-intel`: the bridge tool that pulls threat + * intelligence from CentinelaCI and grounds it in the user's Elastic data. + * + * Two layers, one call: + * 1. CVE layer — pull the top prioritised vulnerabilities (depth/context). + * 2. IOC layer — pull indicators and check each one against the user's + * events, returning which ones actually appear (the indicator match). + */ +export function registerCrossReferenceIntelTool( + server: McpServer, + deps: CrossReferenceIntelDeps, +) { + const { esqlService, intelClient, analytics } = deps; + + registerTrackedAppTool( + analytics, + server, + "cross-reference-intel", + { + title: "Cross-reference Threat Intel", + description: + "Pull live threat intelligence (prioritised CVEs and indicators of compromise) from CentinelaCI and check each indicator against the user's Elasticsearch data. Use when the user asks whether current threats, indicators, or campaigns have been seen in their environment, or wants intel grounded in their own logs.", + _meta: { ui: { visibility: ["model"] } }, + inputSchema: { + indexPattern: z + .string() + .optional() + .describe("Index pattern to search for indicators (default: logs-*)"), + iocLimit: z + .number() + .int() + .positive() + .optional() + .describe("Max indicators to pull and check (default: 20)"), + vulnLimit: z + .number() + .int() + .positive() + .optional() + .describe("Max prioritised CVEs to pull for context (default: 10)"), + }, + }, + async ({ indexPattern, iocLimit, vulnLimit }) => { + // Degrade gracefully when intel isn't wired up, so the demo fails with a + // clear instruction instead of an opaque stack trace. + if (!intelClient) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: + "CentinelaCI intel is not configured. Set CENTINELA_API_URL and CENTINELA_API_PASSWORD, then restart the server.", + }), + }, + ], + }; + } + + const pattern = indexPattern ?? "logs-*"; + + // Layer 1 — CVE context. Pulled first so the model can frame the threat + // landscape before we drill into "did it touch me". + const vulns = await intelClient.topVulns(vulnLimit ?? 10); + + // Layer 2 — IOC match. Pull indicators, then check each against Elastic. + const iocs = await intelClient.listIocs(iocLimit ?? 20); + const checked = await Promise.all( + iocs.map((ioc) => matchIoc(esqlService, pattern, ioc)), + ); + + const matches = checked.filter((m) => m.matched); + const payload = { + summary: + `Intel: ${vulns.length} prioritised CVEs, ${iocs.length} indicators. ` + + `${matches.length} indicator(s) matched in ${pattern}.`, + intel: { + vulns: { count: vulns.length, top: vulns }, + iocs: { count: iocs.length, checked: checked.length }, + }, + matches, + matchCount: matches.length, + indexPattern: pattern, + }; + + return { + content: [{ type: "text" as const, text: JSON.stringify(payload) }], + }; + }, + ); +}