Skip to content

[Security Solution] Report zero-shard rule searches as actionable warnings instead of "expected to find aggregations" - #290924

Merged
maximpn merged 4 commits into
elastic:mainfrom
maximpn:fix/query-rule-zero-shards-missing-aggregations
Sep 15, 2026
Merged

maximpn merged 4 commits into
elastic:mainfrom
maximpn:fix/query-rule-zero-shards-missing-aggregations

Conversation

@maximpn

@maximpn maximpn commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Closes #290840
Closes #289108

Summary

This PR makes Security Solution detection rules report an actionable warning when the events search resolves to zero shards, instead of failing every run with the generic expected to find aggregations on search result or Aggregations were missing on ... search result error. Zero shards happens when the rule owner can see the source indices but cannot read them, for example a role with view_index_metadata but no read privilege, or a cross-project search space whose linked projects silently exclude indices the owner cannot read. Shard and per-cluster failures that arrive without an aggregations object are now surfaced as the rule error instead of being hidden behind the generic message, and classified as user errors so they no longer count against the framework SLO.

Details

Pre-execution validation matches indices through field capabilities, which only needs view_index_metadata, while the events search needs read. When the owner lacks read, Elasticsearch expands the pattern to nothing and returns _shards.total: 0, no failures, and no aggregations object. Cross-project search behaves the same way by design: permissions are evaluated per project and unauthorized indices are excluded without an error. The custom query with alert suppression, New Terms, and Threshold rule types treated a missing aggregations object as a bug and threw, so the real cause never reached the rule execution log, and the failure was counted as a framework error. The plain custom query path succeeded silently with zero events.

Touched layers, all in the Security Solution detection engine rule types:

  • New utils/no_readable_shards.ts with hasZeroShards, getNoReadableShardsWarning, and reportMissingAggregations. Shard or cluster failures fail the run with userError derived from checkErrorDetails, a zero-shard response adds a warning that names the index patterns and, when CPS is in scope, the linked project aliases together with the fix (grant read in the linked projects or re-save the rule as a user who has it), and a response with searched shards but no aggregations object still throws, since that is a genuine bug.
  • singleSearchAfter now merges _clusters.details[*].failures into searchErrors through the new createErrorsFromClusters helper in utils/utils.ts, deduplicated against _shards.failures, and reports skipped or failed clusters that carry no failures. Previously only the ES|QL path read _clusters.
  • SecuritySharedParams gains cpsData, passed from createSecurityRuleTypeWrapper, so warnings can name the linked projects recorded on the rule run.
  • Call sites: group_and_bulk_create.ts (custom query with alert suppression), create_new_terms_alert_type.ts and multi_terms_composite.ts (New Terms, five throw sites), find_threshold_signals.ts and threshold.ts (Threshold, two throw sites, new cpsLinkedProjects parameter), and search_after_bulk_create_factory.ts (plain custom query, saved query, and suppression fallback paths), which now warns and stops on zero shards instead of succeeding silently.
  • checkErrorDetails classifies security_exception as a user error, which is what a linked project returns when it rejects the rule owner's search.

Unit tests cover the new helper, the cluster failure parsing, the suppression path (zero shards, shard failures, genuine missing aggregations), the plain query path, Threshold, and the security_exception classification. A new Scout API suite under test/scout/detection_engine/api/tests/rule_execution/no_readable_source_indices.spec.ts runs a custom query, custom query with alert suppression, Threshold, and New Terms rule as an owner with view_index_metadata only and asserts a partial failure status carrying the new message. It is tagged for stateful classic and serverless security complete.

How to test

  1. Start Elasticsearch and Kibana, create an index scout-no-read-000001 with a @timestamp date field and a host.name keyword field, and index one document.
  2. Create a role with view_index_metadata only on scout-no-read-*, all on .alerts-security*, .internal.alerts-security*, .siem-signals-*, .lists*, .items*, and full Kibana access. Create a user with that role and log in as that user.
  3. Create and enable a custom query rule with alert suppression grouped by host.name over scout-no-read-*. Without this PR the rule status is Failed with expected to find aggregations on search result. With this PR the status is Warning and the execution log explains that the events search returned no shards for the pattern and that the rule owner lacks the read privilege.
  4. Repeat with a plain custom query rule, a Threshold rule on host.name, and a New Terms rule on host.name. All four end in Warning with the same message.
  5. Grant read on scout-no-read-* to the role and run the rule again. The status turns to Succeeded and alerts are created.
  6. Optional CPS check on a serverless project with linked projects: put the source index only in a linked project where the rule owner has no read, create the rule in the origin project, and confirm the warning names the linked project aliases.

Identify risks

  • Behavior change: a zero-shard events search now ends in partial failure for the plain custom query path, where it used to end in succeeded with zero events. This is intentional, since the rule cannot produce alerts in that state, but rule health dashboards will show these rules as warning instead of green.
  • security_exception is now a user error for all rule types using checkErrorDetails. This also covers an invalidated rule API key, which is still an owner-side condition, but it removes those failures from the framework SLO.
  • The zero-shard heuristic assumes validation matched indices, which holds because execution is skipped when no index matches. Closed indices or expand_wildcards differences could produce the same shape and would now get this warning instead of the generic error, which is still more accurate than before.

🤖 Generated with Claude Code

… instead of "expected to find aggregations"

Query rules with alert suppression, New Terms and Threshold rules threw a generic
"expected to find aggregations on search result" error whenever the events search
came back without an aggregations object. That happens when the search resolves to
zero shards, e.g. the rule owner has view_index_metadata but no read privilege on
the source indices, or a cross-project search linked project silently excludes them.
The generic error hid the real cause and counted as a framework error in SLOs.

- Add no_readable_shards helper: shard or cluster failures fail the run as a user
  error, a zero-shard response becomes a warning naming the index patterns and CPS
  linked projects, and only a searched-but-aggregation-less response still throws.
- Merge _clusters.details failures into singleSearchAfter search errors.
- Pass CPS scope into SecuritySharedParams so warnings can name linked projects.
- Apply the same handling to the plain query path, which used to succeed silently.
- Classify security_exception as a user error.
- Add a Scout API suite that runs each rule type as a view_index_metadata-only owner.

Closes elastic#290840
Closes elastic#289108

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@maximpn maximpn added release_note:fix Team: SecuritySolution Security Solutions Team working on SIEM, Endpoint, Timeline, Resolver, etc. backport:version Backport to applied version labels Feature:Rule Execution Team:Detection Engineering Security Detection Engineering Team v9.6.0 v9.5.5 v9.4.8 v8.19.23 labels Sep 14, 2026
@maximpn maximpn self-assigned this Sep 14, 2026
@maximpn
maximpn requested a review from dplumlee September 14, 2026 16:23
@dplumlee
dplumlee marked this pull request as ready for review September 14, 2026 19:41
@dplumlee
dplumlee requested review from a team as code owners September 14, 2026 19:41
@infra-vault-gh-plugin-prod

Copy link
Copy Markdown

Pinging @elastic/security-solution (Team: SecuritySolution)

@kibanamachine kibanamachine added the reviewer:scout Agentic PR Scout test review label Sep 14, 2026

@dplumlee dplumlee 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.

Pulled down and tested this locally with a user role that didn't have appropriate read privileges (only view_index_metadata)

  • Custom query rule with alert suppression: now seeing partial failure with actionable message naming the index pattern and missing privileges
  • Plain custom query rule: same as above
  • After granting read + re-saving: both rules now succeeded

@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 zero-shard / missing-aggregations handling across the query, threshold, and new terms rule paths. The error-push wiring, reportMissingAggregations priority (errors → zero-shard warning → throw), warning dedup, CPS plumbing, and the security_exception reclassification all look sound and are well covered by tests. One reliability concern about the new _clusters handling flipping otherwise-healthy cross-cluster runs to partial failure — left inline.

Generated by Claude Reviewer for #290924 · claude · opus · 279.5 AIC · ⌖ 49.9 AIC · ⊞ 5.5K

const failures = createErrorsFromShard({ errors: detail.failures ?? [] })
.filter((failure) => !knownShardErrors.has(failure))
.map((failure) => `cluster: "${alias}" ${failure}`);

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.

