[Threat Intel] Enrichment eval suite for the LLM-backed routes - #289345
Conversation
…og API (#287204) ## Summary Adds LLM enrichment services and Threat Intelligence HTTP routes, including the fixed-catalog source list and enable/disable API. Manual report creation and provenance URLs go through the shared HTTP/HTTPS normalizer. Also wires the routes, inference features, and one-time bootstrap into `plugin.ts`, gated by `threatIntelSupplyEnabled`. This wiring is interim: it exists so the enrichment eval suite (see Evaluation below) has routes to call ahead of the full pipeline wiring. `#287207` replaces it with a proper `wiring.ts` module; that PR's description has a rebase note covering the `plugin.ts` conflict resolution. ## Where this sits | # | PR | Depends on | Status | |---|---|---|---| | 1 | #287197 workflow gate correction + fixtures | nothing | ✅ merged | | 2 | #287198 contracts, constants, shared libs | nothing | ✅ merged | | 3 | #287199 content parsing | #287198 | closed | | 4 | #287200 SSRF-guarded HTTP client | #287198 | ✅ merged | | 5 | #287201 index templates, seeding, inference features | #287198, #287200 | ✅ merged | | 6 | #287202 indicator alias | #287198, #287201 | ✅ merged | | 7 | #287203 IOC extraction | #287198, #287200 | ✅ merged | | 8 | #287204 LLM services, routes, source catalog API **← this PR** | #287198, #287200, #287202, #287203 | 👀 ready | | 9 | #287205 RSS adapter | #287198, #287200, #287203, #287204 | draft | | 10 | #287206 remaining adapters, dispatcher, fetch_source step | #287198, #287200, #287203, #287205 | draft | | 11 | #287207 promote/scrub tasks and plugin wiring | #287198–#287206 | draft | | 12 | #287479 generator fixture article URLs | nothing | ✅ merged | | 13 | #289343 Scout API tests for the deterministic routes | #287204 | draft | | 14 | #289345 enrichment eval suite | #287204 | draft | #287479 came first in the merge train and is already on `main`. The numbered series (#287197–#287207) stacks on top in dependency order. #287199 (content parsing) was closed; IOC extraction ships its own section-header classifier. ## Scope review follow-up - Shared `normalizeProvenanceUrl` helper (HTTP/HTTPS only, strips credentials, bounded length). - Source create/delete routes removed. Updates may change only `enabled`. - Lists and mutations reject catalog IDs outside the approved set. - Ensures the per-space indicator alias on `list_sources` (idempotent). ## Evaluation A `@kbn/evals` suite exercises all four enrichment stages against the BlackHat demo pack article text, posting each input directly to the internal route and scoring the structured response. It runs as a tracked baseline (scores recorded, no build-failing thresholds yet). Latest full run, EIS Claude Sonnet 4.6 as model and judge, 1 repetition, 20 examples: | Stage | Evaluator | Score | |---|---|---| | assess_relevance | IsIntelligenceMatch | 1.00 | | assess_relevance | RelevanceShapeValid | 1.00 | | classify_severity | SeverityExactMatch | 0.83 | | classify_severity | SeverityWithinOneLevel | 1.00 | | enrich_taxonomy | CategoryRecall | 0.80 | | enrich_taxonomy | RegionRecall | 1.00 | | extract_diamond | DiamondNoIocLeak | 1.00 | | extract_diamond | DiamondSignalCount | 1.00 | | extract_diamond | criteria (LLM judge, majority vote) | 1.00 | The suite ships in #289345, a separate eval-only PR that stacks on this one (it needs the routes and the flag-gated wiring added here). The four deterministic routes are covered separately by Scout API tests in #289343. Any prompt or calibration fix the evals surface lands by amending this PR, not the eval PR. ## To test ``` node scripts/jest --config x-pack/solutions/security/plugins/security_solution/server/threat_intel/jest.config.js routes/list_sources.test.ts services/provenance_url.test.ts ``` _PR developed with Cursor + Auto_ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jon Wålstedt <jon.walstedt@elastic.co> Co-authored-by: Cursor <cursoragent@cursor.com>
00f5042 to
b9bb1d6
Compare
|
Pinging @elastic/security-threat-hunting (Team:Threat Hunting) |
|
Pinging @elastic/security-solution (Team: SecuritySolution) |
|
@elasticmachine merge upstream |
patrykkopycinski
left a comment
There was a problem hiding this comment.
Reviewed the full diff against the PR head (1ba2488), and re-derived each finding from the committed tree rather than the description.
Solid suite overall: real ground truth instead of placeholder strings, CODE evaluators alongside the judge, a serverConfigSet that actually resolves, and an honest "tracked baseline, no build-failing thresholds yet" framing. The commit-2 taxonomy relabel is the right instinct — fixing bad ground truth beats inflating a score.
Three things I'd want addressed before the score table is treated as a baseline. Two are inline suggestions; the third is a request for a test.
1. withMajorityVote drops judge-model attribution (blocking)
evaluators.criteria() is assembled in kbn-evals/src/evaluate.ts as { ...createCriteriaEvaluator(...), getModel }. The wrapper rebuilds the object literal field-by-field, so getModel (and getVersion) are lost. kibana_evals_executor/client.ts:276 reads model: evaluator.getModel?.() when writing the score, so every criteria score from this suite lands with model undefined and the judge becomes unattributable in the scores index. Inline suggestion below.
2. Empty-set recall is a guaranteed 1.00 (blocking)
recall() returns 1 when expected is empty. Commit 2 set regions: [] for aws-iam and github-actions, so RegionRecall scores a hard 1.00 on 2 of 5 examples regardless of what the model returns — which is where the reported RegionRecall 1.00 comes from.
buildStatsAggregation (kbn-evals-common/impl/query_builders.ts:241) aggregates evaluator.score with extended_stats, which skips nulls but averages in 1s. So "no labels to recall" must be score: null (dropped from the mean), not 1 (inflates it). The existing comment — "recall is trivially satisfied — there is nothing to recall" — describes exactly the condition that should be N/A. Inline suggestion below; same latent issue for CategoryRecall if a pack ever gets an empty category set.
3. DiamondSignalCount may not be able to fail (blocking)
signal_count is computed server-side as countNonNone(output) (#287204), the floors are >= 3 / >= 3 / >= 2 on three packs dense in all four vertices, and scoring is binary count >= min. Together with DiamondNoIocLeak both reporting 1.00, this is consistent with a check that cannot go red.
Could you add a mutation test that proves it bites — force a vertex to NONE (or use a genuinely sparse article) and assert the score goes to 0? If it can't be made to fail, two of the three extract_diamond evaluators aren't measuring anything.
Non-blocking
- No
jest.config.jsand no unit tests. 11 of 16 sibling security eval suites ship one, andwithMajorityVoteis real logic — vote tallying, tie-resolution (passes * 2 >= results.length),N/Ahandling, thebyId.size === 0fallback — currently untested. Matchingkbn-evals-suite-alerts-ragverbatim:Worth notingmodule.exports = { preset: '@kbn/test', rootDir: '../../../../..', roots: ['<rootDir>/x-pack/solutions/security/packages/kbn-evals-suite-threat-intel-enrichment'], };
tsconfig.jsonalready declares"types": ["jest"]with no jest config present. For the fixes above, the regression tests should fail on the current committed code — otherwise they're not gates. packs.tscarries dead fields. After commit 2,regionsandmitrehave zero references in the package, andcategoriessurvives only asclassify_severityinput. Two sources of taxonomy truth invites a future edit to the wrong one.withMajorityVoteis arguably platform code.createMultiJudgeEvaluatoris already exported from@kbn/evalswith a'majority'strategy and unit tests. It aggregates across different judges rather than repeated samples of one, so it isn't a drop-in — but a per-criterion self-consistency wrapper looks generic. If it moves, it should be its own PR, not folded in here..eslintrc.jsturns offimport/no-nodejs-modules, but the package imports no Node builtins.samples = 3triples judge cost per example — worth a comment on the intended trade-off.
On the reported scores
I'd hold off on reading the table as a baseline: RegionRecall 1.00 is partly arithmetic (#2), DiamondSignalCount / DiamondNoIocLeak are unproven-to-fail (#3), and the criteria rows are unattributed to a judge model (#1). SeverityExactMatch 0.83 and CategoryRecall 0.80 look like the only numbers carrying real signal today — and they're the two that can actually move.
Separately, the run uses Claude Sonnet 4.6 as both model and judge. Self-judging measurably biases scores, and the 1.00s sit at the ceiling where saturation hides variance. A multi-model run with a cross-family judge would make this a much stronger baseline. Also worth flagging for anyone reading the green checkmark: kibana-evals has no score threshold in its path, so a green check says nothing about suite quality.
| * (the model under test is called once; only the judge repeats). | ||
| */ | ||
| export const withMajorityVote = (base: Evaluator, samples = 3): Evaluator => ({ | ||
| name: base.name, |
There was a problem hiding this comment.
| name: base.name, | |
| export const withMajorityVote = (base: Evaluator, samples = 3): Evaluator => ({ | |
| ...base, |
Spreading base preserves getModel / getVersion, which kibana_evals_executor/client.ts:276 reads (model: evaluator.getModel?.()) to attribute the score to the judge model. Rebuilding the literal field-by-field silently drops them, so every criteria score writes with model undefined.
There was a problem hiding this comment.
Fixed in 03657e6. withMajorityVote now spreads base, so getModel / getVersion survive and every criteria score attributes to the judge model again instead of writing model undefined. Added a unit test asserting the wrapped evaluator keeps both.
| /** Fraction of the labelled set that appears in the model output (recall). */ | ||
| const recall = (expected: string[] | undefined, actual: string[] | undefined): number => { | ||
| if (!expected || expected.length === 0) return 1; |
There was a problem hiding this comment.
| /** Fraction of the labelled set that appears in the model output (recall). */ | |
| const recall = (expected: string[] | undefined, actual: string[] | undefined): number => { | |
| if (!expected || expected.length === 0) return 1; | |
| /** Fraction of the labelled set that appears in the model output (recall). */ | |
| const recall = (expected: string[] | undefined, actual: string[] | undefined): number | null => { | |
| if (!expected || expected.length === 0) return null; |
extended_stats skips nulls but averages 1s, so returning 1 for "nothing to recall" inflates the evaluator mean (this is what makes RegionRecall read 1.00 on the regions: [] packs). Returning null drops those examples from the mean instead. Callers need to handle it, e.g.:
evaluate: async ({ output, expected }) => {
const score = recall(expected?.regions, output?.regions);
if (score === null) {
return { score: null, label: 'N/A', explanation: 'No labelled regions to recall' };
}
return { score, label: `recall_${score.toFixed(2)}` };
},There was a problem hiding this comment.
Fixed in 03657e6. recall() returns null (not 1) when the labelled set is empty, and both CategoryRecall and RegionRecall surface it as { score: null, label: 'N/A' } so extended_stats drops the example from the mean rather than averaging in a 1. A unit test covers the empty regions: [] case that was producing the RegionRecall 1.00.
| const min = expected?.min_signal_count ?? 0; | ||
| const count = typeof output?.signal_count === 'number' ? output.signal_count : 0; | ||
| return { | ||
| score: count >= min ? 1 : 0, |
There was a problem hiding this comment.
Is there a case where this can score 0? signal_count is server-computed as countNonNone(output), and the dataset floors are 3/3/2 on packs dense in all four vertices — so count >= min may hold unconditionally.
A mutation test would settle it: force a vertex to NONE (or add a genuinely sparse article) and assert the score drops to 0. If it can't be driven red, this evaluator and DiamondNoIocLeak are both reporting 1.00 without measuring anything.
There was a problem hiding this comment.
Added the mutation test in 03657e6: it forces the victim vertex to NONE with signal_count below the floor and asserts DiamondSignalCount scores 0, so the check demonstrably bites rather than being inert. To be precise about what that proves: it shows the evaluator logic is live, not that the current three packs ever drive it red. On those packs a passing 1.00 is the expected result for genuinely dense articles; the evaluator is the floor guard against an all-NONE collapse on a report the pipeline sent to extraction.
|
Addressed the review in 03657e6. Blocking
Non-blocking
On moving On the baseline and re-running |
Refreshed baseline with a cross-family judgeRe-ran the suite locally after the fixes, with the judge on a different family from the model under test.
What changed versus the original table:
Still saturated: Caveats: single model under test, local run, small dataset (20 examples x 3 reps). Treat this as a directional baseline, not a CI gate. |
|
💚 CLA has been signed |
c899db5 to
180ad87
Compare
|
Refreshed the baseline after the review fixes. 3 reps,
On the saturated diamond checks: I added a deliberately sparse Two other things from the fixes show up here: |
…d the flag (#287207) ## Summary Wires Threat Intelligence behind `threatIntelSupplyEnabled`: bootstrap, managed workflow installation, promote/scrub tasks, and route registration. Promote mirrors extracted IOCs into `.threat-intel-indicators` for Indicator Match rules. ## Where this sits | # | PR | Depends on | Status | |---|---|---|---| | 1 | #287197 workflow gate correction + fixtures | nothing | ✅ merged | | 2 | #287198 contracts, constants, shared libs | nothing | ✅ merged | | 3 | #287199 content parsing | #287198 | closed | | 4 | #287200 SSRF-guarded HTTP client | #287198 | ✅ merged | | 5 | #287201 index templates, seeding, inference features | #287198, #287200 | ✅ merged | | 6 | #287202 indicator alias | #287198, #287201 | ✅ merged | | 7 | #287203 IOC extraction | #287198, #287200 | ✅ merged | | 8 | #287204 LLM services, routes, source catalog API | #287198, #287200, #287202, #287203 | ✅ merged | | 9 | #287205 RSS adapter | #287198, #287200, #287203, #287204 | ✅ merged | | 10 | #287206 remaining adapters, dispatcher, fetch_source step | #287198, #287200, #287203, #287205 | ✅ merged | | 11 | #287207 promote/scrub tasks and plugin wiring **← this PR** | #287198–#287206 | ready for review | | 12 | #287479 generator fixture article URLs | nothing | ✅ merged | | 13 | #289343 Scout API tests for the deterministic routes | #287204 | ready for review | | 14 | #289345 enrichment eval suite | #287204 | ready for review | | 15 | #290144 read APIs, readiness, space-keyed attribution | #287207 | draft | #287479 came first in the merge train and is already on `main`. The numbered series (#287197–#287205) is merged; #287206 and #287207 now sit directly on `main`. #287199 (content parsing) was closed; IOC extraction ships its own section-header classifier. ## Scope review follow-up - Installs the managed workflows with the right space topology: `attribute_alerts_to_reports` installs per space (it queries `.alerts-security.alerts-*` and writes per-space hit totals, so a global install would clobber every space's totals onto one shared doc), while `ingest_threat_feeds` and `enrich_threat_report` install once globally. Alert analysis and threat intel share a single `ready()` call so neither install set is dropped, and spaces created after boot are reconciled on the promote task. All three ship `enabled: false`, and enrich routes its HTTP calls through a fixed space (`default`) instead of the install-time `workflow.spaceId` (which is `'*'` globally). - Ensures the default-space indicator alias on start. - Chunks promotion bulk writes and treats HTTP 408/500 as retryable bulk failures. - Sanitizes provenance and extracted IOC reference URLs during promotion. - Documents direct-index cross-space isolation as the blocker to enabling the feature. ## Bootstrap fix folded in from #289343 `seed_default_sources.ts` sorted the legacy-source disable scan on `_id`, which Elasticsearch rejects with `illegal_argument_exception: Fielddata access on the _id field is disallowed` unless `indices.id_field_data.enabled` is set, so it failed bootstrap on a stock cluster. Found this while getting #289343's Scout suite green and moved the fix here since this PR is the one wiring up bootstrap and already needs `security-threat-hunting` review, so it isn't adding a reviewer #289343 wouldn't otherwise need. ## Product boundary `threatIntelSupplyEnabled` defaults to false. Do not enable until direct-index cross-space isolation is hardened or the administrator trust model is explicitly accepted. ## To test ``` node scripts/jest --config x-pack/solutions/security/plugins/security_solution/server/threat_intel/jest.config.js wiring.test.ts tasks/promote_threat_indicators.test.ts node scripts/jest x-pack/solutions/security/plugins/security_solution/server/workflows/ node scripts/jest src/platform/packages/shared/kbn-workflows/managed/definitions/threat_intel/ ``` _PR developed with Cursor + Auto + Sonnet 5 + Opus 4.8_ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jon Wålstedt <jon.walstedt@elastic.co> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Adds @kbn/evals-suite-threat-intel-enrichment, a direct-route eval suite for the four threat_intel LLM enrichment stages (assess_relevance, classify_severity, enrich_taxonomy, extract_diamond). The suite posts a self-contained golden dataset (a verbatim snapshot of the four BlackHat demo packs, plus authored distractors and a severity ladder) to the internal enrichment routes and scores the structured JSON with CODE evaluators (is_intelligence match, severity exact and within-one-level, category/region recall, Diamond signal count and no-literal-IOC-leak) and an LLM-judge for Diamond vertex separation. A new evals_threat_intel Scout config set enables threatIntelSupplyEnabled and disables searchInferenceEndpoints so resolveScopedModel falls back to genAiSettings:defaultAIConnector, which the suite points at the per-project connector to exercise each model in the matrix. Co-authored-by: Cursor <cursoragent@cursor.com>
enrich_taxonomy: relabel the golden dataset to categories and regions the article prose actually supports, and document why in the snapshot file. The demo fixtures carried labels the text never substantiates, which penalized the model for correctly declining them. RegionRecall goes to 1.00 and the remaining CategoryRecall misses are now real recall signals rather than bad ground truth. extract_diamond: rewrite the two misfiring judge criteria to scope the verdict to the summary text only (the article is expected to contain IOCs and named victims), and wrap the criteria evaluator in majority vote across judge samples to damp per-run flips. criteria mean goes from 0.58 to 0.93. Co-authored-by: Cursor <cursoragent@cursor.com>
@kbn/evals re-exports Scout tags so eval suites do not need a direct @kbn/scout dependency, and every other security eval suite imports them that way. Follow the same convention and drop @kbn/scout from the package references.
…t recall, tests - withMajorityVote now spreads the base evaluator so getModel/getVersion survive; criteria scores were landing with model undefined and the judge unattributable in the scores index. - recall() returns null (surfaced as an N/A result) when there are no labels to recall, instead of 1. extended_stats skips nulls but averages in 1s, so the empty regions: [] packs were inflating RegionRecall to a hard 1.00. - Add jest.config.js and unit tests for withMajorityVote (vote tally, tie to PASS, N/A handling, no-criteria fallback, model/version preservation), the recall N/A behavior, and a signal-count mutation that drives DiamondSignalCount red when a vertex is NONE. - Drop dead regions/mitre fields from packs.ts (categories still feeds classify_severity); remove the unused import/no-nodejs-modules override. - Document the samples = 3 judge-cost trade-off. Co-authored-by: Cursor <cursoragent@cursor.com>
…count floor The three demo packs are dense in all four Diamond vertices, so DiamondSignalCount clears their floors unconditionally and cannot go red on the dataset (the mutation test was the only thing proving it can fail). This adds a technique-only advisory that genuinely supports a single vertex (capability) and nothing else, at min_signal_count: 1, the true anti-collapse floor. It scores 0 only if the model returns an all-NONE Diamond, the exact under-extraction this evaluator guards against, so the check can fail on live data rather than only in the unit test. Co-authored-by: Cursor <cursoragent@cursor.com>
8d826d3 to
2d4d3ce
Compare
Co-authored-by: Cursor <cursoragent@cursor.com>
|
PR run: bk-01a0929f-d13d-429d-841d-65fbc9ba3684::smoke-tests::anthropic-claude-4.5-haiku | Baseline (main): bk-01a086a9-0774-4273-8efa-e2b095b5e434::smoke-tests::anthropic-claude-4.5-haiku Summary View full comparison in UI | Refresh baseline against latest main (click Unblock in the eval build) No significant changes (5 rows)
|
💛 Build succeeded, but was flaky
Failed CI Steps
Metrics [docs]Unknown metric groupswarm start memory
History
|
Summary
Adds the
@kbn/evalssuite for the four LLM-backed threat intel enrichment routes:assess_relevance,classify_severity,enrich_taxonomy, andextract_diamond.Each example posts article text directly to the internal route and scores the structured response, so the suite measures the route contract rather than a chat transcript. It runs as a tracked baseline: scores are recorded and watched over time, with no build-failing thresholds yet.
The suite is split out from #287204 because the two halves are reviewed and gated differently. This is 1,335 lines of datasets, evaluators, and judges, reviewed for whether the scoring is sound, and it runs on the weekly and on-demand eval pipelines against a real connector. #287204 carries the routes themselves, which are gated by unit tests on every commit.
What it covers
assess_relevanceIsIntelligenceMatch,RelevanceShapeValidclassify_severitySeverityExactMatch,SeverityWithinOneLevelenrich_taxonomyCategoryRecall,RegionRecallextract_diamondDiamondNoIocLeak,DiamondSignalCount, criteria (LLM judge, majority vote)DiamondNoIocLeakis the one worth calling out: the Diamond model summarizes an intrusion, and leaking a raw indicator into that prose would put unvetted values in front of an analyst as though they were confirmed. The evaluator fails the example when that happens rather than scoring the summary on readability.The Scout server config disables
searchInferenceEndpointson purpose, soresolveScopedModeltakes thegenAiSettings:defaultAIConnectorpath and the eval exercises the same resolution branch a deployment without a preconfigured endpoint would hit.Baseline scores
From the recorded run on #287204, EIS Claude Sonnet 4.6 as the model under test, 1 repetition, 20 examples. That run predates the judge pin below, so its LLM-judged row was scored by Claude Sonnet 4.6:
SeverityExactMatchat 0.83 withSeverityWithinOneLevelat 1.00 is the expected shape: every miss is off by one level, none inverts high and low.CategoryRecallat 0.80 is the weakest signal and the one to watch as the baseline accumulates runs.Any prompt or calibration change these scores motivate belongs in #287204 with the route it affects, not here.
Judge
The suite runs with
models:judge:eis/google-gemini-3.1-pro, so the LLM-judged criteria onextract_diamondare scored by Gemini 3.1 Pro rather than by whichever model is under test. Pinning one judge keeps that row comparable across the model matrix, since a per-model judge has each column scoring itself. The judge label maps toEVAL_CONNECTOR_IDineval_pipeline.ts, so it applies on both the PR and on-demand runs.The
criteriaevaluator keeps its majority vote over several judge samples. Voting handles per-run verdict flips on the conjunctive criteria, which is a separate problem from which model does the judging. The baseline table above will need a re-record under the new judge.Testing
Verified locally in a bootstrapped worktree on this branch:
node scripts/check.js --scope=branchexits 0 (moon, lint, tsc across 1498 projects, 828 jest tests)The scores above come from the earlier run recorded on #287204, not from a run on this branch.
playwright --listcannot enumerate an eval suite without a configured connector, sincecreatePlaywrightEvalsConfigvalidatesEVAL_CONNECTOR_IDat config load, so a fresh full run needs a provisioned EIS or LiteLLM connector.Where this sits
#287479 came first in the merge train and is already on
main. The numbered series (#287197–#287205) is merged; #287206 and #287207 now sit directly onmain. #287199 (content parsing) was closed; IOC extraction ships its own section-header classifier. Rows 13 and 14 are the test-only PRs and carry no production code.Both test-only PRs cover code that merged in #287204 and now sit directly on
main.