Skip to content

[Threat Intel 11/11] Promote and scrub tasks, and plugin wiring behind the flag - #287207

Merged
jonwalstedt merged 52 commits into
elastic:mainfrom
stephmilovic:threat-intel-9-tasks-and-wiring
Sep 11, 2026
Merged

jonwalstedt merged 52 commits into
elastic:mainfrom
stephmilovic:threat-intel-9-tasks-and-wiring

Conversation

@stephmilovic

@stephmilovic stephmilovic commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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 ✅ merged
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

@stephmilovic stephmilovic added release_note:skip Skip the PR/issue when compiling release notes backport:skip This PR does not require backporting Team:Threat Hunting Security Solution Threat Hunting Team Team: SecuritySolution Security Solutions Team working on SIEM, Endpoint, Timeline, Resolver, etc. v9.6.0 labels Aug 25, 2026
@stephmilovic
stephmilovic force-pushed the threat-intel-9-tasks-and-wiring branch from 6de32b5 to 4df28f1 Compare August 26, 2026 20:46
@jonwalstedt
jonwalstedt force-pushed the threat-intel-9-tasks-and-wiring branch from 7a67591 to d939706 Compare August 28, 2026 20:00
@stephmilovic
stephmilovic force-pushed the threat-intel-9-tasks-and-wiring branch 5 times, most recently from 0bb3dd3 to 3a0600c Compare September 4, 2026 14:47
@stephmilovic
stephmilovic changed the base branch from main to threat-intel-8-adapters-rest September 4, 2026 14:49
@stephmilovic
stephmilovic force-pushed the threat-intel-8-adapters-rest branch from 3c3f8bc to 3ad1b0b Compare September 4, 2026 15:15
@stephmilovic
stephmilovic force-pushed the threat-intel-9-tasks-and-wiring branch from 3a0600c to b074bec Compare September 4, 2026 15:15
@stephmilovic
stephmilovic force-pushed the threat-intel-8-adapters-rest branch from 3ad1b0b to b53abe0 Compare September 4, 2026 15:25
@stephmilovic
stephmilovic force-pushed the threat-intel-9-tasks-and-wiring branch from b074bec to 5dcf41a Compare September 4, 2026 15:25
@stephmilovic
stephmilovic force-pushed the threat-intel-8-adapters-rest branch from b53abe0 to 3dc08d0 Compare September 4, 2026 15:41
@stephmilovic
stephmilovic force-pushed the threat-intel-9-tasks-and-wiring branch from 5dcf41a to c710fc4 Compare September 4, 2026 15:41
@botelastic botelastic Bot added the Team:One Workflow Team label for One Workflow (Workflow automation) label Sep 8, 2026
stephmilovic and others added 4 commits September 8, 2026 08:53
… param, step handler tests

- Guard notes/knownRansomwareCampaignUse against non-strings so a malformed
  KEV row can't throw in buildFingerprint and fail the whole feed
- Carry source._source.name on KEV reports instead of a hardcoded string
- Add tierBasis param to parseIndicatorList (default maltrail_indicator_list)
- Export IocEntry from fetch_source_common instead of deriving it
- Drop the tautological kev enrich isolation test
- Add fetch_source_step handler tests (string source, UnknownAdapterError,
  generic wrap, happy path)
- Drop the silent globe icon fallback on the fetch_source step
…ters-rest

# Conflicts:
#	x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/fingerprint.test.ts
#	x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/rss/rss_adapter.test.ts
#	x-pack/solutions/security/plugins/security_solution/server/threat_intel/adapters/types.ts
stephmilovic added a commit to stephmilovic/kibana that referenced this pull request Sep 8, 2026
Moving to elastic#287207, the tasks-and-wiring PR, since that
one already requests security-threat-hunting review and this bug
sits on the bootstrap path that PR is wiring up. Keeping it here would
add a reviewer this PR doesn't otherwise need.

This reverts commit e884427 and b461149.
Sorting the legacy-source disable scan on _id requires fielddata on
_id, which Elasticsearch disallows by default, so this threw
illegal_argument_exception and failed the whole bootstrap on a stock
cluster. _doc gives a stable total order for an exhaustive scan
without needing fielddata; order only matters here as a pagination
tie-breaker, not for correctness. The ES client types want the string
form (['_doc']), not the object form.

Found while getting elastic#289343's Scout suite green;
folding it in here since this PR already needs security-threat-hunting
review and elastic#289343 doesn't otherwise need it.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the promote/scrub tasks, plugin wiring, adapters, and the seed bootstrap fix. The task and wiring code is well-defensed (idempotent writes, retryable-vs-permanent bulk classification, PIT-based stable scan, fail-closed bootstrap gate). One concrete concern on the legacy-source disable scan's pagination stability — left inline.

Generated by Claude Reviewer for #287207 · claude · opus · 275.8 AIC · ⌖ 22.6 AIC · ⊞ 5.5K

