From debd211a94cf5a0e63962e81092031a8270eddec Mon Sep 17 00:00:00 2001 From: seth-goodwin Date: Thu, 18 Jun 2026 14:50:38 -0500 Subject: [PATCH 1/9] Add correlation tools (diamond_search, get_report) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ES-retrieval-only addon for threat correlation; the host LLM reasons, guided by a vendored tradecraft rubric. diamond_search returns blind candidate stubs (no scores) from semantic_text kNN + IOC anchors + BM25; get_report batch-fetches full text by id. No LLM calls, no creds beyond ES. Retrieval vendored from the Kibana IntelligenceHub engine. NOTE: requires a custom threat-report corpus index (not stock Elastic) — see src/correlation/README.md. Self-contained: new files + 2 additive lines in server.ts. --- src/correlation/README.md | 108 +++++ src/correlation/tradecraft.ts | 206 +++++++++ src/elastic/service/correlationService.ts | 503 ++++++++++++++++++++++ src/elastic/service/index.ts | 2 + src/server.ts | 4 + src/tools/correlation.ts | 175 ++++++++ 6 files changed, 998 insertions(+) create mode 100644 src/correlation/README.md create mode 100644 src/correlation/tradecraft.ts create mode 100644 src/elastic/service/correlationService.ts create mode 100644 src/tools/correlation.ts diff --git a/src/correlation/README.md b/src/correlation/README.md new file mode 100644 index 0000000..26a746f --- /dev/null +++ b/src/correlation/README.md @@ -0,0 +1,108 @@ +# Correlation tools — corpus index prerequisite + +The two correlation tools (`diamond_search`, `get_report`) differ from every other tool in this project in one critical way: **they query a custom index that does not exist in a stock Elastic deployment.** + +All other tools (alert triage, attack discovery, cases, detection rules, threat hunt) query indices that ship with Elastic Security — `.alerts-security.*`, `.lists-*`, `kibana_cases`, and so on. Those indices are present on any deployment with the Security solution enabled. + +The correlation tools query a **custom threat-report corpus** built and maintained by the Kibana [IntelligenceHub](../../CONTRIBUTING.md) plugin (or a compatible ingest pipeline). If that corpus does not exist, `diamond_search` and `get_report` will return empty results or errors. + +> [!IMPORTANT] +> **Phase 0 scope:** these tools assume the corpus already exists on the target cluster. Standalone corpus ingest is out of scope here. Point the tools at a cluster that already has the IntelligenceHub plugin and a populated data stream. + +--- + +## The corpus index + +### Data stream + +| Property | Value | +|----------|-------| +| Pattern matched | `.kibana-threat-reports*` | +| Backing data stream | `.kibana-threat-reports` | +| Mapping mode | `dynamic: strict` — every field must be declared | +| Template version | v14 (see Kibana `setup/index_templates.ts`) | + +The data stream uses `dynamic: strict`, so any write that references an undeclared field is rejected. All fields listed below must be present in the index template before any document can be indexed. + +--- + +### Fields the correlation tools depend on + +#### Diamond Model extraction — semantic search path + +These fields power `diamond_search`'s per-vertex semantic retrieval. They are populated by the IntelligenceHub extraction pipeline (`extract_diamond` step) and require a configured `semantic_text` inference endpoint. + +| Field | Type | Notes | +|-------|------|-------| +| `extracted.diamond.adversary.summary` | `semantic_text` | 1–3 sentence behavioural summary of the adversary vertex. Embedded via `DIAMOND_INFERENCE_ENDPOINT_ID` at index time. Empty when `signal` is `NONE`. | +| `extracted.diamond.adversary.signal` | `keyword` | `HIGH` \| `PARTIAL` \| `NONE` — confidence of the extraction. | +| `extracted.diamond.capability.summary` | `semantic_text` | Capability vertex summary. Same inference endpoint. | +| `extracted.diamond.capability.signal` | `keyword` | `HIGH` \| `PARTIAL` \| `NONE` | +| `extracted.diamond.infrastructure.summary` | `semantic_text` | Infrastructure vertex summary. | +| `extracted.diamond.infrastructure.signal` | `keyword` | `HIGH` \| `PARTIAL` \| `NONE` | +| `extracted.diamond.victim.summary` | `semantic_text` | Victim vertex summary. | +| `extracted.diamond.victim.signal` | `keyword` | `HIGH` \| `PARTIAL` \| `NONE` | +| `extracted.diamond.suitable` | `boolean` | `true` when the document has at least one non-NONE vertex and passed the extraction quality gate. **Only `suitable: true` documents are searched.** | +| `extracted.diamond.signal_count` | `integer` | Count of non-NONE vertices (0–4). | +| `extracted.diamond.model_id` | `keyword` | Connector / model that produced the extraction (provenance). | +| `extracted.diamond.extracted_at` | `date` | Wall-clock of the extraction run. | +| `extracted.diamond.extraction_mode` | `keyword` | `single_call` \| `per_vertex_fallback` | + +**Inference endpoint requirement:** each `semantic_text` field under `extracted.diamond.*` uses a shared inference endpoint configured at template-creation time (`DIAMOND_INFERENCE_ENDPOINT_ID`). The endpoint must exist and be healthy when documents are indexed; ES validates the `inference_id` at document-index time (not at template PUT). `diamond_search` calls ES `/_msearch` with `{ semantic: { field: "...", query: "..." } }` — this query type requires the inference endpoint to be reachable at search time. When it is unavailable, the service degrades to BM25 (`degraded: true` in the response). + +--- + +#### IOC and actor anchors — exact-match path + +These fields power the hash-IOC anchor path in `diamond_search`. + +| Field | Type | Notes | +|-------|------|-------| +| `extracted.iocs` | `nested` | Array of `{ type: keyword, value: keyword }` pairs. `type` values: `hash`, `ip`, `domain`, `url`. | +| `extracted.ioc_set_hash` | `keyword` | SHA-256 fingerprint of the full IOC set. Exact match across two reports implies identical infrastructure. | +| `extracted.threat_actors` | `keyword` | Named threat-actor strings (array). | +| `extracted.ttps.techniques` | `keyword` | MITRE ATT&CK technique IDs (array, e.g. `T1059.003`). | + +The anchor path requires `extracted.iocs` to be a `nested` type (not a flat object array) so the `nested` query can scope `type` and `value` to the same element. A flat `object` mapping will silently corrupt multi-IOC boolean logic. + +--- + +#### Full-text content — BM25 degradation + `get_report` + +| Field | Type | Notes | +|-------|------|-------| +| `content.title` | `semantic_text` | Report headline. `copy_to: ["content.title_bm25"]`. | +| `content.title_bm25` | `text` | BM25-indexed sibling, populated via `copy_to`. Used in the `multi_match` fallback query. | +| `content.body_text` | `semantic_text` | Full report body. `copy_to: ["content.body_text_bm25"]`. | +| `content.body_text_bm25` | `text` | BM25-indexed sibling. Also the target of `match_phrase` in the keyword gap-fill path. | + +Both `semantic_text` fields on `content.*` intentionally omit `inference_id` — they inherit the cluster default at index creation (Jina v5, ELSER, or multilingual-e5 depending on the deployment). The BM25 siblings receive their content via `copy_to` at index time; the `copy_to` targets must use full paths (`content.title_bm25`, not `title_bm25`) because `dynamic: strict` rejects root-level field creation. + +--- + +#### Display and linking — returned in stubs and full reports + +| Field | Type | Notes | +|-------|------|-------| +| `source.name` | `keyword` | Human-readable feed / vendor name (e.g. `"Mandiant"`, `"CISA"`). Returned as `vendor` in tool responses. | +| `source.type` | `keyword` | Feed type (`rss`, `stix`, `taxii`, `vendor_api`, `telemetry`). Fallback when `source.name` is absent. | +| `source.url` | `keyword` | Canonical URL to the original report. Returned as `url` in tool responses. | +| `severity.level` | `keyword` | Report severity (`critical`, `high`, `medium`, `low`). | +| `provenance.extracted_at` | `date` | Extraction wall-clock. | + +--- + +## Provisioning requirements + +To use the correlation tools you need: + +1. **The data stream and template** — `PUT _index_template/.kibana-threat-reports-template` with the v14 mapping, then `PUT _data_stream/.kibana-threat-reports`. The IntelligenceHub Kibana plugin does this at startup. There is no standalone provisioning script in this repo. + +2. **A `semantic_text` inference endpoint** registered as `DIAMOND_INFERENCE_ENDPOINT_ID` (typically `"threat-report-diamond-embeddings"`, a Jina v5 or ELSER endpoint). Without this, document indexing for diamond fields fails and `diamond_search` always degrades to BM25. + +3. **A populated corpus** — documents must have been ingested and enriched by the IntelligenceHub extraction pipeline. Raw ingest without the `extract_diamond` step produces documents with no diamond fields; those documents are excluded from semantic search by the `extracted.diamond.suitable: true` filter. + +4. **API key permissions** — the key configured in `CLUSTERS_JSON` must have read access to `.kibana-threat-reports*`. The IntelligenceHub plugin uses a `kibana_system` internal user for writes; the MCP app only reads. + +> [!NOTE] +> The `space_id` field on every document provides logical multi-tenancy within a single index. The correlation service in this repo does **not** filter by `space_id` — it searches across all spaces. This matches the MCP server's single-cluster, single-tenant Phase 0 design. diff --git a/src/correlation/tradecraft.ts b/src/correlation/tradecraft.ts new file mode 100644 index 0000000..99c2e43 --- /dev/null +++ b/src/correlation/tradecraft.ts @@ -0,0 +1,206 @@ +/* + * 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. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Apache License, + * Version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +// VENDORED SNAPSHOT from kibana-threat-intel-poc — canonical source is the Kibana plugin +// (x-pack/.../server/threat_intelligence/services/synthesis_guidance.ts). +// Re-vendor on change. Do not edit here. + +// --------------------------------------------------------------------------- +// Diamond Model schema — vertex names + summarisation guidance for the host +// --------------------------------------------------------------------------- + +export const DIAMOND_VERTICES = ['adversary', 'capability', 'infrastructure', 'victim'] as const; +export type DiamondVertex = (typeof DIAMOND_VERTICES)[number]; + +/** + * Instructions for the host model on how to summarise a case into the four + * Diamond Model vertices before calling `diamond_search`. + * + * Each vertex should be a single free-text paragraph that captures the + * behavioural signal for that corner. Omit a vertex (pass undefined or empty + * string) when there is genuinely no observable signal — never invent content. + */ +export const DIAMOND_SUMMARISATION_GUIDANCE = `\ +To drive a Diamond Model correlation search, summarise the case into up to four +vertex paragraphs. Write each as a self-contained behavioural description that +could stand alone as a search query; do NOT include IOC values (hashes, IPs, +domains) in the paragraphs — those are handled separately via the iocs parameter. + +adversary — Who is operating: threat-actor names, aliases, tracked clusters, + attributed nation-state or criminal group, operational objectives. + Omit if unknown. + +capability — What tools and techniques are used: malware families, exploited + CVEs, LOLBIN abuse, C2 frameworks, TTP patterns (MITRE ATT&CK + technique names are fine). Focus on WHAT and HOW. + +infrastructure — How the operation is staged: hosting patterns, bulletproof + providers, TLD preferences, certificate quirks, relay/proxy chains, + legitimate-service abuse. Focus on the operational-security profile. + +victim — Who is targeted: industry verticals, geographies, organisation + types, job titles, technology stack (OS, exposed services). + +Guidelines: +- One paragraph per vertex, 2–5 sentences. +- Use behavioural language, not atomic artifact lists. +- Omit or leave empty any vertex with no observable signal. +- Do NOT embed IOC values inline — pass them separately as iocs[].`; + +// --------------------------------------------------------------------------- +// Triage rubric — the host model uses this to rank candidates returned by +// diamond_search before deciding which to fetch in full via get_report. +// --------------------------------------------------------------------------- + +export const TRIAGE_RUBRIC = `\ +TRIAGE GUIDANCE + +After receiving diamond_search results, triage the candidate stubs in this order: + +1. OVERLAP first — prefer candidates that matched on more Diamond Model vertices + (overlap count, if provided). Multi-vertex overlap is stronger evidence than + any single-vertex hit. + +2. VERTEX ALIGNMENT — weight matches on the vertices where your case has the + strongest signal. A capability match when your case is capability-rich is + more significant than a victim match when capability is the only evidence. + +3. EXCLUDE OBVIOUS MISSES — discard candidates whose titles clearly describe + unrelated threat clusters (different malware family, different target sector, + different era) before spending tokens on full-text reads. + +4. SELECT FOR DEPTH — call get_report for the top candidates that survive triage + (typically 3–7). Prioritise candidates with the highest multi-vertex overlap; + include at least one lower-overlap candidate as a falsification check. + +5. ANCHOR CHECK — if the search also returned anchor (exact IOC / actor) matches, + treat those as higher-confidence leads regardless of semantic score. + +Do NOT anchor on numeric scores if they are not provided — the search is +deliberately score-blind (blind-pack pattern) to avoid over-indexing on any +single similarity metric.`; + +// --------------------------------------------------------------------------- +// Synthesis guidance — verbatim from kibana-threat-intel-poc synthesis_guidance.ts +// --------------------------------------------------------------------------- + +export const SYNTHESIS_GUIDANCE_TEXT = `\ +RELATIONSHIP TAXONOMY + +Assess each candidate at one of three levels: + +same_campaign — The new case and the candidate describe the same operational activity. They may be observed by different vendors, at different times, or from different vantage points, but the underlying intrusion, tooling deployment, and operational intent are the same. + +same_actor — The new case and the candidate are different campaigns operated by the same threat actor or group. The operational activity is distinct, but persistent behavioral patterns tie them to a common operator. + +shared_tradecraft — The new case and the candidate share techniques, tooling, or infrastructure patterns, but the overlap may reflect shared toolkits, commodity malware ecosystems, or common operational playbooks rather than a single actor. + +CONFIDENCE CALIBRATION + +high — Multiple independent behavioral indicators corroborate across at least two Diamond Model vertices. The shared patterns are specific enough that coincidence is unlikely. + +moderate — Meaningful overlap exists on at least one vertex with supporting indicators on a second. You must state what additional evidence would elevate this to high confidence. + +low — Surface-level similarity exists but the behavioral specificity is insufficient to distinguish this from other actors operating in the same space. You must explain why the similarity is weak. + +EVIDENCE WEIGHTS + +Each evidence item in \`evidence[]\` receives exactly one weight: + +smoking_gun — Decisive, highly discriminating; coincidence implausible. The item alone would materially determine the relationship. +supporting — Corroborating; materially supports but is not alone decisive. Combines with other items to build confidence. +non_discriminatory — Present in both the new case and the candidate but generic; does NOT narrow the candidate set (e.g. "both target Windows", commodity malware, broadly used techniques). +counter — Argues against the proposed relationship; introduces doubt. Requires POSITIVE contradictory evidence (e.g. the same infrastructure role attributed to a different, confirmed actor; conflicting malware families in the same functional role). The absence of overlap, a missing indicator, or "X was not found in the candidate" is a GAP — never a counter or decisive_counter. +decisive_counter — Decisively refutes or rules out the relationship. Same positive-evidence requirement as counter, at a higher threshold. + +Each item also names the Diamond Model vertex it belongs to. + +JUDGE REASONING GUIDANCE + +Weight a coherent multi-vertex attack SHAPE over isolated atomic artifacts — the strongest correlation is usually the cross-vertex pattern, not any single item. + +Weight an indicator by its EXCLUSIVITY in real-world malicious use, not merely "tool vs. technique." A generic, independently-reimplemented technique is weak (→ non_discriminatory or counter). A rare, gated, or boutique tool CONFIRMED in-case is strong. An atomic code artifact is a tool-mark: distinctive and corroborating, but narrower than a behavioral-shape match. + +VALUE OF INFORMATION: where an UNCONFIRMED indicator would materially change the assessment if confirmed, say so in that lead's \`gaps\` AND add a HIGH-priority next step to verify it. + +BEHAVIORAL RULES + +1. Evidence-first reasoning. Lead with specific behavioral evidence before stating confidence. +2. Cross-vertex corroboration must be explicit. Populate vertex_signal for all four vertices; use "high" only when specific evidence applies, "partial" for weak or inferred signal, "none" when absent. +3. Articulate the gap. For moderate and low confidence leads, state what evidence is missing. +4. No hallucinated linkage. Work only with the provided source material and candidate reports. +5. Unidirectional output. Produce affirmative matches or no-match statements only. +6. Probability language, not certainty language. +7. Graceful degradation on thin evidence. Never stretch a weak match. +7a. Do not treat absent capabilities as divergent evidence. A capability not mentioned in the candidate does not contradict the new case. +7b. Describe what the case evidence shows, not what happened to it. +8. Distinguish what the new case shows from what candidate reports claim. +8a. Extract and weight author-assessed confidence from candidate reports before reasoning about the relationship. +9. Resolve vendor tracking labels before reasoning. Elastic REF#### = intrusion sets. Mandiant UNC#### = uncategorized clusters. Microsoft weather names = actor groups. CrowdStrike animals = actor designations. +10. Format technical indicators. Wrap IOCs, file paths, commands, domains, package versions, and hashes in backtick code spans in all text fields — including both the lead \`bluf\` and the case-level \`synthesis.bluf\`. +11. Evidence per rated vertex. Every vertex you rate \`partial\` or \`high\` in vertex_signal MUST have at least one evidence item whose \`vertex\` matches it. If you cannot cite evidence for a vertex, rate it \`none\`. (e.g. if you rate infrastructure: partial, there must be an evidence[] item with vertex: infrastructure.)`; + +/** + * Composite tradecraft bundle returned in every `diamond_search` response. + * The host model uses the triage rubric to rank candidates, then the synthesis + * guidance to produce structured correlation findings after reading full reports. + */ +export const TRADECRAFT = { + diamond_summarisation_guidance: DIAMOND_SUMMARISATION_GUIDANCE, + triage_rubric: TRIAGE_RUBRIC, + synthesis_guidance: { + instructions: SYNTHESIS_GUIDANCE_TEXT, + recommended_output: { + leads: [ + { + candidate_ids: [''], + title: '', + relationship: 'same_campaign | same_actor | shared_tradecraft', + confidence: 'high | moderate | low', + vertex_signal: { + adversary: 'high | partial | none', + capability: 'high | partial | none', + infrastructure: 'high | partial | none', + victim: 'high | partial | none', + }, + bluf: '', + evidence: [ + { + vertex: 'capability | infrastructure | adversary | victim', + weight: 'smoking_gun | supporting | non_discriminatory | counter | decisive_counter', + text: '', + }, + ], + gaps: '', + }, + ], + no_match: [ + { + id: '', + title: '', + }, + ], + synthesis: { + bluf: '', + correlation_signal: 'high | moderate | low | none', + reasoning: + '', + gaps: '', + next_steps: [{ priority: 'high | moderate', text: '' }], + }, + }, + }, +} as const; diff --git a/src/elastic/service/correlationService.ts b/src/elastic/service/correlationService.ts new file mode 100644 index 0000000..ba6ce61 --- /dev/null +++ b/src/elastic/service/correlationService.ts @@ -0,0 +1,503 @@ +/* + * 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. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Apache License, + * Version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type { EsClient } from "../es-client/index.js"; +import { DIAMOND_VERTICES } from "../../correlation/tradecraft.js"; +import type { DiamondVertex } from "../../correlation/tradecraft.js"; + +// --------------------------------------------------------------------------- +// Constants — mirror kibana-threat-intel-poc constants +// --------------------------------------------------------------------------- + +const THREAT_REPORTS_INDEX_PATTERN = ".kibana-threat-reports*"; +const NOISE_FLOOR = 0.7; +const KNN_CANDIDATES_PER_VERTEX = 50; +const DEFAULT_SIZE = 20; +const MAX_SIZE = 50; +const HASH_IOC_TYPE = "hash" as const; +const NETWORK_IOC_TYPES = new Set(["ip", "domain", "url"]); + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface DiamondVertexQueries { + adversary?: string; + capability?: string; + infrastructure?: string; + victim?: string; +} + +export interface AnchorIoc { + type: string; + value: string; +} + +export interface DiamondSearchParams { + /** Free-text vertex summaries from the host model's case summarisation. */ + vertex_queries?: DiamondVertexQueries; + /** Optional IOC anchors (hash / ip / domain / url). */ + iocs?: AnchorIoc[]; + /** Maximum stubs to return. Default 20, cap 50. */ + size?: number; +} + +export interface ReportStub { + report_id: string; + title: string; + vendor: string; + url: string; +} + +export interface DiamondSearchResult { + candidates: ReportStub[]; + total: number; + /** True when inference was unavailable and BM25 fallback was used. */ + degraded: boolean; + vertices_queried: DiamondVertex[]; +} + +export interface ReportFull { + report_id: string; + title: string; + vendor: string; + url: string; + body_text: string; +} + +// --------------------------------------------------------------------------- +// Internal ES response shapes +// --------------------------------------------------------------------------- + +interface EsHit { + _id: string; + _score?: number | null; + _source?: T; +} + +interface EsSearchResponse { + hits: { + total?: number | { value: number }; + hits: Array>; + }; +} + +interface EsMsearchResponse { + responses: Array< + | { hits: { hits: Array> }; error?: undefined } + | { error: Record; hits?: undefined } + >; +} + +interface SourceFields { + "@timestamp"?: string; + content?: { title?: string }; + source?: { name?: string; type?: string; url?: string }; + severity?: { level?: string }; + provenance?: { extracted_at?: string }; +} + +interface SourceFieldsFull extends SourceFields { + content?: { title?: string; body_text?: string }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const toStub = (hit: EsHit): ReportStub => ({ + report_id: hit._id, + title: hit._source?.content?.title?.trim() ?? hit._id, + vendor: hit._source?.source?.name ?? hit._source?.source?.type ?? "unknown", + url: hit._source?.source?.url ?? "", +}); + +const splitIocs = ( + iocs: AnchorIoc[] +): { hashValues: string[]; networkValues: string[] } => { + const hashValues = new Set(); + const networkValues = new Set(); + for (const { type, value } of iocs) { + if (!value) continue; + if (type === HASH_IOC_TYPE) { + hashValues.add(value.toLowerCase()); + } else if (NETWORK_IOC_TYPES.has(type)) { + networkValues.add(value.toLowerCase()); + } + } + return { hashValues: [...hashValues], networkValues: [...networkValues] }; +}; + +// --------------------------------------------------------------------------- +// Semantic per-vertex search (msearch via raw REST) +// --------------------------------------------------------------------------- + +const runSemanticSearch = async ( + esClient: EsClient, + queriedVertices: DiamondVertex[], + vertexQueries: DiamondVertexQueries, + size: number +): Promise<{ stubs: ReportStub[]; total: number; degraded: false }> => { + // Build ndjson body: one header + body pair per vertex. + const lines: string[] = []; + for (const vertex of queriedVertices) { + lines.push( + JSON.stringify({ + index: THREAT_REPORTS_INDEX_PATTERN, + ignore_unavailable: true, + }) + ); + lines.push( + JSON.stringify({ + query: { + bool: { + must: [ + { + semantic: { + field: `extracted.diamond.${vertex}.summary`, + query: vertexQueries[vertex], + }, + }, + ], + filter: [{ term: { "extracted.diamond.suitable": true } }], + }, + }, + size: KNN_CANDIDATES_PER_VERTEX, + _source: ["content.title", "source.name", "source.type", "source.url"], + }) + ); + } + const ndjson = lines.join("\n") + "\n"; + + const resp = await esClient.post>( + "/_msearch", + ndjson, + { headers: { "Content-Type": "application/x-ndjson" } } + ); + const msearch = resp.data; + + // Build score matrix: reportId → { source, scores: { vertex → score } } + const matrix = new Map< + string, + { source: SourceFields; scores: Partial> } + >(); + + for (let i = 0; i < queriedVertices.length; i++) { + const vertex = queriedVertices[i]; + const response = msearch.responses[i]; + if ("error" in response && response.error) { + const errMsg = JSON.stringify(response.error).toLowerCase(); + if (errMsg.includes("inference") || errMsg.includes("service_unavailable")) { + throw new Error(`inference_unavailable: ${JSON.stringify(response.error)}`); + } + // Non-inference errors: skip this vertex quietly. + continue; + } + const hits = response.hits?.hits ?? []; + for (const hit of hits) { + if (!matrix.has(hit._id)) { + matrix.set(hit._id, { source: hit._source ?? {}, scores: {} }); + } + const entry = matrix.get(hit._id)!; + entry.scores[vertex] = hit._score ?? 0; + } + } + + // Qualify: at least one vertex score >= NOISE_FLOOR. + const candidates: Array<{ stub: ReportStub; overlap: number; maxScore: number }> = []; + + for (const [reportId, { source, scores }] of matrix) { + const aboveFloor = DIAMOND_VERTICES.filter( + (v) => scores[v] !== undefined && (scores[v] as number) >= NOISE_FLOOR + ); + if (aboveFloor.length === 0) continue; + const aboveScores = aboveFloor.map((v) => scores[v] as number); + candidates.push({ + stub: { + report_id: reportId, + title: source.content?.title?.trim() ?? reportId, + vendor: source.source?.name ?? source.source?.type ?? "unknown", + url: source.source?.url ?? "", + }, + overlap: aboveFloor.length, + maxScore: Math.max(...aboveScores), + }); + } + + // Sort: overlap desc, maxScore desc — mirrors Mustard compact_output sort key. + candidates.sort((a, b) => + b.overlap !== a.overlap ? b.overlap - a.overlap : b.maxScore - a.maxScore + ); + + return { + stubs: candidates.slice(0, size).map((c) => c.stub), + total: candidates.length, + degraded: false, + }; +}; + +// --------------------------------------------------------------------------- +// BM25 fallback +// --------------------------------------------------------------------------- + +const runBm25Fallback = async ( + esClient: EsClient, + queriedVertices: DiamondVertex[], + vertexQueries: DiamondVertexQueries, + size: number +): Promise<{ stubs: ReportStub[]; total: number; degraded: true }> => { + const combinedQuery = queriedVertices + .map((v) => vertexQueries[v]) + .filter(Boolean) + .join(" "); + + const resp = await esClient.post>( + `/${THREAT_REPORTS_INDEX_PATTERN}/_search`, + { + size, + track_total_hits: true, + _source: ["content.title", "source.name", "source.type", "source.url"], + query: { + bool: { + must: [ + { + multi_match: { + query: combinedQuery, + fields: ["content.title_bm25^2", "content.body_text_bm25"], + }, + }, + ], + filter: [{ term: { "extracted.diamond.suitable": true } }], + }, + }, + } + ); + + const hits = resp.data.hits.hits ?? []; + const total = + typeof resp.data.hits.total === "number" + ? resp.data.hits.total + : (resp.data.hits.total?.value ?? hits.length); + + return { + stubs: hits.map(toStub), + total, + degraded: true, + }; +}; + +// --------------------------------------------------------------------------- +// Anchor search (exact hash/ioc_set_hash/actor — discriminating gate) +// --------------------------------------------------------------------------- + +const runAnchorSearch = async ( + esClient: EsClient, + iocs: AnchorIoc[], + size: number, + excludeIds: Set +): Promise => { + const { hashValues, networkValues } = splitIocs(iocs); + if (hashValues.length === 0 && networkValues.length === 0) return []; + + // Gate: at least one hash IOC must match (discriminating only; no network-only). + if (hashValues.length === 0) return []; + + const gateDisc = [ + { + nested: { + path: "extracted.iocs", + query: { + bool: { + must: [ + { term: { "extracted.iocs.type": HASH_IOC_TYPE } }, + { terms: { "extracted.iocs.value": hashValues } }, + ], + }, + }, + }, + }, + ]; + + const shouldClauses: Array> = [ + { + constant_score: { + filter: { + nested: { + path: "extracted.iocs", + query: { + bool: { + must: [ + { term: { "extracted.iocs.type": HASH_IOC_TYPE } }, + { terms: { "extracted.iocs.value": hashValues } }, + ], + }, + }, + }, + }, + boost: 4.0, + }, + }, + ]; + + if (networkValues.length > 0) { + shouldClauses.push({ + constant_score: { + filter: { + nested: { + path: "extracted.iocs", + query: { + bool: { + must: [ + { terms: { "extracted.iocs.type": [...NETWORK_IOC_TYPES] } }, + { terms: { "extracted.iocs.value": networkValues } }, + ], + }, + }, + }, + }, + boost: 1.5, + }, + }); + } + + const mustNotClauses: Array> = excludeIds.size > 0 + ? [{ ids: { values: [...excludeIds] } }] + : []; + + const resp = await esClient.post>( + `/${THREAT_REPORTS_INDEX_PATTERN}/_search`, + { + size, + track_total_hits: true, + _source: ["content.title", "source.name", "source.type", "source.url"], + query: { + bool: { + filter: [{ bool: { should: gateDisc, minimum_should_match: 1 } }], + should: shouldClauses, + minimum_should_match: 0, + ...(mustNotClauses.length > 0 ? { must_not: mustNotClauses } : {}), + }, + }, + } + ); + + return (resp.data.hits.hits ?? []).map(toStub); +}; + +// --------------------------------------------------------------------------- +// Public service +// --------------------------------------------------------------------------- + +interface CorrelationServiceOptions { + readonly esClient: EsClient; +} + +export class CorrelationService { + constructor(private readonly options: CorrelationServiceOptions) {} + + /** + * Diamond Model correlation search. + * + * Runs per-vertex semantic search (one msearch round trip) over + * `extracted.diamond.{vertex}.summary`, qualifies candidates at NOISE_FLOOR + * 0.70, and sorts by (overlap, max_score). Optionally merges exact-hash + * anchor hits at the front. Degrades to BM25 when inference is unavailable. + * + * Returns stubs only — no scores exposed (blind-pack pattern). + */ + async diamondSearch(params: DiamondSearchParams): Promise { + const { esClient } = this.options; + const size = Math.min(params.size ?? DEFAULT_SIZE, MAX_SIZE); + const vertexQueries = params.vertex_queries ?? {}; + + const queriedVertices = DIAMOND_VERTICES.filter( + (v) => (vertexQueries[v] ?? "").trim().length > 0 + ); + + if (queriedVertices.length === 0) { + return { candidates: [], total: 0, degraded: false, vertices_queried: [] }; + } + + let semanticResult: { stubs: ReportStub[]; total: number; degraded: boolean }; + try { + semanticResult = await runSemanticSearch(esClient, queriedVertices, vertexQueries, size); + } catch (err) { + const msg = String((err as Error)?.message ?? "").toLowerCase(); + const isInferenceUnavailable = + msg.includes("inference_unavailable") || + msg.includes("service_unavailable") || + msg.includes("503"); + + if (isInferenceUnavailable) { + semanticResult = await runBm25Fallback(esClient, queriedVertices, vertexQueries, size); + } else { + throw err; + } + } + + // Merge anchor hits (hash IOCs) at the front if provided. + let candidates = semanticResult.stubs; + if (params.iocs && params.iocs.length > 0) { + const anchorHits = await runAnchorSearch(esClient, params.iocs, size, new Set()); + // De-duplicate: anchor-first, then semantic hits not already in anchors. + const anchorIds = new Set(anchorHits.map((c) => c.report_id)); + const semanticOnly = candidates.filter((c) => !anchorIds.has(c.report_id)); + candidates = [...anchorHits, ...semanticOnly].slice(0, size); + } + + return { + candidates, + total: semanticResult.total, + degraded: semanticResult.degraded, + vertices_queried: queriedVertices, + }; + } + + /** + * Fetch full text for a list of report IDs. + * + * Returns content.body_text + title + vendor + url per report. + * Uses a terms query on _id (ES rejects wildcard GET-by-id on data streams). + */ + async getReports(reportIds: string[]): Promise { + if (reportIds.length === 0) return []; + + const { esClient } = this.options; + + const resp = await esClient.post>( + `/${THREAT_REPORTS_INDEX_PATTERN}/_search`, + { + size: reportIds.length, + query: { terms: { _id: reportIds } }, + _source: [ + "content.title", + "content.body_text", + "source.name", + "source.type", + "source.url", + ], + } + ); + + return (resp.data.hits.hits ?? []).map((hit) => ({ + report_id: hit._id, + title: hit._source?.content?.title?.trim() ?? hit._id, + vendor: hit._source?.source?.name ?? hit._source?.source?.type ?? "unknown", + url: hit._source?.source?.url ?? "", + body_text: hit._source?.content?.body_text ?? "", + })); + } +} diff --git a/src/elastic/service/index.ts b/src/elastic/service/index.ts index 93f11e5..e2c5da5 100644 --- a/src/elastic/service/index.ts +++ b/src/elastic/service/index.ts @@ -20,3 +20,5 @@ export type { } from "./sampleDataService.js"; export { SampleDataService, SCENARIO_NAMES, SCENARIO_RULES } from "./sampleDataService.js"; export { TelemetryService } from "./telemetryService.js"; +export { CorrelationService } from "./correlationService.js"; +export type { DiamondSearchParams, DiamondSearchResult, ReportStub, ReportFull } from "./correlationService.js"; diff --git a/src/server.ts b/src/server.ts index f684b05..8d3054f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -27,6 +27,7 @@ import { AlertsService, AttackDiscoveryService, CasesService, + CorrelationService, EntityDetailService, EsqlService, IndicesService, @@ -35,6 +36,7 @@ import { SampleDataService, } from "./elastic/service/index.js"; import { registerAlertTriageTools } from "./tools/alert-triage.js"; +import { registerCorrelationTools } from "./tools/correlation.js"; import { registerAnalyticsTools } from "./tools/analytics.js"; import { registerAttackDiscoveryTools } from "./tools/attack-discovery.js"; import { registerCaseManagementTools } from "./tools/case-management.js"; @@ -105,6 +107,7 @@ export function createServer(deps: CreateServerDeps = {}): McpServer { sampleDataClient: new SampleDataClient({ esClient }), rulesService, }); + const correlationService = new CorrelationService({ esClient }); const server = new McpServer({ name: "elastic-security", @@ -128,6 +131,7 @@ export function createServer(deps: CreateServerDeps = {}): McpServer { analytics, }); registerAnalyticsTools(server, { analytics }); + registerCorrelationTools(server, { correlationService, analytics }); return server; } diff --git a/src/tools/correlation.ts b/src/tools/correlation.ts new file mode 100644 index 0000000..88de835 --- /dev/null +++ b/src/tools/correlation.ts @@ -0,0 +1,175 @@ +/* + * 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. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Apache License, + * Version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { AnalyticsClient } from "../elastic/analytics/index.js"; +import type { CorrelationService } from "../elastic/service/correlationService.js"; +import { TRADECRAFT } from "../correlation/tradecraft.js"; +import { registerTrackedAppTool } from "./tracked-app-tool.js"; + +export interface CorrelationToolDeps { + readonly correlationService: CorrelationService; + readonly analytics: AnalyticsClient; +} + +/** + * Register the two threat-report correlation tools. + * + * HOST LOOP (described in each tool's description): + * 1. Summarise the case into Diamond Model vertices (use TRADECRAFT guidance). + * 2. Call `diamond_search` → receive candidate stubs + triage/synthesis rubric. + * 3. Triage candidates yourself using the returned rubric. + * 4. Call `get_report` for the top candidates. + * 5. Synthesise correlation findings using the returned synthesis guidance. + */ +export function registerCorrelationTools( + server: McpServer, + deps: CorrelationToolDeps +): void { + const { correlationService, analytics } = deps; + + // ------------------------------------------------------------------------- + // diamond_search + // ------------------------------------------------------------------------- + + registerTrackedAppTool( + analytics, + server, + "diamond_search", + { + title: "Diamond Model Correlation Search", + description: `Search the threat-report corpus for reports that correlate with a new case using the Diamond Model of Intrusion Analysis. + +HOST WORKFLOW: +1. Summarise your case into up to four Diamond Model vertex paragraphs (adversary, capability, infrastructure, victim) following the diamond_summarisation_guidance included in every response. Omit vertices with no signal. +2. Call this tool with your vertex summaries and any file-hash IOCs from the case. +3. You will receive ranked candidate stubs (report_id, title, vendor, url) plus the triage_rubric and synthesis_guidance you need for later steps. +4. Triage candidates using the returned triage_rubric — do NOT anchor on numeric scores (none are returned). +5. Call get_report with the IDs of your top candidates. +6. Synthesise correlation findings using the returned synthesis_guidance.`, + _meta: { ui: {} }, + inputSchema: { + adversary: z + .string() + .optional() + .describe( + "Adversary vertex: threat-actor names, aliases, attributed group, operational objectives." + ), + capability: z + .string() + .optional() + .describe( + "Capability vertex: malware families, exploited CVEs, TTP patterns, C2 frameworks." + ), + infrastructure: z + .string() + .optional() + .describe( + "Infrastructure vertex: hosting patterns, TLD preferences, relay chains, opsec profile." + ), + victim: z + .string() + .optional() + .describe( + "Victim vertex: targeted industry verticals, geographies, org types, technology stack." + ), + iocs: z + .array( + z.object({ + type: z + .enum(["hash", "ip", "domain", "url"]) + .describe("IOC type"), + value: z.string().describe("IOC value (lowercase)"), + }) + ) + .optional() + .describe( + "File-hash and network IOCs from the case. Hash IOCs are used as discriminating anchors; network IOCs boost scoring." + ), + size: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("Maximum candidate stubs to return (default 20, max 50)."), + }, + }, + async ({ adversary, capability, infrastructure, victim, iocs, size }) => { + const result = await correlationService.diamondSearch({ + vertex_queries: { adversary, capability, infrastructure, victim }, + iocs, + size, + }); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + candidates: result.candidates, + meta: { + total: result.total, + degraded: result.degraded, + vertices_queried: result.vertices_queried, + }, + tradecraft: TRADECRAFT, + }), + }, + ], + }; + } + ); + + // ------------------------------------------------------------------------- + // get_report + // ------------------------------------------------------------------------- + + registerTrackedAppTool( + analytics, + server, + "get_report", + { + title: "Get Threat Report", + description: `Retrieve the full text of one or more threat reports by ID. + +Call this after triaging the candidates returned by diamond_search. Pass the report_ids of the candidates you selected for in-depth synthesis. The returned body_text + title + url are the source material for your synthesis step.`, + _meta: { ui: {} }, + inputSchema: { + report_ids: z + .array(z.string()) + .min(1) + .max(10) + .describe( + "Array of report IDs from diamond_search candidates. Maximum 10 per call." + ), + }, + }, + async ({ report_ids }) => { + const reports = await correlationService.getReports(report_ids); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ reports }), + }, + ], + }; + } + ); +} From 090a105bd79d328ce0f6e1afe1ecd5eb40f4445d Mon Sep 17 00:00:00 2001 From: seth-goodwin Date: Thu, 18 Jun 2026 15:43:23 -0500 Subject: [PATCH 2/9] Add analyst-led workflow (input gate + scored triage view) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transparent A/B sibling to the blind correlation path. diamond_search_analyst returns scored candidates (per-vertex match scores, overlap, thin-coverage signal) for analyst-supervised triage. correlation_input_check is a display-only gate: the host LLM passes its per-vertex case summaries + self-rated signal (HIGH/PARTIAL/NONE), the analyst reviews a stoplight checkpoint and decides whether to search — "Search this case" injects a proceed message (app.sendMessage, updateModelContext fallback) carrying the vertex queries so the LLM runs the search immediately; "I'll revise first" returns to chat. Two inline views: the stoplight input gate + a ranked collapsed-diamond triage list with a thin-coverage backfill nudge. No LLM calls in the tools (host reasons). The blind diamond_search/get_report path is unchanged. Self-contained: new view dirs + scored types/method + gate tool; no server.ts change. --- src/correlation/tradecraft.ts | 43 +++ src/elastic/service/correlationService.ts | 254 +++++++++++++ src/elastic/service/index.ts | 11 +- src/tools/correlation.ts | 209 +++++++++++ src/views/correlation-input/App.tsx | 396 ++++++++++++++++++++ src/views/correlation-input/mcp-app.html | 12 + src/views/correlation-input/mcp-app.tsx | 12 + src/views/correlation-input/styles.css | 376 +++++++++++++++++++ src/views/correlation/App.tsx | 397 +++++++++++++++++++++ src/views/correlation/mcp-app.html | 12 + src/views/correlation/mcp-app.tsx | 12 + src/views/correlation/styles.css | 416 ++++++++++++++++++++++ 12 files changed, 2149 insertions(+), 1 deletion(-) create mode 100644 src/views/correlation-input/App.tsx create mode 100644 src/views/correlation-input/mcp-app.html create mode 100644 src/views/correlation-input/mcp-app.tsx create mode 100644 src/views/correlation-input/styles.css create mode 100644 src/views/correlation/App.tsx create mode 100644 src/views/correlation/mcp-app.html create mode 100644 src/views/correlation/mcp-app.tsx create mode 100644 src/views/correlation/styles.css diff --git a/src/correlation/tradecraft.ts b/src/correlation/tradecraft.ts index 99c2e43..ea13d21 100644 --- a/src/correlation/tradecraft.ts +++ b/src/correlation/tradecraft.ts @@ -153,6 +153,48 @@ BEHAVIORAL RULES 10. Format technical indicators. Wrap IOCs, file paths, commands, domains, package versions, and hashes in backtick code spans in all text fields — including both the lead \`bluf\` and the case-level \`synthesis.bluf\`. 11. Evidence per rated vertex. Every vertex you rate \`partial\` or \`high\` in vertex_signal MUST have at least one evidence item whose \`vertex\` matches it. If you cannot cite evidence for a vertex, rate it \`none\`. (e.g. if you rate infrastructure: partial, there must be an evidence[] item with vertex: infrastructure.)`; +// --------------------------------------------------------------------------- +// Input-signal self-rating guidance — used with correlation_input_check +// --------------------------------------------------------------------------- + +/** + * Instructions for the host model on how to self-rate each vertex's signal + * quality when calling `correlation_input_check`. + * + * Self-ratings mirror the corpus-side `extracted.diamond.*.signal` scale: + * HIGH — specific, well-attested; multiple concrete behavioural details + * PARTIAL — present but weak or inferred; one vague indicator or indirect evidence + * NONE — genuinely absent from the case; no observable signal for this vertex + * + * The rating is a SELF-ASSESSMENT to help the analyst decide whether the input + * is ready to search or needs more information. It is NOT a search weight and + * does NOT affect retrieval — it is advisory context for the analyst gate. + */ +export const INPUT_SIGNAL_GUIDANCE = `\ +SELF-RATING YOUR DIAMOND VERTEX SIGNAL + +Before running a correlation search, rate each vertex's signal quality using +the same scale as the corpus index: + +HIGH — You have specific, well-attested behavioural details: named malware + families, attributed threat-actor aliases, confirmed infrastructure + patterns, concrete target industry/geography. Multiple corroborating + observations. High-confidence search anchor. + +PARTIAL — You have some signal but it is weak or inferred: one vague indicator, + a single technique without context, a suspected (not confirmed) actor. + The query will be sent but may produce noisier results. + +NONE — Genuinely absent from the case. Do NOT write a placeholder paragraph. + Omit this vertex from the query entirely (pass empty string or omit). + +RULES: +- Rate only the signal you actually have — do NOT inflate a PARTIAL to HIGH. +- NONE is not a failure; many real cases have strong signal on only 2–3 vertices. +- A PARTIAL vertex is still worth querying; a NONE vertex adds noise, omit it. +- The gate view shows the analyst your self-ratings before the search runs; + they may ask you to refine weak vertices before proceeding.`; + /** * Composite tradecraft bundle returned in every `diamond_search` response. * The host model uses the triage rubric to rank candidates, then the synthesis @@ -160,6 +202,7 @@ BEHAVIORAL RULES */ export const TRADECRAFT = { diamond_summarisation_guidance: DIAMOND_SUMMARISATION_GUIDANCE, + input_signal_guidance: INPUT_SIGNAL_GUIDANCE, triage_rubric: TRIAGE_RUBRIC, synthesis_guidance: { instructions: SYNTHESIS_GUIDANCE_TEXT, diff --git a/src/elastic/service/correlationService.ts b/src/elastic/service/correlationService.ts index ba6ce61..b1c15e2 100644 --- a/src/elastic/service/correlationService.ts +++ b/src/elastic/service/correlationService.ts @@ -78,6 +78,51 @@ export interface ReportFull { body_text: string; } +// --------------------------------------------------------------------------- +// Scored types — analyst-led transparent path (diamond_search_analyst) +// --------------------------------------------------------------------------- + +/** Per-report vertex match scores (only vertices that scored >= NOISE_FLOOR). */ +export type VertexScores = Partial>; + +/** A candidate stub WITH scores — returned by the analyst-led search path. */ +export interface ScoredStub { + report_id: string; + title: string; + vendor: string; + url: string; + /** Scores for each vertex that matched above NOISE_FLOOR (0.7). Keys present only for matched vertices. */ + vertex_scores: VertexScores; + /** Number of vertices that scored >= NOISE_FLOOR. */ + overlap: number; + /** Highest score across all matched vertices. */ + max_score: number; +} + +/** + * Coverage signal for the backfill-suggestion nudge. + * thin = true when: the search degraded to BM25, OR avg_overlap across returned + * candidates is less than 2 matched vertices (threshold chosen to flag cases where + * most candidates matched only a single vertex — weak retrieval signal). + */ +export interface CoverageSignal { + /** Number of vertices that had a non-empty query. */ + queried: number; + /** Mean overlap (matched vertices per candidate) across the returned candidates. 0 when no candidates. */ + avg_overlap: number; + /** True when degraded OR avg_overlap < 2. Advisory: consider BM25 backfill. */ + thin: boolean; +} + +export interface DiamondSearchScoredResult { + candidates: ScoredStub[]; + total: number; + /** True when inference was unavailable and BM25 fallback was used. Scores are absent when degraded. */ + degraded: boolean; + vertices_queried: DiamondVertex[]; + coverage: CoverageSignal; +} + // --------------------------------------------------------------------------- // Internal ES response shapes // --------------------------------------------------------------------------- @@ -397,6 +442,113 @@ const runAnchorSearch = async ( return (resp.data.hits.hits ?? []).map(toStub); }; +// --------------------------------------------------------------------------- +// Scored semantic search — surfaces the score matrix instead of stripping it +// --------------------------------------------------------------------------- + +const runSemanticSearchScored = async ( + esClient: EsClient, + queriedVertices: DiamondVertex[], + vertexQueries: DiamondVertexQueries, + size: number +): Promise<{ candidates: ScoredStub[]; total: number; degraded: false }> => { + const lines: string[] = []; + for (const vertex of queriedVertices) { + lines.push( + JSON.stringify({ + index: THREAT_REPORTS_INDEX_PATTERN, + ignore_unavailable: true, + }) + ); + lines.push( + JSON.stringify({ + query: { + bool: { + must: [ + { + semantic: { + field: `extracted.diamond.${vertex}.summary`, + query: vertexQueries[vertex], + }, + }, + ], + filter: [{ term: { "extracted.diamond.suitable": true } }], + }, + }, + size: KNN_CANDIDATES_PER_VERTEX, + _source: ["content.title", "source.name", "source.type", "source.url"], + }) + ); + } + const ndjson = lines.join("\n") + "\n"; + + const resp = await esClient.post>( + "/_msearch", + ndjson, + { headers: { "Content-Type": "application/x-ndjson" } } + ); + const msearch = resp.data; + + const matrix = new Map< + string, + { source: SourceFields; scores: VertexScores } + >(); + + for (let i = 0; i < queriedVertices.length; i++) { + const vertex = queriedVertices[i]; + const response = msearch.responses[i]; + if ("error" in response && response.error) { + const errMsg = JSON.stringify(response.error).toLowerCase(); + if (errMsg.includes("inference") || errMsg.includes("service_unavailable")) { + throw new Error(`inference_unavailable: ${JSON.stringify(response.error)}`); + } + continue; + } + const hits = response.hits?.hits ?? []; + for (const hit of hits) { + if (!matrix.has(hit._id)) { + matrix.set(hit._id, { source: hit._source ?? {}, scores: {} }); + } + const entry = matrix.get(hit._id)!; + entry.scores[vertex] = hit._score ?? 0; + } + } + + const candidates: ScoredStub[] = []; + + for (const [reportId, { source, scores }] of matrix) { + const aboveFloor = DIAMOND_VERTICES.filter( + (v) => scores[v] !== undefined && (scores[v] as number) >= NOISE_FLOOR + ); + if (aboveFloor.length === 0) continue; + const aboveScores = aboveFloor.map((v) => scores[v] as number); + // Include only scores at or above the noise floor in the returned vertex_scores. + const filteredScores: VertexScores = {}; + for (const v of aboveFloor) { + filteredScores[v] = scores[v]; + } + candidates.push({ + report_id: reportId, + title: source.content?.title?.trim() ?? reportId, + vendor: source.source?.name ?? source.source?.type ?? "unknown", + url: source.source?.url ?? "", + vertex_scores: filteredScores, + overlap: aboveFloor.length, + max_score: Math.max(...aboveScores), + }); + } + + candidates.sort((a, b) => + b.overlap !== a.overlap ? b.overlap - a.overlap : b.max_score - a.max_score + ); + + return { + candidates: candidates.slice(0, size), + total: candidates.length, + degraded: false, + }; +}; + // --------------------------------------------------------------------------- // Public service // --------------------------------------------------------------------------- @@ -466,6 +618,108 @@ export class CorrelationService { }; } + /** + * Analyst-led transparent Diamond Model correlation search. + * + * Identical retrieval logic to diamondSearch() but surfaces the full score + * matrix (vertex_scores per candidate) instead of stripping it. Also computes + * a coverage signal that drives the "consider BM25 backfill" nudge in the UI. + * + * coverage.thin = true when: + * - inference degraded to BM25 (scores unavailable), OR + * - avg_overlap across returned candidates < 2 (most candidates matched only + * one vertex — weak multi-vertex retrieval signal) + * + * The 2-vertex threshold is deterministic and documented here. It was chosen + * to flag searches where single-vertex recall dominates, which tends to + * produce noisier rankings than true multi-vertex overlap. + */ + async diamondSearchScored(params: DiamondSearchParams): Promise { + const { esClient } = this.options; + const size = Math.min(params.size ?? DEFAULT_SIZE, MAX_SIZE); + const vertexQueries = params.vertex_queries ?? {}; + + const queriedVertices = DIAMOND_VERTICES.filter( + (v) => (vertexQueries[v] ?? "").trim().length > 0 + ); + + const emptyResult = (degraded: boolean): DiamondSearchScoredResult => ({ + candidates: [], + total: 0, + degraded, + vertices_queried: queriedVertices, + coverage: { queried: queriedVertices.length, avg_overlap: 0, thin: true }, + }); + + if (queriedVertices.length === 0) { + return emptyResult(false); + } + + let semanticResult: { candidates: ScoredStub[]; total: number; degraded: boolean }; + try { + semanticResult = await runSemanticSearchScored(esClient, queriedVertices, vertexQueries, size); + } catch (err) { + const msg = String((err as Error)?.message ?? "").toLowerCase(); + const isInferenceUnavailable = + msg.includes("inference_unavailable") || + msg.includes("service_unavailable") || + msg.includes("503"); + + if (isInferenceUnavailable) { + // BM25 fallback: return stubs with empty vertex_scores. + const bm25 = await runBm25Fallback(esClient, queriedVertices, vertexQueries, size); + const bm25Scored: ScoredStub[] = bm25.stubs.map((s) => ({ + ...s, + vertex_scores: {}, + overlap: 0, + max_score: 0, + })); + return { + candidates: bm25Scored, + total: bm25.total, + degraded: true, + vertices_queried: queriedVertices, + coverage: { queried: queriedVertices.length, avg_overlap: 0, thin: true }, + }; + } else { + throw err; + } + } + + let candidates = semanticResult.candidates; + if (params.iocs && params.iocs.length > 0) { + const anchorStubs = await runAnchorSearch(esClient, params.iocs, size, new Set()); + const anchorIds = new Set(anchorStubs.map((c) => c.report_id)); + const anchorScored: ScoredStub[] = anchorStubs.map((s) => ({ + ...s, + vertex_scores: {}, + overlap: 0, + max_score: 0, + })); + const semanticOnly = candidates.filter((c) => !anchorIds.has(c.report_id)); + candidates = [...anchorScored, ...semanticOnly].slice(0, size); + } + + const avg_overlap = + candidates.length > 0 + ? candidates.reduce((sum, c) => sum + c.overlap, 0) / candidates.length + : 0; + + const coverage: CoverageSignal = { + queried: queriedVertices.length, + avg_overlap, + thin: semanticResult.degraded || avg_overlap < 2, + }; + + return { + candidates, + total: semanticResult.total, + degraded: semanticResult.degraded, + vertices_queried: queriedVertices, + coverage, + }; + } + /** * Fetch full text for a list of report IDs. * diff --git a/src/elastic/service/index.ts b/src/elastic/service/index.ts index e2c5da5..bf3ef4c 100644 --- a/src/elastic/service/index.ts +++ b/src/elastic/service/index.ts @@ -21,4 +21,13 @@ export type { export { SampleDataService, SCENARIO_NAMES, SCENARIO_RULES } from "./sampleDataService.js"; export { TelemetryService } from "./telemetryService.js"; export { CorrelationService } from "./correlationService.js"; -export type { DiamondSearchParams, DiamondSearchResult, ReportStub, ReportFull } from "./correlationService.js"; +export type { + DiamondSearchParams, + DiamondSearchResult, + ReportStub, + ReportFull, + ScoredStub, + DiamondSearchScoredResult, + CoverageSignal, + VertexScores, +} from "./correlationService.js"; diff --git a/src/tools/correlation.ts b/src/tools/correlation.ts index 88de835..f2e6385 100644 --- a/src/tools/correlation.ts +++ b/src/tools/correlation.ts @@ -15,11 +15,20 @@ */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + registerAppResource, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; import { z } from "zod"; +import fs from "fs"; import type { AnalyticsClient } from "../elastic/analytics/index.js"; import type { CorrelationService } from "../elastic/service/correlationService.js"; import { TRADECRAFT } from "../correlation/tradecraft.js"; import { registerTrackedAppTool } from "./tracked-app-tool.js"; +import { resolveViewPath } from "./view-path.js"; + +const CORRELATION_RESOURCE_URI = "ui://correlation/mcp-app.html"; +const CORRELATION_INPUT_RESOURCE_URI = "ui://correlation-input/mcp-app.html"; export interface CorrelationToolDeps { readonly correlationService: CorrelationService; @@ -172,4 +181,204 @@ Call this after triaging the candidates returned by diamond_search. Pass the rep }; } ); + + // ------------------------------------------------------------------------- + // diamond_search_analyst — analyst-led transparent path + // ------------------------------------------------------------------------- + + registerTrackedAppTool( + analytics, + server, + "diamond_search_analyst", + { + title: "Diamond Model Correlation Search (Analyst-Led)", + description: `Analyst-led transparent correlation: returns ranked candidates WITH per-vertex match scores and retrieval coverage, for an analyst (or analyst-supervised LLM) to triage with full visibility. Use this for interactive human-in-the-loop correlation. For blinded independent judgment instead, use \`diamond_search\`. + +The response includes: +- candidates: ScoredStub[] ranked by (overlap desc, max_score desc) — each with vertex_scores showing which Diamond Model vertices matched and their semantic similarity scores +- coverage: { queried, avg_overlap, thin } — thin=true signals weak retrieval (degraded or low multi-vertex overlap); the UI renders a backfill nudge +- tradecraft: the same triage_rubric and synthesis_guidance as diamond_search + +HOST WORKFLOW (analyst-supervised): +1. Summarise the case into Diamond Model vertex paragraphs following diamond_summarisation_guidance. +2. Call this tool; present the scored candidates and coverage signal to the analyst. +3. The analyst triages using vertex_scores as cues alongside the triage_rubric. +4. Call get_report for analyst-selected candidates. +5. Synthesise using synthesis_guidance.`, + _meta: { ui: { resourceUri: CORRELATION_RESOURCE_URI } }, + inputSchema: { + adversary: z + .string() + .optional() + .describe( + "Adversary vertex: threat-actor names, aliases, attributed group, operational objectives." + ), + capability: z + .string() + .optional() + .describe( + "Capability vertex: malware families, exploited CVEs, TTP patterns, C2 frameworks." + ), + infrastructure: z + .string() + .optional() + .describe( + "Infrastructure vertex: hosting patterns, TLD preferences, relay chains, opsec profile." + ), + victim: z + .string() + .optional() + .describe( + "Victim vertex: targeted industry verticals, geographies, org types, technology stack." + ), + iocs: z + .array( + z.object({ + type: z + .enum(["hash", "ip", "domain", "url"]) + .describe("IOC type"), + value: z.string().describe("IOC value (lowercase)"), + }) + ) + .optional() + .describe( + "File-hash and network IOCs from the case. Hash IOCs are used as discriminating anchors; network IOCs boost scoring." + ), + size: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("Maximum candidate stubs to return (default 20, max 50)."), + }, + }, + async ({ adversary, capability, infrastructure, victim, iocs, size }) => { + const result = await correlationService.diamondSearchScored({ + vertex_queries: { adversary, capability, infrastructure, victim }, + iocs, + size, + }); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + candidates: result.candidates, + meta: { + total: result.total, + degraded: result.degraded, + vertices_queried: result.vertices_queried, + }, + coverage: result.coverage, + tradecraft: TRADECRAFT, + }), + }, + ], + }; + } + ); + + const correlationViewPath = resolveViewPath("correlation"); + registerAppResource( + server, + CORRELATION_RESOURCE_URI, + CORRELATION_RESOURCE_URI, + { mimeType: RESOURCE_MIME_TYPE }, + async () => { + const html = fs.readFileSync(correlationViewPath, "utf-8"); + return { + contents: [{ uri: CORRELATION_RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }], + }; + } + ); + + // ------------------------------------------------------------------------- + // correlation_input_check — gate: show vertex signal before search runs + // ------------------------------------------------------------------------- + + const SIGNAL_SCHEMA = z.object({ + query: z.string().describe("The vertex summary paragraph (may be empty if signal is NONE)."), + signal: z.enum(["HIGH", "PARTIAL", "NONE"]).describe( + "Self-rated signal quality for this vertex." + ), + }); + + registerTrackedAppTool( + analytics, + server, + "correlation_input_check", + { + title: "Correlation Input Gate", + description: `Review the diamond-query signal BEFORE running a correlation search. + +Call this first with your per-vertex case summaries and signal self-ratings. The analyst reviews the stoplight (🟢 HIGH / 🟡 PARTIAL / 🔴 NONE) and query text for each vertex, then decides whether the input is ready to search. + +On "Search this case", call diamond_search_analyst with the same vertex queries. +On "I'll revise first", the analyst provides additional case context in chat and you re-summarise before calling this tool again. + +This tool performs NO Elasticsearch call and does NO search — it is a display-only gate. + +INPUT SIGNAL SELF-RATING SCALE: + HIGH — specific, well-attested behavioural details; strong search anchor + PARTIAL — present but weak or inferred; query sent but may produce noise + NONE — genuinely absent; omit this vertex from the search query`, + _meta: { ui: { resourceUri: CORRELATION_INPUT_RESOURCE_URI } }, + inputSchema: { + adversary: SIGNAL_SCHEMA.optional().describe( + "Adversary vertex summary and self-rated signal." + ), + capability: SIGNAL_SCHEMA.optional().describe( + "Capability vertex summary and self-rated signal." + ), + infrastructure: SIGNAL_SCHEMA.optional().describe( + "Infrastructure vertex summary and self-rated signal." + ), + victim: SIGNAL_SCHEMA.optional().describe( + "Victim vertex summary and self-rated signal." + ), + }, + }, + async ({ adversary, capability, infrastructure, victim }) => { + const vertices = { adversary, capability, infrastructure, victim }; + + // Build a compact summary line for the host's context. + const ABBREV = { adversary: "ADV", capability: "CAP", infrastructure: "INF", victim: "VIC" } as const; + const parts = (["adversary", "capability", "infrastructure", "victim"] as const) + .filter((v) => vertices[v] !== undefined) + .map((v) => `${ABBREV[v]} ${vertices[v]!.signal}`); + + const summaryLine = parts.length > 0 + ? `Input signal: ${parts.join(", ")} — review before searching.` + : "No vertex signal provided. Describe the case first."; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + kind: "correlation_input_check", + vertices, + summary: summaryLine, + }), + }, + ], + }; + } + ); + + const correlationInputViewPath = resolveViewPath("correlation-input"); + registerAppResource( + server, + CORRELATION_INPUT_RESOURCE_URI, + CORRELATION_INPUT_RESOURCE_URI, + { mimeType: RESOURCE_MIME_TYPE }, + async () => { + const html = fs.readFileSync(correlationInputViewPath, "utf-8"); + return { + contents: [{ uri: CORRELATION_INPUT_RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }], + }; + } + ); } diff --git a/src/views/correlation-input/App.tsx b/src/views/correlation-input/App.tsx new file mode 100644 index 0000000..7198572 --- /dev/null +++ b/src/views/correlation-input/App.tsx @@ -0,0 +1,396 @@ +/* + * 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 React, { useCallback, useEffect, useState } from "react"; +import { extractToolText } from "../../shared/extract-tool-text"; +import { useMcpApp, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; +import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; +import { useAnalytics } from "../../shared/hooks/useAnalytics"; +import { AppGlyph } from "../../shared/components/icons/icons"; +import "./styles.css"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type SignalLevel = "HIGH" | "PARTIAL" | "NONE"; + +interface VertexInput { + query: string; + signal: SignalLevel; +} + +type DiamondVertex = "adversary" | "capability" | "infrastructure" | "victim"; + +interface InputCheckPayload { + kind: "correlation_input_check"; + vertices: Partial>; + summary: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const VERTEX_ORDER: ReadonlyArray = [ + "adversary", + "capability", + "infrastructure", + "victim", +]; + +const VERTEX_LABEL: Record = { + adversary: "Adversary", + capability: "Capability", + infrastructure: "Infrastructure", + victim: "Victim", +}; + +const VERTEX_ABBREV: Record = { + adversary: "ADV", + capability: "CAP", + infrastructure: "INF", + victim: "VIC", +}; + +// --------------------------------------------------------------------------- +// Stoplight — per-vertex signal indicator +// --------------------------------------------------------------------------- + +const SIGNAL_COLOR: Record = { + HIGH: "#40c790", + PARTIAL: "#f0b840", + NONE: "#474745", +}; + +const SIGNAL_TEXT_COLOR: Record = { + HIGH: "#40c790", + PARTIAL: "#f0b840", + NONE: "#817f78", +}; + +const SIGNAL_LABEL: Record = { + HIGH: "HIGH", + PARTIAL: "PARTIAL", + NONE: "NONE", +}; + +interface StoplightProps { + signal: SignalLevel; +} + +function Stoplight({ signal }: StoplightProps) { + const color = SIGNAL_COLOR[signal]; + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Diamond glyph — shows all 4 vertices lit by signal (reuses 1a encoding) +// --------------------------------------------------------------------------- + +type EdgeCoords = [number, number, number, number]; + +const DIAMOND_NODES: ReadonlyArray<{ vertex: DiamondVertex; cx: number; cy: number }> = [ + { vertex: "adversary", cx: 80, cy: 18 }, + { vertex: "infrastructure", cx: 18, cy: 80 }, + { vertex: "capability", cx: 142, cy: 80 }, + { vertex: "victim", cx: 80, cy: 142 }, +]; + +const DIAMOND_EDGES: ReadonlyArray = [ + [80, 18, 18, 80], + [80, 18, 142, 80], + [18, 80, 80, 142], + [142, 80, 80, 142], +]; + +function signalToVertexColor(signal: SignalLevel | undefined): string { + if (!signal || signal === "NONE") return "#30302f"; + if (signal === "HIGH") return "#40c790"; + return "#f0b840"; +} + +function signalToTextFill(signal: SignalLevel | undefined): string { + if (!signal || signal === "NONE") return "#474745"; + if (signal === "HIGH") return "#ffffff"; + return "#1f1f1e"; +} + +interface InputDiamondProps { + vertices: Partial>; + size?: number; +} + +function InputDiamond({ vertices, size = 72 }: InputDiamondProps) { + return ( + + {DIAMOND_EDGES.map(([x1, y1, x2, y2], i) => ( + + ))} + {DIAMOND_NODES.map(({ vertex, cx, cy }) => { + const signal = vertices[vertex]?.signal; + return ( + + + + + ); + })} + + ); +} + +// --------------------------------------------------------------------------- +// Vertex row — stoplight + abbrev + signal label + query text (expandable) +// --------------------------------------------------------------------------- + +interface VertexRowProps { + vertex: DiamondVertex; + input: VertexInput; +} + +function VertexRow({ vertex, input }: VertexRowProps) { + const [expanded, setExpanded] = useState(false); + const hasQuery = input.query.trim().length > 0; + const truncated = input.query.length > 140 && !expanded; + const displayText = truncated ? input.query.slice(0, 140).trimEnd() + "…" : input.query; + const signalColor = SIGNAL_TEXT_COLOR[input.signal]; + + return ( +
+
+ +
+ {VERTEX_ABBREV[vertex]} + {VERTEX_LABEL[vertex]} +
+ + {SIGNAL_LABEL[input.signal]} + +
+ +
+ {hasQuery ? ( + <> + {displayText} + {input.query.length > 140 && ( + + )} + + ) : ( + — omitted + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main App +// --------------------------------------------------------------------------- + +export function App() { + return ( + + + + ); +} + +function AppContent() { + const [payload, setPayload] = useState(null); + const [proceeding, setProceeding] = useState(false); + const [dismissed, setDismissed] = useState(false); + + const { connected, getApp } = useMcpApp(); + const { trackEvent } = useAnalytics(); + + useEffect(() => { + trackEvent({ eventType: "view_rendered", viewId: "threat-hunt" }); + }, [trackEvent]); + + useMcpAppEvents({ + onToolResult: (toolResult) => { + try { + const text = extractToolText(toolResult); + if (!text) return; + const data = JSON.parse(text); + if (data?.kind === "correlation_input_check" && data.vertices) { + setPayload(data as InputCheckPayload); + setProceeding(false); + setDismissed(false); + } + } catch { + // Not a gate payload — ignore. + } + }, + }); + + const handleProceed = useCallback(async () => { + const app = getApp(); + if (!app || !payload) return; + setProceeding(true); + + // Build a concise proceed message that gives the LLM everything it needs + // to call diamond_search_analyst without the analyst retyping anything. + const vertexLines = VERTEX_ORDER + .filter((v) => payload.vertices[v] && payload.vertices[v]!.signal !== "NONE" && payload.vertices[v]!.query.trim()) + .map((v) => ` ${VERTEX_ABBREV[v]}: ${payload.vertices[v]!.query.trim()}`); + + const message = vertexLines.length > 0 + ? `PROCEED — call diamond_search_analyst with the following vertex queries:\n${vertexLines.join("\n")}` + : "PROCEED — call diamond_search_analyst with the vertex queries from the correlation_input_check you just ran."; + + try { + await app.sendMessage({ + role: "user", + content: [{ type: "text", text: message }], + }); + } catch { + // sendMessage may be unsupported by some hosts; fall back gracefully. + await app.updateModelContext({ + content: [{ type: "text", text: message }], + }); + } finally { + setProceeding(false); + } + }, [getApp, payload]); + + const handleRevise = useCallback(() => { + setDismissed(true); + }, []); + + if (!connected) { + return ( +
+
+
+ Connecting to server... +
+
+ ); + } + + if (dismissed) { + return ( +
+
+
Revising input
+
+ Provide additional case context in the conversation. The model will + re-summarize and call correlation_input_check again when ready. +
+
+
+ ); + } + + return ( +
+
+
+ +

Input Signal Gate

+
+ {payload && ( +
+ +
+ )} +
+ +
+ {!payload ? ( +
+ +
Correlation Input Gate
+
+ Call correlation_input_check with your per-vertex summaries + and signal ratings to review before searching. +
+
+ ) : ( + <> +
+ {VERTEX_ORDER.map((vertex) => { + const input = payload.vertices[vertex]; + // Show all 4 rows; absent vertices shown as NONE. + return ( + + ); + })} +
+ +
+
+ Proceed with this search? +
+
+ + +
+
+ + )} +
+
+ ); +} diff --git a/src/views/correlation-input/mcp-app.html b/src/views/correlation-input/mcp-app.html new file mode 100644 index 0000000..4c82394 --- /dev/null +++ b/src/views/correlation-input/mcp-app.html @@ -0,0 +1,12 @@ + + + + + + Correlation Input Gate + + +
+ + + diff --git a/src/views/correlation-input/mcp-app.tsx b/src/views/correlation-input/mcp-app.tsx new file mode 100644 index 0000000..7251dbf --- /dev/null +++ b/src/views/correlation-input/mcp-app.tsx @@ -0,0 +1,12 @@ +/* + * 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 React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render(); diff --git a/src/views/correlation-input/styles.css b/src/views/correlation-input/styles.css new file mode 100644 index 0000000..de0f3c4 --- /dev/null +++ b/src/views/correlation-input/styles.css @@ -0,0 +1,376 @@ +/* + * 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 "../../shared/base.css"; + +/* ─── Shell ─── */ + +.gate-app { + display: flex; + flex-direction: column; + height: 100vh; + min-height: 360px; + background: #1f1f1e; + color: #e6e6e5; + font-family: var(--font-sans, "Fira Sans", system-ui, sans-serif); + overflow: hidden; + border: 1px solid #474745; +} + +/* ─── Header ─── */ + +.gate-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 20px; + border-bottom: 1px solid #30302f; + background: #1f1f1e; + flex-shrink: 0; +} + +.gate-header-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.gate-header-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + color: #e6e6e5; +} + +.gate-header-title { + font-size: 15px; + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.01em; + color: #e6e6e5; + margin: 0; +} + +.gate-header-meta { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.gate-diamond-svg { + display: block; +} + +/* ─── Body ─── */ + +.gate-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +/* ─── Vertex list ─── */ + +.gate-vertex-list { + flex: 1; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* ─── Vertex row ─── */ + +.gate-vertex-row { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid transparent; + transition: background 0.12s; +} + +.gate-vertex-row.gate-vertex-high { + background: rgba(64, 199, 144, 0.04); + border-color: rgba(64, 199, 144, 0.12); +} + +.gate-vertex-row.gate-vertex-partial { + background: rgba(240, 184, 64, 0.04); + border-color: rgba(240, 184, 64, 0.12); +} + +.gate-vertex-row.gate-vertex-none { + background: rgba(255, 255, 255, 0.01); + border-color: rgba(71, 71, 69, 0.4); + opacity: 0.65; +} + +.gate-vertex-left { + display: flex; + align-items: center; + gap: 10px; +} + +/* ─── Stoplight ─── */ + +.gate-stoplight { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} + +/* ─── Vertex identity ─── */ + +.gate-vertex-id { + display: flex; + align-items: baseline; + gap: 6px; +} + +.gate-vertex-abbrev { + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + color: #b9b9ae; +} + +.gate-vertex-name { + font-size: 12px; + font-weight: 500; + color: #817f78; +} + +/* ─── Signal badge ─── */ + +.gate-signal-badge { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: 4px; + border: 1px solid; + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.05em; + margin-left: auto; +} + +/* ─── Query text ─── */ + +.gate-vertex-query { + padding-left: 20px; /* align under vertex-id, past stoplight */ + display: flex; + align-items: flex-start; + gap: 6px; + flex-wrap: wrap; +} + +.gate-vertex-query-text { + font-size: 12px; + line-height: 1.55; + color: #adaca1; + word-break: break-word; +} + +.gate-vertex-query-empty { + font-size: 12px; + color: #474745; + font-style: italic; +} + +.gate-expand-btn { + padding: 0; + background: transparent; + border: 0; + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 10.5px; + color: #5c7cfa; + cursor: pointer; + flex-shrink: 0; + transition: color 0.12s; +} + +.gate-expand-btn:hover { + color: #8fa6fb; +} + +/* ─── Footer ─── */ + +.gate-footer { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid #30302f; + background: #1f1f1e; +} + +.gate-footer-question { + font-size: 13px; + font-weight: 500; + color: #b9b9ae; +} + +.gate-footer-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* ─── Buttons ─── */ + +.gate-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: 6px; + font-family: inherit; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, filter 0.15s; + white-space: nowrap; +} + +.gate-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.gate-btn-primary { + background: #0b64dd; + border: 1px solid #0b64dd; + color: #ffffff; +} + +.gate-btn-primary:hover:not(:disabled) { + filter: brightness(1.1); +} + +.gate-btn-ghost { + background: transparent; + border: 1px solid #474745; + color: #adaca1; +} + +.gate-btn-ghost:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.04); + border-color: #5a5a58; + color: #e6e6e5; +} + +/* ─── Dismissed state ─── */ + +.gate-dismissed { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 60px 24px; + text-align: center; +} + +.gate-dismissed-title { + font-size: 14px; + font-weight: 600; + color: #b9b9ae; +} + +.gate-dismissed-hint { + font-size: 12px; + color: #817f78; + max-width: 340px; + line-height: 1.55; +} + +.gate-dismissed-hint code { + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 11px; + background: rgba(255, 255, 255, 0.06); + padding: 1px 5px; + border-radius: 4px; + color: #b9b9ae; +} + +/* ─── Idle state ─── */ + +.gate-idle { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 60px 24px; + text-align: center; +} + +.gate-idle-diamond { + opacity: 0.3; +} + +.gate-idle-title { + font-size: 14px; + font-weight: 600; + color: #b9b9ae; +} + +.gate-idle-hint { + font-size: 12px; + color: #817f78; + max-width: 320px; + line-height: 1.55; +} + +.gate-idle-hint code { + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 11px; + background: rgba(255, 255, 255, 0.06); + padding: 1px 5px; + border-radius: 4px; + color: #b9b9ae; +} + +/* ─── Loading ─── */ + +.gate-loading { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 24px; + color: #817f78; + font-size: 12.5px; +} + +.gate-spinner { + width: 20px; + height: 20px; + border: 2.5px solid #30302f; + border-top-color: #5c7cfa; + border-radius: 50%; + animation: gate-spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes gate-spin { + to { transform: rotate(360deg); } +} diff --git a/src/views/correlation/App.tsx b/src/views/correlation/App.tsx new file mode 100644 index 0000000..5c6a3d3 --- /dev/null +++ b/src/views/correlation/App.tsx @@ -0,0 +1,397 @@ +/* + * 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 React, { useEffect, useState } from "react"; +import { extractToolText } from "../../shared/extract-tool-text"; +import { useMcpApp, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; +import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; +import { useAnalytics } from "../../shared/hooks/useAnalytics"; +import { AppGlyph } from "../../shared/components/icons/icons"; +import "./styles.css"; + +// --------------------------------------------------------------------------- +// Types — mirror correlationService scored types (no import of server code) +// --------------------------------------------------------------------------- + +type DiamondVertex = "adversary" | "capability" | "infrastructure" | "victim"; + +interface VertexScores { + adversary?: number; + capability?: number; + infrastructure?: number; + victim?: number; +} + +interface ScoredStub { + report_id: string; + title: string; + vendor: string; + url: string; + vertex_scores: VertexScores; + overlap: number; + max_score: number; +} + +interface CoverageSignal { + queried: number; + avg_overlap: number; + thin: boolean; +} + +interface AnalystSearchResult { + candidates: ScoredStub[]; + meta: { + total: number; + degraded: boolean; + vertices_queried: DiamondVertex[]; + }; + coverage: CoverageSignal; +} + +// --------------------------------------------------------------------------- +// Diamond geometry — ported from Kibana correlation_report.tsx +// viewBox: 0 0 160 160; nodes at ADV(top), INF(left), CAP(right), VIC(bottom) +// Collapsed size: 56px (matches Kibana's 80px < 100px label-hidden branch) +// --------------------------------------------------------------------------- + +const VERTICES: ReadonlyArray = [ + "adversary", + "infrastructure", + "capability", + "victim", +]; + +const VERTEX_ABBREV: Record = { + adversary: "ADV", + capability: "CAP", + infrastructure: "INF", + victim: "VIC", +}; + +type EdgeCoords = [number, number, number, number]; + +const DIAMOND_NODES: ReadonlyArray<{ vertex: DiamondVertex; cx: number; cy: number }> = [ + { vertex: "adversary", cx: 80, cy: 18 }, + { vertex: "infrastructure", cx: 18, cy: 80 }, + { vertex: "capability", cx: 142, cy: 80 }, + { vertex: "victim", cx: 80, cy: 142 }, +]; + +const DIAMOND_EDGES: ReadonlyArray = [ + [80, 18, 18, 80], + [80, 18, 142, 80], + [18, 80, 80, 142], + [142, 80, 80, 142], +]; + +// Score thresholds for vertex color intensity (all scores are already >= NOISE_FLOOR 0.7) +const SCORE_HIGH = 0.9; + +function vertexColor(score: number | undefined): string { + if (score === undefined) return "#30302f"; // no match — muted + if (score >= SCORE_HIGH) return "#40c790"; // strong match — green + return "#f0b840"; // above noise floor but below high — amber +} + +function vertexTextFill(score: number | undefined): string { + if (score === undefined) return "#474745"; + if (score >= SCORE_HIGH) return "#ffffff"; + return "#1f1f1e"; // amber background needs dark text +} + +interface CollapsedDiamondProps { + vertex_scores: VertexScores; + size?: number; +} + +function CollapsedDiamond({ vertex_scores, size = 56 }: CollapsedDiamondProps) { + return ( + + {DIAMOND_EDGES.map(([x1, y1, x2, y2], i) => ( + + ))} + {DIAMOND_NODES.map(({ vertex, cx, cy }) => { + const score = vertex_scores[vertex]; + return ( + + + + + ); + })} + + ); +} + +// --------------------------------------------------------------------------- +// Coverage nudge banner +// --------------------------------------------------------------------------- + +interface CoverageBannerProps { + coverage: CoverageSignal; + onDismiss: () => void; +} + +function CoverageBanner({ coverage, onDismiss }: CoverageBannerProps) { + const reason = coverage.avg_overlap < 2 + ? "low vertex overlap across candidates" + : "inference unavailable — BM25 fallback used"; + return ( +
+ + + Diamond retrieval coverage is thin ({reason}) — consider a BM25 keyword backfill + to surface additional candidates. + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Candidate row +// --------------------------------------------------------------------------- + +interface CandidateRowProps { + stub: ScoredStub; + rank: number; +} + +function CandidateRow({ stub, rank }: CandidateRowProps) { + const hasUrl = stub.url.length > 0; + return ( +
+ + {rank} + + + + +
+
+ {hasUrl ? ( + + {stub.title} + + ) : ( + {stub.title} + )} +
+
+ {stub.vendor} + +
+
+ +
+ {stub.overlap} + vtx +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Vertex score pills — compact inline cues +// --------------------------------------------------------------------------- + +interface VertexScorePillsProps { + scores: VertexScores; +} + +function VertexScorePills({ scores }: VertexScorePillsProps) { + const matched = VERTICES.filter((v) => scores[v] !== undefined); + if (matched.length === 0) return null; + return ( + + {matched.map((v) => { + const score = scores[v]!; + const high = score >= SCORE_HIGH; + return ( + + {VERTEX_ABBREV[v]} {score.toFixed(2)} + + ); + })} + + ); +} + +// --------------------------------------------------------------------------- +// Empty / idle states +// --------------------------------------------------------------------------- + +function IdleState() { + return ( +
+ +
Correlation Triage
+
+ Call diamond_search_analyst to surface ranked threat report candidates + with per-vertex match scores. +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main App +// --------------------------------------------------------------------------- + +export function App() { + return ( + + + + ); +} + +function AppContent() { + const [result, setResult] = useState(null); + const [coverageDismissed, setCoverageDismissed] = useState(false); + + const { connected } = useMcpApp(); + const { trackEvent } = useAnalytics(); + + useEffect(() => { + trackEvent({ eventType: "view_rendered", viewId: "threat-hunt" }); + }, [trackEvent]); + + useMcpAppEvents({ + onToolResult: (toolResult) => { + try { + const text = extractToolText(toolResult); + if (!text) return; + const data = JSON.parse(text); + // Accept any payload that looks like an analyst search result. + if (data && Array.isArray(data.candidates) && data.coverage && data.meta) { + setResult(data as AnalystSearchResult); + setCoverageDismissed(false); + } + } catch { + // Not a correlation result — ignore. + } + }, + }); + + if (!connected) { + return ( +
+
+
+ Connecting to server... +
+
+ ); + } + + return ( +
+
+
+ +

Correlation Triage

+
+ {result && ( +
+ + {result.candidates.length} of {result.meta.total} candidates + + {result.meta.degraded && ( + BM25 + )} + {result.meta.vertices_queried.length > 0 && ( + + {result.meta.vertices_queried.map((v) => VERTEX_ABBREV[v]).join(" · ")} + + )} +
+ )} +
+ +
+ {result && result.coverage.thin && !coverageDismissed && ( + setCoverageDismissed(true)} + /> + )} + + {!result ? ( + + ) : result.candidates.length === 0 ? ( +
+
No candidates found
+
+ Try adjusting your vertex queries or adding IOC anchors. +
+
+ ) : ( +
+
+ + + Report + Vtx +
+ {result.candidates.map((stub, i) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/views/correlation/mcp-app.html b/src/views/correlation/mcp-app.html new file mode 100644 index 0000000..39caf29 --- /dev/null +++ b/src/views/correlation/mcp-app.html @@ -0,0 +1,12 @@ + + + + + + Correlation Triage + + +
+ + + diff --git a/src/views/correlation/mcp-app.tsx b/src/views/correlation/mcp-app.tsx new file mode 100644 index 0000000..7251dbf --- /dev/null +++ b/src/views/correlation/mcp-app.tsx @@ -0,0 +1,12 @@ +/* + * 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 React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render(); diff --git a/src/views/correlation/styles.css b/src/views/correlation/styles.css new file mode 100644 index 0000000..04100bc --- /dev/null +++ b/src/views/correlation/styles.css @@ -0,0 +1,416 @@ +/* + * 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 "../../shared/base.css"; + +/* ─── Shell ─── */ + +.corr-app { + display: flex; + flex-direction: column; + height: 100vh; + min-height: 400px; + background: #1f1f1e; + color: #e6e6e5; + font-family: var(--font-sans, "Fira Sans", system-ui, sans-serif); + overflow: hidden; + border: 1px solid #474745; +} + +/* ─── Header ─── */ + +.corr-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 20px; + border-bottom: 1px solid #30302f; + background: #1f1f1e; + flex-shrink: 0; +} + +.corr-header-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.corr-header-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + color: #e6e6e5; +} + +.corr-header-title { + font-size: 15px; + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.01em; + color: #e6e6e5; + margin: 0; +} + +.corr-header-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.corr-header-count { + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 11px; + color: #817f78; +} + +.corr-header-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.03em; +} + +.corr-header-pill-warn { + background: rgba(240, 184, 64, 0.08); + border: 1px solid rgba(240, 184, 64, 0.25); + color: #f0b840; +} + +.corr-header-pill-info { + background: rgba(92, 124, 250, 0.08); + border: 1px solid rgba(92, 124, 250, 0.2); + color: #8fa6fb; + font-family: var(--font-mono, "Fira Mono", monospace); +} + +/* ─── Body ─── */ + +.corr-body { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* ─── Coverage nudge banner ─── */ + +.corr-coverage-banner { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 12px 16px 0; + padding: 10px 14px; + background: rgba(240, 184, 64, 0.06); + border: 1px solid rgba(240, 184, 64, 0.28); + border-radius: 8px; + flex-shrink: 0; +} + +.corr-coverage-banner-icon { + font-size: 14px; + color: #f0b840; + flex-shrink: 0; + margin-top: 1px; +} + +.corr-coverage-banner-text { + flex: 1; + font-size: 12px; + line-height: 1.55; + color: #d4b86a; +} + +.corr-coverage-banner-dismiss { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + background: transparent; + border: 0; + color: #817f78; + font-size: 18px; + line-height: 1; + cursor: pointer; + border-radius: 4px; + transition: background 0.15s, color 0.15s; +} + +.corr-coverage-banner-dismiss:hover { + background: rgba(255, 255, 255, 0.06); + color: #e6e6e5; +} + +/* ─── Candidate list ─── */ + +.corr-list { + display: flex; + flex-direction: column; + padding: 12px 16px 16px; + gap: 0; +} + +.corr-list-header { + display: grid; + grid-template-columns: 28px 64px 1fr 44px; + align-items: center; + gap: 12px; + padding: 6px 12px 6px 12px; + margin-bottom: 4px; + font-size: 9.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #474745; +} + +.corr-list-col-rank { text-align: center; } +.corr-list-col-diamond { text-align: center; } +.corr-list-col-overlap { text-align: center; } + +/* ─── Candidate row ─── */ + +.corr-candidate-row { + display: grid; + grid-template-columns: 28px 64px 1fr 44px; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + transition: background 0.12s; +} + +.corr-candidate-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.corr-candidate-row + .corr-candidate-row { + border-top: 1px solid #262626; +} + +.corr-candidate-rank { + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 11px; + font-weight: 500; + color: #474745; + text-align: center; + flex-shrink: 0; +} + +/* ─── Diamond SVG ─── */ + +.corr-diamond-svg { + display: block; + flex-shrink: 0; +} + +/* ─── Report meta ─── */ + +.corr-candidate-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.corr-candidate-title { + font-size: 13px; + font-weight: 500; + line-height: 1.35; + color: #e6e6e5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.corr-candidate-link { + color: #7ca4fb; + text-decoration: none; + transition: color 0.12s; +} + +.corr-candidate-link:hover { + color: #a5bcfd; + text-decoration: underline; +} + +.corr-candidate-title-text { + color: #e6e6e5; +} + +.corr-candidate-footer { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.corr-candidate-vendor { + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 10.5px; + color: #817f78; + white-space: nowrap; +} + +/* ─── Vertex score pills ─── */ + +.corr-score-pills { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.corr-score-pill { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 4px; + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.corr-score-pill-high { + background: rgba(64, 199, 144, 0.1); + border: 1px solid rgba(64, 199, 144, 0.25); + color: #40c790; +} + +.corr-score-pill-mid { + background: rgba(240, 184, 64, 0.08); + border: 1px solid rgba(240, 184, 64, 0.22); + color: #c9992b; +} + +/* ─── Overlap badge ─── */ + +.corr-candidate-overlap { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + flex-shrink: 0; +} + +.corr-overlap-count { + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 16px; + font-weight: 600; + line-height: 1; + color: #e6e6e5; +} + +.corr-overlap-label { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #474745; +} + +/* ─── Idle / empty states ─── */ + +.corr-idle { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 60px 24px; + text-align: center; +} + +.corr-idle-diamond { + opacity: 0.35; +} + +.corr-idle-title { + font-size: 14px; + font-weight: 600; + color: #b9b9ae; +} + +.corr-idle-hint { + font-size: 12px; + color: #817f78; + max-width: 320px; + line-height: 1.55; +} + +.corr-idle-hint code { + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 11px; + background: rgba(255, 255, 255, 0.06); + padding: 1px 5px; + border-radius: 4px; + color: #b9b9ae; +} + +.corr-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 60px 24px; + text-align: center; +} + +.corr-empty-title { + font-size: 13px; + font-weight: 600; + color: #b9b9ae; +} + +.corr-empty-hint { + font-size: 12px; + color: #817f78; + max-width: 300px; + line-height: 1.5; +} + +/* ─── Loading ─── */ + +.corr-loading { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 24px; + color: #817f78; + font-size: 12.5px; +} + +.corr-spinner { + width: 20px; + height: 20px; + border: 2.5px solid #30302f; + border-top-color: #5c7cfa; + border-radius: 50%; + animation: corr-spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes corr-spin { + to { transform: rotate(360deg); } +} From 5c2ec94fbe72560b76a53d796aae76f412684ba1 Mon Sep 17 00:00:00 2001 From: seth-goodwin Date: Thu, 18 Jun 2026 15:56:15 -0500 Subject: [PATCH 3/9] Add deep-dive synthesis render (completes analyst-led workflow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the triage view, the analyst selects report(s) or a cluster and hits Synthesize → app.sendMessage tells the host to get_report the full text, synthesize per the vendored rubric, then call render_correlation with the result. render_correlation is a pure pass-through (no ES, no synthesis — the host does all reasoning) that hands the structured CorrelationFindings to a new deep-dive view: BLUF + correlation-signal stoplight, lead cards with collapsed/expanded diamonds + weighted evidence (supporting/counter), no-match list, synthesis + next steps. Completes the gate → search → triage → deep-dive flow. Additive: new view + render tool + triage-view selection; blind path and prior tools unchanged. --- src/tools/correlation.ts | 139 ++++ src/views/correlation-report/App.tsx | 759 ++++++++++++++++++++++ src/views/correlation-report/mcp-app.html | 12 + src/views/correlation-report/mcp-app.tsx | 12 + src/views/correlation-report/styles.css | 689 ++++++++++++++++++++ src/views/correlation/App.tsx | 113 +++- src/views/correlation/styles.css | 82 ++- 7 files changed, 1787 insertions(+), 19 deletions(-) create mode 100644 src/views/correlation-report/App.tsx create mode 100644 src/views/correlation-report/mcp-app.html create mode 100644 src/views/correlation-report/mcp-app.tsx create mode 100644 src/views/correlation-report/styles.css diff --git a/src/tools/correlation.ts b/src/tools/correlation.ts index f2e6385..ef802a4 100644 --- a/src/tools/correlation.ts +++ b/src/tools/correlation.ts @@ -381,4 +381,143 @@ INPUT SIGNAL SELF-RATING SCALE: }; } ); + + // ------------------------------------------------------------------------- + // render_correlation — pure pass-through; the host synthesized, we render + // ------------------------------------------------------------------------- + + const CORRELATION_REPORT_RESOURCE_URI = "ui://correlation-report/mcp-app.html"; + + const VERTEX_SIGNAL_SCHEMA = z.enum(["high", "partial", "none"]); + + const EVIDENCE_ITEM_SCHEMA = z.object({ + vertex: z.enum(["adversary", "capability", "infrastructure", "victim"]), + weight: z.enum([ + "smoking_gun", + "supporting", + "non_discriminatory", + "counter", + "decisive_counter", + ]), + text: z.string(), + }); + + const CONSOLIDATED_CANDIDATE_SCHEMA = z.object({ + id: z.string(), + title: z.string(), + reason: z.string(), + }); + + const LEAD_SCHEMA = z.object({ + candidate_ids: z.array(z.string()).min(1), + title: z.string(), + relationship: z.enum(["same_campaign", "same_actor", "shared_tradecraft"]), + confidence: z.enum(["high", "moderate", "low"]), + vertex_signal: z.object({ + adversary: VERTEX_SIGNAL_SCHEMA, + capability: VERTEX_SIGNAL_SCHEMA, + infrastructure: VERTEX_SIGNAL_SCHEMA, + victim: VERTEX_SIGNAL_SCHEMA, + }), + bluf: z.string(), + evidence: z.array(EVIDENCE_ITEM_SCHEMA), + gaps: z.string(), + consolidated_candidates: z.array(CONSOLIDATED_CANDIDATE_SCHEMA).default([]), + }); + + const NO_MATCH_SCHEMA = z.object({ + id: z.string(), + title: z.string(), + vendor: z.string().optional(), + }); + + const SYNTHESIS_SCHEMA = z.object({ + bluf: z.string(), + correlation_signal: z.enum(["high", "moderate", "low", "none"]), + reasoning: z.string(), + gaps: z.string(), + next_steps: z.array( + z.object({ + priority: z.enum(["high", "moderate"]), + text: z.string(), + }) + ), + inferential_hops: z.number().int().optional(), + atomic_ioc_overlap: z + .object({ assessed: z.boolean(), note: z.string().optional() }) + .optional(), + case_title: z.string().optional(), + }); + + const CANDIDATE_META_ENTRY_SCHEMA = z.object({ + title: z.string().optional(), + vendor: z.string().optional(), + url: z.string().optional(), + }); + + registerTrackedAppTool( + analytics, + server, + "render_correlation", + { + title: "Render Correlation Report", + description: `Render a structured correlation report you (the host) synthesized. + +Call this AFTER calling get_report and completing your synthesis. Pass your CorrelationFindings; the analyst sees the rendered deep-dive report. + +This tool performs NO synthesis and NO Elasticsearch queries — it is a pure pass-through to the analyst view. The host is responsible for all reasoning; this tool only hands the structured result to the UI.`, + _meta: { ui: { resourceUri: CORRELATION_REPORT_RESOURCE_URI } }, + inputSchema: { + findings: z + .object({ + leads: z.array(LEAD_SCHEMA), + no_match: z.array(NO_MATCH_SCHEMA), + synthesis: SYNTHESIS_SCHEMA, + case_vertex_signal: z + .object({ + adversary: VERTEX_SIGNAL_SCHEMA, + capability: VERTEX_SIGNAL_SCHEMA, + infrastructure: VERTEX_SIGNAL_SCHEMA, + victim: VERTEX_SIGNAL_SCHEMA, + }) + .optional(), + candidate_labels: z.record(z.string(), z.string()).optional(), + candidate_meta: z.record(z.string(), CANDIDATE_META_ENTRY_SCHEMA).optional(), + }) + .describe("CorrelationFindings you synthesized from get_report output."), + }, + }, + async ({ findings }) => { + const leadsCount = findings.leads.length; + const signal = findings.synthesis.correlation_signal; + const caseTitle = findings.synthesis.case_title ?? "Correlation deep-dive"; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + kind: "correlation_report", + findings, + summary: `${caseTitle} — ${leadsCount} lead${leadsCount !== 1 ? "s" : ""}, signal: ${signal}`, + }), + }, + ], + }; + } + ); + + const correlationReportViewPath = resolveViewPath("correlation-report"); + registerAppResource( + server, + CORRELATION_REPORT_RESOURCE_URI, + CORRELATION_REPORT_RESOURCE_URI, + { mimeType: RESOURCE_MIME_TYPE }, + async () => { + const html = fs.readFileSync(correlationReportViewPath, "utf-8"); + return { + contents: [{ uri: CORRELATION_REPORT_RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }], + }; + } + ); } diff --git a/src/views/correlation-report/App.tsx b/src/views/correlation-report/App.tsx new file mode 100644 index 0000000..4e182ea --- /dev/null +++ b/src/views/correlation-report/App.tsx @@ -0,0 +1,759 @@ +/* + * 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 React, { useEffect, useMemo, useState } from "react"; +import { extractToolText } from "../../shared/extract-tool-text"; +import { useMcpApp, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; +import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; +import { useAnalytics } from "../../shared/hooks/useAnalytics"; +import { AppGlyph } from "../../shared/components/icons/icons"; +import "./styles.css"; + +// --------------------------------------------------------------------------- +// Types — mirror CorrelationFindings schema (no import of server/kibana code) +// --------------------------------------------------------------------------- + +type DiamondVertex = "adversary" | "capability" | "infrastructure" | "victim"; +type VertexSignal = "high" | "partial" | "none"; +type EvidenceWeight = + | "smoking_gun" + | "supporting" + | "non_discriminatory" + | "counter" + | "decisive_counter"; +type Relationship = "same_campaign" | "same_actor" | "shared_tradecraft"; +type CorrelationSignal = "high" | "moderate" | "low" | "none"; +type Confidence = "high" | "moderate" | "low"; +type Priority = "high" | "moderate"; + +interface VertexSignalMap { + adversary: VertexSignal; + capability: VertexSignal; + infrastructure: VertexSignal; + victim: VertexSignal; +} + +interface EvidenceItem { + vertex: DiamondVertex; + weight: EvidenceWeight; + text: string; +} + +interface ConsolidatedCandidate { + id: string; + title: string; + reason: string; +} + +interface Lead { + candidate_ids: string[]; + title: string; + relationship: Relationship; + confidence: Confidence; + vertex_signal: VertexSignalMap; + bluf: string; + evidence: EvidenceItem[]; + gaps: string; + consolidated_candidates: ConsolidatedCandidate[]; +} + +interface NoMatch { + id: string; + title: string; + vendor?: string; +} + +interface Synthesis { + bluf: string; + correlation_signal: CorrelationSignal; + reasoning: string; + gaps: string; + next_steps: Array<{ priority: Priority; text: string }>; + inferential_hops?: number; + atomic_ioc_overlap?: { assessed: boolean; note?: string }; + case_title?: string; +} + +interface CandidateMetaEntry { + title?: string; + vendor?: string; + url?: string; +} + +interface CorrelationFindings { + leads: Lead[]; + no_match: NoMatch[]; + synthesis: Synthesis; + case_vertex_signal?: VertexSignalMap; + candidate_labels?: Record; + candidate_meta?: Record; +} + +interface ReportPayload { + kind: "correlation_report"; + findings: CorrelationFindings; + summary: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const VERTICES: ReadonlyArray = [ + "adversary", + "infrastructure", + "capability", + "victim", +]; + +const VERTEX_ABBREV: Record = { + adversary: "ADV", + capability: "CAP", + infrastructure: "INF", + victim: "VIC", +}; + +const VERTEX_LABEL: Record = { + adversary: "Adversary", + capability: "Capability", + infrastructure: "Infrastructure", + victim: "Victim", +}; + +const RELATIONSHIP_LABEL: Record = { + same_campaign: "Same campaign", + same_actor: "Same actor", + shared_tradecraft: "Shared tradecraft", +}; + +const WEIGHT_LABEL: Record = { + smoking_gun: "Smoking gun", + supporting: "Supporting", + non_discriminatory: "Non-discriminatory", + counter: "Counter", + decisive_counter: "Decisive counter", +}; + +// --------------------------------------------------------------------------- +// Color helpers +// --------------------------------------------------------------------------- + +function signalColor(signal: CorrelationSignal | Confidence | VertexSignal): string { + if (signal === "high") return "#40c790"; + if (signal === "moderate" || signal === "partial") return "#f0b840"; + if (signal === "low") return "#f87171"; + return "#474745"; +} + +function weightColor(weight: EvidenceWeight): string { + if (weight === "smoking_gun" || weight === "supporting") return "#40c790"; + if (weight === "non_discriminatory") return "#f0b840"; + return "#f87171"; +} + +function vertexNodeFill(signal: VertexSignal): string { + if (signal === "high") return "#40c790"; + if (signal === "partial") return "#f0b840"; + return "#30302f"; +} + +function vertexNodeText(signal: VertexSignal): string { + if (signal === "high") return "#ffffff"; + if (signal === "partial") return "#1f1f1e"; + return "#474745"; +} + +// --------------------------------------------------------------------------- +// Shared SVG diamond (reused from triage view; vertex_signal encoding) +// --------------------------------------------------------------------------- + +type EdgeCoords = [number, number, number, number]; + +const DIAMOND_NODES: ReadonlyArray<{ vertex: DiamondVertex; cx: number; cy: number }> = [ + { vertex: "adversary", cx: 80, cy: 18 }, + { vertex: "infrastructure", cx: 18, cy: 80 }, + { vertex: "capability", cx: 142, cy: 80 }, + { vertex: "victim", cx: 80, cy: 142 }, +]; + +const DIAMOND_EDGES: ReadonlyArray = [ + [80, 18, 18, 80], + [80, 18, 142, 80], + [18, 80, 80, 142], + [142, 80, 80, 142], +]; + +interface DiamondProps { + vertexSignal: VertexSignalMap; + size?: number; +} + +function DiamondSvg({ vertexSignal, size = 80 }: DiamondProps) { + const showLabels = size >= 80; + return ( + + {DIAMOND_EDGES.map(([x1, y1, x2, y2], i) => ( + + ))} + {DIAMOND_NODES.map(({ vertex, cx, cy }) => { + const sig = vertexSignal[vertex]; + return ( + + + {showLabels && ( + + )} + + ); + })} + + ); +} + +// --------------------------------------------------------------------------- +// WeightDots — 1 (single) or 2 (double) colored dots encoding evidence weight +// --------------------------------------------------------------------------- + +interface WeightDotsProps { + weight: EvidenceWeight; +} + +function WeightDots({ weight }: WeightDotsProps) { + const color = weightColor(weight); + const label = WEIGHT_LABEL[weight]; + const isDouble = weight === "smoking_gun" || weight === "decisive_counter"; + return ( + +
diff --git a/src/views/correlation/styles.css b/src/views/correlation/styles.css index 04100bc..660b1f7 100644 --- a/src/views/correlation/styles.css +++ b/src/views/correlation/styles.css @@ -164,7 +164,7 @@ .corr-list-header { display: grid; - grid-template-columns: 28px 64px 1fr 44px; + grid-template-columns: 28px 64px 1fr 44px 28px; align-items: center; gap: 12px; padding: 6px 12px 6px 12px; @@ -179,21 +179,31 @@ .corr-list-col-rank { text-align: center; } .corr-list-col-diamond { text-align: center; } .corr-list-col-overlap { text-align: center; } +.corr-list-col-sel { text-align: center; } /* ─── Candidate row ─── */ .corr-candidate-row { display: grid; - grid-template-columns: 28px 64px 1fr 44px; + grid-template-columns: 28px 64px 1fr 44px 28px; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; transition: background 0.12s; + cursor: pointer; } .corr-candidate-row:hover { - background: rgba(255, 255, 255, 0.03); + background: rgba(255, 255, 255, 0.04); +} + +.corr-candidate-row-selected { + background: rgba(92, 124, 250, 0.07); +} + +.corr-candidate-row-selected:hover { + background: rgba(92, 124, 250, 0.10); } .corr-candidate-row + .corr-candidate-row { @@ -387,6 +397,72 @@ line-height: 1.5; } +/* ─── Selection checkbox glyph ─── */ + +.corr-candidate-check { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 1.5px solid #474745; + border-radius: 4px; + font-size: 11px; + font-weight: 700; + color: #ffffff; + background: transparent; + transition: border-color 0.12s, background 0.12s; + flex-shrink: 0; +} + +.corr-candidate-check-on { + border-color: #5c7cfa; + background: #5c7cfa; +} + +/* ─── Synthesize action bar ─── */ + +.corr-synth-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + background: rgba(92, 124, 250, 0.06); + border-top: 1px solid rgba(92, 124, 250, 0.18); + flex-shrink: 0; +} + +.corr-synth-bar-count { + font-size: 12px; + color: #8fa6fb; + font-family: var(--font-mono, "Fira Mono", monospace); +} + +.corr-synth-btn { + display: inline-flex; + align-items: center; + padding: 6px 16px; + background: #5c7cfa; + color: #ffffff; + border: none; + border-radius: 6px; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + white-space: nowrap; +} + +.corr-synth-btn:hover { + background: #7a97fb; +} + +.corr-synth-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + /* ─── Loading ─── */ .corr-loading { From cd3fbef66b5c80d01df99e258db98c039ce24147 Mon Sep 17 00:00:00 2001 From: seth-goodwin Date: Thu, 18 Jun 2026 16:48:56 -0500 Subject: [PATCH 4/9] Add correlation skill, manifest entries, and tool tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest.json: register the 5 correlation tools (diamond_search, get_report, diamond_search_analyst, correlation_input_check, render_correlation). skills/threat-correlation/SKILL.md: host-LLM steering — analyst-led path (input gate → scored triage → get_report → render) primary, blind path alternate. src/tools/correlation.test.ts: cover all 5 tools (pass-through tools assert no service calls; blind vs. scored stubs; input-gate branches; UI resources). --- manifest.json | 7 +- skills/threat-correlation/SKILL.md | 91 +++++++++ src/tools/correlation.test.ts | 315 +++++++++++++++++++++++++++++ 3 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 skills/threat-correlation/SKILL.md create mode 100644 src/tools/correlation.test.ts diff --git a/manifest.json b/manifest.json index 6250ef0..ed0b3c4 100644 --- a/manifest.json +++ b/manifest.json @@ -57,7 +57,12 @@ { "name": "generate-sample-data", "description": "Generate ECS-compliant security events for demos" - } + }, + { "name": "diamond_search", "description": "Blind diamond-model correlation search over the threat corpus" }, + { "name": "get_report", "description": "Fetch full threat report(s) by id for correlation deep-dive" }, + { "name": "diamond_search_analyst", "description": "Scored diamond-model correlation search with per-vertex triage view" }, + { "name": "correlation_input_check", "description": "Pre-search input gate: per-vertex signal stoplight before correlating" }, + { "name": "render_correlation", "description": "Render a host-synthesized correlation deep-dive report" } ], "tools_generated": true, "user_config": { diff --git a/skills/threat-correlation/SKILL.md b/skills/threat-correlation/SKILL.md new file mode 100644 index 0000000..94d1d6b --- /dev/null +++ b/skills/threat-correlation/SKILL.md @@ -0,0 +1,91 @@ +--- +name: threat-correlation +description: > + Correlate a case or incident against the threat-report corpus using the Diamond Model + of Intrusion Analysis. ALWAYS use this skill when the user asks to correlate an alert, + case, incident, or report, or when they ask whether an actor or campaign is related to + anything seen before. Trigger for: "correlate this", "is this actor/campaign related to + anything we've seen", "find related threat reports", "diamond correlation", "correlate + this case", "correlate this incident", "correlate this report", "any matching threat intel", + "diamond model search", "related actor", "related campaign". +--- + +# Threat Correlation + +Correlate SOC cases and incidents against the threat-report corpus using the `elastic-security` +MCP connector and the Diamond Model of Intrusion Analysis (adversary, capability, infrastructure, +victim). + +## ALWAYS call the tool + +When the user asks to correlate a case or find related threat intel, ALWAYS start with +`correlation_input_check` to surface the per-vertex signal stoplight. Do not attempt to +answer from memory or describe correlation results without calling the tools. + +## Primary path — analyst-led (transparent) + +Use this path for interactive human-in-the-loop correlation. It gives the analyst full +visibility into what signal you have before the search runs. + +| Step | User says / situation | Tool call | +|------|-----------------------|-----------| +| 1 | Summarise the case into Diamond Model vertices, then show the analyst the signal quality | `correlation_input_check` with `adversary`, `capability`, `infrastructure`, `victim` — each with a `query` paragraph and a `signal` self-rating (HIGH / PARTIAL / NONE) | +| 2 | Analyst confirms signal is ready | `diamond_search_analyst` with the same vertex queries — presents scored candidates with per-vertex match detail | +| 3 | Analyst selects top candidates from the scored list | `get_report` with the chosen `report_ids` (1–10) | +| 4 | You (the host) synthesize CorrelationFindings from the report text | `render_correlation` with your completed `findings` object | + +### Input signal self-rating scale + +| Rating | Meaning | +|--------|---------| +| HIGH | Specific, well-attested behavioural detail — strong search anchor | +| PARTIAL | Present but weak or inferred — query sent but may add noise | +| NONE | Genuinely absent — omit this vertex from the search | + +### Step 1 — `correlation_input_check` + +``` +correlation_input_check with: + adversary: { query: "APT28 / Fancy Bear; attributed to Russian GRU Unit 26165", signal: "HIGH" } + capability: { query: "Zebrocy downloader, Sofacy implant, spear-phishing lures", signal: "HIGH" } + infrastructure: { query: "dynamic DNS, .ru TLD hosting", signal: "PARTIAL" } + victim: { query: "NATO defence contractors, Eastern European governments", signal: "PARTIAL" } +``` + +The analyst reviews the stoplight and decides whether to proceed or refine the input. + +### Step 2 — `diamond_search_analyst` + +Pass the same vertex queries (omit NONE-rated vertices). The response includes: +- `candidates`: ScoredStub[] ranked by (overlap desc, max_score desc) with per-vertex match scores +- `coverage`: signal quality summary — `thin: true` signals weak multi-vertex retrieval +- `tradecraft`: triage_rubric and synthesis_guidance for steps 3–4 + +### Step 3 — `get_report` + +Call with the `report_ids` the analyst selected. Returns full `body_text`, `title`, `vendor`, `url` +per report — source material for your synthesis. + +### Step 4 — `render_correlation` + +After completing your synthesis, call `render_correlation` with your full `CorrelationFindings` +object (`leads`, `no_match`, `synthesis`). This is a pure pass-through to the analyst view — +the tool performs no reasoning. + +## Alternate path — blind autonomous (no analyst triage) + +Use `diamond_search` + `get_report` when operating autonomously without analyst oversight. +`diamond_search` returns candidate stubs WITHOUT scores (scores are stripped server-side +to preserve independent judgment). Triage candidates yourself using the `triage_rubric` +in the response, then call `get_report` for the top picks and synthesise findings inline +in the conversation (no `render_correlation` required unless you want the rendered UI). + +## Tools + +| Tool | Purpose | +|------|---------| +| `correlation_input_check` | Per-vertex signal stoplight gate. Params: `adversary`, `capability`, `infrastructure`, `victim` (each: `{ query, signal }`) | +| `diamond_search_analyst` | Scored transparent search. Params: `adversary`, `capability`, `infrastructure`, `victim` (strings), `iocs`, `size` | +| `get_report` | Fetch full report text by ID. Params: `report_ids` (array, 1–10) | +| `render_correlation` | Render host-synthesized findings. Params: `findings` (CorrelationFindings) | +| `diamond_search` | Blind autonomous search — stubs only, no scores. Same vertex + IOC params as `diamond_search_analyst` | diff --git a/src/tools/correlation.test.ts b/src/tools/correlation.test.ts new file mode 100644 index 0000000..6103231 --- /dev/null +++ b/src/tools/correlation.test.ts @@ -0,0 +1,315 @@ +/* + * 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 fs from "fs"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { registerCorrelationTools } from "./correlation.js"; +import { + createMockMcpServer, + parseToolText, + type MockMcpServer, +} from "../test/helpers/mockMcpServer.js"; +import { noopAnalyticsClient } from "../test/helpers/mockAnalytics.js"; +import type { CorrelationService } from "../elastic/service/correlationService.js"; + +// --------------------------------------------------------------------------- +// Local mock — NOT added to shared mockServices.ts (off-limits) +// --------------------------------------------------------------------------- + +function makeMockCorrelationService(): CorrelationService { + return { + diamondSearch: vi.fn(), + diamondSearchScored: vi.fn(), + getReports: vi.fn(), + } as unknown as CorrelationService; +} + +// --------------------------------------------------------------------------- +// Resource URIs +// --------------------------------------------------------------------------- + +const CORRELATION_URI = "ui://correlation/mcp-app.html"; +const CORRELATION_INPUT_URI = "ui://correlation-input/mcp-app.html"; +const CORRELATION_REPORT_URI = "ui://correlation-report/mcp-app.html"; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("registerCorrelationTools", () => { + let server: MockMcpServer; + let correlationService: CorrelationService; + + beforeEach(() => { + server = createMockMcpServer(); + correlationService = makeMockCorrelationService(); + vi.spyOn(fs, "existsSync").mockReturnValue(false); + vi.spyOn(fs, "readFileSync").mockReturnValue("correlation"); + registerCorrelationTools(server as unknown as McpServer, { + correlationService, + analytics: noopAnalyticsClient, + }); + }); + + it("registers all 5 correlation tools plus the 3 UI resources", () => { + expect([...server.tools.keys()].sort()).toEqual( + [ + "diamond_search", + "get_report", + "diamond_search_analyst", + "correlation_input_check", + "render_correlation", + ].sort() + ); + expect([...server.resources.keys()].sort()).toEqual( + [CORRELATION_URI, CORRELATION_INPUT_URI, CORRELATION_REPORT_URI].sort() + ); + }); + + // ------------------------------------------------------------------------- + // diamond_search — blind result, no scores in output + // ------------------------------------------------------------------------- + + describe("diamond_search", () => { + it("returns candidate stubs without scores and attaches tradecraft", async () => { + vi.mocked(correlationService.diamondSearch).mockResolvedValueOnce({ + candidates: [ + { report_id: "rpt-1", title: "APT28 Zebrocy", vendor: "elastic", url: "https://example.com/1" }, + ], + total: 1, + degraded: false, + vertices_queried: ["adversary", "capability"], + }); + + const out = await server.tool("diamond_search").callback({ + adversary: "APT28", + capability: "Zebrocy downloader", + }); + + expect(correlationService.diamondSearch).toHaveBeenCalledWith({ + vertex_queries: { + adversary: "APT28", + capability: "Zebrocy downloader", + infrastructure: undefined, + victim: undefined, + }, + iocs: undefined, + size: undefined, + }); + + const body = parseToolText<{ + candidates: Array<{ report_id: string; title: string; vendor: string; url: string }>; + meta: { total: number; degraded: boolean; vertices_queried: string[] }; + tradecraft: unknown; + }>(out); + + expect(body.candidates).toHaveLength(1); + expect(body.candidates[0].report_id).toBe("rpt-1"); + // Blind path: no vertex_scores key in the stubs + expect((body.candidates[0] as Record).vertex_scores).toBeUndefined(); + expect(body.meta.total).toBe(1); + expect(body.meta.degraded).toBe(false); + expect(body.tradecraft).toBeDefined(); + }); + }); + + // ------------------------------------------------------------------------- + // diamond_search_analyst — scored stubs + // ------------------------------------------------------------------------- + + describe("diamond_search_analyst", () => { + it("returns scored stubs with vertex_scores and coverage signal", async () => { + vi.mocked(correlationService.diamondSearchScored).mockResolvedValueOnce({ + candidates: [ + { + report_id: "rpt-2", + title: "Sofacy Campaign", + vendor: "elastic", + url: "https://example.com/2", + vertex_scores: { adversary: 0.92, capability: 0.87 }, + overlap: 2, + max_score: 0.92, + }, + ], + total: 1, + degraded: false, + vertices_queried: ["adversary", "capability"], + coverage: { queried: 2, avg_overlap: 2, thin: false }, + }); + + const out = await server.tool("diamond_search_analyst").callback({ + adversary: "APT28", + capability: "Sofacy", + }); + + expect(correlationService.diamondSearchScored).toHaveBeenCalledOnce(); + + const body = parseToolText<{ + candidates: Array<{ report_id: string; vertex_scores: Record; overlap: number }>; + coverage: { queried: number; avg_overlap: number; thin: boolean }; + tradecraft: unknown; + }>(out); + + expect(body.candidates).toHaveLength(1); + expect(body.candidates[0].vertex_scores).toEqual({ adversary: 0.92, capability: 0.87 }); + expect(body.candidates[0].overlap).toBe(2); + expect(body.coverage.thin).toBe(false); + expect(body.tradecraft).toBeDefined(); + }); + }); + + // ------------------------------------------------------------------------- + // get_report — delegates to service and returns reports array + // ------------------------------------------------------------------------- + + describe("get_report", () => { + it("returns the full reports fetched by the service", async () => { + vi.mocked(correlationService.getReports).mockResolvedValueOnce([ + { + report_id: "rpt-1", + title: "APT28 Zebrocy", + vendor: "elastic", + url: "https://example.com/1", + body_text: "The actor used Zebrocy...", + }, + ]); + + const out = await server.tool("get_report").callback({ report_ids: ["rpt-1"] }); + + expect(correlationService.getReports).toHaveBeenCalledWith(["rpt-1"]); + + const body = parseToolText<{ reports: Array<{ report_id: string; body_text: string }> }>(out); + expect(body.reports).toHaveLength(1); + expect(body.reports[0].report_id).toBe("rpt-1"); + expect(body.reports[0].body_text).toBe("The actor used Zebrocy..."); + }); + }); + + // ------------------------------------------------------------------------- + // correlation_input_check — pure display gate, no service calls + // ------------------------------------------------------------------------- + + describe("correlation_input_check", () => { + it("returns a gate payload with kind=correlation_input_check and no service calls", async () => { + const out = await server.tool("correlation_input_check").callback({ + adversary: { query: "APT28", signal: "HIGH" }, + capability: { query: "Zebrocy", signal: "PARTIAL" }, + }); + + // No service methods should have been called — pure display gate + expect(correlationService.diamondSearch).not.toHaveBeenCalled(); + expect(correlationService.diamondSearchScored).not.toHaveBeenCalled(); + expect(correlationService.getReports).not.toHaveBeenCalled(); + + const body = parseToolText<{ + kind: string; + vertices: Record; + summary: string; + }>(out); + + expect(body.kind).toBe("correlation_input_check"); + expect(body.vertices.adversary).toEqual({ query: "APT28", signal: "HIGH" }); + expect(body.vertices.capability).toEqual({ query: "Zebrocy", signal: "PARTIAL" }); + expect(body.summary).toContain("ADV HIGH"); + expect(body.summary).toContain("CAP PARTIAL"); + }); + + it("renders the no-signal message when no vertices are provided", async () => { + const out = await server.tool("correlation_input_check").callback({}); + + const body = parseToolText<{ kind: string; summary: string }>(out); + expect(body.kind).toBe("correlation_input_check"); + expect(body.summary).toContain("No vertex signal provided"); + }); + }); + + // ------------------------------------------------------------------------- + // render_correlation — pure pass-through, no service calls + // ------------------------------------------------------------------------- + + describe("render_correlation", () => { + it("renders kind=correlation_report from host findings without calling any service method", async () => { + const findings = { + leads: [ + { + candidate_ids: ["rpt-1"], + title: "APT28 campaign overlap", + relationship: "same_actor" as const, + confidence: "high" as const, + vertex_signal: { + adversary: "high" as const, + capability: "high" as const, + infrastructure: "partial" as const, + victim: "none" as const, + }, + bluf: "Strong actor overlap.", + evidence: [ + { + vertex: "adversary" as const, + weight: "smoking_gun" as const, + text: "Alias Fancy Bear confirmed.", + }, + ], + gaps: "Infrastructure not corroborated.", + consolidated_candidates: [], + }, + ], + no_match: [], + synthesis: { + bluf: "High confidence same-actor correlation.", + correlation_signal: "high" as const, + reasoning: "Two matching vertices with smoking-gun evidence.", + gaps: "None critical.", + next_steps: [{ priority: "high" as const, text: "Pivot on adversary infrastructure." }], + case_title: "Test Case", + }, + }; + + const out = await server.tool("render_correlation").callback({ findings }); + + // Pure pass-through — no service methods invoked + expect(correlationService.diamondSearch).not.toHaveBeenCalled(); + expect(correlationService.diamondSearchScored).not.toHaveBeenCalled(); + expect(correlationService.getReports).not.toHaveBeenCalled(); + + const body = parseToolText<{ + kind: string; + findings: typeof findings; + summary: string; + }>(out); + + expect(body.kind).toBe("correlation_report"); + expect(body.findings.leads).toHaveLength(1); + expect(body.findings.synthesis.correlation_signal).toBe("high"); + expect(body.summary).toContain("1 lead"); + expect(body.summary).toContain("signal: high"); + }); + }); + + // ------------------------------------------------------------------------- + // UI resources + // ------------------------------------------------------------------------- + + describe("UI resources", () => { + it("reads the correlation HTML resource", async () => { + const out = await server.resource(CORRELATION_URI).readCallback(); + expect(out.contents[0].text).toBe("correlation"); + }); + + it("reads the correlation-input HTML resource", async () => { + const out = await server.resource(CORRELATION_INPUT_URI).readCallback(); + expect(out.contents[0].text).toBe("correlation"); + }); + + it("reads the correlation-report HTML resource", async () => { + const out = await server.resource(CORRELATION_REPORT_URI).readCallback(); + expect(out.contents[0].text).toBe("correlation"); + }); + }); +}); From d40feee0d199851fbd6675d4bb91e97b0279e64c Mon Sep 17 00:00:00 2001 From: seth-goodwin Date: Thu, 18 Jun 2026 22:16:44 -0500 Subject: [PATCH 5/9] Fix correlation gate: drop dead button, add run-mode fork, fix viewId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit correlation-input view: the "Search this case" button drove a new turn via app.sendMessage(), unsupported on Claude Desktop — removed it for a passive prompt; the conversation is the control surface. SKILL.md: gate now offers Full run (autonomous, ~10-candidate cap) vs Analyst-led (interactive) — both use the same 5 tools. analytics: correlation views reported viewId "threat-hunt"; extend VIEW_IDS and label each view correctly. --- skills/threat-correlation/SKILL.md | 38 +++++++++++- src/shared/analytics-events.ts | 3 + src/shared/mcp-app-bootstrap.ts | 3 + src/views/correlation-input/App.tsx | 87 +++++----------------------- src/views/correlation-report/App.tsx | 2 +- src/views/correlation/App.tsx | 2 +- 6 files changed, 60 insertions(+), 75 deletions(-) diff --git a/skills/threat-correlation/SKILL.md b/skills/threat-correlation/SKILL.md index 94d1d6b..6565955 100644 --- a/skills/threat-correlation/SKILL.md +++ b/skills/threat-correlation/SKILL.md @@ -22,6 +22,42 @@ When the user asks to correlate a case or find related threat intel, ALWAYS star `correlation_input_check` to surface the per-vertex signal stoplight. Do not attempt to answer from memory or describe correlation results without calling the tools. +## Gate: choose a run mode after `correlation_input_check` + +After `correlation_input_check` surfaces the per-vertex stoplight, PAUSE and offer the analyst +two run modes. Branch on their reply. + +### Mode A — Full run (autonomous, disciplined) + +1. Call `diamond_search_analyst` with the confirmed vertex queries. +2. Review the scored candidates yourself using the `triage_rubric` in the response. Pull + `get_report` for the **top ~10 candidates** ranked by (overlap desc, max_score desc) — cap + at ~10 to bound token cost. +3. Apply the full `synthesis_guidance` and `triage_rubric` tradecraft from the tool's payload. +4. Call `render_correlation` with the completed `CorrelationFindings`. + +Frame honestly: more thorough, full tradecraft and bias-reduction discipline, but **slower +and higher token cost** (synthesis across many reports); results are model-dependent. + +### Mode B — Analyst-led (interactive, short-circuitable) + +1. Call `diamond_search_analyst` with the confirmed vertex queries. +2. **Present the ranked candidates to the analyst** — show titles, scores, and per-vertex + match detail from the response. +3. Wait for the analyst to pick which candidates to deep-dive. +4. Call `get_report` for only the analyst-selected `report_ids`. +5. Synthesize findings and call `render_correlation`. + +Frame honestly: **faster, cheaper, more interactive** — analyst steers depth and can +short-circuit at any point — but less programmatically disciplined (relies on analyst judgment +rather than a full autonomous triage pass). + +### Both modes use the same 5 tools + +The only difference is who performs triage: the model (Mode A) or the analyst (Mode B). + +--- + ## Primary path — analyst-led (transparent) Use this path for interactive human-in-the-loop correlation. It gives the analyst full @@ -30,7 +66,7 @@ visibility into what signal you have before the search runs. | Step | User says / situation | Tool call | |------|-----------------------|-----------| | 1 | Summarise the case into Diamond Model vertices, then show the analyst the signal quality | `correlation_input_check` with `adversary`, `capability`, `infrastructure`, `victim` — each with a `query` paragraph and a `signal` self-rating (HIGH / PARTIAL / NONE) | -| 2 | Analyst confirms signal is ready | `diamond_search_analyst` with the same vertex queries — presents scored candidates with per-vertex match detail | +| 2 | Analyst confirms signal is ready (or chooses Mode A/B at the gate) | `diamond_search_analyst` with the same vertex queries — presents scored candidates with per-vertex match detail | | 3 | Analyst selects top candidates from the scored list | `get_report` with the chosen `report_ids` (1–10) | | 4 | You (the host) synthesize CorrelationFindings from the report text | `render_correlation` with your completed `findings` object | diff --git a/src/shared/analytics-events.ts b/src/shared/analytics-events.ts index 1eff153..35cf5ff 100644 --- a/src/shared/analytics-events.ts +++ b/src/shared/analytics-events.ts @@ -12,6 +12,9 @@ export const VIEW_IDS = [ "detection-rules", "sample-data", "threat-hunt", + "correlation", + "correlation-input", + "correlation-report", ] as const; export type ViewId = (typeof VIEW_IDS)[number]; diff --git a/src/shared/mcp-app-bootstrap.ts b/src/shared/mcp-app-bootstrap.ts index dfafc94..9cad785 100644 --- a/src/shared/mcp-app-bootstrap.ts +++ b/src/shared/mcp-app-bootstrap.ts @@ -175,6 +175,9 @@ export interface ViewBootstrapPayloads { "detection-rules": DetectionRulesBootstrapPayload; "sample-data": SampleDataBootstrapPayload; "threat-hunt": ThreatHuntBootstrapPayload; + "correlation": never; + "correlation-input": never; + "correlation-report": never; } export interface McpAppBootstrapEnvelope { diff --git a/src/views/correlation-input/App.tsx b/src/views/correlation-input/App.tsx index 7198572..dd990e8 100644 --- a/src/views/correlation-input/App.tsx +++ b/src/views/correlation-input/App.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { extractToolText } from "../../shared/extract-tool-text"; import { useMcpApp, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; @@ -237,14 +237,12 @@ export function App() { function AppContent() { const [payload, setPayload] = useState(null); - const [proceeding, setProceeding] = useState(false); - const [dismissed, setDismissed] = useState(false); - const { connected, getApp } = useMcpApp(); + const { connected } = useMcpApp(); const { trackEvent } = useAnalytics(); useEffect(() => { - trackEvent({ eventType: "view_rendered", viewId: "threat-hunt" }); + trackEvent({ eventType: "view_rendered", viewId: "correlation-input" }); }, [trackEvent]); useMcpAppEvents({ @@ -255,8 +253,6 @@ function AppContent() { const data = JSON.parse(text); if (data?.kind === "correlation_input_check" && data.vertices) { setPayload(data as InputCheckPayload); - setProceeding(false); - setDismissed(false); } } catch { // Not a gate payload — ignore. @@ -264,40 +260,6 @@ function AppContent() { }, }); - const handleProceed = useCallback(async () => { - const app = getApp(); - if (!app || !payload) return; - setProceeding(true); - - // Build a concise proceed message that gives the LLM everything it needs - // to call diamond_search_analyst without the analyst retyping anything. - const vertexLines = VERTEX_ORDER - .filter((v) => payload.vertices[v] && payload.vertices[v]!.signal !== "NONE" && payload.vertices[v]!.query.trim()) - .map((v) => ` ${VERTEX_ABBREV[v]}: ${payload.vertices[v]!.query.trim()}`); - - const message = vertexLines.length > 0 - ? `PROCEED — call diamond_search_analyst with the following vertex queries:\n${vertexLines.join("\n")}` - : "PROCEED — call diamond_search_analyst with the vertex queries from the correlation_input_check you just ran."; - - try { - await app.sendMessage({ - role: "user", - content: [{ type: "text", text: message }], - }); - } catch { - // sendMessage may be unsupported by some hosts; fall back gracefully. - await app.updateModelContext({ - content: [{ type: "text", text: message }], - }); - } finally { - setProceeding(false); - } - }, [getApp, payload]); - - const handleRevise = useCallback(() => { - setDismissed(true); - }, []); - if (!connected) { return (
@@ -309,20 +271,6 @@ function AppContent() { ); } - if (dismissed) { - return ( -
-
-
Revising input
-
- Provide additional case context in the conversation. The model will - re-summarize and call correlation_input_check again when ready. -
-
-
- ); - } - return (
@@ -369,24 +317,19 @@ function AppContent() {
- Proceed with this search? -
-
- - + Signal looks ready. Tell me how to proceed:
+
    +
  • + Full run — I'll triage and synthesize the strongest matches + autonomously (more thorough, uses our full correlation tradecraft; slower and + higher token cost). +
  • +
  • + Analyst-led — I'll show you the ranked candidates and you pick + which to deep-dive (faster, cheaper, more interactive; you steer the depth). +
  • +
)} diff --git a/src/views/correlation-report/App.tsx b/src/views/correlation-report/App.tsx index 4e182ea..fe80a06 100644 --- a/src/views/correlation-report/App.tsx +++ b/src/views/correlation-report/App.tsx @@ -642,7 +642,7 @@ function AppContent() { const { trackEvent } = useAnalytics(); useEffect(() => { - trackEvent({ eventType: "view_rendered", viewId: "threat-hunt" }); + trackEvent({ eventType: "view_rendered", viewId: "correlation-report" }); }, [trackEvent]); useMcpAppEvents({ diff --git a/src/views/correlation/App.tsx b/src/views/correlation/App.tsx index da06e7d..0e0338c 100644 --- a/src/views/correlation/App.tsx +++ b/src/views/correlation/App.tsx @@ -323,7 +323,7 @@ function AppContent() { const { trackEvent } = useAnalytics(); useEffect(() => { - trackEvent({ eventType: "view_rendered", viewId: "threat-hunt" }); + trackEvent({ eventType: "view_rendered", viewId: "correlation" }); }, [trackEvent]); useMcpAppEvents({ From 3dd768a4698d464c0c47efa5df0344e2d1c8f759 Mon Sep 17 00:00:00 2001 From: seth-goodwin Date: Mon, 22 Jun 2026 12:32:08 -0500 Subject: [PATCH 6/9] Blind correlation triages on evidence, not scores --- skills/threat-correlation/SKILL.md | 84 ++++++++++++++--------- src/elastic/service/correlationService.ts | 58 ++++++++++++++-- src/tools/correlation.test.ts | 53 ++++++++++++-- src/tools/correlation.ts | 20 ++++-- 4 files changed, 164 insertions(+), 51 deletions(-) diff --git a/skills/threat-correlation/SKILL.md b/skills/threat-correlation/SKILL.md index 6565955..5492c15 100644 --- a/skills/threat-correlation/SKILL.md +++ b/skills/threat-correlation/SKILL.md @@ -3,11 +3,14 @@ name: threat-correlation description: > Correlate a case or incident against the threat-report corpus using the Diamond Model of Intrusion Analysis. ALWAYS use this skill when the user asks to correlate an alert, - case, incident, or report, or when they ask whether an actor or campaign is related to - anything seen before. Trigger for: "correlate this", "is this actor/campaign related to - anything we've seen", "find related threat reports", "diamond correlation", "correlate - this case", "correlate this incident", "correlate this report", "any matching threat intel", - "diamond model search", "related actor", "related campaign". + case, incident, or report, or when they ask whether an actor, campaign, or intrusion set + is related to anything seen before. Trigger for: "correlate this", "is this actor/campaign + related to anything we've seen", "find related threat reports", "diamond correlation", + "correlate this case", "correlate this incident", "correlate this report", "any matching + threat intel", "diamond model search", "related actor", "related campaign", + "is this a known intrusion set", "have we seen this before", "is this attributed", + "known campaign", "known actor", "match this case to threat intel", "who is behind this", + "is this attributed to", "have we seen this actor before", "is this a known campaign". --- # Threat Correlation @@ -18,28 +21,38 @@ victim). ## ALWAYS call the tool -When the user asks to correlate a case or find related threat intel, ALWAYS start with -`correlation_input_check` to surface the per-vertex signal stoplight. Do not attempt to -answer from memory or describe correlation results without calling the tools. +When the user asks to correlate a case or find related threat intel — including phrasings +like "is this a known intrusion set", "have we seen this before", "is this attributed", +"known campaign/actor", "match this case to threat intel", "who is behind this" — ALWAYS +start with `correlation_input_check` to surface the per-vertex signal stoplight. Do not +attempt to answer from memory or describe correlation results without calling the tools. +The gate is the mandatory entry point for ALL correlation requests, not just explicit +"analyst-led" asks. ## Gate: choose a run mode after `correlation_input_check` After `correlation_input_check` surfaces the per-vertex stoplight, PAUSE and offer the analyst two run modes. Branch on their reply. -### Mode A — Full run (autonomous, disciplined) +### Mode A — Full run (AUTONOMOUS, no human triaging) → use `diamond_search` (BLIND) -1. Call `diamond_search_analyst` with the confirmed vertex queries. -2. Review the scored candidates yourself using the `triage_rubric` in the response. Pull - `get_report` for the **top ~10 candidates** ranked by (overlap desc, max_score desc) — cap - at ~10 to bound token cost. +**Why blind:** Scores are withheld in autonomous mode to prevent the model from anchoring on +similarity rank instead of judging evidence on its merits. + +1. Call `diamond_search` with the confirmed vertex queries. +2. Triage candidates yourself by reading their `matched_vertices` evidence text against the + `triage_rubric` in the response. Pull `get_report` for the **top ~10 candidates** judged + strongest by evidence — cap at ~10 to bound token cost. 3. Apply the full `synthesis_guidance` and `triage_rubric` tradecraft from the tool's payload. 4. Call `render_correlation` with the completed `CorrelationFindings`. Frame honestly: more thorough, full tradecraft and bias-reduction discipline, but **slower and higher token cost** (synthesis across many reports); results are model-dependent. -### Mode B — Analyst-led (interactive, short-circuitable) +### Mode B — Analyst-led (INTERACTIVE, human triages) → use `diamond_search_analyst` (SCORED) + +**Why scored:** Scores are present here because the analyst, not the model, makes the selection +decision. The analyst can see and judge the numeric match signal directly. 1. Call `diamond_search_analyst` with the confirmed vertex queries. 2. **Present the ranked candidates to the analyst** — show titles, scores, and per-vertex @@ -49,16 +62,18 @@ and higher token cost** (synthesis across many reports); results are model-depen 5. Synthesize findings and call `render_correlation`. Frame honestly: **faster, cheaper, more interactive** — analyst steers depth and can -short-circuit at any point — but less programmatically disciplined (relies on analyst judgment -rather than a full autonomous triage pass). +short-circuit at any point — but relies on analyst judgment rather than a full autonomous +triage pass. -### Both modes use the same 5 tools +### These are ALTERNATIVES — pick ONE -The only difference is who performs triage: the model (Mode A) or the analyst (Mode B). +`diamond_search` and `diamond_search_analyst` serve different run modes. Do NOT run both in +sequence — that is not a workflow. Autonomous runs → `diamond_search`. Analyst-led runs → +`diamond_search_analyst`. --- -## Primary path — analyst-led (transparent) +## Primary path — analyst-led (interactive, Mode B) Use this path for interactive human-in-the-loop correlation. It gives the analyst full visibility into what signal you have before the search runs. @@ -90,13 +105,15 @@ correlation_input_check with: The analyst reviews the stoplight and decides whether to proceed or refine the input. -### Step 2 — `diamond_search_analyst` +### Step 2 — `diamond_search_analyst` (Mode B) or `diamond_search` (Mode A) -Pass the same vertex queries (omit NONE-rated vertices). The response includes: +**Mode B (analyst-led):** Pass the same vertex queries to `diamond_search_analyst` (omit NONE-rated vertices). The response includes: - `candidates`: ScoredStub[] ranked by (overlap desc, max_score desc) with per-vertex match scores - `coverage`: signal quality summary — `thin: true` signals weak multi-vertex retrieval - `tradecraft`: triage_rubric and synthesis_guidance for steps 3–4 +**Mode A (autonomous):** Call `diamond_search` instead. Candidates include `matched_vertices` evidence text; no scores are returned. + ### Step 3 — `get_report` Call with the `report_ids` the analyst selected. Returns full `body_text`, `title`, `vendor`, `url` @@ -108,20 +125,21 @@ After completing your synthesis, call `render_correlation` with your full `Corre object (`leads`, `no_match`, `synthesis`). This is a pure pass-through to the analyst view — the tool performs no reasoning. -## Alternate path — blind autonomous (no analyst triage) +## Alternate path — blind autonomous (Mode A, no analyst triage) Use `diamond_search` + `get_report` when operating autonomously without analyst oversight. -`diamond_search` returns candidate stubs WITHOUT scores (scores are stripped server-side -to preserve independent judgment). Triage candidates yourself using the `triage_rubric` -in the response, then call `get_report` for the top picks and synthesise findings inline -in the conversation (no `render_correlation` required unless you want the rendered UI). +`diamond_search` returns candidate stubs WITHOUT scores (scores are withheld server-side +to prevent anchoring on similarity rank). Each candidate includes `matched_vertices` evidence +text — the summary from the report for each vertex that matched. Triage candidates by reading +that evidence against the `triage_rubric` in the response, then call `get_report` for the top +picks and synthesise findings. ## Tools -| Tool | Purpose | -|------|---------| -| `correlation_input_check` | Per-vertex signal stoplight gate. Params: `adversary`, `capability`, `infrastructure`, `victim` (each: `{ query, signal }`) | -| `diamond_search_analyst` | Scored transparent search. Params: `adversary`, `capability`, `infrastructure`, `victim` (strings), `iocs`, `size` | -| `get_report` | Fetch full report text by ID. Params: `report_ids` (array, 1–10) | -| `render_correlation` | Render host-synthesized findings. Params: `findings` (CorrelationFindings) | -| `diamond_search` | Blind autonomous search — stubs only, no scores. Same vertex + IOC params as `diamond_search_analyst` | +| Tool | When to use | Purpose | +|------|-------------|---------| +| `correlation_input_check` | Always first | Per-vertex signal stoplight gate. Params: `adversary`, `capability`, `infrastructure`, `victim` (each: `{ query, signal }`) | +| `diamond_search` | Mode A (autonomous, no human triage) | Blind search — matched_vertices evidence, NO scores. Same vertex + IOC params as `diamond_search_analyst` | +| `diamond_search_analyst` | Mode B (analyst-led, human triages) | Scored transparent search. Returns vertex_scores for analyst review. Params: `adversary`, `capability`, `infrastructure`, `victim` (strings), `iocs`, `size` | +| `get_report` | After triage | Fetch full report text by ID. Params: `report_ids` (array, 1–10) | +| `render_correlation` | Final step | Render host-synthesized findings. Params: `findings` (CorrelationFindings) | diff --git a/src/elastic/service/correlationService.ts b/src/elastic/service/correlationService.ts index b1c15e2..9375b52 100644 --- a/src/elastic/service/correlationService.ts +++ b/src/elastic/service/correlationService.ts @@ -62,8 +62,28 @@ export interface ReportStub { url: string; } +/** A matched vertex with its evidence summary text (no score). */ +export interface MatchedVertex { + vertex: DiamondVertex; + summary: string; +} + +/** + * A candidate stub for the BLIND autonomous path (diamond_search). + * Carries which vertices matched and their evidence summaries — NO scores. + * The model triages on evidence text, not similarity rank. + */ +export interface BlindReportStub { + report_id: string; + title: string; + vendor: string; + url: string; + /** Vertices that scored >= NOISE_FLOOR, in DIAMOND_VERTICES order, with evidence text. */ + matched_vertices?: MatchedVertex[]; +} + export interface DiamondSearchResult { - candidates: ReportStub[]; + candidates: BlindReportStub[]; total: number; /** True when inference was unavailable and BM25 fallback was used. */ degraded: boolean; @@ -153,6 +173,9 @@ interface SourceFields { source?: { name?: string; type?: string; url?: string }; severity?: { level?: string }; provenance?: { extracted_at?: string }; + extracted?: { + diamond?: Partial>; + }; } interface SourceFieldsFull extends SourceFields { @@ -195,8 +218,9 @@ const runSemanticSearch = async ( queriedVertices: DiamondVertex[], vertexQueries: DiamondVertexQueries, size: number -): Promise<{ stubs: ReportStub[]; total: number; degraded: false }> => { +): Promise<{ stubs: BlindReportStub[]; total: number; degraded: false }> => { // Build ndjson body: one header + body pair per vertex. + const vertexSummaryFields = DIAMOND_VERTICES.map((v) => `extracted.diamond.${v}.summary`); const lines: string[] = []; for (const vertex of queriedVertices) { lines.push( @@ -221,7 +245,13 @@ const runSemanticSearch = async ( }, }, size: KNN_CANDIDATES_PER_VERTEX, - _source: ["content.title", "source.name", "source.type", "source.url"], + _source: [ + "content.title", + "source.name", + "source.type", + "source.url", + ...vertexSummaryFields, + ], }) ); } @@ -262,7 +292,7 @@ const runSemanticSearch = async ( } // Qualify: at least one vertex score >= NOISE_FLOOR. - const candidates: Array<{ stub: ReportStub; overlap: number; maxScore: number }> = []; + const candidates: Array<{ stub: BlindReportStub; overlap: number; maxScore: number }> = []; for (const [reportId, { source, scores }] of matrix) { const aboveFloor = DIAMOND_VERTICES.filter( @@ -270,12 +300,25 @@ const runSemanticSearch = async ( ); if (aboveFloor.length === 0) continue; const aboveScores = aboveFloor.map((v) => scores[v] as number); + + // Build matched_vertices in deterministic DIAMOND_VERTICES order, no scores. + const matched_vertices: MatchedVertex[] = aboveFloor + .filter((v) => { + const summary = source.extracted?.diamond?.[v]?.summary; + return summary != null && summary.length > 0; + }) + .map((v) => ({ + vertex: v, + summary: source.extracted!.diamond![v]!.summary as string, + })); + candidates.push({ stub: { report_id: reportId, title: source.content?.title?.trim() ?? reportId, vendor: source.source?.name ?? source.source?.type ?? "unknown", url: source.source?.url ?? "", + ...(matched_vertices.length > 0 ? { matched_vertices } : {}), }, overlap: aboveFloor.length, maxScore: Math.max(...aboveScores), @@ -303,7 +346,7 @@ const runBm25Fallback = async ( queriedVertices: DiamondVertex[], vertexQueries: DiamondVertexQueries, size: number -): Promise<{ stubs: ReportStub[]; total: number; degraded: true }> => { +): Promise<{ stubs: BlindReportStub[]; total: number; degraded: true }> => { const combinedQuery = queriedVertices .map((v) => vertexQueries[v]) .filter(Boolean) @@ -583,7 +626,7 @@ export class CorrelationService { return { candidates: [], total: 0, degraded: false, vertices_queried: [] }; } - let semanticResult: { stubs: ReportStub[]; total: number; degraded: boolean }; + let semanticResult: { stubs: BlindReportStub[]; total: number; degraded: boolean }; try { semanticResult = await runSemanticSearch(esClient, queriedVertices, vertexQueries, size); } catch (err) { @@ -607,7 +650,8 @@ export class CorrelationService { // De-duplicate: anchor-first, then semantic hits not already in anchors. const anchorIds = new Set(anchorHits.map((c) => c.report_id)); const semanticOnly = candidates.filter((c) => !anchorIds.has(c.report_id)); - candidates = [...anchorHits, ...semanticOnly].slice(0, size); + // Anchor hits carry no vertex summary data (BM25 path); matched_vertices omitted. + candidates = [...(anchorHits as BlindReportStub[]), ...semanticOnly].slice(0, size); } return { diff --git a/src/tools/correlation.test.ts b/src/tools/correlation.test.ts index 6103231..af705f5 100644 --- a/src/tools/correlation.test.ts +++ b/src/tools/correlation.test.ts @@ -16,7 +16,7 @@ import { type MockMcpServer, } from "../test/helpers/mockMcpServer.js"; import { noopAnalyticsClient } from "../test/helpers/mockAnalytics.js"; -import type { CorrelationService } from "../elastic/service/correlationService.js"; +import type { CorrelationService, MatchedVertex } from "../elastic/service/correlationService.js"; // --------------------------------------------------------------------------- // Local mock — NOT added to shared mockServices.ts (off-limits) @@ -77,10 +77,21 @@ describe("registerCorrelationTools", () => { // ------------------------------------------------------------------------- describe("diamond_search", () => { - it("returns candidate stubs without scores and attaches tradecraft", async () => { + it("returns candidate stubs with matched_vertices evidence (no scores) and attaches tradecraft", async () => { + const matchedVertices: MatchedVertex[] = [ + { vertex: "adversary", summary: "APT28 / Fancy Bear, attributed to GRU Unit 26165" }, + { vertex: "capability", summary: "Zebrocy downloader used as first-stage implant" }, + ]; + vi.mocked(correlationService.diamondSearch).mockResolvedValueOnce({ candidates: [ - { report_id: "rpt-1", title: "APT28 Zebrocy", vendor: "elastic", url: "https://example.com/1" }, + { + report_id: "rpt-1", + title: "APT28 Zebrocy", + vendor: "elastic", + url: "https://example.com/1", + matched_vertices: matchedVertices, + }, ], total: 1, degraded: false, @@ -104,19 +115,51 @@ describe("registerCorrelationTools", () => { }); const body = parseToolText<{ - candidates: Array<{ report_id: string; title: string; vendor: string; url: string }>; + candidates: Array<{ + report_id: string; + title: string; + vendor: string; + url: string; + matched_vertices?: MatchedVertex[]; + }>; meta: { total: number; degraded: boolean; vertices_queried: string[] }; tradecraft: unknown; }>(out); expect(body.candidates).toHaveLength(1); expect(body.candidates[0].report_id).toBe("rpt-1"); - // Blind path: no vertex_scores key in the stubs + + // Blind path: matched_vertices with evidence text present, no numeric scores + expect(body.candidates[0].matched_vertices).toEqual(matchedVertices); expect((body.candidates[0] as Record).vertex_scores).toBeUndefined(); + expect(body.meta.total).toBe(1); expect(body.meta.degraded).toBe(false); expect(body.tradecraft).toBeDefined(); }); + + it("returns stubs without matched_vertices when service returns none (BM25 fallback)", async () => { + vi.mocked(correlationService.diamondSearch).mockResolvedValueOnce({ + candidates: [ + { report_id: "rpt-bm25", title: "BM25 hit", vendor: "elastic", url: "https://example.com/bm25" }, + ], + total: 1, + degraded: true, + vertices_queried: ["adversary"], + }); + + const out = await server.tool("diamond_search").callback({ adversary: "APT28" }); + + const body = parseToolText<{ + candidates: Array>; + meta: { degraded: boolean }; + }>(out); + + expect(body.meta.degraded).toBe(true); + // BM25 stubs: no matched_vertices, no vertex_scores + expect(body.candidates[0].matched_vertices).toBeUndefined(); + expect(body.candidates[0].vertex_scores).toBeUndefined(); + }); }); // ------------------------------------------------------------------------- diff --git a/src/tools/correlation.ts b/src/tools/correlation.ts index ef802a4..226e433 100644 --- a/src/tools/correlation.ts +++ b/src/tools/correlation.ts @@ -61,13 +61,17 @@ export function registerCorrelationTools( "diamond_search", { title: "Diamond Model Correlation Search", - description: `Search the threat-report corpus for reports that correlate with a new case using the Diamond Model of Intrusion Analysis. + description: `AUTONOMOUS / unsupervised threat-report correlation search. Use this when NO human analyst will triage the candidates. -HOST WORKFLOW: +This tool is the BLIND path: it returns candidates with per-vertex matched_vertices evidence summaries and NO numeric scores, BY DESIGN. Scores are withheld so the model judges evidence on its merits instead of anchoring on similarity rank. Triage candidates using their matched_vertices text and the triage_rubric in the response. + +Do NOT use this tool when a human analyst will review and select candidates — use diamond_search_analyst instead. Pick ONE: diamond_search for autonomous runs, diamond_search_analyst for analyst-led runs. + +HOST WORKFLOW (autonomous): 1. Summarise your case into up to four Diamond Model vertex paragraphs (adversary, capability, infrastructure, victim) following the diamond_summarisation_guidance included in every response. Omit vertices with no signal. 2. Call this tool with your vertex summaries and any file-hash IOCs from the case. -3. You will receive ranked candidate stubs (report_id, title, vendor, url) plus the triage_rubric and synthesis_guidance you need for later steps. -4. Triage candidates using the returned triage_rubric — do NOT anchor on numeric scores (none are returned). +3. You receive candidate stubs with matched_vertices evidence text (which vertices matched + what the report said about each). No numeric scores are returned. +4. Triage candidates by reading matched_vertices evidence against the triage_rubric. Pull the top ~10 by evidence strength. 5. Call get_report with the IDs of your top candidates. 6. Synthesise correlation findings using the returned synthesis_guidance.`, _meta: { ui: {} }, @@ -192,12 +196,16 @@ Call this after triaging the candidates returned by diamond_search. Pass the rep "diamond_search_analyst", { title: "Diamond Model Correlation Search (Analyst-Led)", - description: `Analyst-led transparent correlation: returns ranked candidates WITH per-vertex match scores and retrieval coverage, for an analyst (or analyst-supervised LLM) to triage with full visibility. Use this for interactive human-in-the-loop correlation. For blinded independent judgment instead, use \`diamond_search\`. + description: `INTERACTIVE / analyst-led threat-report correlation search. Use this when a human analyst WILL review and select candidates. Do NOT use this for autonomous unsupervised correlation — use diamond_search instead. + +This tool is the SCORED path: it returns ranked candidates with per-vertex match scores (vertex_scores) for the analyst to triage with full score visibility. Scores are present here because the analyst, not the model, makes the selection decision. + +Pick ONE: diamond_search_analyst for analyst-led runs, diamond_search for autonomous runs. Running both in sequence is not a workflow. The response includes: - candidates: ScoredStub[] ranked by (overlap desc, max_score desc) — each with vertex_scores showing which Diamond Model vertices matched and their semantic similarity scores - coverage: { queried, avg_overlap, thin } — thin=true signals weak retrieval (degraded or low multi-vertex overlap); the UI renders a backfill nudge -- tradecraft: the same triage_rubric and synthesis_guidance as diamond_search +- tradecraft: triage_rubric and synthesis_guidance for subsequent steps HOST WORKFLOW (analyst-supervised): 1. Summarise the case into Diamond Model vertex paragraphs following diamond_summarisation_guidance. From dcd987af2839b0e890f2eb82e9b6a2132d750077 Mon Sep 17 00:00:00 2001 From: seth-goodwin Date: Mon, 13 Jul 2026 08:26:52 -0500 Subject: [PATCH 7/9] Wire workflow-backed correlation + paint-by-numbers canvas Route authoritative correlation through the ti-correlation Kibana workflow (correlate -> get_correlation_run -> render_correlation); keep diamond_search, diamond_search_analyst, get_report, and correlation_input_check as analyst exploration aids and mark host-driven synthesis deprecated. Roll the canvas generator into src/canvas (emitCorrelationCanvas): stamp a run's render-shape findings into correlation-report.canvas.tmpl, opt-in via CORRELATION_CANVAS_DIR. Add dev CLIs (gen-correlation-canvas, dump-run-findings, smoke-correlate). Hygiene: fix stale server integration tests (correlation tools + views), drop a hardcoded personal canvas path from gen-correlation-canvas, and document correlation/canvas env vars in .env.example. Co-authored-by: Cursor --- .env.example | 16 + .gitignore | 1 + manifest.json | 12 +- scripts/correlation-report.canvas.tmpl | 719 ++++++++++++++++++ scripts/dump-run-findings.ts | 63 ++ scripts/gen-correlation-canvas.ts | 87 +++ scripts/smoke-correlate.ts | 140 ++++ skills/threat-correlation/SKILL.md | 153 ++-- src/canvas/correlation-canvas.ts | 102 +++ src/correlation/tradecraft.ts | 18 +- src/elastic/service/correlationService.ts | 185 ++++- src/server.ts | 2 +- .../integration/server.integration.test.ts | 12 + src/tools/correlation.test.ts | 254 ++++++- src/tools/correlation.ts | 612 ++++++++++++++- src/views/correlation-report/App.tsx | 438 ++++++++++- src/views/correlation-report/styles.css | 265 +++++++ tsconfig.server.json | 2 +- 18 files changed, 2909 insertions(+), 172 deletions(-) create mode 100644 scripts/correlation-report.canvas.tmpl create mode 100644 scripts/dump-run-findings.ts create mode 100644 scripts/gen-correlation-canvas.ts create mode 100644 scripts/smoke-correlate.ts create mode 100644 src/canvas/correlation-canvas.ts diff --git a/.env.example b/.env.example index 9e4f951..76fc6f2 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,22 @@ CLUSTERS_JSON=[{"name":"primary","elasticsearchUrl":"https://your-cluster.es.clo # Alternative: load the same JSON from a file. # CLUSTERS_FILE=/absolute/path/to/clusters.json +# --- Threat-report correlation (threat-intel-ingest deployment) ------------ +# Report corpus index pattern the correlation tools search. Defaults to +# `ti-reports*` (the threat-intel-ingest corpus). Set to `.kibana-threat-reports*` +# for the IntelligenceHub corpus. +# TI_REPORTS_INDEX_PATTERN=ti-reports* +# +# Authoritative correlation is delegated to the `ti-correlation` Kibana Workflow. +# Override the workflow id / run-record index if your deploy.sh uses non-defaults. +# TI_CORRELATION_WORKFLOW_ID=ti-correlation +# TI_CORRELATIONS_INDEX=ti-correlations +# +# Opt-in: when set, a completed correlation is also stamped into a self-contained +# Cursor canvas (.canvas.tsx) in this dir. Unset → no-op (app behaves normally). +# Point it at your Cursor project's canvases dir. +# CORRELATION_CANVAS_DIR=/path/to/.cursor/projects//canvases + # Telemetry endpoint override. Defaults to production (telemetry.elastic.co). # Set to `staging` to point at telemetry-staging.elastic.co — useful when # verifying dashboards or working on the MCP App's analytics locally. diff --git a/.gitignore b/.gitignore index cc05db5..f3d5469 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ .mcp.json .vscode/mcp.json .env +clusters.json .DS_Store *.code-workspace *.backup diff --git a/manifest.json b/manifest.json index ed0b3c4..116b3bf 100644 --- a/manifest.json +++ b/manifest.json @@ -58,11 +58,13 @@ "name": "generate-sample-data", "description": "Generate ECS-compliant security events for demos" }, - { "name": "diamond_search", "description": "Blind diamond-model correlation search over the threat corpus" }, - { "name": "get_report", "description": "Fetch full threat report(s) by id for correlation deep-dive" }, - { "name": "diamond_search_analyst", "description": "Scored diamond-model correlation search with per-vertex triage view" }, - { "name": "correlation_input_check", "description": "Pre-search input gate: per-vertex signal stoplight before correlating" }, - { "name": "render_correlation", "description": "Render a host-synthesized correlation deep-dive report" } + { "name": "correlate", "description": "Correlate a case via the ti-correlation Kibana workflow (authoritative)" }, + { "name": "get_correlation_run", "description": "Poll a correlation run by id; return render-ready findings" }, + { "name": "render_correlation", "description": "Render the workflow's correlation deep-dive findings" }, + { "name": "diamond_search", "description": "Exploration aid: blind diamond-model corpus search" }, + { "name": "get_report", "description": "Exploration aid: fetch full threat report(s) by id" }, + { "name": "diamond_search_analyst", "description": "Exploration aid: scored diamond-model corpus search with triage view" }, + { "name": "correlation_input_check", "description": "Exploration aid: per-vertex signal stoplight gate" } ], "tools_generated": true, "user_config": { diff --git a/scripts/correlation-report.canvas.tmpl b/scripts/correlation-report.canvas.tmpl new file mode 100644 index 0000000..b98e006 --- /dev/null +++ b/scripts/correlation-report.canvas.tmpl @@ -0,0 +1,719 @@ +import { + Divider, + Grid, + H2, + H3, + Link, + Pill, + Row, + Stack, + Stat, + Table, + Text, + useCanvasState, + useHostTheme, +} from "cursor/canvas"; + +// --------------------------------------------------------------------------- +// Data contract: the exact `findings` object returned by the elastic-security +// MCP app's get_correlation_run (== render_correlation input). This canvas is +// run-driven — the FINDINGS below is a REAL ti-correlation run (depth: full) +// dropped in verbatim. To refresh for another run: call `correlate`, poll +// `get_correlation_run`, and replace FINDINGS with its `findings` payload. +// --------------------------------------------------------------------------- + +interface Evidence { vertex: string; weight: string; text: string } +interface Lead { + title: string; + relationship: string; + confidence: string; + vertex_signal: Record; + bluf: string; + evidence: Evidence[]; + gaps: string; + candidate_ids: string[]; + consolidated_candidates?: unknown[]; +} +interface Stage { + stage: string; + tier: "sonnet" | "opus" | null; + input_tokens: number; + output_tokens: number; + candidates?: number; + anchors?: number; + started_at?: string; + ended_at?: string; +} +interface Findings { + synthesis: { + case_title?: string; + bluf: string; + correlation_signal: string; + reasoning: string; + gaps: string; + next_steps: Array<{ priority: string; text: string }>; + inferential_hops?: number; + atomic_ioc_overlap?: { assessed: boolean; note?: string }; + }; + case_vertex_signal?: Record; + leads: Lead[]; + no_match: Array<{ id: string; title: string; vendor?: string }>; + candidate_meta?: Record; + counts?: Record; + trace?: { total_input_tokens?: number | string; total_output_tokens?: number | string; stages?: Stage[] }; + run_meta?: { run_id?: string; depth?: string; status?: string }; + anchors_searched?: { hashes: string[]; network: string[]; artifacts: string[]; techniques: string[]; code_tokens?: string[] }; + anchor_trail?: AnchorTrailEntry[]; + phrase_anchor_trail?: AnchorTrailEntry[]; +} +interface AnchorTrailEntry { + fp: string; + anchor_score: number; + overlap: number; + title?: string; + vendor?: string; + url?: string; + triage_confidence?: number; + justification?: string; + outcome: "lead" | "picked_no_lead" | "dropped_at_triage"; + lead_title?: string; + relationship?: string; + lead_confidence?: string; +} + +// Data injected by scripts/gen-correlation-canvas.ts (render-shape findings from get_correlation_run). +const FINDINGS: Findings = __FINDINGS_JSON__; + +// --------------------------------------------------------------------------- +// Intentional signal palette (mirrors the threat-intel POC / MCP app diamond): +// color IS the data — a fixed legend reused across the diamond, confidence +// badges, and evidence markers so the whole report reads at a glance. +// --------------------------------------------------------------------------- + +const SIG_GREEN = "#40c790"; // high +const SIG_AMBER = "#f0b840"; // partial / moderate +const SIG_RED = "#f87171"; // low / counter +const SIG_GRAY = "#474745"; // none / edges +const NODE_NONE = "#30302f"; + +const lc = (s: string | undefined): string => (s ?? "").toLowerCase(); + +function signalColor(s: string): string { + const v = lc(s); + if (v === "high") return SIG_GREEN; + if (v === "moderate" || v === "partial") return SIG_AMBER; + if (v === "low") return SIG_RED; + return SIG_GRAY; +} +function weightColor(w: string): string { + const v = lc(w); + if (v === "smoking_gun" || v === "supporting") return SIG_GREEN; + if (v === "non_discriminatory") return SIG_AMBER; + return SIG_RED; +} +const DECISIVE_WEIGHTS = new Set(["smoking_gun", "decisive_counter"]); +function badgeText(s: string): string { + const v = lc(s); + if (v === "moderate" || v === "partial") return "#1f1f1e"; + return "#ffffff"; +} +function sourceHost(url: string): string { + return url.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0]; +} +function money(n: number): string { + return `$${n.toFixed(2)}`; +} + +const REL_LABEL: Record = { + same_campaign: "Same campaign", + same_actor: "Same actor", + shared_tradecraft: "Shared tradecraft", +}; +const WEIGHT_LABEL: Record = { + smoking_gun: "Smoking gun", + supporting: "Supporting", + non_discriminatory: "Non-discriminatory", + counter: "Counter", + decisive_counter: "Decisive counter", +}; +const VERTEX_LABEL: Record = { + adversary: "Adversary", + capability: "Capability", + infrastructure: "Infrastructure", + victim: "Victim", +}; +const VERTEX_ABBR: Record = { + adversary: "ADV", + capability: "CAP", + infrastructure: "INF", + victim: "VIC", +}; +const VERTEX_ORDER = ["adversary", "capability", "infrastructure", "victim"]; + +// Anthropic list prices, USD per 1M tokens (in / out). Estimate only — managed +// EIS billing may differ; captioned as such in the Pipeline & cost card. +const PRICES: Record<"sonnet" | "opus", { in: number; out: number }> = { + sonnet: { in: 3, out: 15 }, + opus: { in: 15, out: 75 }, +}; +const STAGE_MODEL: Record = { + extract_core: "Claude Sonnet (raw_text IOCs/behaviors)", + extract_diamond: "Claude Opus (raw_text vertices)", + retrieval: "Elasticsearch (kNN + BM25 + anchors)", + triage: "Claude Sonnet", + synthesis: "Claude Opus", +}; +const STAGE_LABEL: Record = { + extract_core: "Case extraction", + extract_diamond: "Case diamond", + retrieval: "Retrieval", + triage: "Triage", + synthesis: "Synthesis", +}; +function stageCost(s: Stage): number { + if (!s.tier) return 0; + const p = PRICES[s.tier]; + return (s.input_tokens * p.in + s.output_tokens * p.out) / 1_000_000; +} +function stageDurationMs(s: Stage): number | null { + if (!s.started_at || !s.ended_at) return null; + const a = Date.parse(s.started_at); + const b = Date.parse(s.ended_at); + if (!Number.isFinite(a) || !Number.isFinite(b) || b < a) return null; + return b - a; +} +function fmtDuration(ms: number | null): string { + if (ms == null) return "—"; + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(1)} s`; +} + +// --------------------------------------------------------------------------- +// Diamond Model graphic — colored vertices encode per-vertex signal. +// --------------------------------------------------------------------------- + +const NODES: Array<{ v: string; cx: number; cy: number }> = [ + { v: "adversary", cx: 80, cy: 18 }, + { v: "infrastructure", cx: 18, cy: 80 }, + { v: "capability", cx: 142, cy: 80 }, + { v: "victim", cx: 80, cy: 142 }, +]; +const EDGES: Array<[number, number, number, number]> = [ + [80, 18, 18, 80], + [80, 18, 142, 80], + [18, 80, 80, 142], + [142, 80, 80, 142], +]; + +function Diamond({ signal, size = 130 }: { signal: Record; size?: number }) { + const theme = useHostTheme(); + const showLabels = size >= 64; + const fill = (s: string) => (lc(s) === "high" ? SIG_GREEN : lc(s) === "partial" ? SIG_AMBER : NODE_NONE); + const txt = (s: string) => (lc(s) === "high" ? "#ffffff" : lc(s) === "partial" ? "#1f1f1e" : theme.text.quaternary); + // Pad the viewBox + scale the pixel size so the r=20 nodes don't clip. + const PAD = 12; + const vb = 160 + PAD * 2; + const px = Math.round(size * (vb / 160)); + return ( + + {EDGES.map(([x1, y1, x2, y2], i) => ( + + ))} + {NODES.map(({ v, cx, cy }) => { + const s = signal[v] ?? "none"; + return ( + + + {showLabels && ( + + {VERTEX_ABBR[v]} + + )} + + ); + })} + + ); +} + +function Chevron({ open }: { open: boolean }) { + const theme = useHostTheme(); + return ( + + + + ); +} + +function CalloutIcon({ tone }: { tone: "info" | "success" | "warning" }) { + const theme = useHostTheme(); + const bg = tone === "info" ? theme.accent.primary : tone === "success" ? SIG_GREEN : SIG_AMBER; + const fg = tone === "warning" ? "#1f1f1e" : "#ffffff"; + return ( + + ); +} + +function SignalCallout({ + tone, title, children, emphasis = false, +}: { + tone: "info" | "success" | "warning"; + title: string; + children: string; + emphasis?: boolean; +}) { + const theme = useHostTheme(); + const accent = tone === "info" ? theme.accent.primary : tone === "success" ? SIG_GREEN : SIG_AMBER; + return ( +
+ + + {title} + + {children} + + +
+ ); +} + +function SignalLegend() { + const theme = useHostTheme(); + const items: Array<[string, string]> = [ + [SIG_GREEN, "High"], + [SIG_AMBER, "Partial / moderate"], + [SIG_RED, "Low / counter"], + [SIG_GRAY, "None"], + ]; + return ( + + {items.map(([color, label]) => ( + + + + {label} + + + ))} + + ); +} + +function EvidenceGroups({ evidence }: { evidence: Evidence[] }) { + const theme = useHostTheme(); + const groups = VERTEX_ORDER.map((v) => ({ v, items: evidence.filter((e) => e.vertex === v) })).filter((g) => g.items.length > 0); + return ( + + {groups.map(({ v, items }) => ( + + + {VERTEX_LABEL[v] ?? v} + + {items.map((e, i) => { + const wc = weightColor(e.weight); + const decisive = DECISIVE_WEIGHTS.has(lc(e.weight)); + return ( + + + + + {decisive ? ( + + {WEIGHT_LABEL[lc(e.weight)] ?? e.weight} + + ) : ( + + {WEIGHT_LABEL[lc(e.weight)] ?? e.weight} + + )} + {e.text} + + + + ); + })} + + + + ))} + + ); +} + +function ConfidenceBadge({ confidence }: { confidence: string }) { + const c = signalColor(confidence); + return ( + + {lc(confidence)} confidence + + ); +} + +function PriorityBadge({ priority }: { priority: string }) { + const theme = useHostTheme(); + const high = lc(priority) === "high"; + return ( + + + {lc(priority)} + + ); +} + +const CM = FINDINGS.candidate_meta ?? {}; +function leadVendor(lead: Lead): string | undefined { + for (const id of lead.candidate_ids) { const v = CM[id]?.vendor; if (v) return v; } + return undefined; +} +function leadUrl(lead: Lead): string | undefined { + for (const id of lead.candidate_ids) { const u = CM[id]?.url; if (u) return u; } + return undefined; +} + +function LeadAccordion({ lead, open, onToggle }: { lead: Lead; open: boolean; onToggle: () => void }) { + const theme = useHostTheme(); + const vendor = leadVendor(lead); + const url = leadUrl(lead); + return ( +
+
+
+ +
+
+ {lead.title} + + {vendor && ( + {vendor} + )} + {REL_LABEL[lc(lead.relationship)] ?? lead.relationship} + +
+
+ +
+ +
+ {open && ( +
+ + {lead.bluf} + + + + Gaps + {lead.gaps} + + {url && ( + + {sourceHost(url)} — source report ↗ + + )} + report_id: {lead.candidate_ids.join(", ")} +
+ )} +
+ ); +} + +function PipelineCost() { + const theme = useHostTheme(); + const [open, setOpen] = useCanvasState("pipelineOpen", false); + const stages = FINDINGS.trace?.stages ?? []; + if (stages.length === 0) return null; + const totalIn = stages.reduce((a, s) => a + s.input_tokens, 0); + const totalOut = stages.reduce((a, s) => a + s.output_tokens, 0); + const totalCost = stages.reduce((a, s) => a + stageCost(s), 0); + const totalMs = stages.reduce((a, s) => a + (stageDurationMs(s) ?? 0), 0); + const anyDuration = stages.some((s) => stageDurationMs(s) != null); + const numf = (n: number) => (n === 0 ? "—" : n.toLocaleString()); + const rows = stages.map((s) => [ + + {STAGE_LABEL[s.stage] ?? s.stage} + + {s.stage === "retrieval" ? `${s.candidates ?? 0} candidates · ${s.anchors ?? 0} anchor hits` : STAGE_MODEL[s.stage] ?? ""} + + , + numf(s.input_tokens), + numf(s.output_tokens), + fmtDuration(stageDurationMs(s)), + s.tier ? money(stageCost(s)) : "$0.00", + ]); + rows.push([ + Total, + {totalIn.toLocaleString()}, + {totalOut.toLocaleString()}, + {anyDuration ? fmtDuration(totalMs) : "—"}, + {money(totalCost)}, + ]); + return ( +
+
setOpen((v) => !v)} style={{ display: "flex", gap: 12, alignItems: "center", padding: 14, cursor: "pointer" }}> +
+ Pipeline & cost + + {(FINDINGS.run_meta?.depth ?? "full")} depth · {(totalIn + totalOut).toLocaleString()} tokens · ~{money(totalCost)} est.{anyDuration ? ` · ${fmtDuration(totalMs)}` : ""} + +
+ +
+ {open && ( +
+
+ + + + Est. cost at Anthropic list prices (Sonnet $3 / $15, Opus $15 / $75 per 1M in / out) — managed EIS + billing may differ. Retrieval is Elasticsearch-only. Source: ti-correlations trace. + + + )} + + ); +} + +const OUTCOME_STYLE: Record = { + lead: { label: "Lead", fg: "#0b0b0a", bg: "#7fd18b" }, + picked_no_lead: { label: "Triaged, no lead", fg: "#e6d27f", bg: "#3a3620" }, + dropped_at_triage: { label: "Dropped at triage", fg: "#918f88", bg: "#2b2b29" }, +}; + +function AnchorChipRow({ label, values, theme }: { label: string; values: string[]; theme: ReturnType }) { + if (!values || values.length === 0) return null; + return ( +
+ {label} +
+ {values.map((v, i) => ( + {v} + ))} +
+
+ ); +} + +function TrailRowList({ rows, scoreLabel, theme }: { rows: AnchorTrailEntry[]; scoreLabel: string; theme: any }) { + return ( + + {rows.map((r, i) => { + const os = OUTCOME_STYLE[r.outcome]; + return ( +
+
+ {os.label} + + {r.url ? {r.title ?? r.fp} : (r.title ?? r.fp)} + + {r.vendor && {r.vendor}} +
+
+ + {scoreLabel} {r.anchor_score} · diamond overlap {r.overlap} + {r.triage_confidence != null ? ` · triage ${(r.triage_confidence * 100).toFixed(0)}%` : ""} + {r.outcome === "lead" && r.relationship ? ` · synthesis: ${r.relationship.replace(/_/g, " ")} (${r.lead_confidence})` : ""} + +
+ {r.justification && ( + “{r.justification}” + )} +
+ ); + })} +
+ ); +} + +function AnchorTrail() { + const theme = useHostTheme(); + const [open, setOpen] = useCanvasState("anchorTrailOpen", false); + const anchors = FINDINGS.anchors_searched; + const trail = FINDINGS.anchor_trail ?? []; + const phraseTrail = FINDINGS.phrase_anchor_trail ?? []; + const codeTokens = anchors?.code_tokens ?? []; + const searched = anchors ? anchors.hashes.length + anchors.network.length + anchors.artifacts.length + anchors.techniques.length + codeTokens.length : 0; + if (searched === 0 && trail.length === 0 && phraseTrail.length === 0) return null; + const matched = trail.length + phraseTrail.length; + const leadCount = [...trail, ...phraseTrail].filter((r) => r.outcome === "lead").length; + const subhead = (label: string) => ( + {label} + ); + return ( +
+
setOpen((v) => !v)} style={{ display: "flex", gap: 12, alignItems: "center", padding: 14, cursor: "pointer" }}> +
+ Anchor trail + + {searched} anchor{searched !== 1 ? "s" : ""} searched · {matched} matched · {leadCount} in a lead + +
+ +
+ {open && ( +
+ {anchors && searched > 0 && ( +
+ Case anchors backfilled into the exact-match retrieval clause: + + + + + +
+ )} + +
{subhead("IOC / artifact anchors")}
+ {trail.length > 0 ? ( + + ) : ( + No corpus report shared an exact IOC/artifact anchor with the case. + )} + +
{subhead("Code-token (phrase) anchors")}
+ {phraseTrail.length > 0 ? ( + + ) : ( + + {codeTokens.length > 0 ? "No corpus report shared a distinctive code token with the case." : "The case exposed no distinctive code tokens to phrase-match."} + + )} + + + Anchors = shared file-hash IOCs + discriminating artifacts (network IOCs / techniques are boosts). Code-token + anchors match distinctive execution tokens (e.g. [Class]::Method()) exactly against corpus extracted.code_tokens. + Trail joins retrieval hits → triage picks → synthesis leads. Source: ti-correlations run record. + +
+ )} +
+ ); +} + +export default function CorrelationReport() { + const theme = useHostTheme(); + const S = FINDINGS.synthesis; + const counts = FINDINGS.counts ?? {}; + const leads = FINDINGS.leads ?? []; + + const relOptions = ["all", "same_campaign", "same_actor", "shared_tradecraft"]; + const [rel, setRel] = useCanvasState("relFilter", "all"); + const [openMap, setOpenMap] = useCanvasState>( + "leadsOpen", + Object.fromEntries(leads.map((l) => [l.title, lc(l.confidence) === "high"])) + ); + const relCounts = leads.reduce>((acc, l) => { + acc[lc(l.relationship)] = (acc[lc(l.relationship)] ?? 0) + 1; + return acc; + }, {}); + const shown = rel === "all" ? leads : leads.filter((l) => lc(l.relationship) === rel); + const toggle = (title: string) => setOpenMap((prev) => ({ ...prev, [title]: !prev[title] })); + + const totalTokens = + Number(FINDINGS.trace?.total_input_tokens ?? 0) + Number(FINDINGS.trace?.total_output_tokens ?? 0); + + return ( + + + + ti-correlation workflow · depth {FINDINGS.run_meta?.depth ?? "full"} · {FINDINGS.run_meta?.status ?? "completed"} + +

{S.case_title ?? "Correlation report"}

+
+ + +
+ Case signal + + +
+ + + {counts.candidates !== undefined && } + {counts.picks !== undefined && } + {counts.leads !== undefined && } + {counts.no_match !== undefined && } + + + + {S.bluf} + +
+ + + + + +

Correlation leads

+ + {relOptions.map((r) => ( + + setRel(r)}> + {r === "all" ? `All (${leads.length})` : `${REL_LABEL[r] ?? r} (${relCounts[r] ?? 0})`} + + + ))} + +
+ {shown.map((lead) => ( + + toggle(lead.title)} /> + + ))} +
+ + + + +

Analyst synthesis

+ {S.reasoning} + {S.atomic_ioc_overlap?.note && ( + {S.atomic_ioc_overlap.note} + )} + {S.gaps && {S.gaps}} +
+ + {S.next_steps?.length > 0 && ( + +

Recommended next steps

+
[, s.text])} + /> + + )} + + {FINDINGS.no_match?.length > 0 && ( + +

Reviewed, no meaningful overlap

+
[n.title, n.vendor ?? ""])} /> + + )} + + + + + + + Source: ti-correlation workflow · run {FINDINGS.run_meta?.run_id ?? "—"} ·{" "} + {totalTokens.toLocaleString()} total tokens · {S.inferential_hops ?? 0} inferential hop + + + ); +} diff --git a/scripts/dump-run-findings.ts b/scripts/dump-run-findings.ts new file mode 100644 index 0000000..c96a7fe --- /dev/null +++ b/scripts/dump-run-findings.ts @@ -0,0 +1,63 @@ +/* + * 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. + */ + +/* + * Dump the render-shape CorrelationFindings for a completed run to stdout. + * This is the EXACT object get_correlation_run hands to render_correlation + * (counts + trace + run_meta + anchors_searched + anchor_trail folded in), so + * it can be embedded verbatim into the correlation-report Cursor canvas demo. + * + * Creds from env: ES_URL, KBN_URL, API_KEY (KBN_API_KEY/ES_API_KEY accepted). + * Usage: RUN_ID= npx tsx scripts/dump-run-findings.ts + */ + +import { createEsClient } from "../src/elastic/es-client/index.js"; +import { createKibanaClient } from "../src/elastic/kibana-client/index.js"; +import { CorrelationService } from "../src/elastic/service/correlationService.js"; +import { workflowFindingsToRenderShape } from "../src/tools/correlation.js"; + +const API_KEY = process.env.API_KEY || process.env.KBN_API_KEY || process.env.ES_API_KEY || ""; +const ES_URL = process.env.ES_URL || ""; +const KBN_URL = process.env.KBN_URL || ""; +const RUN_ID = process.env.RUN_ID?.trim() || ""; + +function die(msg: string): never { + console.error(`[dump] FAIL: ${msg}`); + process.exit(1); +} + +async function main() { + if (!ES_URL || !KBN_URL || !API_KEY) die("set ES_URL, KBN_URL, API_KEY in env"); + if (!RUN_ID) die("set RUN_ID in env"); + + const creds = { + name: "dump", + elasticsearchUrl: ES_URL, + kibanaUrl: KBN_URL, + elasticsearchApiKey: API_KEY, + }; + const svc = new CorrelationService({ + esClient: createEsClient(creds), + kibanaClient: createKibanaClient(creds), + }); + + const record = await svc.getCorrelationRun(RUN_ID); + if (!record.found) die(`run ${RUN_ID} not found / still pending`); + + const { findings } = workflowFindingsToRenderShape(record.findings, record.picks, { + trace: record.trace, + counts: record.counts, + run: { run_id: record.run_id ?? RUN_ID, depth: record.depth, status: record.status }, + caseAnchors: record.case?.anchors, + pool: record.candidates, + }); + if (findings === null) die(`run ${RUN_ID} has no synthesized findings (depth=${record.depth}, status=${record.status})`); + + process.stdout.write(JSON.stringify(findings, null, 2)); +} + +main().catch((err) => die(String(err?.stack || err?.message || err))); diff --git a/scripts/gen-correlation-canvas.ts b/scripts/gen-correlation-canvas.ts new file mode 100644 index 0000000..c00e7f8 --- /dev/null +++ b/scripts/gen-correlation-canvas.ts @@ -0,0 +1,87 @@ +/* + * 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. + */ + +/* + * Paint-by-numbers canvas generator. + * + * Cursor renders MCP app views inline in chat only — it has no side-panel + * surface for MCP UI resources. The Cursor-native "beside the chat" surface is + * a Canvas, which must be a self-contained .canvas.tsx file (no fetch). So we + * keep the presentation as a fixed template (correlation-report.canvas.tmpl) + * and inject a run's render-shape findings as the data ("the numbers"). + * + * This is the exact `findings` object get_correlation_run hands to + * render_correlation (counts + trace + run_meta + anchors_searched + + * anchor_trail folded in), stamped into the template's FINDINGS constant. + * + * Creds from env: ES_URL, KBN_URL, API_KEY (KBN_API_KEY/ES_API_KEY accepted). + * Output dir: CORRELATION_CANVAS_DIR (or CANVAS_DIR) — point it at your Cursor + * project's canvases dir. + * Usage: RUN_ID= npx tsx scripts/gen-correlation-canvas.ts + */ + +import { createEsClient } from "../src/elastic/es-client/index.js"; +import { createKibanaClient } from "../src/elastic/kibana-client/index.js"; +import { CorrelationService } from "../src/elastic/service/correlationService.js"; +import { workflowFindingsToRenderShape } from "../src/tools/correlation.js"; +import { emitCorrelationCanvas } from "../src/canvas/correlation-canvas.js"; + +const API_KEY = process.env.API_KEY || process.env.KBN_API_KEY || process.env.ES_API_KEY || ""; +const ES_URL = process.env.ES_URL || ""; +const KBN_URL = process.env.KBN_URL || ""; +const RUN_ID = process.env.RUN_ID?.trim() || ""; +// Same env the MCP server reads; point it at your Cursor project's canvases dir. +const CANVAS_DIR = process.env.CORRELATION_CANVAS_DIR || process.env.CANVAS_DIR || ""; + +function die(msg: string): never { + console.error(`[gen-canvas] FAIL: ${msg}`); + process.exit(1); +} + +async function main() { + if (!ES_URL || !KBN_URL || !API_KEY) die("set ES_URL, KBN_URL, API_KEY in env"); + if (!RUN_ID) die("set RUN_ID in env"); + if (!CANVAS_DIR) die("set CORRELATION_CANVAS_DIR (or CANVAS_DIR) to your canvases output dir"); + + const creds = { + name: "gen-canvas", + elasticsearchUrl: ES_URL, + kibanaUrl: KBN_URL, + elasticsearchApiKey: API_KEY, + }; + const svc = new CorrelationService({ + esClient: createEsClient(creds), + kibanaClient: createKibanaClient(creds), + }); + + const record = await svc.getCorrelationRun(RUN_ID); + if (!record.found) die(`run ${RUN_ID} not found / still pending`); + + const { findings } = workflowFindingsToRenderShape(record.findings, record.picks, { + trace: record.trace, + counts: record.counts, + run: { run_id: record.run_id ?? RUN_ID, depth: record.depth, status: record.status }, + caseAnchors: record.case?.anchors, + caseVertexSignal: record.case?.vertex_signal as Record | undefined, + pool: record.candidates, + }); + if (!findings) { + die(`run ${RUN_ID} has no synthesized findings (depth=${record.depth}, status=${record.status})`); + } + + const emitted = emitCorrelationCanvas({ + findings, + runId: record.run_id ?? RUN_ID, + caseTitle: record.case?.title, + dir: CANVAS_DIR, + }); + if (!emitted) die("no output dir resolved"); + console.log(`[gen-canvas] wrote ${emitted.file}`); + console.log(`[gen-canvas] run ${RUN_ID} · depth ${record.depth} · ${record.counts?.leads ?? 0} leads`); +} + +main().catch((err) => die(String(err?.stack || err?.message || err))); diff --git a/scripts/smoke-correlate.ts b/scripts/smoke-correlate.ts new file mode 100644 index 0000000..92cbe4d --- /dev/null +++ b/scripts/smoke-correlate.ts @@ -0,0 +1,140 @@ +/* + * 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. + */ + +/* + * Live smoke test for the workflow-driven correlation path: + * correlate (trigger ti-correlation workflow) → poll get_correlation_run → + * transform workflow findings into the render_correlation shape. + * + * Exercises the REAL Kibana/ES HTTP seam (auth headers, workflow run route, + * ti-correlations doc retrieval) plus the title→fingerprint findings transform. + * + * Creds come from env (no secrets in-repo): + * ES_URL, KBN_URL, API_KEY (KBN_API_KEY/ES_API_KEY also accepted) + * Optional: + * REPORT_ID — correlate this report (else auto-pick a diamond_suitable one) + * DEPTH — free | cheap | med | full (default full) + * TI_REPORTS_INDEX_PATTERN (default ti-reports*) + * + * Run: npx tsx scripts/smoke-correlate.ts + */ + +import { createEsClient } from "../src/elastic/es-client/index.js"; +import { createKibanaClient } from "../src/elastic/kibana-client/index.js"; +import { CorrelationService } from "../src/elastic/service/correlationService.js"; +import { workflowFindingsToRenderShape } from "../src/tools/correlation.js"; + +const API_KEY = process.env.API_KEY || process.env.KBN_API_KEY || process.env.ES_API_KEY || ""; +const ES_URL = process.env.ES_URL || ""; +const KBN_URL = process.env.KBN_URL || ""; +const DEPTH = (process.env.DEPTH || "full") as "free" | "cheap" | "med" | "full"; +const INDEX = process.env.TI_REPORTS_INDEX_PATTERN?.trim() || "ti-reports*"; + +const POLL_INTERVAL_MS = 8_000; +const POLL_TIMEOUT_MS = 8 * 60_000; + +function die(msg: string): never { + console.error(`\n[smoke] FAIL: ${msg}`); + process.exit(1); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + if (!ES_URL || !KBN_URL || !API_KEY) { + die("set ES_URL, KBN_URL, and API_KEY (or KBN_API_KEY/ES_API_KEY) in env"); + } + + const creds = { + name: "smoke", + elasticsearchUrl: ES_URL, + kibanaUrl: KBN_URL, + elasticsearchApiKey: API_KEY, + }; + const esClient = createEsClient(creds); + const kibanaClient = createKibanaClient(creds); + const svc = new CorrelationService({ esClient, kibanaClient }); + + // 1. Resolve a case report_id. + let reportId = process.env.REPORT_ID?.trim() || ""; + let reportTitle = ""; + if (!reportId) { + console.log(`[smoke] no REPORT_ID given — picking a diamond_suitable report from ${INDEX} …`); + const resp = await esClient.post<{ + hits: { hits: Array<{ _source: { content_fingerprint: string; content?: { title?: string } } }> }; + }>(`/${INDEX}/_search`, { + size: 1, + _source: ["content_fingerprint", "content.title"], + query: { term: { "extracted.diamond.suitable": { value: true } } }, + }); + const hit = resp.data?.hits?.hits?.[0]; + if (!hit) die(`no diamond_suitable report found in ${INDEX}`); + reportId = hit._source.content_fingerprint; + reportTitle = hit._source.content?.title ?? ""; + } + console.log(`[smoke] case report_id=${reportId}${reportTitle ? ` ("${reportTitle}")` : ""} depth=${DEPTH}`); + + // 2. Trigger the workflow (async). + const t0 = Date.now(); + const run = await svc.runCorrelation({ report_id: reportId, depth: DEPTH }); + console.log(`[smoke] correlate → run_id=${run.run_id} workflow=${run.workflow_id}`); + + // 3. Poll until the run record is persisted with a terminal status. + let record: Awaited> | null = null; + while (Date.now() - t0 < POLL_TIMEOUT_MS) { + await sleep(POLL_INTERVAL_MS); + const r = await svc.getCorrelationRun(run.run_id); + const elapsed = Math.round((Date.now() - t0) / 1000); + if (!r.found) { + console.log(`[smoke] +${elapsed}s pending …`); + continue; + } + console.log(`[smoke] +${elapsed}s status=${r.status}`); + if (r.status && r.status !== "pending" && r.status !== "running") { + record = r; + break; + } + } + if (!record) die(`timed out after ${POLL_TIMEOUT_MS / 1000}s waiting for run ${run.run_id}`); + + // 4. Report counts / trace and validate the render-shape transform. + console.log(`[smoke] counts:`, JSON.stringify(record.counts ?? {})); + console.log(`[smoke] trace :`, JSON.stringify(record.trace ?? {})); + if (record.error) console.log(`[smoke] error :`, record.error); + + const { findings, unresolved } = workflowFindingsToRenderShape(record.findings, record.picks); + if (findings === null) { + console.log(`[smoke] no synthesized report (status=${record.status}, depth=${record.depth}) — findings=null.`); + console.log(`[smoke] PASS: live correlate→poll round-trip OK (non-full/no-synthesis path).`); + return; + } + if (unresolved.length > 0) { + console.log(`[smoke] WARNING: ${unresolved.length} candidate title(s) did not resolve to a report id:`); + for (const u of unresolved) console.log(`[smoke] ${u.where}[${u.index}] "${u.title}"`); + } + + const leads = (findings.leads as Array>) ?? []; + const noMatch = (findings.no_match as Array>) ?? []; + const meta = (findings.candidate_meta as Record) ?? {}; + console.log(`[smoke] findings: ${leads.length} lead(s), ${noMatch.length} no_match, ${Object.keys(meta).length} candidate_meta`); + + // Assertions on the transform. + const leftoverTitles = leads.filter((l) => Array.isArray((l as { candidate_titles?: unknown }).candidate_titles)); + if (leftoverTitles.length > 0) die("transform left candidate_titles on a lead (should be stripped)"); + + for (const [i, l] of leads.entries()) { + const ids = (l as { candidate_ids?: unknown }).candidate_ids; + if (!Array.isArray(ids) || ids.length === 0) { + die(`lead[${i}] has no candidate_ids after transform`); + } + console.log(`[smoke] lead[${i}] "${(l as { title?: string }).title}" → candidate_ids=${JSON.stringify(ids)}`); + } + + console.log(`\n[smoke] PASS: live correlate→poll→transform round-trip OK.`); +} + +main().catch((err) => die(String(err?.stack || err?.message || err))); diff --git a/skills/threat-correlation/SKILL.md b/skills/threat-correlation/SKILL.md index 5492c15..a05082d 100644 --- a/skills/threat-correlation/SKILL.md +++ b/skills/threat-correlation/SKILL.md @@ -16,130 +16,79 @@ description: > # Threat Correlation Correlate SOC cases and incidents against the threat-report corpus using the `elastic-security` -MCP connector and the Diamond Model of Intrusion Analysis (adversary, capability, infrastructure, -victim). +MCP connector. The authoritative correlation is done by the server-side **`ti-correlation` +Kibana Workflow** (retrieval → Sonnet triage → Opus synthesis, all consistent tradecraft). The +host does NOT synthesize findings itself — it triggers the workflow and renders the result. -## ALWAYS call the tool +## ALWAYS call the tools When the user asks to correlate a case or find related threat intel — including phrasings like "is this a known intrusion set", "have we seen this before", "is this attributed", -"known campaign/actor", "match this case to threat intel", "who is behind this" — ALWAYS -start with `correlation_input_check` to surface the per-vertex signal stoplight. Do not -attempt to answer from memory or describe correlation results without calling the tools. -The gate is the mandatory entry point for ALL correlation requests, not just explicit -"analyst-led" asks. +"known campaign/actor", "match this case to threat intel", "who is behind this" — ALWAYS drive +the correlation through the tools below. Do not answer from memory or describe correlation +results without running the workflow. -## Gate: choose a run mode after `correlation_input_check` +## Authoritative path — `correlate` → poll → `render_correlation` -After `correlation_input_check` surfaces the per-vertex stoplight, PAUSE and offer the analyst -two run modes. Branch on their reply. +This is the ONE correlation workflow. Use it for every correlation request. -### Mode A — Full run (AUTONOMOUS, no human triaging) → use `diamond_search` (BLIND) +| Step | Situation | Tool call | +|------|-----------|-----------| +| 1 | You have a stored corpus report to correlate, or pasted case text | `correlate` with `report_id` (a report's content_fingerprint) OR `raw_text`. Optionally set `depth` (default `full`), `triage_pool`, `triage_floor`. Returns a `run_id`. | +| 2 | The workflow runs asynchronously in Kibana (full depth can take a few minutes) | `get_correlation_run` with the `run_id`. Poll until `status` is `completed` (while running you get `{ found: false, status: "pending" }` — wait and retry). | +| 3 | The run completed and returned render-ready `findings` | `render_correlation` with the `findings` from step 2. Pure renderer — no reasoning. | -**Why blind:** Scores are withheld in autonomous mode to prevent the model from anchoring on -similarity rank instead of judging evidence on its merits. - -1. Call `diamond_search` with the confirmed vertex queries. -2. Triage candidates yourself by reading their `matched_vertices` evidence text against the - `triage_rubric` in the response. Pull `get_report` for the **top ~10 candidates** judged - strongest by evidence — cap at ~10 to bound token cost. -3. Apply the full `synthesis_guidance` and `triage_rubric` tradecraft from the tool's payload. -4. Call `render_correlation` with the completed `CorrelationFindings`. - -Frame honestly: more thorough, full tradecraft and bias-reduction discipline, but **slower -and higher token cost** (synthesis across many reports); results are model-dependent. - -### Mode B — Analyst-led (INTERACTIVE, human triages) → use `diamond_search_analyst` (SCORED) - -**Why scored:** Scores are present here because the analyst, not the model, makes the selection -decision. The analyst can see and judge the numeric match signal directly. - -1. Call `diamond_search_analyst` with the confirmed vertex queries. -2. **Present the ranked candidates to the analyst** — show titles, scores, and per-vertex - match detail from the response. -3. Wait for the analyst to pick which candidates to deep-dive. -4. Call `get_report` for only the analyst-selected `report_ids`. -5. Synthesize findings and call `render_correlation`. - -Frame honestly: **faster, cheaper, more interactive** — analyst steers depth and can -short-circuit at any point — but relies on analyst judgment rather than a full autonomous -triage pass. - -### These are ALTERNATIVES — pick ONE - -`diamond_search` and `diamond_search_analyst` serve different run modes. Do NOT run both in -sequence — that is not a workflow. Autonomous runs → `diamond_search`. Analyst-led runs → -`diamond_search_analyst`. - ---- - -## Primary path — analyst-led (interactive, Mode B) - -Use this path for interactive human-in-the-loop correlation. It gives the analyst full -visibility into what signal you have before the search runs. - -| Step | User says / situation | Tool call | -|------|-----------------------|-----------| -| 1 | Summarise the case into Diamond Model vertices, then show the analyst the signal quality | `correlation_input_check` with `adversary`, `capability`, `infrastructure`, `victim` — each with a `query` paragraph and a `signal` self-rating (HIGH / PARTIAL / NONE) | -| 2 | Analyst confirms signal is ready (or chooses Mode A/B at the gate) | `diamond_search_analyst` with the same vertex queries — presents scored candidates with per-vertex match detail | -| 3 | Analyst selects top candidates from the scored list | `get_report` with the chosen `report_ids` (1–10) | -| 4 | You (the host) synthesize CorrelationFindings from the report text | `render_correlation` with your completed `findings` object | - -### Input signal self-rating scale - -| Rating | Meaning | -|--------|---------| -| HIGH | Specific, well-attested behavioural detail — strong search anchor | -| PARTIAL | Present but weak or inferred — query sent but may add noise | -| NONE | Genuinely absent — omit this vertex from the search | - -### Step 1 — `correlation_input_check` +### Step 1 — `correlate` ``` -correlation_input_check with: - adversary: { query: "APT28 / Fancy Bear; attributed to Russian GRU Unit 26165", signal: "HIGH" } - capability: { query: "Zebrocy downloader, Sofacy implant, spear-phishing lures", signal: "HIGH" } - infrastructure: { query: "dynamic DNS, .ru TLD hosting", signal: "PARTIAL" } - victim: { query: "NATO defence contractors, Eastern European governments", signal: "PARTIAL" } +correlate with: + report_id: "" # OR raw_text: "" + depth: "full" # free | cheap | med | full (default full) ``` -The analyst reviews the stoplight and decides whether to proceed or refine the input. - -### Step 2 — `diamond_search_analyst` (Mode B) or `diamond_search` (Mode A) +- Provide EITHER `report_id` OR `raw_text`, never both. +- Only `depth: full` produces a renderable report (Opus synthesis). `free`/`cheap`/`med` + are cheaper diagnostic tiers that stop before synthesis. +- The tool returns immediately with `{ run_id }`; the workflow does the heavy lifting + server-side (no host token cost, no 120s host timeout). -**Mode B (analyst-led):** Pass the same vertex queries to `diamond_search_analyst` (omit NONE-rated vertices). The response includes: -- `candidates`: ScoredStub[] ranked by (overlap desc, max_score desc) with per-vertex match scores -- `coverage`: signal quality summary — `thin: true` signals weak multi-vertex retrieval -- `tradecraft`: triage_rubric and synthesis_guidance for steps 3–4 +### Step 2 — `get_correlation_run` -**Mode A (autonomous):** Call `diamond_search` instead. Candidates include `matched_vertices` evidence text; no scores are returned. +Poll with the `run_id` until `status` is `completed`: +- `pending` — still executing (or record not yet written). Wait a few seconds and poll again. +- `completed` — `findings` is a render-ready `CorrelationFindings` object (candidate titles + already resolved to report ids via the run's `picks`). Go to step 3. +- `budget_exceeded` — the case + candidates exceeded the synthesis input budget; no findings. +- `failed` — inspect `error`, `counts`, and `picks`. -### Step 3 — `get_report` +For non-`full` depths, `findings` is `null` — report the `counts`/`picks` instead of rendering. -Call with the `report_ids` the analyst selected. Returns full `body_text`, `title`, `vendor`, `url` -per report — source material for your synthesis. +### Step 3 — `render_correlation` -### Step 4 — `render_correlation` +Call with the `findings` returned by `get_correlation_run`. This hands the structured result +to the analyst view. It performs no reasoning and no queries. -After completing your synthesis, call `render_correlation` with your full `CorrelationFindings` -object (`leads`, `no_match`, `synthesis`). This is a pure pass-through to the analyst view — -the tool performs no reasoning. +## Exploration aids (NOT the correlation path) -## Alternate path — blind autonomous (Mode A, no analyst triage) +`correlation_input_check`, `diamond_search`, `diamond_search_analyst`, and `get_report` let an +analyst browse the corpus by Diamond-vertex similarity or read a report's text. They are +exploration aids — useful for scoping a case or sanity-checking what the corpus holds — but they +do NOT produce authoritative findings. **Host-driven synthesis from these tools is deprecated; +always run `correlate` to correlate a case.** -Use `diamond_search` + `get_report` when operating autonomously without analyst oversight. -`diamond_search` returns candidate stubs WITHOUT scores (scores are withheld server-side -to prevent anchoring on similarity rank). Each candidate includes `matched_vertices` evidence -text — the summary from the report for each vertex that matched. Triage candidates by reading -that evidence against the `triage_rubric` in the response, then call `get_report` for the top -picks and synthesise findings. +- `correlation_input_check` — per-vertex signal stoplight (`{ query, signal }` per vertex). +- `diamond_search` — blind corpus search (matched_vertices evidence, no scores). +- `diamond_search_analyst` — scored corpus search (per-vertex scores + coverage) for the triage UI. +- `get_report` — fetch full report text by id (`report_ids`, 1–10). ## Tools | Tool | When to use | Purpose | |------|-------------|---------| -| `correlation_input_check` | Always first | Per-vertex signal stoplight gate. Params: `adversary`, `capability`, `infrastructure`, `victim` (each: `{ query, signal }`) | -| `diamond_search` | Mode A (autonomous, no human triage) | Blind search — matched_vertices evidence, NO scores. Same vertex + IOC params as `diamond_search_analyst` | -| `diamond_search_analyst` | Mode B (analyst-led, human triages) | Scored transparent search. Returns vertex_scores for analyst review. Params: `adversary`, `capability`, `infrastructure`, `victim` (strings), `iocs`, `size` | -| `get_report` | After triage | Fetch full report text by ID. Params: `report_ids` (array, 1–10) | -| `render_correlation` | Final step | Render host-synthesized findings. Params: `findings` (CorrelationFindings) | +| `correlate` | Correlate a case (authoritative) | Trigger the ti-correlation workflow. Params: `report_id` OR `raw_text`, `depth`, `triage_pool`, `triage_floor`. Returns `run_id`. | +| `get_correlation_run` | Poll after `correlate` | Fetch a run by `run_id`; returns status + render-ready `findings`. Param: `run_id`. | +| `render_correlation` | Final step | Render the workflow's findings. Param: `findings` (from `get_correlation_run`). | +| `correlation_input_check` | Exploration | Per-vertex signal stoplight gate. | +| `diamond_search` | Exploration | Blind corpus search — matched_vertices evidence, no scores. | +| `diamond_search_analyst` | Exploration | Scored corpus search — vertex_scores for analyst browse. | +| `get_report` | Exploration | Fetch full report text by id. | diff --git a/src/canvas/correlation-canvas.ts b/src/canvas/correlation-canvas.ts new file mode 100644 index 0000000..81cb03d --- /dev/null +++ b/src/canvas/correlation-canvas.ts @@ -0,0 +1,102 @@ +/* + * 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. + */ + +/* + * Paint-by-numbers correlation canvas. + * + * Cursor renders MCP app views inline in chat only — it has no side-panel + * surface for MCP UI resources. Cursor's native "beside the chat" surface is a + * Canvas: a self-contained .canvas.tsx file (no runtime fetch). So we keep the + * presentation frozen as a template (correlation-report.canvas.tmpl) and inject + * a run's render-shape findings as the data — the same `findings` object + * get_correlation_run hands to render_correlation. + * + * This is OPT-IN: it only fires when CORRELATION_CANVAS_DIR is set (point it at + * the Cursor workspace's canvases dir). Unset → no-op, app behaves unchanged. + */ + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const TEMPLATE_NAME = "correlation-report.canvas.tmpl"; +const PLACEHOLDER = "__FINDINGS_JSON__"; + +/** Canvas output dir from env, or undefined when emission is disabled. */ +export function canvasDirFromEnv(): string | undefined { + const dir = process.env.CORRELATION_CANVAS_DIR?.trim(); + return dir ? dir : undefined; +} + +/** Locate the canvas template across dev (tsx), tsc, and bundle layouts. */ +function resolveTemplatePath(): string { + const candidates = [ + // co-located when bundled/copied next to this module + path.resolve(HERE, TEMPLATE_NAME), + // tsx dev: HERE = src/canvas → repo-root/scripts + path.resolve(HERE, "../../scripts", TEMPLATE_NAME), + // tsc: HERE = dist/src/canvas → repo-root/scripts + path.resolve(HERE, "../../../scripts", TEMPLATE_NAME), + // esbuild bundle: HERE = dist → repo-root/scripts + path.resolve(HERE, "../scripts", TEMPLATE_NAME), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return candidates[0]; +} + +/** Short, stable, kebab slug for the canvas filename. */ +function slug(caseTitle: string | undefined, runId: string): string { + const title = caseTitle?.trim(); + if (title && title.toLowerCase() !== "pasted case") { + const s = title + .replace(/[^A-Za-z0-9 ]/g, "") + .trim() + .split(/\s+/) + .slice(0, 5) + .join("-"); + if (s) return s; + } + return runId.slice(0, 8); +} + +export interface EmitCanvasParams { + /** Render-shape findings (from workflowFindingsToRenderShape). */ + findings: Record; + runId: string; + caseTitle?: string; + /** Output dir; defaults to CORRELATION_CANVAS_DIR. */ + dir?: string; +} + +export interface EmitCanvasResult { + file: string; +} + +/** + * Stamp `findings` into the canvas template and write a self-contained + * .canvas.tsx. Returns null when no output dir is configured (opt-in). Throws + * only on real IO/template errors so a misconfiguration is loud. + */ +export function emitCorrelationCanvas(params: EmitCanvasParams): EmitCanvasResult | null { + const dir = params.dir ?? canvasDirFromEnv(); + if (!dir) return null; + + const templatePath = resolveTemplatePath(); + const template = fs.readFileSync(templatePath, "utf-8"); + if (!template.includes(PLACEHOLDER)) { + throw new Error(`canvas template ${templatePath} missing ${PLACEHOLDER} placeholder`); + } + + const canvas = template.replace(PLACEHOLDER, JSON.stringify(params.findings, null, 2)); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `correlation-${slug(params.caseTitle, params.runId)}.canvas.tsx`); + fs.writeFileSync(file, canvas); + return { file }; +} diff --git a/src/correlation/tradecraft.ts b/src/correlation/tradecraft.ts index ea13d21..cd36260 100644 --- a/src/correlation/tradecraft.ts +++ b/src/correlation/tradecraft.ts @@ -196,15 +196,27 @@ RULES: they may ask you to refine weak vertices before proceeding.`; /** - * Composite tradecraft bundle returned in every `diamond_search` response. - * The host model uses the triage rubric to rank candidates, then the synthesis - * guidance to produce structured correlation findings after reading full reports. + * Composite tradecraft bundle returned in every corpus-search response. + * + * The summarisation/input/triage guidance help an analyst interpret the + * EXPLORATION-AID search tools (diamond_search*, get_report). + * + * `synthesis_guidance` is DEPRECATED: host-driven synthesis has been replaced by + * the server-side `ti-correlation` Kibana Workflow (see the `correlate` tool), + * which owns retrieval → Sonnet triage → Opus synthesis with consistent + * tradecraft and no 120s host timeout. The block is retained only so any legacy + * host loop still has the output shape; new flows should NOT synthesize here — + * call `correlate`, poll `get_correlation_run`, then `render_correlation`. */ export const TRADECRAFT = { diamond_summarisation_guidance: DIAMOND_SUMMARISATION_GUIDANCE, input_signal_guidance: INPUT_SIGNAL_GUIDANCE, triage_rubric: TRIAGE_RUBRIC, + /** @deprecated Use the `correlate` workflow tool instead of host synthesis. */ synthesis_guidance: { + deprecated: true, + deprecation_note: + "Host-driven synthesis is deprecated. Use the `correlate` tool (ti-correlation workflow) → `get_correlation_run` → `render_correlation`. This block remains only for legacy compatibility.", instructions: SYNTHESIS_GUIDANCE_TEXT, recommended_output: { leads: [ diff --git a/src/elastic/service/correlationService.ts b/src/elastic/service/correlationService.ts index 9375b52..48f4022 100644 --- a/src/elastic/service/correlationService.ts +++ b/src/elastic/service/correlationService.ts @@ -15,6 +15,7 @@ */ import type { EsClient } from "../es-client/index.js"; +import type { KibanaClient } from "../kibana-client/index.js"; import { DIAMOND_VERTICES } from "../../correlation/tradecraft.js"; import type { DiamondVertex } from "../../correlation/tradecraft.js"; @@ -22,7 +23,24 @@ import type { DiamondVertex } from "../../correlation/tradecraft.js"; // Constants — mirror kibana-threat-intel-poc constants // --------------------------------------------------------------------------- -const THREAT_REPORTS_INDEX_PATTERN = ".kibana-threat-reports*"; +// Report corpus index pattern. Env-configurable so this app can point at the +// threat-intel-ingest corpus (`ti-reports*`, the default) or a different +// deployment's index without a code change. Set TI_REPORTS_INDEX_PATTERN to +// override (e.g. back to ".kibana-threat-reports*" for the IntelligenceHub corpus). +const THREAT_REPORTS_INDEX_PATTERN = + process.env.TI_REPORTS_INDEX_PATTERN?.trim() || "ti-reports*"; + +// Authoritative correlation path: the `ti-correlation` Kibana Workflow does the +// retrieval → Sonnet triage → Opus synthesis and writes one run record per +// execution (`_id = execution id`) into the correlations index. The MCP app +// triggers the workflow and polls that index by run_id — it does NOT synthesize. +// Both are env-overridable to match a deployment's config.sh values. +const CORRELATIONS_INDEX = + process.env.TI_CORRELATIONS_INDEX?.trim() || "ti-correlations"; +const CORRELATION_WORKFLOW_ID = + process.env.TI_CORRELATION_WORKFLOW_ID?.trim() || "ti-correlation"; +// Kibana Workflows public route is date-versioned on 9.5.x (see deploy.sh). +const WORKFLOWS_API_VERSION = "2023-10-31"; const NOISE_FLOOR = 0.7; const KNN_CANDIDATES_PER_VERTEX = 50; const DEFAULT_SIZE = 20; @@ -598,11 +616,176 @@ const runSemanticSearchScored = async ( interface CorrelationServiceOptions { readonly esClient: EsClient; + /** Required for the workflow-driven `correlate` path; retrieval tools work without it. */ + readonly kibanaClient?: KibanaClient; +} + +export type CorrelationDepth = "free" | "cheap" | "med" | "full"; + +export interface RunCorrelationParams { + /** Stored corpus report _id (content_fingerprint). Mutually exclusive with raw_text. */ + report_id?: string; + /** Pasted case text. Mutually exclusive with report_id. */ + raw_text?: string; + depth?: CorrelationDepth; + triage_pool?: number; + triage_floor?: number; +} + +export interface RunCorrelationResult { + run_id: string; + workflow_id: string; + depth: CorrelationDepth; +} + +/** One triage pick as stored on the run record (title↔fp bridge for rendering). */ +export interface CorrelationPick { + candidate_id: number; + fp: string; + title?: string; + /** Source vendor (source.name) — carried on the pick by the workflow build_picks step. */ + vendor?: string; + /** Source article URL (source.url) — carried on the pick by the workflow build_picks step. */ + url?: string; + hypothesis?: string; + confidence?: number; + justification?: string; +} + +/** One fused-pool candidate (audit-only `candidates` array on the run record). The + * anchor-trail builder joins these to picks (by fp/id) and leads (by title). */ +export interface CorrelationPoolCandidate { + id: string; + overlap?: number; + has_anchor?: boolean; + anchor_score?: number; + /** Shares a distinctive code/execution token (extracted.code_tokens) with the case. */ + has_phrase_anchor?: boolean; + phrase_score?: number; + free_score?: number; + diamond_max?: number; + retrieval_source?: string; +} + +/** Case anchors the workflow searched (audit-only `case.anchors`, enabled:false). */ +export interface CorrelationCaseAnchors { + hashes?: string[]; + network?: string[]; + artifacts?: string[]; + techniques?: string[]; + /** Distinctive code/execution tokens searched as exact "phrase anchors". */ + code_tokens?: string[]; + iocs?: Array<{ type?: string; value?: string; defanged?: string }>; + artifact_objs?: Array<{ type?: string; value?: string; context?: string }>; +} + +/** Verbatim `_source` of a ti-correlations run record (subset we surface). */ +export interface CorrelationRunRecord { + found: boolean; + run_id?: string; + status?: string; + depth?: string; + counts?: Record; + case?: { + mode?: string; + title?: string; + anchors?: CorrelationCaseAnchors; + /** Case's own per-vertex signal (NONE/PARTIAL/HIGH) — drives the case-signal diamond. */ + vertex_signal?: Record; + } & Record; + /** Workflow-shaped CorrelationFindings (leads reference candidates by title). */ + findings?: Record; + picks?: CorrelationPick[]; + /** Fused-pool candidates (audit-only) — drives the anchor trail. */ + candidates?: CorrelationPoolCandidate[]; + trace?: Record; + error?: string; } export class CorrelationService { constructor(private readonly options: CorrelationServiceOptions) {} + /** + * Trigger the `ti-correlation` Kibana Workflow (async). Returns immediately + * with the execution id — the workflow runs in Task Manager (full depth can + * take minutes; the POST does not block). Poll {@link getCorrelationRun} with + * the returned run_id to read the authoritative findings. + */ + async runCorrelation(params: RunCorrelationParams): Promise { + const { kibanaClient } = this.options; + if (!kibanaClient) { + throw new Error( + "correlate requires a Kibana client — the correlation workflow is triggered via the Kibana Workflows API." + ); + } + const reportId = params.report_id?.trim() ?? ""; + const rawText = params.raw_text?.trim() ?? ""; + if (!reportId && !rawText) { + throw new Error("correlate requires either report_id or raw_text."); + } + if (reportId && rawText) { + throw new Error("correlate takes report_id OR raw_text, not both."); + } + const depth: CorrelationDepth = params.depth ?? "full"; + const inputs: Record = { + report_id: reportId, + raw_text: rawText, + depth, + triage_pool: params.triage_pool ?? 120, + triage_floor: params.triage_floor ?? 0.65, + }; + const resp = await kibanaClient.post<{ workflowExecutionId: string }>( + `/api/workflows/workflow/${CORRELATION_WORKFLOW_ID}/run`, + { inputs }, + { headers: { "elastic-api-version": WORKFLOWS_API_VERSION } } + ); + const runId = resp.data?.workflowExecutionId; + if (!runId) { + throw new Error(`workflow run did not return an execution id: ${JSON.stringify(resp.data)}`); + } + return { run_id: runId, workflow_id: CORRELATION_WORKFLOW_ID, depth }; + } + + /** + * Read one correlation run record by run_id (= workflow execution id) from the + * correlations index. Returns `{ found: false }` while the run is still in + * flight (the doc is written by the workflow's terminal persist step) or if the + * id is unknown. + */ + async getCorrelationRun(runId: string): Promise { + const { esClient } = this.options; + const id = runId.trim(); + if (!id) throw new Error("get_correlation_run requires a run_id."); + try { + const resp = await esClient.get<{ + found: boolean; + _source?: Record; + }>(`/${CORRELATIONS_INDEX}/_doc/${encodeURIComponent(id)}`); + const source = resp.data?._source; + if (!resp.data?.found || !source) return { found: false }; + return { + found: true, + run_id: source.run_id as string | undefined, + status: source.status as string | undefined, + depth: source.depth as string | undefined, + counts: source.counts as Record | undefined, + case: source.case as CorrelationRunRecord["case"], + findings: source.findings as Record | undefined, + picks: source.picks as CorrelationPick[] | undefined, + candidates: source.candidates as CorrelationPoolCandidate[] | undefined, + trace: source.trace as Record | undefined, + error: source.error as string | undefined, + }; + } catch (err) { + const msg = String((err as Error)?.message ?? ""); + // 404 = index/doc not present yet (run still in flight) → not-found, not fatal. + if (msg.includes("404") || msg.includes("index_not_found")) { + return { found: false }; + } + throw err; + } + } + /** * Diamond Model correlation search. * diff --git a/src/server.ts b/src/server.ts index 8d3054f..39593fa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -107,7 +107,7 @@ export function createServer(deps: CreateServerDeps = {}): McpServer { sampleDataClient: new SampleDataClient({ esClient }), rulesService, }); - const correlationService = new CorrelationService({ esClient }); + const correlationService = new CorrelationService({ esClient, kibanaClient }); const server = new McpServer({ name: "elastic-security", diff --git a/src/test/integration/server.integration.test.ts b/src/test/integration/server.integration.test.ts index 274233d..77c3a5b 100644 --- a/src/test/integration/server.integration.test.ts +++ b/src/test/integration/server.integration.test.ts @@ -142,6 +142,14 @@ describe("MCP server integration (in-process Client + Server)", () => { "list-ai-connectors", // analytics "report-analytics-event", + // correlation (workflow-backed path + analyst-aid retrieval tools) + "correlate", + "get_correlation_run", + "render_correlation", + "correlation_input_check", + "diamond_search", + "diamond_search_analyst", + "get_report", ].sort() ); } finally { @@ -162,6 +170,10 @@ describe("MCP server integration (in-process Client + Server)", () => { "ui://threat-hunt/mcp-app.html", "ui://generate-sample-data/mcp-app.html", "ui://triage-attack-discoveries/mcp-app.html", + // correlation views + "ui://correlation/mcp-app.html", + "ui://correlation-input/mcp-app.html", + "ui://correlation-report/mcp-app.html", ].sort() ); } finally { diff --git a/src/tools/correlation.test.ts b/src/tools/correlation.test.ts index af705f5..867f32a 100644 --- a/src/tools/correlation.test.ts +++ b/src/tools/correlation.test.ts @@ -27,6 +27,8 @@ function makeMockCorrelationService(): CorrelationService { diamondSearch: vi.fn(), diamondSearchScored: vi.fn(), getReports: vi.fn(), + runCorrelation: vi.fn(), + getCorrelationRun: vi.fn(), } as unknown as CorrelationService; } @@ -57,9 +59,11 @@ describe("registerCorrelationTools", () => { }); }); - it("registers all 5 correlation tools plus the 3 UI resources", () => { + it("registers all 7 correlation tools plus the 3 UI resources", () => { expect([...server.tools.keys()].sort()).toEqual( [ + "correlate", + "get_correlation_run", "diamond_search", "get_report", "diamond_search_analyst", @@ -335,6 +339,254 @@ describe("registerCorrelationTools", () => { }); }); + // ------------------------------------------------------------------------- + // correlate — triggers the workflow, returns a run_id + // ------------------------------------------------------------------------- + + describe("correlate", () => { + it("triggers the workflow and returns the run_id", async () => { + vi.mocked(correlationService.runCorrelation).mockResolvedValueOnce({ + run_id: "exec-123", + workflow_id: "ti-correlation", + depth: "full", + }); + + const out = await server.tool("correlate").callback({ + report_id: "fp-abc", + depth: "full", + }); + + expect(correlationService.runCorrelation).toHaveBeenCalledWith({ + report_id: "fp-abc", + raw_text: undefined, + depth: "full", + triage_pool: undefined, + triage_floor: undefined, + }); + + const body = parseToolText<{ kind: string; run_id: string; depth: string }>(out); + expect(body.kind).toBe("correlation_run_started"); + expect(body.run_id).toBe("exec-123"); + expect(body.depth).toBe("full"); + }); + }); + + // ------------------------------------------------------------------------- + // get_correlation_run — poll + transform workflow findings to render shape + // ------------------------------------------------------------------------- + + describe("get_correlation_run", () => { + it("reports pending when the run record does not exist yet", async () => { + vi.mocked(correlationService.getCorrelationRun).mockResolvedValueOnce({ + found: false, + }); + + const out = await server.tool("get_correlation_run").callback({ run_id: "exec-x" }); + const body = parseToolText<{ found: boolean; status: string }>(out); + expect(body.found).toBe(false); + expect(body.status).toBe("pending"); + }); + + it("resolves candidate titles to report ids via picks on completion", async () => { + vi.mocked(correlationService.getCorrelationRun).mockResolvedValueOnce({ + found: true, + run_id: "exec-9", + status: "completed", + depth: "full", + picks: [ + { candidate_id: 0, fp: "fp-1", title: "APT28 Zebrocy" }, + { candidate_id: 1, fp: "fp-2", title: "Sofacy Infra" }, + ], + findings: { + leads: [ + { + candidate_titles: ["APT28 Zebrocy", "Sofacy Infra"], + title: "Actor overlap", + relationship: "same_actor", + confidence: "high", + vertex_signal: { adversary: "high", capability: "high", infrastructure: "partial", victim: "none" }, + bluf: "Overlap.", + evidence: [{ vertex: "adversary", weight: "smoking_gun", text: "Fancy Bear." }], + gaps: "none", + }, + ], + no_match: [{ title: "Unrelated report" }], + synthesis: { + bluf: "b", + correlation_signal: "high", + reasoning: "r", + gaps: "g", + next_steps: [], + }, + }, + }); + + const out = await server.tool("get_correlation_run").callback({ run_id: "exec-9" }); + const body = parseToolText<{ + found: boolean; + status: string; + findings: { + leads: Array<{ candidate_ids: string[]; candidate_titles?: string[] }>; + no_match: Array<{ id: string; title: string }>; + candidate_meta: Record; + }; + }>(out); + + expect(body.found).toBe(true); + expect(body.status).toBe("completed"); + // candidate_titles resolved to fingerprints, and the title array dropped. + expect(body.findings.leads[0].candidate_ids).toEqual(["fp-1", "fp-2"]); + expect(body.findings.leads[0].candidate_titles).toBeUndefined(); + // no_match falls back to the title string when unmatched by picks. + expect(body.findings.no_match[0]).toEqual({ id: "Unrelated report", title: "Unrelated report" }); + // candidate_meta bridges id -> title for the renderer. + expect(body.findings.candidate_meta["fp-1"]).toEqual({ title: "APT28 Zebrocy" }); + }); + + it("resolves titles across typographic vs ASCII quote differences", async () => { + vi.mocked(correlationService.getCorrelationRun).mockResolvedValueOnce({ + found: true, + run_id: "exec-q", + status: "completed", + depth: "full", + // Stored pick titles use curly apostrophes (as written in the corpus). + picks: [ + { candidate_id: 0, fp: "fp-fish", title: "FishMonger\u2019s arsenal upgraded: SprySOCKS for Windows" }, + { candidate_id: 1, fp: "fp-isoon", title: "A comprehensive analysis of I-Soon\u2019s commercial offering" }, + ], + findings: { + leads: [ + { + // Synthesis LLM re-emitted them with straight ASCII apostrophes. + candidate_titles: ["FishMonger's arsenal upgraded: SprySOCKS for Windows"], + title: "FishMonger lead", + relationship: "same_actor", + confidence: "moderate", + vertex_signal: { adversary: "high", capability: "partial", infrastructure: "none", victim: "none" }, + bluf: "b", + evidence: [], + gaps: "g", + }, + { + candidate_titles: ["A comprehensive analysis of I-Soon's commercial offering"], + title: "I-Soon lead", + relationship: "same_actor", + confidence: "low", + vertex_signal: { adversary: "partial", capability: "none", infrastructure: "none", victim: "none" }, + bluf: "b2", + evidence: [], + gaps: "g2", + }, + ], + no_match: [], + synthesis: { bluf: "b", correlation_signal: "moderate", reasoning: "r", gaps: "g", next_steps: [] }, + }, + }); + + const out = await server.tool("get_correlation_run").callback({ run_id: "exec-q" }); + const body = parseToolText<{ + findings: { leads: Array<{ candidate_ids: string[] }> }; + }>(out); + expect(body.findings.leads[0].candidate_ids).toEqual(["fp-fish"]); + expect(body.findings.leads[1].candidate_ids).toEqual(["fp-isoon"]); + }); + + it("surfaces a loud miss when a candidate title does not resolve to a pick", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(correlationService.getCorrelationRun).mockResolvedValueOnce({ + found: true, + run_id: "exec-miss", + status: "completed", + depth: "full", + picks: [{ candidate_id: 0, fp: "fp-1", title: "Known report" }], + findings: { + leads: [ + { + candidate_titles: ["A title the LLM reworded and no pick matches"], + title: "Reworded lead", + relationship: "shared_tradecraft", + confidence: "low", + vertex_signal: { adversary: "none", capability: "partial", infrastructure: "none", victim: "none" }, + bluf: "b", + evidence: [], + gaps: "g", + }, + ], + no_match: [], + synthesis: { bluf: "b", correlation_signal: "low", reasoning: "r", gaps: "g", next_steps: [] }, + }, + }); + + const out = await server.tool("get_correlation_run").callback({ run_id: "exec-miss" }); + const body = parseToolText<{ + unresolved_candidate_titles: Array<{ where: string; index: number; title: string }>; + summary: string; + findings: { leads: Array<{ candidate_ids: string[] }> }; + }>(out); + + // Reported in the response … + expect(body.unresolved_candidate_titles).toHaveLength(1); + expect(body.unresolved_candidate_titles[0]).toMatchObject({ where: "lead", index: 0 }); + expect(body.summary).toContain("WARNING"); + // … logged loudly … + expect(warnSpy).toHaveBeenCalledOnce(); + // … and still rendered (fallback to the title string). + expect(body.findings.leads[0].candidate_ids).toEqual([ + "A title the LLM reworded and no pick matches", + ]); + warnSpy.mockRestore(); + }); + + it("reports no unresolved titles on a clean resolve", async () => { + vi.mocked(correlationService.getCorrelationRun).mockResolvedValueOnce({ + found: true, + run_id: "exec-clean", + status: "completed", + depth: "full", + picks: [{ candidate_id: 0, fp: "fp-1", title: "Known report" }], + findings: { + leads: [ + { + candidate_titles: ["Known report"], + title: "Clean lead", + relationship: "same_actor", + confidence: "high", + vertex_signal: { adversary: "high", capability: "high", infrastructure: "none", victim: "none" }, + bluf: "b", + evidence: [], + gaps: "g", + }, + ], + no_match: [], + synthesis: { bluf: "b", correlation_signal: "high", reasoning: "r", gaps: "g", next_steps: [] }, + }, + }); + + const out = await server.tool("get_correlation_run").callback({ run_id: "exec-clean" }); + const body = parseToolText<{ + unresolved_candidate_titles: unknown[]; + summary: string; + }>(out); + expect(body.unresolved_candidate_titles).toHaveLength(0); + expect(body.summary).not.toContain("WARNING"); + }); + + it("returns null findings for non-full depth (no synthesis to render)", async () => { + vi.mocked(correlationService.getCorrelationRun).mockResolvedValueOnce({ + found: true, + run_id: "exec-cheap", + status: "completed", + depth: "cheap", + counts: { pool: 42 }, + }); + + const out = await server.tool("get_correlation_run").callback({ run_id: "exec-cheap" }); + const body = parseToolText<{ found: boolean; findings: unknown }>(out); + expect(body.found).toBe(true); + expect(body.findings).toBeNull(); + }); + }); + // ------------------------------------------------------------------------- // UI resources // ------------------------------------------------------------------------- diff --git a/src/tools/correlation.ts b/src/tools/correlation.ts index 226e433..263869c 100644 --- a/src/tools/correlation.ts +++ b/src/tools/correlation.ts @@ -26,6 +26,7 @@ import type { CorrelationService } from "../elastic/service/correlationService.j import { TRADECRAFT } from "../correlation/tradecraft.js"; import { registerTrackedAppTool } from "./tracked-app-tool.js"; import { resolveViewPath } from "./view-path.js"; +import { emitCorrelationCanvas, canvasDirFromEnv } from "../canvas/correlation-canvas.js"; const CORRELATION_RESOURCE_URI = "ui://correlation/mcp-app.html"; const CORRELATION_INPUT_RESOURCE_URI = "ui://correlation-input/mcp-app.html"; @@ -35,15 +36,304 @@ export interface CorrelationToolDeps { readonly analytics: AnalyticsClient; } +// Map the ti-correlation workflow's CorrelationFindings (leads keyed by exact +// candidate TITLE) into the shape render_correlation expects (leads keyed by +// candidate_ids). The run record's picks[] carry the title→fingerprint bridge. +// +// A title that does not resolve to a pick fingerprint is a LOUD miss: it is +// collected in `unresolved` (so get_correlation_run can warn + surface it) and +// its candidate_id falls back to the raw title string so the report still +// renders. Returns findings=null when there is nothing to render (non-full +// depth, or synthesis absent/failed). Exported for the smoke test + unit tests. +export interface RenderShapeResult { + findings: Record | null; + /** Titles the synthesis referenced that no pick fingerprint matched. */ + unresolved: Array<{ where: "lead" | "no_match"; index: number; title: string }>; +} + +/** Run-level context folded into the render-shape findings (the App view only + * receives `findings`, so counts/trace/run metadata ride along inside it). */ +export interface RenderShapeMeta { + trace?: Record; + counts?: Record; + run?: { run_id?: string; depth?: string; status?: string }; + /** Case anchors the workflow searched (case.anchors on the run record). */ + caseAnchors?: { + hashes?: string[]; + network?: string[]; + artifacts?: string[]; + techniques?: string[]; + /** Distinctive code/execution tokens searched as exact phrase anchors. */ + code_tokens?: string[]; + }; + /** Per-vertex signal of the case under analysis (case.vertex_signal on the + * run record; NONE/PARTIAL/HIGH). Drives the "Case signal" diamond. */ + caseVertexSignal?: Record; + /** Fused-pool candidates (run.candidates) — used to build the anchor trail. */ + pool?: Array<{ + id: string; + has_anchor?: boolean; + anchor_score?: number; + has_phrase_anchor?: boolean; + phrase_score?: number; + overlap?: number; + }>; +} + +/** One row of the anchor trail: an anchor-matched corpus report and its fate + * through triage → synthesis. */ +export interface AnchorTrailEntry { + fp: string; + anchor_score: number; + overlap: number; + /** Present when triage picked this candidate (title/vendor/url come from picks). */ + title?: string; + vendor?: string; + url?: string; + triage_confidence?: number; + justification?: string; + /** Terminal fate. */ + outcome: "lead" | "picked_no_lead" | "dropped_at_triage"; + lead_title?: string; + relationship?: string; + lead_confidence?: string; +} + +export function workflowFindingsToRenderShape( + findings: Record | undefined, + picks: Array<{ fp: string; title?: string; vendor?: string; url?: string }> | undefined, + meta?: RenderShapeMeta +): RenderShapeResult { + if (!findings || typeof findings !== "object") return { findings: null, unresolved: [] }; + const synthesis = (findings as { synthesis?: unknown }).synthesis; + if (!synthesis || typeof synthesis !== "object") return { findings: null, unresolved: [] }; + + // Normalize titles before the join: the synthesis LLM commonly re-emits a + // candidate title with straight ASCII quotes where the stored pick title has + // typographic ones (’ “ ” …), which breaks a raw string match. Fold quotes, + // collapse whitespace, and lowercase so those match; genuine rewordings still + // fall back to the title string (and are reported as unresolved). + const normalizeTitle = (t: string): string => + t + .normalize("NFKC") + .replace(/[\u2018\u2019\u02BC\u2032]/g, "'") + .replace(/[\u201C\u201D\u2033]/g, '"') + .replace(/[\u2010-\u2015]/g, "-") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + + const titleToFp = new Map(); + const candidateMeta: Record = {}; + for (const p of picks ?? []) { + if (p.title && p.fp) { + titleToFp.set(normalizeTitle(p.title), p.fp); + // vendor/url come from the workflow pick (source.name / source.url); omit + // empties so the view's source chips only light up when we actually have a + // link. This is what populates candidate_meta[id].{vendor,url} in App.tsx. + candidateMeta[p.fp] = { + title: p.title, + ...(p.vendor ? { vendor: p.vendor } : {}), + ...(p.url ? { url: p.url } : {}), + }; + } + } + const unresolved: RenderShapeResult["unresolved"] = []; + // Resolve a title to a pick fp; record a loud miss when it does not join. + const resolve = (title: string, where: "lead" | "no_match", index: number): string => { + const fp = titleToFp.get(normalizeTitle(title)); + if (fp) return fp; + unresolved.push({ where, index, title }); + return title; + }; + + const rawLeads = Array.isArray((findings as { leads?: unknown }).leads) + ? ((findings as { leads: Array> }).leads) + : []; + const leads = rawLeads.map((lead, i) => { + const titles = Array.isArray(lead.candidate_titles) + ? (lead.candidate_titles as string[]) + : []; + const { candidate_titles: _drop, ...rest } = lead; + return { + ...rest, + candidate_ids: titles.map((t) => resolve(t, "lead", i)), + consolidated_candidates: Array.isArray(lead.consolidated_candidates) + ? lead.consolidated_candidates + : [], + }; + }); + + const rawNoMatch = Array.isArray((findings as { no_match?: unknown }).no_match) + ? ((findings as { no_match: Array> }).no_match) + : []; + const no_match = rawNoMatch.map((nm, i) => { + const title = typeof nm.title === "string" ? nm.title : ""; + return { id: resolve(title, "no_match", i), title }; + }); + + // --- Anchor trail: searched → matched → triage → synthesis -------------- + // Traces each exact-anchor-matched corpus report from retrieval through its + // terminal fate. leads[].candidate_ids are already resolved to fps above, so + // we can map fp → its lead; picks give the triage confidence/justification. + const fpToLead = new Map(); + for (const lead of leads) { + const ids = Array.isArray((lead as { candidate_ids?: unknown }).candidate_ids) + ? ((lead as { candidate_ids: string[] }).candidate_ids) + : []; + for (const fp of ids) { + if (!fpToLead.has(fp)) { + fpToLead.set(fp, { + lead_title: (lead as { title?: string }).title, + relationship: (lead as { relationship?: string }).relationship, + lead_confidence: (lead as { confidence?: string }).confidence, + }); + } + } + } + const pickByFp = new Map(); + for (const p of picks ?? []) { + const pp = p as { fp: string; title?: string; vendor?: string; url?: string; confidence?: number; justification?: string }; + if (pp.fp) pickByFp.set(pp.fp, pp); + } + const anchorHits = (meta?.pool ?? []).filter((c) => c.has_anchor); + const anchor_trail: AnchorTrailEntry[] = anchorHits + .map((c) => { + const pick = pickByFp.get(c.id); + const lead = fpToLead.get(c.id); + const outcome: AnchorTrailEntry["outcome"] = lead + ? "lead" + : pick + ? "picked_no_lead" + : "dropped_at_triage"; + return { + fp: c.id, + anchor_score: c.anchor_score ?? 0, + overlap: c.overlap ?? 0, + ...(pick?.title ? { title: pick.title } : {}), + ...(pick?.vendor ? { vendor: pick.vendor } : {}), + ...(pick?.url ? { url: pick.url } : {}), + ...(pick?.confidence != null ? { triage_confidence: pick.confidence } : {}), + ...(pick?.justification ? { justification: pick.justification } : {}), + outcome, + ...(lead?.lead_title ? { lead_title: lead.lead_title } : {}), + ...(lead?.relationship ? { relationship: lead.relationship } : {}), + ...(lead?.lead_confidence ? { lead_confidence: lead.lead_confidence } : {}), + }; + }) + // lead first, then picked-no-lead, then dropped; higher anchor_score first. + .sort((a, b) => { + const rank = { lead: 0, picked_no_lead: 1, dropped_at_triage: 2 } as const; + return rank[a.outcome] - rank[b.outcome] || b.anchor_score - a.anchor_score; + }); + + // Phrase-anchor trail: same join, but for candidates that shared a distinctive + // code/execution token (extracted.code_tokens) with the case. Tracked as its own + // group so the debug accordion can surface code-token "smoking guns" separately + // from IOC/artifact anchors. anchor_score carries the phrase-match score. + const phraseHits = (meta?.pool ?? []).filter((c) => c.has_phrase_anchor); + const phrase_anchor_trail: AnchorTrailEntry[] = phraseHits + .map((c) => { + const pick = pickByFp.get(c.id); + const lead = fpToLead.get(c.id); + const outcome: AnchorTrailEntry["outcome"] = lead + ? "lead" + : pick + ? "picked_no_lead" + : "dropped_at_triage"; + return { + fp: c.id, + anchor_score: c.phrase_score ?? 0, + overlap: c.overlap ?? 0, + ...(pick?.title ? { title: pick.title } : {}), + ...(pick?.vendor ? { vendor: pick.vendor } : {}), + ...(pick?.url ? { url: pick.url } : {}), + ...(pick?.confidence != null ? { triage_confidence: pick.confidence } : {}), + ...(pick?.justification ? { justification: pick.justification } : {}), + outcome, + ...(lead?.lead_title ? { lead_title: lead.lead_title } : {}), + ...(lead?.relationship ? { relationship: lead.relationship } : {}), + ...(lead?.lead_confidence ? { lead_confidence: lead.lead_confidence } : {}), + }; + }) + .sort((a, b) => { + const rank = { lead: 0, picked_no_lead: 1, dropped_at_triage: 2 } as const; + return rank[a.outcome] - rank[b.outcome] || b.anchor_score - a.anchor_score; + }); + + const ca = meta?.caseAnchors; + const anchors_searched = ca + ? { + hashes: ca.hashes ?? [], + network: ca.network ?? [], + artifacts: ca.artifacts ?? [], + techniques: ca.techniques ?? [], + code_tokens: ca.code_tokens ?? [], + } + : undefined; + + // Case-signal diamond: the workflow persists the case's own per-vertex signal + // (case.vertex_signal, NONE/PARTIAL/HIGH) but does not put it inside findings. + // Fold it in (lowercased to match lead vertex_signal) unless synthesis already + // supplied one; skip when every vertex is NONE so the view can hide an empty + // diamond instead of drawing an all-grey one. + const existingCaseSignal = (findings as { case_vertex_signal?: Record }) + .case_vertex_signal; + const cvsIn = meta?.caseVertexSignal; + const derivedCaseSignal = + !existingCaseSignal && cvsIn + ? (() => { + const lowered: Record = {}; + let anySignal = false; + for (const [k, v] of Object.entries(cvsIn)) { + const lv = String(v ?? "").toLowerCase(); + lowered[k] = lv; + if (lv && lv !== "none") anySignal = true; + } + return anySignal ? lowered : undefined; + })() + : undefined; + + return { + findings: { + ...findings, + leads, + no_match, + candidate_meta: { + ...candidateMeta, + ...((findings as { candidate_meta?: Record }).candidate_meta ?? {}), + }, + // Fold run-level context into findings so the view (which only receives + // `findings`) can render counts + the Pipeline & cost panel. Omitted when + // not provided so existing callers/tests are unaffected. + ...(meta?.counts ? { counts: meta.counts } : {}), + ...(meta?.trace ? { trace: meta.trace } : {}), + ...(meta?.run ? { run_meta: meta.run } : {}), + ...(derivedCaseSignal ? { case_vertex_signal: derivedCaseSignal } : {}), + ...(anchors_searched ? { anchors_searched } : {}), + ...(anchor_trail.length > 0 ? { anchor_trail } : {}), + ...(phrase_anchor_trail.length > 0 ? { phrase_anchor_trail } : {}), + }, + unresolved, + }; +} + /** - * Register the two threat-report correlation tools. + * Register the threat-report correlation tools. + * + * AUTHORITATIVE PATH (workflow-driven — use this to correlate a case): + * 1. `correlate` → trigger the `ti-correlation` Kibana Workflow (retrieval → + * Sonnet triage → Opus synthesis, all server-side). Returns a run_id. + * 2. `get_correlation_run` → poll by run_id until status is "completed"; + * returns render-ready CorrelationFindings (candidate titles resolved to + * report ids via the run's picks[]). + * 3. `render_correlation` → hand those findings to the analyst view. * - * HOST LOOP (described in each tool's description): - * 1. Summarise the case into Diamond Model vertices (use TRADECRAFT guidance). - * 2. Call `diamond_search` → receive candidate stubs + triage/synthesis rubric. - * 3. Triage candidates yourself using the returned rubric. - * 4. Call `get_report` for the top candidates. - * 5. Synthesise correlation findings using the returned synthesis guidance. + * EXPLORATION AIDS (analyst-driven, NOT the correlation path): + * `diamond_search` / `diamond_search_analyst` / `get_report` let an analyst + * browse the corpus by Diamond-vertex similarity or pull a report's text. + * They do NOT synthesize — host-driven synthesis is deprecated in favour of + * the workflow above (consistent tradecraft + no 120s host timeout). */ export function registerCorrelationTools( server: McpServer, @@ -52,28 +342,234 @@ export function registerCorrelationTools( const { correlationService, analytics } = deps; // ------------------------------------------------------------------------- - // diamond_search + // correlate — AUTHORITATIVE path: trigger the ti-correlation Kibana Workflow // ------------------------------------------------------------------------- registerTrackedAppTool( analytics, server, - "diamond_search", + "correlate", + { + title: "Correlate Threat Report (Workflow)", + description: `Correlate a case against the report corpus using the server-side ti-correlation Kibana Workflow. This is the AUTHORITATIVE correlation path — the workflow runs retrieval (anchor + diamond kNN + BM25), Sonnet triage, and Opus synthesis with consistent tradecraft. Do NOT hand-synthesize findings from diamond_search/get_report; those are analyst exploration aids only. + +Provide EITHER report_id (a stored corpus report's content_fingerprint) OR raw_text (pasted case text) — not both. + +This tool triggers the run ASYNCHRONOUSLY and returns a run_id immediately. The workflow executes in Kibana Task Manager; full-depth synthesis can take a few minutes. Poll get_correlation_run with the returned run_id until status is "completed" (or "budget_exceeded"/"failed"), then call render_correlation with the returned findings. + +DEPTH TIERS (each adds cost on top of the previous): + free — exact anchor match + behavioral/BM25 retrieval only (no LLM) + cheap — + per-vertex diamond kNN (no LLM) + med — + Sonnet triage over the fused candidate pool + full — + Opus synthesis into CorrelationFindings (default; the only depth that yields a renderable report)`, + _meta: { ui: {} }, + inputSchema: { + report_id: z + .string() + .optional() + .describe( + "Stored corpus report _id (content_fingerprint) to correlate. Mutually exclusive with raw_text." + ), + raw_text: z + .string() + .optional() + .describe( + "Pasted case text to correlate. Mutually exclusive with report_id." + ), + depth: z + .enum(["free", "cheap", "med", "full"]) + .optional() + .describe( + "How far to run: free/cheap (retrieval only), med (+triage), full (+synthesis; default). Only full yields a renderable report." + ), + triage_pool: z + .number() + .int() + .min(1) + .max(500) + .optional() + .describe("Max fused candidates presented to triage (default 120)."), + triage_floor: z + .number() + .min(0) + .max(1) + .optional() + .describe("Triage confidence floor 0..1 — picks below this are dropped (default 0.65)."), + }, + }, + async ({ report_id, raw_text, depth, triage_pool, triage_floor }) => { + const result = await correlationService.runCorrelation({ + report_id, + raw_text, + depth, + triage_pool, + triage_floor, + }); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + kind: "correlation_run_started", + run_id: result.run_id, + workflow_id: result.workflow_id, + depth: result.depth, + summary: `Correlation run ${result.run_id} started (depth: ${result.depth}). Poll get_correlation_run until status is "completed".`, + }), + }, + ], + }; + } + ); + + // ------------------------------------------------------------------------- + // get_correlation_run — poll a run by run_id; return render-ready findings + // ------------------------------------------------------------------------- + + registerTrackedAppTool( + analytics, + server, + "get_correlation_run", { - title: "Diamond Model Correlation Search", - description: `AUTONOMOUS / unsupervised threat-report correlation search. Use this when NO human analyst will triage the candidates. + title: "Get Correlation Run", + description: `Fetch a correlation run record by run_id (returned by correlate) from the correlations index. -This tool is the BLIND path: it returns candidates with per-vertex matched_vertices evidence summaries and NO numeric scores, BY DESIGN. Scores are withheld so the model judges evidence on its merits instead of anchoring on similarity rank. Triage candidates using their matched_vertices text and the triage_rubric in the response. +Poll this until status is "completed". While the workflow is still running the record does not exist yet — you'll get { found: false, status: "pending" }; wait and retry. -Do NOT use this tool when a human analyst will review and select candidates — use diamond_search_analyst instead. Pick ONE: diamond_search for autonomous runs, diamond_search_analyst for analyst-led runs. +On completion the response includes render-ready \`findings\` (a CorrelationFindings object with candidate titles already resolved to report ids via the run's picks). When findings is present, pass it straight to render_correlation. For non-full depths (free/cheap/med) there is no synthesized report, so findings is null — inspect counts/picks instead. -HOST WORKFLOW (autonomous): -1. Summarise your case into up to four Diamond Model vertex paragraphs (adversary, capability, infrastructure, victim) following the diamond_summarisation_guidance included in every response. Omit vertices with no signal. -2. Call this tool with your vertex summaries and any file-hash IOCs from the case. -3. You receive candidate stubs with matched_vertices evidence text (which vertices matched + what the report said about each). No numeric scores are returned. -4. Triage candidates by reading matched_vertices evidence against the triage_rubric. Pull the top ~10 by evidence strength. -5. Call get_report with the IDs of your top candidates. -6. Synthesise correlation findings using the returned synthesis_guidance.`, +Statuses: "pending" (still running / not yet persisted), "completed" (synthesis done), "budget_exceeded" (input too large — no synthesis), "failed".`, + _meta: { ui: {} }, + inputSchema: { + run_id: z + .string() + .describe("The run_id (workflow execution id) returned by correlate."), + }, + }, + async ({ run_id }) => { + const record = await correlationService.getCorrelationRun(run_id); + if (!record.found) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + kind: "correlation_run", + run_id, + found: false, + status: "pending", + summary: `Run ${run_id} not persisted yet — still executing. Wait and poll again.`, + }), + }, + ], + }; + } + + const status = record.status ?? "completed"; + const rid = record.run_id ?? run_id; + const { findings, unresolved } = workflowFindingsToRenderShape( + record.findings, + record.picks, + { + trace: record.trace, + counts: record.counts, + run: { run_id: rid, depth: record.depth, status }, + caseAnchors: record.case?.anchors, + caseVertexSignal: record.case?.vertex_signal as Record | undefined, + pool: record.candidates, + } + ); + const leadsCount = Array.isArray((findings as { leads?: unknown[] } | null)?.leads) + ? (findings as { leads: unknown[] }).leads.length + : 0; + + // LOUD miss: a synthesis title that no pick fingerprint matched. The report + // still renders (candidate_id falls back to the title), but the id won't + // join to a report — so warn on the server and surface it in the response + // rather than letting it pass silently. + if (unresolved.length > 0) { + const detail = unresolved + .map((u) => `${u.where}[${u.index}] "${u.title}"`) + .join("; "); + console.warn( + `[correlation] run ${rid}: ${unresolved.length} candidate title(s) did not resolve to a report id (fell back to the title string): ${detail}` + ); + } + + // Opt-in Cursor canvas: when CORRELATION_CANVAS_DIR is set, stamp these + // render-shape findings into the paint-by-numbers template so Cursor gets + // a native side-panel report (its MCP app views only render inline). This + // is best-effort — a canvas write failure must not fail the poll. + let canvasPath: string | undefined; + if (findings && canvasDirFromEnv()) { + try { + const emitted = emitCorrelationCanvas({ + findings, + runId: rid, + caseTitle: record.case?.title, + }); + if (emitted) { + canvasPath = emitted.file; + console.error(`[correlation] run ${rid}: wrote Cursor canvas ${emitted.file}`); + } + } catch (err) { + console.warn( + `[correlation] run ${rid}: canvas emit failed: ${String( + (err as Error)?.message ?? err + )}` + ); + } + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + kind: "correlation_run", + run_id: rid, + found: true, + status, + depth: record.depth, + counts: record.counts, + picks: record.picks, + trace: record.trace, + error: record.error, + findings, + unresolved_candidate_titles: unresolved, + ...(canvasPath ? { canvas_path: canvasPath } : {}), + summary: findings + ? `Run ${rid} ${status} — ${leadsCount} lead(s)${ + unresolved.length > 0 + ? `; WARNING: ${unresolved.length} candidate title(s) did not resolve to a report id (see unresolved_candidate_titles)` + : "" + }.${canvasPath ? ` Cursor canvas written to ${canvasPath} — open it for the side-panel report.` : ""} Pass findings to render_correlation.` + : `Run ${rid} ${status} — no synthesized report (depth ${record.depth ?? "?"}); inspect counts/picks.`, + }), + }, + ], + }; + } + ); + + // ------------------------------------------------------------------------- + // diamond_search — EXPLORATION AID (analyst browse; not the correlation path) + // ------------------------------------------------------------------------- + + registerTrackedAppTool( + analytics, + server, + "diamond_search", + { + title: "Diamond Model Corpus Search (Exploration Aid)", + description: `EXPLORATION AID — browse the report corpus by Diamond-vertex similarity. This is NOT the correlation path: to correlate a case, use \`correlate\` (the ti-correlation workflow), which does retrieval + triage + synthesis server-side. Host-driven synthesis from these stubs is deprecated. + +Returns candidates with per-vertex matched_vertices evidence summaries and NO numeric scores, so an analyst can eyeball what the corpus holds for a given case sketch. Use diamond_search_analyst if you want visible scores. + +USAGE: +1. Summarise the case into up to four Diamond Model vertex paragraphs (adversary, capability, infrastructure, victim) following diamond_summarisation_guidance. Omit vertices with no signal. +2. Call this tool with your vertex summaries and any file-hash IOCs. +3. Inspect the returned candidate stubs. To produce authoritative findings, feed the case to \`correlate\` rather than synthesizing here.`, _meta: { ui: {} }, inputSchema: { adversary: z @@ -157,10 +653,10 @@ HOST WORKFLOW (autonomous): server, "get_report", { - title: "Get Threat Report", - description: `Retrieve the full text of one or more threat reports by ID. + title: "Get Threat Report (Exploration Aid)", + description: `Retrieve the full text of one or more threat reports by ID — an analyst exploration aid for reading source material. -Call this after triaging the candidates returned by diamond_search. Pass the report_ids of the candidates you selected for in-depth synthesis. The returned body_text + title + url are the source material for your synthesis step.`, +Note: authoritative correlation is done by \`correlate\` (the ti-correlation workflow), which reads report bodies itself during synthesis. Use this to let an analyst read a report the workflow surfaced, or to inspect a diamond_search candidate — not to hand-synthesize a correlation report.`, _meta: { ui: {} }, inputSchema: { report_ids: z @@ -195,24 +691,20 @@ Call this after triaging the candidates returned by diamond_search. Pass the rep server, "diamond_search_analyst", { - title: "Diamond Model Correlation Search (Analyst-Led)", - description: `INTERACTIVE / analyst-led threat-report correlation search. Use this when a human analyst WILL review and select candidates. Do NOT use this for autonomous unsupervised correlation — use diamond_search instead. + title: "Diamond Model Corpus Search — Scored (Exploration Aid)", + description: `EXPLORATION AID — analyst-led browse of the corpus with visible per-vertex scores. This is NOT the correlation path: to correlate a case, use \`correlate\` (the ti-correlation workflow). Host-driven synthesis from these candidates is deprecated. -This tool is the SCORED path: it returns ranked candidates with per-vertex match scores (vertex_scores) for the analyst to triage with full score visibility. Scores are present here because the analyst, not the model, makes the selection decision. - -Pick ONE: diamond_search_analyst for analyst-led runs, diamond_search for autonomous runs. Running both in sequence is not a workflow. +Returns ranked candidates with per-vertex match scores (vertex_scores) so an analyst can see how the corpus ranks against a case sketch and drive the triage UI. The response includes: - candidates: ScoredStub[] ranked by (overlap desc, max_score desc) — each with vertex_scores showing which Diamond Model vertices matched and their semantic similarity scores - coverage: { queried, avg_overlap, thin } — thin=true signals weak retrieval (degraded or low multi-vertex overlap); the UI renders a backfill nudge -- tradecraft: triage_rubric and synthesis_guidance for subsequent steps +- tradecraft: triage_rubric for interpreting candidates -HOST WORKFLOW (analyst-supervised): +USAGE: 1. Summarise the case into Diamond Model vertex paragraphs following diamond_summarisation_guidance. -2. Call this tool; present the scored candidates and coverage signal to the analyst. -3. The analyst triages using vertex_scores as cues alongside the triage_rubric. -4. Call get_report for analyst-selected candidates. -5. Synthesise using synthesis_guidance.`, +2. Call this tool; present the scored candidates and coverage signal to the analyst for exploration. +3. To produce authoritative findings, run \`correlate\` on the case and render the resulting run — do not hand-synthesize here.`, _meta: { ui: { resourceUri: CORRELATION_RESOURCE_URI } }, inputSchema: { adversary: z @@ -323,7 +815,7 @@ HOST WORKFLOW (analyst-supervised): Call this first with your per-vertex case summaries and signal self-ratings. The analyst reviews the stoplight (🟢 HIGH / 🟡 PARTIAL / 🔴 NONE) and query text for each vertex, then decides whether the input is ready to search. -On "Search this case", call diamond_search_analyst with the same vertex queries. +On "Search this case", call diamond_search_analyst with the same vertex queries to explore the corpus (an exploration aid). For an authoritative correlation, run \`correlate\` on the case instead. On "I'll revise first", the analyst provides additional case context in chat and you re-summarise before calling this tool again. This tool performs NO Elasticsearch call and does NO search — it is a display-only gate. @@ -463,17 +955,33 @@ INPUT SIGNAL SELF-RATING SCALE: url: z.string().optional(), }); + // Shared shape for both the exact-anchor and code-token (phrase) anchor trails. + const ANCHOR_TRAIL_ENTRY_SCHEMA = z.object({ + fp: z.string(), + anchor_score: z.number(), + overlap: z.number(), + title: z.string().optional(), + vendor: z.string().optional(), + url: z.string().optional(), + triage_confidence: z.number().optional(), + justification: z.string().optional(), + outcome: z.enum(["lead", "picked_no_lead", "dropped_at_triage"]), + lead_title: z.string().optional(), + relationship: z.string().optional(), + lead_confidence: z.string().optional(), + }); + registerTrackedAppTool( analytics, server, "render_correlation", { title: "Render Correlation Report", - description: `Render a structured correlation report you (the host) synthesized. + description: `Render a structured correlation report in the analyst view. -Call this AFTER calling get_report and completing your synthesis. Pass your CorrelationFindings; the analyst sees the rendered deep-dive report. +Call this with the \`findings\` returned by get_correlation_run once a correlate run has completed (findings are already resolved to the render shape — candidate ids populated). The analyst sees the rendered deep-dive report. -This tool performs NO synthesis and NO Elasticsearch queries — it is a pure pass-through to the analyst view. The host is responsible for all reasoning; this tool only hands the structured result to the UI.`, +This tool performs NO synthesis and NO Elasticsearch queries — it is a pure renderer. The authoritative reasoning is done by the ti-correlation workflow (see \`correlate\`); this tool only hands the structured result to the UI.`, _meta: { ui: { resourceUri: CORRELATION_REPORT_RESOURCE_URI } }, inputSchema: { findings: z @@ -491,8 +999,34 @@ This tool performs NO synthesis and NO Elasticsearch queries — it is a pure pa .optional(), candidate_labels: z.record(z.string(), z.string()).optional(), candidate_meta: z.record(z.string(), CANDIDATE_META_ENTRY_SCHEMA).optional(), + // Run-level context folded in by get_correlation_run so the view can + // render the counts strip + Pipeline & cost panel from findings alone. + counts: z.record(z.string(), z.number()).optional(), + trace: z.record(z.string(), z.unknown()).optional(), + run_meta: z + .object({ + run_id: z.string().optional(), + depth: z.string().optional(), + status: z.string().optional(), + }) + .optional(), + // Anchor trail: the case anchors the workflow searched + how each + // exact-anchor-matched report fared through triage into synthesis. + anchors_searched: z + .object({ + hashes: z.array(z.string()).default([]), + network: z.array(z.string()).default([]), + artifacts: z.array(z.string()).default([]), + techniques: z.array(z.string()).default([]), + code_tokens: z.array(z.string()).default([]), + }) + .optional(), + anchor_trail: z.array(ANCHOR_TRAIL_ENTRY_SCHEMA).optional(), + // Code-token (phrase) anchor trail: candidates that shared a + // distinctive code/execution token with the case. + phrase_anchor_trail: z.array(ANCHOR_TRAIL_ENTRY_SCHEMA).optional(), }) - .describe("CorrelationFindings you synthesized from get_report output."), + .describe("CorrelationFindings from get_correlation_run (render-ready)."), }, }, async ({ findings }) => { diff --git a/src/views/correlation-report/App.tsx b/src/views/correlation-report/App.tsx index fe80a06..e6c2d2a 100644 --- a/src/views/correlation-report/App.tsx +++ b/src/views/correlation-report/App.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { extractToolText } from "../../shared/extract-tool-text"; import { useMcpApp, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; import { useAnalytics } from "../../shared/hooks/useAnalytics"; -import { AppGlyph } from "../../shared/components/icons/icons"; +import { useFullscreen } from "../../shared/hooks/useFullscreen"; +import { AppGlyph, FullscreenIcon, ExitFullscreenIcon } from "../../shared/components/icons/icons"; import "./styles.css"; // --------------------------------------------------------------------------- @@ -84,6 +85,52 @@ interface CandidateMetaEntry { url?: string; } +interface TraceStage { + stage: string; + tier?: "sonnet" | "opus" | null; + input_tokens?: number | string; + output_tokens?: number | string; + candidates?: number | string; + anchors?: number | string; + started_at?: string; + ended_at?: string; +} + +interface Trace { + total_input_tokens?: number | string; + total_output_tokens?: number | string; + stages?: TraceStage[]; +} + +interface RunMeta { + run_id?: string; + depth?: string; + status?: string; +} + +interface AnchorsSearched { + hashes: string[]; + network: string[]; + artifacts: string[]; + techniques: string[]; + code_tokens?: string[]; +} + +interface AnchorTrailEntry { + fp: string; + anchor_score: number; + overlap: number; + title?: string; + vendor?: string; + url?: string; + triage_confidence?: number; + justification?: string; + outcome: "lead" | "picked_no_lead" | "dropped_at_triage"; + lead_title?: string; + relationship?: string; + lead_confidence?: string; +} + interface CorrelationFindings { leads: Lead[]; no_match: NoMatch[]; @@ -91,6 +138,13 @@ interface CorrelationFindings { case_vertex_signal?: VertexSignalMap; candidate_labels?: Record; candidate_meta?: Record; + // Run-level context folded in by get_correlation_run (see workflowFindingsToRenderShape). + counts?: Record; + trace?: Trace; + run_meta?: RunMeta; + anchors_searched?: AnchorsSearched; + anchor_trail?: AnchorTrailEntry[]; + phrase_anchor_trail?: AnchorTrailEntry[]; } interface ReportPayload { @@ -194,11 +248,17 @@ interface DiamondProps { function DiamondSvg({ vertexSignal, size = 80 }: DiamondProps) { const showLabels = size >= 80; + // Nodes (r=16) reach past the 0–160 box, so pad the viewBox and scale the + // pixel size to match — keeps the diamond's apparent size while giving the + // circles room so they don't clip at the container edges. + const PAD = 10; + const vb = 160 + PAD * 2; + const px = Math.round(size * (vb / 160)); return ( ; runMeta?: RunMeta }) { + if (!counts && !runMeta) return null; + const items: Array<{ label: string; value: string | number }> = []; + if (counts) { + if (counts.candidates !== undefined) items.push({ label: "Candidates", value: counts.candidates }); + if (counts.picks !== undefined) items.push({ label: "Triage picks", value: counts.picks }); + if (counts.leads !== undefined) items.push({ label: "Leads", value: counts.leads }); + if (counts.no_match !== undefined) items.push({ label: "No-match", value: counts.no_match }); + } + if (runMeta?.depth) items.push({ label: "Depth", value: runMeta.depth }); + if (items.length === 0) return null; + return ( +
+ {items.map((it) => ( +
+ {it.value} + {it.label} +
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// PipelineCostCard — per-tier token spend + est. cost (collapsible) +// --------------------------------------------------------------------------- + +// Anthropic list prices, USD per 1M tokens (in / out). Estimate only — managed +// EIS billing may differ; captioned as such in the card. +const PRICES: Record<"sonnet" | "opus", { in: number; out: number }> = { + sonnet: { in: 3, out: 15 }, + opus: { in: 15, out: 75 }, +}; + +const STAGE_LABEL: Record = { + extract_core: "Case extraction", + extract_diamond: "Case diamond", + retrieval: "Retrieval", + triage: "Triage", + synthesis: "Synthesis", +}; +const STAGE_MODEL: Record = { + extract_core: "Claude Sonnet (raw_text IOCs/behaviors)", + extract_diamond: "Claude Opus (raw_text vertices)", + retrieval: "Elasticsearch (kNN + BM25 + anchors)", + triage: "Claude Sonnet", + synthesis: "Claude Opus", +}; + +function num(v: number | string | undefined): number { + const n = typeof v === "string" ? Number(v) : v; + return Number.isFinite(n as number) ? (n as number) : 0; +} +function money(n: number): string { + return `$${n.toFixed(2)}`; +} +function stageCost(s: TraceStage): number { + if (!s.tier) return 0; + const p = PRICES[s.tier]; + return (num(s.input_tokens) * p.in + num(s.output_tokens) * p.out) / 1_000_000; +} +// Duration in ms from the stage's ISO timestamps; null when either mark is absent +// (e.g. a tier that was gated off for the run's depth). +function stageDurationMs(s: TraceStage): number | null { + if (!s.started_at || !s.ended_at) return null; + const a = Date.parse(s.started_at); + const b = Date.parse(s.ended_at); + if (!Number.isFinite(a) || !Number.isFinite(b) || b < a) return null; + return b - a; +} +function fmtDuration(ms: number | null): string { + if (ms == null) return "—"; + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(1)} s`; +} + +function PipelineCostCard({ trace }: { trace?: Trace }) { + const [open, setOpen] = useState(false); + const stages = Array.isArray(trace?.stages) ? trace!.stages : []; + if (stages.length === 0) return null; + + const totalIn = stages.reduce((a, s) => a + num(s.input_tokens), 0); + const totalOut = stages.reduce((a, s) => a + num(s.output_tokens), 0); + const totalCost = stages.reduce((a, s) => a + stageCost(s), 0); + const totalMs = stages.reduce((a, s) => a + (stageDurationMs(s) ?? 0), 0); + const anyDuration = stages.some((s) => stageDurationMs(s) != null); + const fmt = (n: number) => (n === 0 ? "—" : n.toLocaleString()); + + return ( +
+ + {open && ( +
+
+ + + + + + + + + + + {stages.map((s) => { + const detail = + s.stage === "retrieval" + ? `${num(s.candidates)} candidates · ${num(s.anchors)} anchor hits` + : STAGE_MODEL[s.stage] ?? ""; + return ( + + + + + + + + ); + })} + + + + + + + + +
StageTokens inTokens outDurationEst. cost
+
{STAGE_LABEL[s.stage] ?? s.stage}
+
{detail}
+
{fmt(num(s.input_tokens))}{fmt(num(s.output_tokens))}{fmtDuration(stageDurationMs(s))}{s.tier ? money(stageCost(s)) : "$0.00"}
Total{totalIn.toLocaleString()}{totalOut.toLocaleString()}{anyDuration ? fmtDuration(totalMs) : "—"}{money(totalCost)}
+
+ Est. cost at Anthropic list prices (Sonnet $3/$15, Opus $15/$75 per 1M in/out) — managed EIS + billing may differ. Retrieval is Elasticsearch-only. Source: ti-correlations trace. +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// AnchorTrailCard — what exact anchors were searched, which reports they matched, +// and how each match fared through triage → synthesis (collapsible; diagnostic). +// --------------------------------------------------------------------------- + +const OUTCOME_LABEL: Record = { + lead: "Lead", + picked_no_lead: "Triaged, no lead", + dropped_at_triage: "Dropped at triage", +}; +const OUTCOME_CLASS: Record = { + lead: "crr-outcome-lead", + picked_no_lead: "crr-outcome-picked", + dropped_at_triage: "crr-outcome-dropped", +}; + +function AnchorChips({ label, values }: { label: string; values: string[] }) { + if (!values || values.length === 0) return null; + return ( +
+ {label} +
+ {values.map((v, i) => ( + {v} + ))} +
+
+ ); +} + +function TrailRows({ rows, scoreLabel }: { rows: AnchorTrailEntry[]; scoreLabel: string }) { + return ( +
+ {rows.map((r, i) => ( +
+
+ + {OUTCOME_LABEL[r.outcome]} + + + {r.url ? ( + {r.title ?? r.fp} + ) : ( + r.title ?? r.fp + )} + + {r.vendor && {r.vendor}} +
+
+ {scoreLabel} {r.anchor_score} + · diamond overlap {r.overlap} + {r.triage_confidence != null && ( + · triage {(r.triage_confidence * 100).toFixed(0)}% + )} + {r.outcome === "lead" && r.relationship && ( + · synthesis: {r.relationship.replace(/_/g, " ")} ({r.lead_confidence}) + )} +
+ {r.justification && ( +
“{r.justification}”
+ )} +
+ ))} +
+ ); +} + +function AnchorTrailCard({ + anchors, + trail, + phraseTrail, +}: { + anchors?: AnchorsSearched; + trail?: AnchorTrailEntry[]; + phraseTrail?: AnchorTrailEntry[]; +}) { + const [open, setOpen] = useState(false); + const codeTokens = anchors?.code_tokens ?? []; + const searched = anchors + ? (anchors.hashes.length + anchors.network.length + anchors.artifacts.length + anchors.techniques.length + codeTokens.length) + : 0; + const rows = trail ?? []; + const phraseRows = phraseTrail ?? []; + if (searched === 0 && rows.length === 0 && phraseRows.length === 0) return null; + const matched = rows.length + phraseRows.length; + const leadCount = [...rows, ...phraseRows].filter((r) => r.outcome === "lead").length; + + return ( +
+ + {open && ( +
+ {anchors && searched > 0 && ( +
+
+ Case anchors backfilled into the exact-match retrieval clause: +
+ + + + + +
+ )} + +
IOC / artifact anchors
+ {rows.length > 0 ? ( + + ) : ( +
+ No corpus report shared an exact IOC/artifact anchor with the case. +
+ )} + +
+ Code-token (phrase) anchors +
+ {phraseRows.length > 0 ? ( + + ) : ( +
+ {codeTokens.length > 0 + ? "No corpus report shared a distinctive code token with the case." + : "The case exposed no distinctive code tokens to phrase-match."} +
+ )} + +
+ Anchors = shared file-hash IOCs + discriminating artifacts (network IOCs / techniques are boosts). + Code-token anchors match distinctive execution tokens (e.g. [Class]::Method()) exactly against + corpus extracted.code_tokens. Trail joins retrieval hits → triage picks → synthesis leads. Source: ti-correlations run record. +
+
+ )} +
+ ); +} + // --------------------------------------------------------------------------- // Main App // --------------------------------------------------------------------------- @@ -638,8 +994,10 @@ export function App() { function AppContent() { const [payload, setPayload] = useState(null); - const { connected } = useMcpApp(); + const { connected, getApp } = useMcpApp(); const { trackEvent } = useAnalytics(); + const fullscreen = useFullscreen(getApp); + const autoExpandedRef = useRef(false); useEffect(() => { trackEvent({ eventType: "view_rendered", viewId: "correlation-report" }); @@ -660,6 +1018,17 @@ function AppContent() { }, }); + // The report is dense — when findings first arrive, request the host's + // larger surface (fullscreen display mode → side panel) instead of leaving + // it in the inline chat card. Best-effort: hosts that require a user gesture + // ignore it, and the header toggle is the manual fallback. + useEffect(() => { + if (payload?.findings && !autoExpandedRef.current) { + autoExpandedRef.current = true; + if (!fullscreen.isFullscreen) fullscreen.toggle(); + } + }, [payload, fullscreen]); + if (!connected) { return (
@@ -684,19 +1053,30 @@ function AppContent() { {findings?.synthesis.case_title ?? "Correlation Report"}
- {findings && ( -
- - {findings.synthesis.correlation_signal.toUpperCase()} - - - {findings.leads.length} lead{findings.leads.length !== 1 ? "s" : ""} - -
- )} +
+ {findings && ( + <> + + {findings.synthesis.correlation_signal.toUpperCase()} + + + {findings.leads.length} lead{findings.leads.length !== 1 ? "s" : ""} + + + )} + +
@@ -718,6 +1098,9 @@ function AppContent() {
+ {/* Retrieval → triage → synthesis funnel */} + + {/* Case vertex signal */} {findings.case_vertex_signal && (
@@ -751,6 +1134,23 @@ function AppContent() { {/* Next steps */} + + {/* Anchor trail (diagnostic; collapsed) — searched → matched → synthesis */} + + + {/* Pipeline & cost (diagnostic; collapsed) */} + + + {findings.run_meta?.run_id && ( +
+ run {findings.run_meta.run_id} + {findings.run_meta.status ? ` · ${findings.run_meta.status}` : ""} +
+ )}
)}
diff --git a/src/views/correlation-report/styles.css b/src/views/correlation-report/styles.css index 26d8987..ee9bac2 100644 --- a/src/views/correlation-report/styles.css +++ b/src/views/correlation-report/styles.css @@ -81,6 +81,27 @@ color: #817f78; } +.crr-header-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + background: transparent; + border: 1px solid #30302f; + border-radius: 6px; + color: #b9b9ae; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} + +.crr-header-icon-btn:hover { + background: rgba(255, 255, 255, 0.05); + border-color: #474745; + color: #e6e6e5; +} + /* ─── Body ─── */ .crr-body { @@ -687,3 +708,247 @@ a.crr-consolidated-title:hover { @keyframes crr-spin { to { transform: rotate(360deg); } } + +/* ─── Counts strip (retrieval → triage → synthesis funnel) ─── */ + +.crr-counts-strip { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.crr-count-stat { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1 1 auto; + min-width: 96px; + padding: 10px 14px; + background: #252523; + border: 1px solid #30302f; + border-radius: 8px; +} + +.crr-count-value { + font-size: 20px; + font-weight: 700; + line-height: 1; + color: #e6e6e5; + text-transform: capitalize; +} + +.crr-count-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #817f78; +} + +/* ─── Pipeline & cost ─── */ + +.crr-pipeline-summary { + margin-left: auto; + font-size: 11.5px; + color: #817f78; +} + +.crr-pipeline-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.crr-pipeline-table th { + text-align: left; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #817f78; + padding: 4px 8px 8px; + border-bottom: 1px solid #30302f; +} + +.crr-pipeline-table td { + padding: 8px; + border-bottom: 1px solid #262625; + vertical-align: top; + color: #b9b9ae; +} + +.crr-pipeline-table .crr-num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.crr-pipeline-stage { + font-weight: 600; + color: #e6e6e5; +} + +.crr-pipeline-detail { + font-size: 10.5px; + color: #817f78; + margin-top: 2px; +} + +.crr-pipeline-total td { + border-bottom: none; + border-top: 1px solid #474745; + font-weight: 700; + color: #e6e6e5; +} + +.crr-pipeline-caption { + font-size: 10.5px; + line-height: 1.5; + color: #817f78; + margin-top: 10px; +} + +.crr-run-footer { + font-size: 10.5px; + color: #5f5e59; + font-family: var(--font-mono, "Fira Mono", monospace); + padding-top: 2px; +} + +/* ─── Anchor trail ─── */ + +.crr-anchors-searched { + padding-bottom: 12px; + margin-bottom: 12px; + border-bottom: 1px solid #262625; +} + +.crr-anchor-chip-row { + display: flex; + gap: 8px; + align-items: baseline; + margin-bottom: 6px; +} + +.crr-anchor-chip-label { + flex: 0 0 64px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #817f78; +} + +.crr-anchor-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.crr-anchor-chip { + font-family: var(--font-mono, "Fira Mono", monospace); + font-size: 11px; + color: #d9d9cf; + background: #2b2b29; + border: 1px solid #3a3a38; + border-radius: 5px; + padding: 2px 7px; + word-break: break-all; +} + +.crr-anchor-trail-subhead { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--crr-text-subtle, #94a3b8); + margin: 6px 0 8px; +} + +.crr-anchor-trail-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.crr-anchor-trail-row { + padding: 10px 12px; + background: #252523; + border: 1px solid #30302f; + border-radius: 8px; +} + +.crr-anchor-trail-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.crr-anchor-trail-title { + font-weight: 600; + color: #e6e6e5; + font-size: 13px; +} + +.crr-anchor-trail-title a { + color: #7fb0e6; + text-decoration: none; +} + +.crr-anchor-trail-title a:hover { + text-decoration: underline; +} + +.crr-anchor-trail-vendor { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #817f78; +} + +.crr-anchor-trail-meta { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 5px; + font-size: 11px; + color: #918f88; + font-variant-numeric: tabular-nums; +} + +.crr-anchor-trail-just { + margin-top: 6px; + font-size: 11.5px; + font-style: italic; + line-height: 1.5; + color: #b0aea4; +} + +.crr-outcome-badge { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 7px; + border-radius: 4px; + white-space: nowrap; +} + +.crr-outcome-lead { + color: #0b0b0a; + background: #7fd18b; +} + +.crr-outcome-picked { + color: #e6d27f; + background: #3a3620; + border: 1px solid #5f5730; +} + +.crr-outcome-dropped { + color: #918f88; + background: #2b2b29; + border: 1px solid #3a3a38; +} diff --git a/tsconfig.server.json b/tsconfig.server.json index 7cda632..9d91e52 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -13,7 +13,7 @@ "rootDir": ".", "lib": ["ES2022"] }, - "include": ["main.ts", "src/server.ts", "src/elastic/**/*", "src/tools/**/*", "src/shared/logger.ts", "src/shared/types.ts"], + "include": ["main.ts", "src/server.ts", "src/elastic/**/*", "src/tools/**/*", "src/canvas/**/*", "src/shared/logger.ts", "src/shared/types.ts"], "exclude": [ "node_modules", "src/views/**/*", From f1e9a2a4495046fda5a3a125a14d6fd124579b66 Mon Sep 17 00:00:00 2001 From: seth-goodwin Date: Mon, 13 Jul 2026 14:42:33 -0500 Subject: [PATCH 8/9] Generalize deployment-specific references for the example app Drop internal codenames from comments (no behavior change): remove the "Mustard" sort-key reference and generalize "threat-intel-ingest" corpus wording in correlationService and .env.example. Index pattern stays env-configurable via TI_REPORTS_INDEX_PATTERN (default ti-reports*). Co-authored-by: Cursor --- .env.example | 6 +++--- src/elastic/service/correlationService.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 76fc6f2..778b706 100644 --- a/.env.example +++ b/.env.example @@ -14,10 +14,10 @@ CLUSTERS_JSON=[{"name":"primary","elasticsearchUrl":"https://your-cluster.es.clo # Alternative: load the same JSON from a file. # CLUSTERS_FILE=/absolute/path/to/clusters.json -# --- Threat-report correlation (threat-intel-ingest deployment) ------------ +# --- Threat-report correlation --------------------------------------------- # Report corpus index pattern the correlation tools search. Defaults to -# `ti-reports*` (the threat-intel-ingest corpus). Set to `.kibana-threat-reports*` -# for the IntelligenceHub corpus. +# `ti-reports*`. Set TI_REPORTS_INDEX_PATTERN to point at a different corpus +# (e.g. `.kibana-threat-reports*`). # TI_REPORTS_INDEX_PATTERN=ti-reports* # # Authoritative correlation is delegated to the `ti-correlation` Kibana Workflow. diff --git a/src/elastic/service/correlationService.ts b/src/elastic/service/correlationService.ts index 48f4022..522d49a 100644 --- a/src/elastic/service/correlationService.ts +++ b/src/elastic/service/correlationService.ts @@ -24,9 +24,9 @@ import type { DiamondVertex } from "../../correlation/tradecraft.js"; // --------------------------------------------------------------------------- // Report corpus index pattern. Env-configurable so this app can point at the -// threat-intel-ingest corpus (`ti-reports*`, the default) or a different -// deployment's index without a code change. Set TI_REPORTS_INDEX_PATTERN to -// override (e.g. back to ".kibana-threat-reports*" for the IntelligenceHub corpus). +// threat-report corpus (`ti-reports*`, the default) or a different deployment's +// index without a code change. Set TI_REPORTS_INDEX_PATTERN to override +// (e.g. ".kibana-threat-reports*" for an alternative corpus). const THREAT_REPORTS_INDEX_PATTERN = process.env.TI_REPORTS_INDEX_PATTERN?.trim() || "ti-reports*"; @@ -343,7 +343,7 @@ const runSemanticSearch = async ( }); } - // Sort: overlap desc, maxScore desc — mirrors Mustard compact_output sort key. + // Sort: overlap desc, then maxScore desc (anchor overlap outranks similarity). candidates.sort((a, b) => b.overlap !== a.overlap ? b.overlap - a.overlap : b.maxScore - a.maxScore ); From 1bf524a2bd171d712053e75a37d19f6f5599fbf9 Mon Sep 17 00:00:00 2001 From: seth-goodwin Date: Mon, 13 Jul 2026 14:55:06 -0500 Subject: [PATCH 9/9] Add required sslVerify to script cluster credentials ClusterCredentials now requires sslVerify; the correlation dev/smoke scripts built creds without it, breaking `tsc --noEmit`. Default to true (verify), overridable via ELASTIC_SSL_VERIFY=false for self-signed dev clusters. Co-authored-by: Cursor --- scripts/dump-run-findings.ts | 1 + scripts/gen-correlation-canvas.ts | 1 + scripts/smoke-correlate.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/scripts/dump-run-findings.ts b/scripts/dump-run-findings.ts index c96a7fe..ede520b 100644 --- a/scripts/dump-run-findings.ts +++ b/scripts/dump-run-findings.ts @@ -39,6 +39,7 @@ async function main() { elasticsearchUrl: ES_URL, kibanaUrl: KBN_URL, elasticsearchApiKey: API_KEY, + sslVerify: process.env.ELASTIC_SSL_VERIFY === "false" ? false : true, }; const svc = new CorrelationService({ esClient: createEsClient(creds), diff --git a/scripts/gen-correlation-canvas.ts b/scripts/gen-correlation-canvas.ts index c00e7f8..6411e49 100644 --- a/scripts/gen-correlation-canvas.ts +++ b/scripts/gen-correlation-canvas.ts @@ -52,6 +52,7 @@ async function main() { elasticsearchUrl: ES_URL, kibanaUrl: KBN_URL, elasticsearchApiKey: API_KEY, + sslVerify: process.env.ELASTIC_SSL_VERIFY === "false" ? false : true, }; const svc = new CorrelationService({ esClient: createEsClient(creds), diff --git a/scripts/smoke-correlate.ts b/scripts/smoke-correlate.ts index 92cbe4d..d6f5e39 100644 --- a/scripts/smoke-correlate.ts +++ b/scripts/smoke-correlate.ts @@ -54,6 +54,7 @@ async function main() { elasticsearchUrl: ES_URL, kibanaUrl: KBN_URL, elasticsearchApiKey: API_KEY, + sslVerify: process.env.ELASTIC_SSL_VERIFY === "false" ? false : true, }; const esClient = createEsClient(creds); const kibanaClient = createKibanaClient(creds);