singleSearchAfter now folds createErrorsFromClusters(...) into searchErrors, and every caller (group_and_bulk_create, new terms, threshold, plain search-after) pushes searchErrors into result.errors on the normal path too — not just when aggregations are missing. A non-empty result.errors flips the run to partial failure.

This fallback branch reports any skipped or failed cluster that carries no per-shard failures. skipped is the status Elasticsearch returns for a skip_unavailable: true remote cluster that is temporarily unavailable — a normal, non-fatal condition, and the whole reason skip_unavailable exists. Before this PR the search-after path never read _clusters, so such a classic CCS rule kept running green on the reachable clusters and creating alerts.

Failure scenario: a query/threshold/new_terms rule targets local-*,remote:logs-* with remote set to skip_unavailable: true. remote goes down. ES returns _clusters.details.remote.status = "skipped" with the readable local shards succeeding and aggregations present. The run now surfaces cluster: "remote" status: "skipped" indices: "..." as an error and reports partial failure on every execution while remote is down, even though the rule is working as designed. This is a behavior change for CCS users beyond the CPS no-read case the PR targets.

Consider distinguishing intentionally-skipped (skip_unavailable) clusters from ones that should fail the run — e.g. only surface failed here, or gate the skipped case on the presence of an actual failure — so a healthy CCS run with an intentionally skipped remote does not regress to partial failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 0599ae8.

maximpn and others added 2 commits September 15, 2026 11:38
…rors

Folding _clusters.details entries into searchErrors turned a skipped remote
cluster into a failed rule run. A skip_unavailable remote that is down returns
status "skipped" with a shard: -1 failure and no _shards.failures, so the
search-after rule types used to run green on the reachable clusters and now
failed every execution as a framework error.

- singleSearchAfter returns cluster-level entries as searchWarnings and keeps
  searchErrors limited to _shards.failures, as before.
- Rename createErrorsFromClusters to createWarningsFromClusters and word the
  messages as warnings naming the cluster and its status.
- Callers push searchWarnings into warningMessages, which yields a partial
  failure status while alerts keep being created from the reachable clusters.
- reportMissingAggregations and the threshold single-bucket branch no longer
  throw when shard errors or cluster warnings explain the missing aggregations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment on lines +140 to +141
apiTest.afterAll(async ({ esClient, kbnClient }) => {
for (const id of createdRuleIds) {

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.

Contribute to Scout when possible

Cleanup rolls its own per-id delete loop when apiServices.detectionRule.deleteAll() already ships this in @kbn/scout-security. Reuse that helper so the suite gets the standard bulk-delete path and future refactors flow through one place.

See details

getDetectionRuleApiService in x-pack/solutions/security/packages/kbn-scout-security/src/playwright/fixtures/worker/apis/detection_rule.ts exposes a deleteAll() that posts to /api/detection_engine/rules/_bulk_action (space-aware, via scoutSpace) with { query: '', action: 'delete' }. It's the pattern used by the existing Security Scout suites, and the security-scout skill lists it under "Data cleanup — Security Solution resources" as the standard cleanup helper for detection rules.

Suggested change:

apiTest.afterAll(async ({ esClient, apiServices }) => {
  await apiServices.detectionRule.deleteAll();
  await esClient.indices.delete({ index: sourceIndex }, { ignore: [404] });
});

That also drops the need for createdRuleIds, the manual PUBLIC_HEADERS on the DELETE call, and the per-id ignoreErrors: [404] — the helper handles all of that once at the framework level.

Share feedback in the #kibana-qa Slack channel.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not applicable here, for two reasons.

  1. apiTest exported from @kbn/scout-security is the base @kbn/scout apiTest, whose apiServices fixture has no detectionRule service. That service is attached only in the Security test / spaceTest fixtures (single_thread_fixtures.ts / parallel_run_fixtures.ts), so apiServices.detectionRule.deleteAll() does not type check in this suite.

  2. deleteAll() posts _bulk_action with action: 'delete' and an empty query, i.e. it deletes every rule in the space. This suite runs under apiTest without scoutSpace, so it executes in the default space shared with the other API suites in the same run. Every existing deleteAll() caller is a spaceTest suite with a per-worker space, where the wipe is safe. Here it would remove rules created by sibling suites.

Keeping cleanup scoped to the rules this suite created.

@dplumlee dplumlee 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.

New changes lgtm @maximpn

@vgomez-el vgomez-el 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.

LGTM from the test-quality / Scout side. The suite sits at the right level of the pyramid: the logic is well covered by unit tests, and this API suite validates the real ES permission behaviour (zero shards when the owner lacks read) that units can only mock, so it earns its keep. Auth, isolation, tags and placement all look good, and it will be picked up in CI. Approving. I left two small non-blocking nits inline — feel free to take them or leave them, they don't block the approval.

});

const createRule = async (
apiClient: { post: Function },

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.

nit (non-blocking): these helpers type apiClient as { post: Function } here and { get: Function } on line 83. Function drops type-safety and goes against the repo guidance of avoiding untyped params. You could use the real apiClient fixture type from @kbn/scout-security instead so the calls stay typed. Not blocking.

import { ELASTIC_INTERNAL_ORIGIN_HEADER, PUBLIC_API_HEADERS } from '@kbn/scout-security';
import type { KibanaRole } from '@kbn/scout-security';

export const DETECTION_ENGINE_RULES_URL = '/api/detection_engine/rules';

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.

nit (non-blocking): DETECTION_ENGINE_RULES_URL is already exported from security_solution/common/constants.ts, so you could reuse it here to avoid the two drifting apart. That said, hardcoding the URL in a test as a contract check is also a fair choice, so up to you. Not blocking.

@maximpn
maximpn requested a review from a team as a code owner September 15, 2026 15:39
@maximpn
maximpn enabled auto-merge (squash) September 15, 2026 15:39
},
{ timeout: 120_000, intervals: [2_000] }
)
.toBeDefined();

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.

waitForFirstExecution resolves as soon as execution_summary.last_execution.status is defined, but that status can be a non-terminal going to run / running while the rule is mid-execution — so the later toBe('partial failure') assertion can fail intermittently in CI. Poll until the status reaches a terminal value instead.

See details

RuleExecutionStatus is z.enum(['going to run', 'running', 'partial failure', 'failed', 'succeeded']) (common/api/detection_engine/rule_monitoring/model/execution_status.gen.ts). On a freshly enabled rule the execution logger records going to run / running before the terminal status, and both are surfaced in execution_summary.last_execution.status.

The poll returns on the first defined status:

return lastExecution?.last_execution.status;   // resolves on 'running' too
...
.toBeDefined();

The rule's first run starts within a few seconds of creation and, for the zero-shard path, finishes almost immediately, so the 2s poll can land on running / going to run. expectNoReadableShardsPartialFailure then asserts toBe('partial failure') and fails — a classic passes-locally, fails-rarely-in-CI flake.

The established detections helper waitForRuleStatus(expectedStatus, ...) in @kbn/detections-response-ftr-services (e.g. waitForRulePartialFailure) polls until the status equals the specific expected value for exactly this reason. Mirror that here — wait until a terminal status before returning:

await expect
  .poll(
    async () => {
      const response = await apiClient.get(`${DETECTION_ENGINE_RULES_URL}?id=${ruleId}`, {
        headers: ruleOwnerHeaders,
        responseType: 'json',
      });
      lastExecution = (response.body as RuleResponse).execution_summary;

      const status = lastExecution?.last_execution.status;
      return status !== undefined && status !== 'going to run' && status !== 'running';
    },
    { timeout: 120_000, intervals: [2_000] }
  )
  .toBe(true);

expectNoReadableShardsPartialFailure then still asserts the exact partial failure status and message. (Polling directly on .toBe('partial failure') works too, but the terminal-status form keeps the helper reusable and surfaces an unexpected succeeded / failed faster.)

Share feedback in the #kibana-qa Slack channel.

@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 zero-shard / no-readable-shards handling across the custom query, suppression, Threshold, and New Terms paths. The design is sound: security_exception classification, the shard-failure vs zero-shard vs genuine-bug branching in reportMissingAggregations, and the switch to reporting per-cluster conditions as warnings (addressing the earlier CCS skip_unavailable regression) all look correct, with solid unit + Scout coverage. One non-blocking finding about duplicated per-page cluster warnings is left inline.

Generated by Claude Reviewer for #290924 · claude · opus · 265.3 AIC · ⌖ 26.4 AIC · ⊞ 5.5K

});

searchAfterResults.searchDurations.push(searchDuration);
warnings.push(...searchWarnings);

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.

Inside this paging do...while loop, searchWarnings is re-pushed on every iteration. For a CCS/CPS search where a linked/remote cluster is skipped (or failed) but the readable clusters still return aggregations, the loop keeps paging and appends the identical per-cluster warning once per page, so the rule's execution message repeats the same warning N times (N = number of composite pages).

reportMissingAggregations deliberately dedupes its no-readable-shards warning (if (!result.warningMessages.includes(warning))), but these per-page cluster warnings coming from createWarningsFromClusters are not deduped anywhere. The New Terms paging loops (create_new_terms_alert_type.ts and multi_terms_composite.ts) push searchWarnings inside their while loops the same way and have the same duplication.

Failure scenario: a threshold rule with fields over local-*,remote:logs-* where remote is skip_unavailable: true and temporarily down, and the local data produces several composite pages — the "Cluster "remote" ... data is missing" warning is emitted once per page. Consider collecting per-cluster warnings into a Set / deduping before pushing so the message stays clean.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch on the raw duplication, but the user-visible part is already covered. The rule execution status message is built via truncateList(warnings) in the execution log client, which runs uniq() before take(20), so repeated cluster warnings collapse into one and can't crowd out other warnings like the max signals one.

What remains is the per-message ruleExecutionLogger.warn call in the rule type wrapper producing one Kibana log line and one event-log entry per repeat. That's not specific to threshold rules: the query rule search_after loop and both New Terms loops push searchWarnings per page the same way, and errors have the same behavior. The wrapper already has a note about it and it's tracked in #261333, where the fix belongs at the logger level rather than in each rule type. Leaving this as is here.

@maximpn
maximpn disabled auto-merge September 15, 2026 16:07
@maximpn
maximpn enabled auto-merge (squash) September 15, 2026 16:17
@kibanamachine

Copy link
Copy Markdown
Contributor

💛 Build succeeded, but was flaky

Failed CI Steps

Metrics [docs]

✅ unchanged

Test Failures

  • [job] [logs] Serverless Rule Management - Prebuilt Rules Upgrade - Security Solution Cypress Tests #1 / Detection rules, Prebuilt Rules Upgrade With Preview preview prebuilt rule upgrade "before each" hook for "shows custom query prebuilt rule properties" "before each" hook for "shows custom query prebuilt rule properties"

History

cc @maximpn

@maximpn
maximpn merged commit 9576e6f into elastic:main Sep 15, 2026
42 checks passed
@kibanamachine

Copy link
Copy Markdown
Contributor

Starting backport for target branches: 8.19, 9.4, 9.5

https://github.com/elastic/kibana/actions/runs/34996781726

@maximpn
maximpn deleted the fix/query-rule-zero-shards-missing-aggregations branch September 15, 2026 16:59
@kibanamachine

Copy link
Copy Markdown
Contributor

💔 All backports failed

Status Branch Result
8.19 Backport failed because of merge conflicts
9.4 Backport failed because of merge conflicts

You might need to backport the following PRs to 9.4:
- [Security Solution] Enforce exception list and sub-feature privileges on rule params written via the generic Alerting APIs (#281745)
- [Detection Engine] Reclassify some illegal_argument_exceptions as user errors (#272916)
9.5 Backport failed because of merge conflicts

You might need to backport the following PRs to 9.5:
- [Security Solution] Enforce exception list and sub-feature privileges on rule params written via the generic Alerting APIs (#281745)

Manual backport

To create the backport manually run:

node scripts/backport --pr 290924

Questions ?

Please refer to the Backport tool documentation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:version Backport to applied version labels Feature:Rule Execution release_note:fix reviewer:scout Agentic PR Scout test review Team:Detection Engineering Security Detection Engineering Team Team: SecuritySolution Security Solutions Team working on SIEM, Endpoint, Timeline, Resolver, etc. v8.19.23 v9.4.8 v9.5.5 v9.6.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants