From fe3d455c1f96136e281126a3f83d20f868bf9428 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:00:15 -0400 Subject: [PATCH] [Security Solution][Detection Engine] Fix alert count so max alerts warning shows correctly (#259199) Fixes https://github.com/elastic/kibana/issues/259169 `createEventSignal` has a bug where it returns incorrect summary results for pages of source docs that matched no indicators. The calling code expects `createEventSignal` to return results pertaining only to the current page, but if no indicators are matched or an error is encountered, the function instead returns `currentResults` i.e. the sum of results from all prior pages. The effect is that each time a page matches no indicators the alert count we track in `createThreatSignals` _doubles_ because we add `currentResults` to itself. (cherry picked from commit 22c93a4b288217542614978c098814c6157f3b4d) --- .../threat_mapping/create_event_signal.ts | 9 +- .../threat_mapping/create_threat_signal.ts | 4 +- .../threat_mapping/create_threat_signals.ts | 2 - .../indicator_match/threat_mapping/types.ts | 2 - .../indicator_match.ts | 139 ++++++++++++++++++ 5 files changed, 146 insertions(+), 10 deletions(-) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts index eeb79fdd24ee5..c16d8006dd6bc 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_event_signal.ts @@ -21,10 +21,10 @@ import { searchAfterAndBulkCreateSuppressedAlerts } from '../../utils/search_aft import { threatEnrichmentFactory } from './threat_enrichment_factory'; import { FAILED_CREATE_QUERY_MAX_CLAUSE, MANY_NESTED_CLAUSES_ERR } from './utils'; import { alertSuppressionTypeGuard } from '../../utils/get_is_alert_suppression_active'; +import { createSearchAfterReturnType } from '../../utils/utils'; export const createEventSignal = async ({ sharedParams, - currentResult, currentEventList, eventsTelemetry, filters, @@ -72,8 +72,9 @@ export const createEventSignal = async ({ exc.message.includes(MANY_NESTED_CLAUSES_ERR) || exc.message.includes(FAILED_CREATE_QUERY_MAX_CLAUSE) ) { - currentResult.errors.push(exc.message); - return currentResult; + const result = createSearchAfterReturnType(); + result.errors.push(exc.message); + return result; } else { throw exc; } @@ -81,7 +82,7 @@ export const createEventSignal = async ({ const ids = Array.from(signalIdToMatchedQueriesMap.keys()); if (ids.length === 0) { - return currentResult; + return createSearchAfterReturnType(); } const indexFilter = { query: { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts index e5b20455e488f..1d7ecfd01d72d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signal.ts @@ -19,9 +19,9 @@ import { searchAfterAndBulkCreateSuppressedAlerts } from '../../utils/search_aft import { buildThreatEnrichment } from './build_threat_enrichment'; import { alertSuppressionTypeGuard } from '../../utils/get_is_alert_suppression_active'; +import { createSearchAfterReturnType } from '../../utils/utils'; export const createThreatSignal = async ({ sharedParams, - currentResult, currentThreatList, eventsTelemetry, filters, @@ -59,7 +59,7 @@ export const createThreatSignal = async ({ ruleExecutionLogger.debug( 'Indicator items are empty after filtering for missing data, returning without attempting a match' ); - return currentResult; + return createSearchAfterReturnType(); } else { const esFilter = await getFilter({ type, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts index 4d2d81f9d99ac..0080be93ea9c5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/create_threat_signals.ts @@ -339,7 +339,6 @@ export const createThreatSignals = async ({ createEventSignal({ sharedParams, currentEventList: slicedChunk, - currentResult: results, eventsTelemetry, filters: allEventFilters, reassignThreatPitId, @@ -373,7 +372,6 @@ export const createThreatSignals = async ({ createSignal: (slicedChunk) => createThreatSignal({ sharedParams, - currentResult: results, currentThreatList: slicedChunk, eventsTelemetry, filters: allEventFilters, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/types.ts index b1d7755a4d150..84c1849ac058c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/threat_mapping/types.ts @@ -47,7 +47,6 @@ export interface CreateThreatSignalsOptions { export interface CreateThreatSignalOptions { sharedParams: SecuritySharedParams; - currentResult: SearchAfterAndBulkCreateReturnType; currentThreatList: ThreatListItem[]; eventsTelemetry: ITelemetryEventsSender | undefined; filters: unknown[]; @@ -66,7 +65,6 @@ export interface CreateThreatSignalOptions { export interface CreateEventSignalOptions { sharedParams: SecuritySharedParams; - currentResult: SearchAfterAndBulkCreateReturnType; currentEventList: EventItem[]; eventsTelemetry: ITelemetryEventsSender | undefined; filters: unknown[]; diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/indicator_match/trial_license_complete_tier/indicator_match.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/indicator_match/trial_license_complete_tier/indicator_match.ts index 78158c147c12e..8eb827347631b 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/indicator_match/trial_license_complete_tier/indicator_match.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/indicator_match/trial_license_complete_tier/indicator_match.ts @@ -1090,6 +1090,72 @@ export default ({ getService }: FtrProviderContext) => { const previewAlerts = await getPreviewAlerts({ es, previewId }); expect(previewAlerts).toHaveLength(2); }); + + // Similar to https://github.com/elastic/kibana/issues/259169, but with indicators first + // We seed 2 matching events followed by 10 non-matching events and force 1 event/page. + // The first page of indicators creates 2 alerts; later no-op pages verify that the count of created alerts doesn't + // get inflated and trigger a false max-signals warning despite only 2 created preview alerts. + it('reproduces false max alerts warning when later event pages have no threat matches', async () => { + const id = uuidv4(); + const baseTs = moment(); + + const matchingEvents = [ + { + id, + user: { name: 'matchuser' }, + '@timestamp': baseTs.clone().subtract(1, 's').toISOString(), + 'event.ingested': baseTs.clone().subtract(1, 's').toISOString(), + }, + { + id, + user: { name: 'matchuser' }, + '@timestamp': baseTs.clone().subtract(2, 's').toISOString(), + 'event.ingested': baseTs.clone().subtract(2, 's').toISOString(), + }, + ]; + const nonMatchingEvents = Array.from({ length: 100 }, (_, i) => ({ + id, + user: { name: `eventmiss${i + 1}` }, + '@timestamp': baseTs + .clone() + .subtract(i + 3, 's') + .toISOString(), + 'event.ingested': baseTs + .clone() + .subtract(i + 3, 's') + .toISOString(), + })); + const numThreats = 20; + const threats = [ + { + ...threatDoc(id, baseTs.clone().subtract(numThreats, 'm').toISOString()), + user: { name: 'matchuser' }, + }, + ...Array.from({ length: numThreats - 1 }, (_, i) => ({ + ...threatDoc(id, baseTs.clone().subtract(i, 'm').toISOString()), + })), + ]; + + await indexListOfDocuments([...matchingEvents, ...nonMatchingEvents, ...threats]); + + const rule: ThreatMatchRuleCreateProps = { + ...threatMatchRuleEcsComplaint(id), + threat_mapping: [ + { + entries: [{ field: 'user.name', value: 'user.name', type: 'mapping' }], + }, + ], + items_per_search: 1, + concurrent_searches: 1, + }; + + const { logs, previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId, size: 1000 }); + const allWarnings = logs.flatMap((l) => l.warnings ?? []); + + expect(previewAlerts.length).toEqual(2); + expect(allWarnings).not.toContain(getMaxAlertsWarning()); + }); }); describe('indicator enrichment: event-first search', () => { @@ -1603,6 +1669,79 @@ export default ({ getService }: FtrProviderContext) => { const previewAlerts = await getPreviewAlerts({ es, previewId }); expect(previewAlerts).toHaveLength(2); }); + + // https://github.com/elastic/kibana/issues/259169 + // We seed 2 matching events followed by 10 non-matching events and force 1 event/page. + // The first two pages create 2 alerts; later no-op pages verify that the count of created alerts doesn't + // get inflated and trigger a false max-signals warning despite only 2 created preview alerts. + it('reproduces false max alerts warning when later event pages have no threat matches', async () => { + const id = uuidv4(); + const baseTs = moment(); + const timestamp = baseTs.toISOString(); + + const matchingEvents = [ + { + id, + user: { name: 'matchuser' }, + '@timestamp': baseTs.clone().subtract(1, 's').toISOString(), + 'event.ingested': baseTs.clone().subtract(1, 's').toISOString(), + }, + { + id, + user: { name: 'matchuser' }, + '@timestamp': baseTs.clone().subtract(2, 's').toISOString(), + 'event.ingested': baseTs.clone().subtract(2, 's').toISOString(), + }, + ]; + const nonMatchingEvents = Array.from({ length: 10 }, (_, i) => ({ + id, + user: { name: `eventmiss${i + 1}` }, + '@timestamp': baseTs + .clone() + .subtract(i + 3, 's') + .toISOString(), + 'event.ingested': baseTs + .clone() + .subtract(i + 3, 's') + .toISOString(), + })); + const threats = [ + { + ...threatDoc(id, timestamp), + user: { name: 'matchuser' }, + }, + ...Array.from({ length: 19 }, (_, i) => ({ + ...threatDoc( + id, + baseTs + .clone() + .subtract(i + 1, 'm') + .toISOString() + ), + user: { name: `threatfiller${i + 1}` }, + })), + ]; + + await indexListOfDocuments([...matchingEvents, ...nonMatchingEvents, ...threats]); + + const rule: ThreatMatchRuleCreateProps = { + ...threatMatchRuleEcsComplaint(id), + threat_mapping: [ + { + entries: [{ field: 'user.name', value: 'user.name', type: 'mapping' }], + }, + ], + items_per_search: 1, + concurrent_searches: 1, + }; + + const { logs, previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId, size: 1000 }); + const allWarnings = logs.flatMap((l) => l.warnings ?? []); + + expect(previewAlerts.length).toEqual(2); + expect(allWarnings).not.toContain(getMaxAlertsWarning()); + }); }); // skips serverless MKI due to feature flag