Replaces the paged search/update loop (which sorted on _doc and risked
skipping documents across refreshes/merges) with a single updateByQuery
call using conflicts: 'proceed', matching the existing scrub_report_content
pattern. Removes LEGACY_SOURCE_DISABLE_PAGE_SIZE and the unstable _doc
sort. Rewrites the unit tests to mock updateByQuery instead of search/
update pagination.
@stephmilovic
stephmilovic force-pushed the threat-intel-9-tasks-and-wiring branch from dcc0b24 to 30191e2 Compare September 8, 2026 22:03
@stephmilovic

Copy link
Copy Markdown
Contributor Author

@elasticmachine merge upstream

attribute_alerts_to_reports installs per space (workflowIdSuffix) because it
queries .alerts-security.alerts-* and writes per-space hit totals; a global
install would land every space's totals on one shared document. ingest_threat_feeds
and enrich_threat_report install once globally, since the source catalog and the
LLM enrichment are identical for every space.

Alert analysis and threat intel now install through one
installSecurityManagedWorkflowsAndMarkReady that calls ready() once, so neither
install set is silently dropped. New spaces are covered by a reconcile pass on the
promote_threat_indicators task.

All three workflows ship enabled: false, and enrich routes its HTTP calls through
a fixed space (default) instead of the install-time workflow.spaceId, which is '*'
globally and produced invalid /s/*/... routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
@stephmilovic
stephmilovic force-pushed the threat-intel-9-tasks-and-wiring branch from a8d8670 to eb25c98 Compare September 9, 2026 21:44

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the threat-intel wiring, promote/scrub tasks, and managed-workflow install. The change is well-structured and thoroughly tested; the flag-off/flag-on wiring guarantees and the retryable-vs-permanent bulk classification in the promote task are solid. One concrete finding on per-space enumeration is left inline. The cross-space isolation caveat is already acknowledged in the PR and gated behind the default-off flag.

Generated by Claude Reviewer for #287207 · claude · opus · 183.6 AIC · ⌖ 22.6 AIC · ⊞ 5.5K

stephmilovic and others added 2 commits September 9, 2026 19:22
Shortens several multi-paragraph rationale comments in
promote_threat_indicators.ts (MAX_SOURCE_CITATIONS, PROMOTABLE_TIERS,
canonicalIndicatorValue, indicatorId, isRetryableBulkFailure) to their
load-bearing conclusions, keeping the why but cutting the derivation.

Adds a shared isTransientEsStatus helper (threat_intel/lib/es_retry.ts)
so the promote and scrub tasks classify retryable ES statuses the same
way instead of each inlining its own 503/429 (or 503/429/500/408)
check. This also fixes a minor inconsistency: the promote task's PIT-open
and search-scan error handlers now treat 500/502/504/408 as retryable
too, matching the bulk-item classification, instead of only 503/429.

Also factors the repeated uninstall-and-tolerate-not-found block in
install.ts into a single uninstallTolerant helper.

No behavior change other than the retryable-status broadening above.

Co-authored-by: Cursor <cursoragent@cursor.com>
Promote is a recurring task, and Task Manager deletes a recurring task's
saved object when a run throws an unrecoverable error: rescheduleFailedRun
checks isUnrecoverableError before it looks at the schedule, and
processResultForRecurringTask then calls removeTask(). Nothing re-creates
this task except startThreatIntel, so a single 403 or socket hang up
stopped promotion in every space until Kibana restarted.

The transport case was the likely one. ConnectionError, TimeoutError, and
RequestAbortedError carry no statusCode at all, only ResponseError does,
so the routine 2m task timeout landed in that branch. Replaces the calls
with a throwForNextRun helper that retries sooner on a transient status
and otherwise throws plainly, and the search and bulk paths now check
signal.aborted before classifying, so a timeout is a graceful stop that
holds the cursor.

Five more fixes in the same area:

ioc_tier was frozen at its first citation, because the upsert document is
ignored on an update. A value first cited as uncertain stayed out of the
precision alias no matter how many later reports called it discriminating.
It is now refreshed best-tier-wins in the script, next to the identical
severity logic, and the rank map is derived from the same ordered list the
membership gate uses so the two cannot drift.

Scrub read only update_by_query's updated count. Per-document problems
come back in failures[] with a 200, so a run that failed on every document
returned updated: 0 and looked like a clean no-op while third-party body
text sat past its retention window. Failures are now counted in task state
and logged at error.

Seed reported version_conflicts as failed. A non-zero failed escalates to
a bootstrap rejection, which gates every threat intel route behind a 503
and leaves both tasks unscheduled, so a benign write race could do that.
Only real failures[] entries count now, and those were previously read by
nothing at all.

The workflow install loops were bare awaits. One failing space skipped
every install after it on every pass, and the caller's warn swallowed the
rejection, so a deployment one space deep looked fully covered. Each
install is isolated now, with a summary naming the spaces left without
alert attribution. uninstallTolerant logs at debug instead of swallowing,
so a 403 or 5xx is not as invisible as an expected not-found.

enumerateSpaceIds fetched a single page of 1000 and dropped the tail.

Every fix has a test that fails when the fix is reverted, checked by
mutating each one in place. Also narrows uninstallTolerant's workflowId
off string, which did not type check.

Co-authored-by: Cursor <cursoragent@cursor.com>

@jonwalstedt jonwalstedt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, one finding that needs addressing though

stephmilovic and others added 3 commits September 10, 2026 07:56
load_sources and check_dedup still filtered on workflow.spaceId plus '*',
a leftover from before this workflow moved to a single global install.
Now that workflow.spaceId is pinned to '*' for that install, the filter
only ever matches '*', so a future space-owned custom source would never
be fetched or deduped by this single instance even though the adapters
already stamp reports with the source's own space_id.

Drop the space_id filter from both steps so enabled is the only gate,
the same treatment enrich_threat_report already got. No behavior change
today since every source is global; this just stops the global install
from silently boxing out space-owned sources later.

Co-authored-by: Cursor <cursoragent@cursor.com>
routeSpaceId was already hardcoded to 'default' to avoid an invalid /s/*/...
route once this workflow moved to a single global install, but the comment
only explained the routing mechanics, not the consequence: the enrich
routes resolve their LLM connector through a per-space saved object
(Model Settings, namespaceType: single) and the per-space
genAi:defaultAIConnector setting, so pinning routeSpaceId to 'default'
makes model choice deployment-wide regardless of a report's real space.

Spell that out as an accepted MVP tradeoff (consistent with sources being
global-only for now) and point at the future upgrade path: resolving
routeSpaceId per report inside extract_each_report from that report's own
space_id, once per-space model settings matter. Tracked as kibana-6vs.

No functional change.

Co-authored-by: Cursor <cursoragent@cursor.com>
Skip enumerateSpaceIds and the per-space uninstall loop when neither
global threat intel workflow is persisted, collapsing every flag-off
boot on a deployment that never enabled threat intel supply from
N+3 sequential no-op calls to 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
jonwalstedt and others added 2 commits September 10, 2026 22:30
A flag-off boot that only checked the two global workflows skipped leftover per-space attribute_alerts_to_reports instances.

Co-authored-by: Cursor <cursoragent@cursor.com>
text_indicator_list stamps one extracted_at across chunks, so gt skipped siblings after a completed PIT. uninstallTolerant only runs after the leftover probe, so debug hid real 403/5xx.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kibanamachine

Copy link
Copy Markdown
Contributor

💛 Build succeeded, but was flaky

Failed CI Steps

Metrics [docs]

Async chunks

Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app

id before after diff
securitySolution 1010.2KB 1016.0KB +5.9KB

Page load bundle

Size of the bundles that are downloaded on every page load. Target size is below 100kb

id before after diff
securitySolution 140.5KB 140.6KB +184.0B
Unknown metric groups

async chunk count

id before after diff
securitySolution 106 108 +2

shared chunks total size

id before after diff
all 7.0MB 7.0MB +33.0B

total optimizer output size

id before after diff
all 63.8MB 63.8MB +6.1KB

warm start memory

id before after diff
post forced gc heap baseline - 833387770 +833387770
post forced gc heap delta - -1453068 -1453068
post forced gc heap delta standard deviation - 2605710 +2605710
post forced gc heap target - 831934702 +831934702
tail heap delta - -11895103 -11895103
total +1654580011

Test Failures

  • [job] [logs] FTR Configs #126 / lens app - group 1 lens layers should transition from a multi-layer stacked bar to treemap chart using suggestions
  • [job] [logs] FTR Configs #40 / serverless search UI - search features Console Notebooks has notebooks view available

History

@jonwalstedt
jonwalstedt merged commit 80ec257 into elastic:main Sep 11, 2026
41 checks passed
stephmilovic added a commit to stephmilovic/kibana that referenced this pull request Sep 11, 2026
Two cleanups to workflow yaml that landed in elastic#287207:

- Concurrency keys were -global suffixed (threat-intel-enrich-global,
  threat-intel-ingest-global). If these workflows ever become space
  aware and a real space is literally named "global", a space running
  the old global-only version would collide with that space's key.
  Drop the suffix entirely (threat-intel-enrich, threat-intel-ingest)
  so there is no string that a future space name can collide with.
- enrich_threat_report.yaml routed its kibana.request calls through
  /s/{{ variables.routeSpaceId }}/..., with routeSpaceId hardcoded to
  "default". A request path with no /s/{id}/ prefix already resolves
  to the default space, so this produced identical behavior through an
  unnecessary variable. Drop routeSpaceId and call the routes directly.

Also trimmed the enrich_threat_report.yaml header, which had grown long
explaining routeSpaceId specifically; the MVP model-settings tradeoff it
described still holds and is kept, just without the removed variable.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:skip This PR does not require backporting release_note:skip Skip the PR/issue when compiling release notes Team:One Workflow Team label for One Workflow (Workflow automation) Team: SecuritySolution Security Solutions Team working on SIEM, Endpoint, Timeline, Resolver, etc. Team:Threat Hunting Security Solution Threat Hunting Team v9.6.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants