From a92a3d1af7ad5ce547e76b9c813b1b6dd3e65920 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Tue, 20 Oct 2020 13:56:07 -0400 Subject: [PATCH] [Security Solution][Detections] - rule query preview bug fix (#80750) (#81196) ### Summary This PR addresses the remaining query preview bugs. - it adds index, and request information to eql inspect - it seems that for some reason the eql search strategy response returns `null` for the `params.body` in complete responses, but not in partial responses and does not include index info. As a workaround, I set the inspect info on partial responses and manually add index info - added to-dos pointing this out in the code - updated eql sequence queries preview to use the last event timestamp of a sequence to display the hits within a histogram - it checks buckets length to determine noise warning for threshold rules, as opposed to total hit count - remove unused i18n text - fixes bug where threshold is being passed in for all rule types as it's always defined in the creation step, added a check to only pass through to `useMatrixHistogram` hook when rule type is threshold --- .../hooks/eql/eql_search_response.mock.ts | 178 ++++ .../public/common/hooks/eql/helpers.test.ts | 976 ++++++++++-------- .../public/common/hooks/eql/helpers.ts | 135 ++- .../common/hooks/eql/use_eql_preview.test.ts | 40 +- .../common/hooks/eql/use_eql_preview.ts | 37 +- .../query_preview/custom_histogram.test.tsx | 2 +- .../rules/query_preview/custom_histogram.tsx | 4 +- .../query_preview/eql_histogram.test.tsx | 33 +- .../rules/query_preview/eql_histogram.tsx | 12 +- .../components/rules/query_preview/helpers.ts | 42 +- .../rules/query_preview/index.test.tsx | 189 +++- .../components/rules/query_preview/index.tsx | 60 +- .../rules/query_preview/reducer.test.ts | 8 +- .../components/rules/query_preview/reducer.ts | 2 - .../query_preview/threshold_histogram.tsx | 6 +- .../rules/query_preview/translations.ts | 40 +- .../rules/step_define_rule/index.tsx | 16 +- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 19 files changed, 1191 insertions(+), 595 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/hooks/eql/eql_search_response.mock.ts diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/eql_search_response.mock.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/eql_search_response.mock.ts new file mode 100644 index 0000000000000..b3d3d39088d72 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/eql_search_response.mock.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EqlSearchStrategyResponse } from '../../../../../data_enhanced/common'; +import { Source } from './types'; +import { EqlSearchResponse } from '../../../../common/detection_engine/types'; +import { Connection } from '@elastic/elasticsearch'; + +export const getMockEqlResponse = (): EqlSearchStrategyResponse> => ({ + id: 'some-id', + rawResponse: { + body: { + hits: { + events: [ + { + _index: 'index', + _id: '1', + _source: { + '@timestamp': '2020-10-04T15:16:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '2', + _source: { + '@timestamp': '2020-10-04T15:50:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '3', + _source: { + '@timestamp': '2020-10-04T15:06:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '4', + _source: { + '@timestamp': '2020-10-04T15:15:54.368707900Z', + }, + }, + ], + total: { + value: 4, + relation: '', + }, + }, + is_partial: false, + is_running: false, + took: 300, + timed_out: false, + }, + headers: {}, + warnings: [], + meta: { + aborted: false, + attempts: 0, + context: null, + name: 'elasticsearch-js', + connection: {} as Connection, + request: { + params: { + body: JSON.stringify({ + filter: { + range: { + '@timestamp': { + gte: '2020-10-07T00:46:12.414Z', + lte: '2020-10-07T01:46:12.414Z', + format: 'strict_date_optional_time', + }, + }, + }, + }), + method: 'GET', + path: '/_eql/search/', + querystring: 'some query string', + }, + options: {}, + id: '', + }, + }, + statusCode: 200, + }, +}); + +export const getMockEqlSequenceResponse = (): EqlSearchStrategyResponse< + EqlSearchResponse +> => ({ + id: 'some-id', + rawResponse: { + body: { + hits: { + sequences: [ + { + join_keys: [], + events: [ + { + _index: 'index', + _id: '1', + _source: { + '@timestamp': '2020-10-04T15:16:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '2', + _source: { + '@timestamp': '2020-10-04T15:50:54.368707900Z', + }, + }, + ], + }, + { + join_keys: [], + events: [ + { + _index: 'index', + _id: '3', + _source: { + '@timestamp': '2020-10-04T15:06:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '4', + _source: { + '@timestamp': '2020-10-04T15:15:54.368707900Z', + }, + }, + ], + }, + ], + total: { + value: 4, + relation: '', + }, + }, + is_partial: false, + is_running: false, + took: 300, + timed_out: false, + }, + headers: {}, + warnings: [], + meta: { + aborted: false, + attempts: 0, + context: null, + name: 'elasticsearch-js', + connection: {} as Connection, + request: { + params: { + body: JSON.stringify({ + filter: { + range: { + '@timestamp': { + gte: '2020-10-07T00:46:12.414Z', + lte: '2020-10-07T01:46:12.414Z', + format: 'strict_date_optional_time', + }, + }, + }, + }), + method: 'GET', + path: '/_eql/search/', + querystring: 'some query string', + }, + options: {}, + id: '', + }, + }, + statusCode: 200, + }, +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts index 07e8caa0bf0b9..52804c0b17e75 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts @@ -17,141 +17,10 @@ import { getEqlAggsData, createIntervalArray, getInterval, - getSequenceAggs, + formatInspect, + getEventsToBucket, } from './helpers'; - -export const getMockResponse = (): EqlSearchStrategyResponse> => - ({ - id: 'some-id', - rawResponse: { - body: { - hits: { - events: [ - { - _index: 'index', - _id: '1', - _source: { - '@timestamp': '2020-10-04T15:16:54.368707900Z', - }, - }, - { - _index: 'index', - _id: '2', - _source: { - '@timestamp': '2020-10-04T15:50:54.368707900Z', - }, - }, - { - _index: 'index', - _id: '3', - _source: { - '@timestamp': '2020-10-04T15:06:54.368707900Z', - }, - }, - { - _index: 'index', - _id: '4', - _source: { - '@timestamp': '2020-10-04T15:15:54.368707900Z', - }, - }, - ], - total: { - value: 4, - relation: '', - }, - }, - }, - meta: { - request: { - params: { - method: 'GET', - path: '/_eql/search/', - }, - options: {}, - id: '', - }, - }, - statusCode: 200, - }, - } as EqlSearchStrategyResponse>); - -const getMockSequenceResponse = (): EqlSearchStrategyResponse> => - (({ - id: 'some-id', - rawResponse: { - body: { - hits: { - sequences: [ - { - join_keys: [], - events: [ - { - _index: 'index', - _id: '1', - _source: { - '@timestamp': '2020-10-04T15:16:54.368707900Z', - }, - }, - { - _index: 'index', - _id: '2', - _source: { - '@timestamp': '2020-10-04T15:50:54.368707900Z', - }, - }, - ], - }, - { - join_keys: [], - events: [ - { - _index: 'index', - _id: '3', - _source: { - '@timestamp': '2020-10-04T15:06:54.368707900Z', - }, - }, - { - _index: 'index', - _id: '4', - _source: { - '@timestamp': '2020-10-04T15:15:54.368707900Z', - }, - }, - ], - }, - ], - total: { - value: 4, - relation: '', - }, - }, - }, - meta: { - request: { - params: { - body: JSON.stringify({ - filter: { - range: { - '@timestamp': { - gte: '2020-10-07T00:46:12.414Z', - lte: '2020-10-07T01:46:12.414Z', - format: 'strict_date_optional_time', - }, - }, - }, - }), - method: 'GET', - path: '/_eql/search/', - }, - options: {}, - id: '', - }, - }, - statusCode: 200, - }, - } as unknown) as EqlSearchStrategyResponse>); +import { getMockEqlResponse, getMockEqlSequenceResponse } from './eql_search_response.mock'; describe('eql/helpers', () => { describe('calculateBucketForHour', () => { @@ -208,6 +77,15 @@ describe('eql/helpers', () => { expect(diff).toEqual(0); }); + + test('returns 2 if event occurred within 2 minutes of "now" but arguments are flipped', () => { + const diff = calculateBucketForHour( + Number(dateMath.parse('now')?.format('x')), + Number(dateMath.parse('now-1m')?.format('x')) + ); + + expect(diff).toEqual(2); + }); }); describe('calculateBucketForDay', () => { @@ -255,198 +133,426 @@ describe('eql/helpers', () => { expect(diff).toEqual(4); }); - }); - describe('getEqlAggsData', () => { - test('it returns results bucketed into 2 min intervals when range is "h"', () => { - const mockResponse = getMockResponse(); - - const aggs = getEqlAggsData( - mockResponse, - 'h', - '2020-10-04T16:00:00.368707900Z', - jest.fn() as inputsModel.Refetch + test('returns 2 if event occurred 60-120 minutes of "now" but arguments are flipped', () => { + const diff = calculateBucketForDay( + Number(dateMath.parse('now')?.format('x')), + Number(dateMath.parse('now-118m')?.format('x')) ); - const date1 = moment(aggs.data[0].x); - const date2 = moment(aggs.data[1].x); - // This'll be in ms - const diff = date1.diff(date2); - - expect(diff).toEqual(120000); - expect(aggs.data).toHaveLength(31); - expect(aggs.data).toEqual([ - { g: 'hits', x: 1601827200368, y: 0 }, - { g: 'hits', x: 1601827080368, y: 0 }, - { g: 'hits', x: 1601826960368, y: 0 }, - { g: 'hits', x: 1601826840368, y: 0 }, - { g: 'hits', x: 1601826720368, y: 0 }, - { g: 'hits', x: 1601826600368, y: 1 }, - { g: 'hits', x: 1601826480368, y: 0 }, - { g: 'hits', x: 1601826360368, y: 0 }, - { g: 'hits', x: 1601826240368, y: 0 }, - { g: 'hits', x: 1601826120368, y: 0 }, - { g: 'hits', x: 1601826000368, y: 0 }, - { g: 'hits', x: 1601825880368, y: 0 }, - { g: 'hits', x: 1601825760368, y: 0 }, - { g: 'hits', x: 1601825640368, y: 0 }, - { g: 'hits', x: 1601825520368, y: 0 }, - { g: 'hits', x: 1601825400368, y: 0 }, - { g: 'hits', x: 1601825280368, y: 0 }, - { g: 'hits', x: 1601825160368, y: 0 }, - { g: 'hits', x: 1601825040368, y: 0 }, - { g: 'hits', x: 1601824920368, y: 0 }, - { g: 'hits', x: 1601824800368, y: 0 }, - { g: 'hits', x: 1601824680368, y: 0 }, - { g: 'hits', x: 1601824560368, y: 2 }, - { g: 'hits', x: 1601824440368, y: 0 }, - { g: 'hits', x: 1601824320368, y: 0 }, - { g: 'hits', x: 1601824200368, y: 0 }, - { g: 'hits', x: 1601824080368, y: 0 }, - { g: 'hits', x: 1601823960368, y: 1 }, - { g: 'hits', x: 1601823840368, y: 0 }, - { g: 'hits', x: 1601823720368, y: 0 }, - { g: 'hits', x: 1601823600368, y: 0 }, - ]); + expect(diff).toEqual(2); }); + }); - test('it returns results bucketed into 1 hour intervals when range is "d"', () => { - const mockResponse = getMockResponse(); - const response = { - ...mockResponse, - rawResponse: { - ...mockResponse.rawResponse, - body: { - is_partial: false, - is_running: false, - timed_out: false, - took: 15, - hits: { - events: [ - { - _index: 'index', - _id: '1', - _source: { - '@timestamp': '2020-10-04T15:16:54.368707900Z', + describe('getEqlAggsData', () => { + describe('non-sequence', () => { + test('it returns results bucketed into 2 min intervals when range is "h"', () => { + const mockResponse = getMockEqlResponse(); + + const aggs = getEqlAggsData( + mockResponse, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + false + ); + + const date1 = moment(aggs.data[0].x); + const date2 = moment(aggs.data[1].x); + // This will be in ms + const diff = date1.diff(date2); + + expect(diff).toEqual(120000); + expect(aggs.data).toHaveLength(31); + expect(aggs.data).toEqual([ + { g: 'hits', x: 1601827200368, y: 0 }, + { g: 'hits', x: 1601827080368, y: 0 }, + { g: 'hits', x: 1601826960368, y: 0 }, + { g: 'hits', x: 1601826840368, y: 0 }, + { g: 'hits', x: 1601826720368, y: 0 }, + { g: 'hits', x: 1601826600368, y: 1 }, + { g: 'hits', x: 1601826480368, y: 0 }, + { g: 'hits', x: 1601826360368, y: 0 }, + { g: 'hits', x: 1601826240368, y: 0 }, + { g: 'hits', x: 1601826120368, y: 0 }, + { g: 'hits', x: 1601826000368, y: 0 }, + { g: 'hits', x: 1601825880368, y: 0 }, + { g: 'hits', x: 1601825760368, y: 0 }, + { g: 'hits', x: 1601825640368, y: 0 }, + { g: 'hits', x: 1601825520368, y: 0 }, + { g: 'hits', x: 1601825400368, y: 0 }, + { g: 'hits', x: 1601825280368, y: 0 }, + { g: 'hits', x: 1601825160368, y: 0 }, + { g: 'hits', x: 1601825040368, y: 0 }, + { g: 'hits', x: 1601824920368, y: 0 }, + { g: 'hits', x: 1601824800368, y: 0 }, + { g: 'hits', x: 1601824680368, y: 0 }, + { g: 'hits', x: 1601824560368, y: 2 }, + { g: 'hits', x: 1601824440368, y: 0 }, + { g: 'hits', x: 1601824320368, y: 0 }, + { g: 'hits', x: 1601824200368, y: 0 }, + { g: 'hits', x: 1601824080368, y: 0 }, + { g: 'hits', x: 1601823960368, y: 1 }, + { g: 'hits', x: 1601823840368, y: 0 }, + { g: 'hits', x: 1601823720368, y: 0 }, + { g: 'hits', x: 1601823600368, y: 0 }, + ]); + }); + + test('it returns results bucketed into 1 hour intervals when range is "d"', () => { + const mockResponse = getMockEqlResponse(); + const response: EqlSearchStrategyResponse> = { + ...mockResponse, + rawResponse: { + ...mockResponse.rawResponse, + body: { + is_partial: false, + is_running: false, + timed_out: false, + took: 15, + hits: { + events: [ + { + _index: 'index', + _id: '1', + _source: { + '@timestamp': '2020-10-04T15:16:54.368707900Z', + }, }, - }, - { - _index: 'index', - _id: '2', - _source: { - '@timestamp': '2020-10-04T05:50:54.368707900Z', + { + _index: 'index', + _id: '2', + _source: { + '@timestamp': '2020-10-04T05:50:54.368707900Z', + }, }, - }, - { - _index: 'index', - _id: '3', - _source: { - '@timestamp': '2020-10-04T18:06:54.368707900Z', + { + _index: 'index', + _id: '3', + _source: { + '@timestamp': '2020-10-04T18:06:54.368707900Z', + }, }, - }, - { - _index: 'index', - _id: '4', - _source: { - '@timestamp': '2020-10-04T23:15:54.368707900Z', + { + _index: 'index', + _id: '4', + _source: { + '@timestamp': '2020-10-04T23:15:54.368707900Z', + }, }, + ], + total: { + value: 4, + relation: '', }, - ], - total: { - value: 4, - relation: '', }, }, }, - }, - }; - - const aggs = getEqlAggsData( - response, - 'd', - '2020-10-04T23:50:00.368707900Z', - jest.fn() as inputsModel.Refetch - ); - const date1 = moment(aggs.data[0].x); - const date2 = moment(aggs.data[1].x); - // This'll be in ms - const diff = date1.diff(date2); - - expect(diff).toEqual(3600000); - expect(aggs.data).toHaveLength(25); - expect(aggs.data).toEqual([ - { g: 'hits', x: 1601855400368, y: 0 }, - { g: 'hits', x: 1601851800368, y: 1 }, - { g: 'hits', x: 1601848200368, y: 0 }, - { g: 'hits', x: 1601844600368, y: 0 }, - { g: 'hits', x: 1601841000368, y: 0 }, - { g: 'hits', x: 1601837400368, y: 0 }, - { g: 'hits', x: 1601833800368, y: 1 }, - { g: 'hits', x: 1601830200368, y: 0 }, - { g: 'hits', x: 1601826600368, y: 0 }, - { g: 'hits', x: 1601823000368, y: 1 }, - { g: 'hits', x: 1601819400368, y: 0 }, - { g: 'hits', x: 1601815800368, y: 0 }, - { g: 'hits', x: 1601812200368, y: 0 }, - { g: 'hits', x: 1601808600368, y: 0 }, - { g: 'hits', x: 1601805000368, y: 0 }, - { g: 'hits', x: 1601801400368, y: 0 }, - { g: 'hits', x: 1601797800368, y: 0 }, - { g: 'hits', x: 1601794200368, y: 0 }, - { g: 'hits', x: 1601790600368, y: 1 }, - { g: 'hits', x: 1601787000368, y: 0 }, - { g: 'hits', x: 1601783400368, y: 0 }, - { g: 'hits', x: 1601779800368, y: 0 }, - { g: 'hits', x: 1601776200368, y: 0 }, - { g: 'hits', x: 1601772600368, y: 0 }, - { g: 'hits', x: 1601769000368, y: 0 }, - ]); - }); - - test('it correctly returns total hits', () => { - const mockResponse = getMockResponse(); - - const aggs = getEqlAggsData( - mockResponse, - 'h', - '2020-10-04T16:00:00.368707900Z', - jest.fn() as inputsModel.Refetch - ); - - expect(aggs.totalCount).toEqual(4); + }; + + const aggs = getEqlAggsData( + response, + 'd', + '2020-10-04T23:50:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + false + ); + const date1 = moment(aggs.data[0].x); + const date2 = moment(aggs.data[1].x); + // This'll be in ms + const diff = date1.diff(date2); + + expect(diff).toEqual(3600000); + expect(aggs.data).toHaveLength(25); + expect(aggs.data).toEqual([ + { g: 'hits', x: 1601855400368, y: 0 }, + { g: 'hits', x: 1601851800368, y: 1 }, + { g: 'hits', x: 1601848200368, y: 0 }, + { g: 'hits', x: 1601844600368, y: 0 }, + { g: 'hits', x: 1601841000368, y: 0 }, + { g: 'hits', x: 1601837400368, y: 0 }, + { g: 'hits', x: 1601833800368, y: 1 }, + { g: 'hits', x: 1601830200368, y: 0 }, + { g: 'hits', x: 1601826600368, y: 0 }, + { g: 'hits', x: 1601823000368, y: 1 }, + { g: 'hits', x: 1601819400368, y: 0 }, + { g: 'hits', x: 1601815800368, y: 0 }, + { g: 'hits', x: 1601812200368, y: 0 }, + { g: 'hits', x: 1601808600368, y: 0 }, + { g: 'hits', x: 1601805000368, y: 0 }, + { g: 'hits', x: 1601801400368, y: 0 }, + { g: 'hits', x: 1601797800368, y: 0 }, + { g: 'hits', x: 1601794200368, y: 0 }, + { g: 'hits', x: 1601790600368, y: 1 }, + { g: 'hits', x: 1601787000368, y: 0 }, + { g: 'hits', x: 1601783400368, y: 0 }, + { g: 'hits', x: 1601779800368, y: 0 }, + { g: 'hits', x: 1601776200368, y: 0 }, + { g: 'hits', x: 1601772600368, y: 0 }, + { g: 'hits', x: 1601769000368, y: 0 }, + ]); + }); + + test('it correctly returns total hits', () => { + const mockResponse = getMockEqlResponse(); + + const aggs = getEqlAggsData( + mockResponse, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + false + ); + + expect(aggs.totalCount).toEqual(4); + }); + + test('it returns array with each item having a "total" of 0 if response returns no hits', () => { + const mockResponse = getMockEqlResponse(); + const response: EqlSearchStrategyResponse> = { + ...mockResponse, + rawResponse: { + ...mockResponse.rawResponse, + body: { + is_partial: false, + is_running: false, + timed_out: false, + took: 15, + hits: { + total: { + value: 0, + relation: '', + }, + }, + }, + }, + }; + + const aggs = getEqlAggsData( + response, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + false + ); + + expect(aggs.data.every(({ y }) => y === 0)).toBeTruthy(); + expect(aggs.totalCount).toEqual(0); + }); }); - test('it returns array with each item having a "total" of 0 if response returns no hits', () => { - const mockResponse = getMockResponse(); - const response = { - ...mockResponse, - rawResponse: { - ...mockResponse.rawResponse, - body: { - id: 'some-id', - is_partial: false, - is_running: false, - timed_out: false, - took: 15, - hits: { - total: { - value: 0, - relation: '', + describe('sequence', () => { + test('it returns results bucketed into 2 min intervals when range is "h"', () => { + const mockResponse = getMockEqlSequenceResponse(); + + const aggs = getEqlAggsData( + mockResponse, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + true + ); + + const date1 = moment(aggs.data[0].x); + const date2 = moment(aggs.data[1].x); + // This will be in ms + const diff = date1.diff(date2); + + expect(diff).toEqual(120000); + expect(aggs.data).toHaveLength(31); + expect(aggs.data).toEqual([ + { g: 'hits', x: 1601827200368, y: 0 }, + { g: 'hits', x: 1601827080368, y: 0 }, + { g: 'hits', x: 1601826960368, y: 0 }, + { g: 'hits', x: 1601826840368, y: 0 }, + { g: 'hits', x: 1601826720368, y: 0 }, + { g: 'hits', x: 1601826600368, y: 1 }, + { g: 'hits', x: 1601826480368, y: 0 }, + { g: 'hits', x: 1601826360368, y: 0 }, + { g: 'hits', x: 1601826240368, y: 0 }, + { g: 'hits', x: 1601826120368, y: 0 }, + { g: 'hits', x: 1601826000368, y: 0 }, + { g: 'hits', x: 1601825880368, y: 0 }, + { g: 'hits', x: 1601825760368, y: 0 }, + { g: 'hits', x: 1601825640368, y: 0 }, + { g: 'hits', x: 1601825520368, y: 0 }, + { g: 'hits', x: 1601825400368, y: 0 }, + { g: 'hits', x: 1601825280368, y: 0 }, + { g: 'hits', x: 1601825160368, y: 0 }, + { g: 'hits', x: 1601825040368, y: 0 }, + { g: 'hits', x: 1601824920368, y: 0 }, + { g: 'hits', x: 1601824800368, y: 0 }, + { g: 'hits', x: 1601824680368, y: 0 }, + { g: 'hits', x: 1601824560368, y: 1 }, + { g: 'hits', x: 1601824440368, y: 0 }, + { g: 'hits', x: 1601824320368, y: 0 }, + { g: 'hits', x: 1601824200368, y: 0 }, + { g: 'hits', x: 1601824080368, y: 0 }, + { g: 'hits', x: 1601823960368, y: 0 }, + { g: 'hits', x: 1601823840368, y: 0 }, + { g: 'hits', x: 1601823720368, y: 0 }, + { g: 'hits', x: 1601823600368, y: 0 }, + ]); + }); + + test('it returns results bucketed into 1 hour intervals when range is "d"', () => { + const mockResponse = getMockEqlSequenceResponse(); + const response: EqlSearchStrategyResponse> = { + ...mockResponse, + rawResponse: { + ...mockResponse.rawResponse, + body: { + is_partial: false, + is_running: false, + timed_out: false, + took: 15, + hits: { + sequences: [ + { + join_keys: [], + events: [ + { + _index: 'index', + _id: '1', + _source: { + '@timestamp': '2020-10-04T15:16:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '2', + _source: { + '@timestamp': '2020-10-04T05:50:54.368707900Z', + }, + }, + ], + }, + { + join_keys: [], + events: [ + { + _index: 'index', + _id: '3', + _source: { + '@timestamp': '2020-10-04T18:06:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '4', + _source: { + '@timestamp': '2020-10-04T23:15:54.368707900Z', + }, + }, + ], + }, + ], + total: { + value: 4, + relation: '', + }, }, }, }, - }, - }; - - const aggs = getEqlAggsData( - response, - 'h', - '2020-10-04T16:00:00.368707900Z', - jest.fn() as inputsModel.Refetch - ); - - expect(aggs.data.every(({ y }) => y === 0)).toBeTruthy(); - expect(aggs.totalCount).toEqual(0); + }; + + const aggs = getEqlAggsData( + response, + 'd', + '2020-10-04T23:50:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + true + ); + const date1 = moment(aggs.data[0].x); + const date2 = moment(aggs.data[1].x); + // This'll be in ms + const diff = date1.diff(date2); + + expect(diff).toEqual(3600000); + expect(aggs.data).toHaveLength(25); + expect(aggs.data).toEqual([ + { g: 'hits', x: 1601855400368, y: 0 }, + { g: 'hits', x: 1601851800368, y: 1 }, + { g: 'hits', x: 1601848200368, y: 0 }, + { g: 'hits', x: 1601844600368, y: 0 }, + { g: 'hits', x: 1601841000368, y: 0 }, + { g: 'hits', x: 1601837400368, y: 0 }, + { g: 'hits', x: 1601833800368, y: 0 }, + { g: 'hits', x: 1601830200368, y: 0 }, + { g: 'hits', x: 1601826600368, y: 0 }, + { g: 'hits', x: 1601823000368, y: 0 }, + { g: 'hits', x: 1601819400368, y: 0 }, + { g: 'hits', x: 1601815800368, y: 0 }, + { g: 'hits', x: 1601812200368, y: 0 }, + { g: 'hits', x: 1601808600368, y: 0 }, + { g: 'hits', x: 1601805000368, y: 0 }, + { g: 'hits', x: 1601801400368, y: 0 }, + { g: 'hits', x: 1601797800368, y: 0 }, + { g: 'hits', x: 1601794200368, y: 0 }, + { g: 'hits', x: 1601790600368, y: 1 }, + { g: 'hits', x: 1601787000368, y: 0 }, + { g: 'hits', x: 1601783400368, y: 0 }, + { g: 'hits', x: 1601779800368, y: 0 }, + { g: 'hits', x: 1601776200368, y: 0 }, + { g: 'hits', x: 1601772600368, y: 0 }, + { g: 'hits', x: 1601769000368, y: 0 }, + ]); + }); + + test('it correctly returns total hits', () => { + const mockResponse = getMockEqlSequenceResponse(); + + const aggs = getEqlAggsData( + mockResponse, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + true + ); + + expect(aggs.totalCount).toEqual(4); + }); + + test('it returns array with each item having a "total" of 0 if response returns no hits', () => { + const mockResponse = getMockEqlSequenceResponse(); + const response: EqlSearchStrategyResponse> = { + ...mockResponse, + rawResponse: { + ...mockResponse.rawResponse, + body: { + is_partial: false, + is_running: false, + timed_out: false, + took: 15, + hits: { + total: { + value: 0, + relation: '', + }, + }, + }, + }, + }; + + const aggs = getEqlAggsData( + response, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + true + ); + + expect(aggs.data.every(({ y }) => y === 0)).toBeTruthy(); + expect(aggs.totalCount).toEqual(0); + }); }); }); @@ -456,41 +562,9 @@ describe('eql/helpers', () => { expect(arrayOfNumbers).toEqual([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60]); }); - test('returns array of 30 numbers from 0 to 60 by 2', () => { - const arrayOfNumbers = createIntervalArray(0, 30, 2); - expect(arrayOfNumbers).toEqual([ - 0, - 2, - 4, - 6, - 8, - 10, - 12, - 14, - 16, - 18, - 20, - 22, - 24, - 26, - 28, - 30, - 32, - 34, - 36, - 38, - 40, - 42, - 44, - 46, - 48, - 50, - 52, - 54, - 56, - 58, - 60, - ]); + test('returns array of 5 numbers from 0 to 10 by 2', () => { + const arrayOfNumbers = createIntervalArray(0, 5, 2); + expect(arrayOfNumbers).toEqual([0, 2, 4, 6, 8, 10]); }); test('returns array of numbers from start param to end param if multiplier is 1', () => { @@ -500,103 +574,46 @@ describe('eql/helpers', () => { }); describe('getInterval', () => { - test('returns object with 2 minute interval keys if range is "h"', () => { - const intervals = getInterval('h', Date.parse('2020-10-04T15:00:00.368707900Z')); - const keys = Object.keys(intervals); - const date1 = moment(Number(intervals['0'].timestamp)); - const date2 = moment(Number(intervals['2'].timestamp)); - - // This'll be in ms - const diff = date1.diff(date2); - - expect(diff).toEqual(120000); - expect(keys).toEqual([ - '0', - '2', - '4', - '6', - '8', - '10', - '12', - '14', - '16', - '18', - '20', - '22', - '24', - '26', - '28', - '30', - '32', - '34', - '36', - '38', - '40', - '42', - '44', - '46', - '48', - '50', - '52', - '54', - '56', - '58', - '60', - ]); - }); - test('returns object with 2 minute interval timestamps if range is "h"', () => { const intervals = getInterval('h', 1601856270140); - const date1 = moment(Number(intervals['0'].timestamp)); - const date2 = moment(Number(intervals['2'].timestamp)); - // This'll be in ms - const diff = date1.diff(date2); + const allAre2MinApart = Object.keys(intervals).every((int) => { + const interval1 = intervals[int]; + const interval2 = intervals[`${Number(int) + 2}`]; + if (interval1 != null && interval2 != null) { + const date1 = moment(Number(interval1.timestamp)); + const date2 = moment(Number(interval2.timestamp)); + // This'll be in ms + const diff = date1.diff(date2); - expect(diff).toEqual(120000); - }); + return diff === 120000; + } - test('returns object with 1 hour interval keys if range is "d"', () => { - const intervals = getInterval('d', 1601856270140); - const keys = Object.keys(intervals); - expect(keys).toEqual([ - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '19', - '20', - '21', - '22', - '23', - '24', - ]); + return true; + }); + + expect(allAre2MinApart).toBeTruthy(); }); test('returns object with 1 hour interval timestamps if range is "d"', () => { const intervals = getInterval('d', 1601856270140); - const date1 = moment(Number(intervals['0'].timestamp)); - const date2 = moment(Number(intervals['1'].timestamp)); - // This'll be in ms - const diff = date1.diff(date2); + const allAre1HourApart = Object.keys(intervals).every((int) => { + const interval1 = intervals[int]; + const interval2 = intervals[`${Number(int) + 1}`]; + if (interval1 != null && interval2 != null) { + const date1 = moment(Number(interval1.timestamp)); + const date2 = moment(Number(interval2.timestamp)); + // This'll be in ms + const diff = date1.diff(date2); + + return diff === 3600000; + } + + return true; + }); - expect(diff).toEqual(3600000); + expect(allAre1HourApart).toBeTruthy(); }); test('returns error if range is anything other than "h" or "d"', () => { @@ -604,17 +621,100 @@ describe('eql/helpers', () => { }); }); - describe('getSequenceAggs', () => { - test('it aggregates events by sequences', () => { - const mockResponse = getMockSequenceResponse(); - const sequenceAggs = getSequenceAggs(mockResponse, jest.fn() as inputsModel.Refetch); + describe('formatInspect', () => { + test('it should return "dsl" with response params and index info', () => { + const { dsl } = formatInspect(getMockEqlResponse(), ['foo-*']); + + expect(JSON.parse(dsl[0])).toEqual({ + body: { + filter: { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gte: '2020-10-07T00:46:12.414Z', + lte: '2020-10-07T01:46:12.414Z', + }, + }, + }, + }, + index: ['foo-*'], + method: 'GET', + path: '/_eql/search/', + querystring: 'some query string', + }); + }); + + test('it should return "response"', () => { + const mockResponse = getMockEqlResponse(); + const { response } = formatInspect(mockResponse, ['foo-*']); + + expect(JSON.parse(response[0])).toEqual(mockResponse.rawResponse.body); + }); + }); + + describe('getEventsToBucket', () => { + test('returns events for non-sequence queries', () => { + const events = getEventsToBucket(false, getMockEqlResponse()); + + expect(events).toEqual([ + { _id: '1', _index: 'index', _source: { '@timestamp': '2020-10-04T15:16:54.368707900Z' } }, + { _id: '2', _index: 'index', _source: { '@timestamp': '2020-10-04T15:50:54.368707900Z' } }, + { _id: '3', _index: 'index', _source: { '@timestamp': '2020-10-04T15:06:54.368707900Z' } }, + { _id: '4', _index: 'index', _source: { '@timestamp': '2020-10-04T15:15:54.368707900Z' } }, + ]); + }); + + test('returns empty array if no hits', () => { + const resp = getMockEqlResponse(); + const mockResponse: EqlSearchStrategyResponse> = { + ...resp, + rawResponse: { + ...resp.rawResponse, + body: { + ...resp.rawResponse.body, + hits: { + total: { + value: 0, + relation: '', + }, + }, + }, + }, + }; + const events = getEventsToBucket(false, mockResponse); + + expect(events).toEqual([]); + }); + + test('returns events for sequence queries', () => { + const events = getEventsToBucket(true, getMockEqlSequenceResponse()); - expect(sequenceAggs.data).toEqual([ - { g: 'Seq. 1', x: '2020-10-04T15:16:54.368707900Z', y: 1 }, - { g: 'Seq. 1', x: '2020-10-04T15:50:54.368707900Z', y: 1 }, - { g: 'Seq. 2', x: '2020-10-04T15:06:54.368707900Z', y: 1 }, - { g: 'Seq. 2', x: '2020-10-04T15:15:54.368707900Z', y: 1 }, + expect(events).toEqual([ + { _id: '2', _index: 'index', _source: { '@timestamp': '2020-10-04T15:50:54.368707900Z' } }, + { _id: '4', _index: 'index', _source: { '@timestamp': '2020-10-04T15:15:54.368707900Z' } }, ]); }); + + test('returns empty array if no sequences', () => { + const resp = getMockEqlSequenceResponse(); + const mockResponse: EqlSearchStrategyResponse> = { + ...resp, + rawResponse: { + ...resp.rawResponse, + body: { + ...resp.rawResponse.body, + hits: { + total: { + value: 0, + relation: '', + }, + }, + }, + }, + }; + const events = getEventsToBucket(true, mockResponse); + + expect(events).toEqual([]); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts index 4b5986d966df3..d1a29987c8ced 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts @@ -10,55 +10,105 @@ import { inputsModel } from '../../../common/store'; import { EqlSearchStrategyResponse } from '../../../../../data_enhanced/common'; import { InspectResponse } from '../../../types'; import { EqlPreviewResponse, Source } from './types'; -import { EqlSearchResponse } from '../../../../common/detection_engine/types'; +import { BaseHit, EqlSearchResponse } from '../../../../common/detection_engine/types'; type EqlAggBuckets = Record; export const EQL_QUERY_EVENT_SIZE = 100; -// Calculates which 2 min bucket segment, event should be -// sorted into +/** + * Calculates which 2 min bucket segment, event should be sorted into + * @param eventTimestamp The event to be bucketed timestamp + * @param relativeNow The timestamp we are using to calculate how far from 'now' event occurred + */ export const calculateBucketForHour = (eventTimestamp: number, relativeNow: number): number => { - const diff: number = relativeNow - eventTimestamp; - const minutes: number = Math.floor(diff / 60000); + const diff = Math.abs(relativeNow - eventTimestamp); + const minutes = Math.floor(diff / 60000); return Math.ceil(minutes / 2) * 2; }; -// Calculates which 1 hour bucket segment, event should be -// sorted into +/** + * Calculates which 1 hour bucket segment, event should be sorted into + * @param eventTimestamp The event to be bucketed timestamp + * @param relativeNow The timestamp we are using to calculate how far from 'now' event occurred + */ export const calculateBucketForDay = (eventTimestamp: number, relativeNow: number): number => { - const diff: number = relativeNow - eventTimestamp; - const minutes: number = Math.floor(diff / 60000); + const diff = Math.abs(relativeNow - eventTimestamp); + const minutes = Math.floor(diff / 60000); return Math.ceil(minutes / 60); }; +/** + * Formats the response for the UI inspect modal + * @param response The query search response + * @param indices The indices the query searched + * TODO: Update eql search strategy to return index in it's meta + * params info, currently not being returned, but expected for + * inspect modal display + */ export const formatInspect = ( - response: EqlSearchStrategyResponse> + response: EqlSearchStrategyResponse>, + indices: string[] ): InspectResponse => { const body = response.rawResponse.meta.request.params.body; - const bodyParse = typeof body === 'string' ? JSON.parse(body) : body; + const bodyParse: Record | undefined = + typeof body === 'string' ? JSON.parse(body) : body; return { dsl: [ - JSON.stringify({ ...response.rawResponse.meta.request.params, body: bodyParse }, null, 2), + JSON.stringify( + { ...response.rawResponse.meta.request.params, index: indices, body: bodyParse }, + null, + 2 + ), ], response: [JSON.stringify(response.rawResponse.body, null, 2)], }; }; -// NOTE: Eql does not support aggregations, this is an in-memory -// hand-spun aggregation for the events to give the user a visual -// representation of their query results +/** + * Gets the events out of the response based on type of query + * @param isSequence Is the eql query a sequence query + * @param response The query search response + */ +export const getEventsToBucket = ( + isSequence: boolean, + response: EqlSearchStrategyResponse> +): Array> => { + const hits = response.rawResponse.body.hits ?? []; + if (isSequence) { + return ( + hits.sequences?.map((seq) => { + return seq.events[seq.events.length - 1]; + }) ?? [] + ); + } else { + return hits.events ?? []; + } +}; + +/** + * Eql does not support aggregations, this is an in-memory + * hand-spun aggregation for the events to give the user a visual + * representation of their query results + * @param response The query search response + * @param range User chosen timeframe (last hour, day) + * @param to Based on range chosen + * @param refetch Callback used in inspect button, ref just passed through + * @param indices Indices searched by query + * @param isSequence Is the eql query a sequence query + */ export const getEqlAggsData = ( response: EqlSearchStrategyResponse>, range: Unit, to: string, - refetch: inputsModel.Refetch + refetch: inputsModel.Refetch, + indices: string[], + isSequence: boolean ): EqlPreviewResponse => { - const { dsl, response: inspectResponse } = formatInspect(response); - // The upper bound of the timestamps + const { dsl, response: inspectResponse } = formatInspect(response, indices); const relativeNow = Date.parse(to); const accumulator = getInterval(range, relativeNow); - const events = response.rawResponse.body.hits.events ?? []; + const events = getEventsToBucket(isSequence, response); const totalCount = response.rawResponse.body.hits.total.value; const buckets = events.reduce((acc, hit) => { @@ -94,12 +144,23 @@ export const getEqlAggsData = ( }; }; -export const createIntervalArray = (start: number, end: number, multiplier: number) => { +/** + * Helper method to create an array to be used for calculating bucket intervals + * @param start + * @param end + * @param multiplier + */ +export const createIntervalArray = (start: number, end: number, multiplier: number): number[] => { return Array(end - start + 1) .fill(0) .map((_, idx) => start + idx * multiplier); }; +/** + * Helper method to create an array to be used for calculating bucket intervals + * @param range User chosen timeframe (last hour, day) + * @param relativeNow Based on range chosen + */ export const getInterval = (range: Unit, relativeNow: number): EqlAggBuckets => { switch (range) { case 'h': @@ -117,38 +178,6 @@ export const getInterval = (range: Unit, relativeNow: number): EqlAggBuckets => }; }, {}); default: - throw new Error('Invalid time range selected'); + throw new RangeError('Invalid time range selected. Must be "Last hour" or "Last day".'); } }; - -export const getSequenceAggs = ( - response: EqlSearchStrategyResponse>, - refetch: inputsModel.Refetch -): EqlPreviewResponse => { - const { dsl, response: inspectResponse } = formatInspect(response); - const sequences = response.rawResponse.body.hits.sequences ?? []; - const totalCount = response.rawResponse.body.hits.total.value; - - const data = sequences.map((sequence, i) => { - return sequence.events.map((seqEvent) => { - if (seqEvent._source['@timestamp'] == null) { - return {}; - } - return { - x: seqEvent._source['@timestamp'], - y: 1, - g: `Seq. ${i + 1}`, - }; - }); - }); - - return { - data: data.flat(), - totalCount, - inspect: { - dsl, - response: inspectResponse, - }, - refetch, - }; -}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.test.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.test.ts index ae7a263cc7012..663791a00940f 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.test.ts @@ -14,7 +14,7 @@ import { Source } from './types'; import { EqlSearchResponse } from '../../../../common/detection_engine/types'; import { useKibana } from '../../../common/lib/kibana'; import { useEqlPreview } from '.'; -import { getMockResponse } from './helpers.test'; +import { getMockEqlResponse } from './eql_search_response.mock'; jest.mock('../../../common/lib/kibana'); @@ -32,7 +32,9 @@ describe('useEqlPreview', () => { useKibana().services.notifications.toasts.addWarning = jest.fn(); - (useKibana().services.data.search.search as jest.Mock).mockReturnValue(of(getMockResponse())); + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ); }); it('should initiate hook', async () => { @@ -96,7 +98,7 @@ describe('useEqlPreview', () => { it('should not resolve values after search is invoked if component unmounted', async () => { await act(async () => { (useKibana().services.data.search.search as jest.Mock).mockReturnValue( - of(getMockResponse()).pipe(delay(5000)) + of(getMockEqlResponse()).pipe(delay(5000)) ); const { result, waitForNextUpdate, unmount } = renderHook(() => useEqlPreview()); @@ -117,9 +119,11 @@ describe('useEqlPreview', () => { it('should not resolve new values on search if response is error response', async () => { await act(async () => { (useKibana().services.data.search.search as jest.Mock).mockReturnValue( - of({ isRunning: false, isPartial: true } as EqlSearchStrategyResponse< - EqlSearchResponse - >) + of>>({ + ...getMockEqlResponse(), + isRunning: false, + isPartial: true, + }) ); const { result, waitForNextUpdate } = renderHook(() => useEqlPreview()); @@ -136,6 +140,30 @@ describe('useEqlPreview', () => { }); }); + // TODO: Determine why eql search strategy returns null for meta.params.body + // in complete responses, but not in partial responses + it('should update inspect information on partial response', async () => { + const mockResponse = getMockEqlResponse(); + await act(async () => { + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of>>({ + isRunning: true, + isPartial: true, + rawResponse: mockResponse.rawResponse, + }) + ); + + const { result, waitForNextUpdate } = renderHook(() => useEqlPreview()); + + await waitForNextUpdate(); + + result.current[1](params); + + expect(result.current[2].inspect.dsl.length).toEqual(1); + expect(result.current[2].inspect.response.length).toEqual(1); + }); + }); + it('should add danger toast if search throws', async () => { await act(async () => { (useKibana().services.data.search.search as jest.Mock).mockReturnValue( diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.ts index 1bfaecdf089be..93236381753bf 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.ts @@ -14,12 +14,13 @@ import { AbortError, isCompleteResponse, isErrorResponse, + isPartialResponse, } from '../../../../../../../src/plugins/data/common'; import { EqlSearchStrategyRequest, EqlSearchStrategyResponse, } from '../../../../../data_enhanced/common'; -import { getEqlAggsData, getSequenceAggs } from './helpers'; +import { formatInspect, getEqlAggsData } from './helpers'; import { EqlPreviewResponse, EqlPreviewRequest, Source } from './types'; import { hasEqlSequenceQuery } from '../../../../common/detection_engine/utils'; import { EqlSearchResponse } from '../../../../common/detection_engine/types'; @@ -106,13 +107,37 @@ export const useEqlPreview = (): [ if (isCompleteResponse(res)) { if (!didCancel.current) { setLoading(false); - if (hasEqlSequenceQuery(query)) { - setResponse(getSequenceAggs(res, refetch.current)); - } else { - setResponse(getEqlAggsData(res, interval, to, refetch.current)); - } + + setResponse((prev) => { + const { inspect, ...rest } = getEqlAggsData( + res, + interval, + to, + refetch.current, + index, + hasEqlSequenceQuery(query) + ); + const inspectDsl = prev.inspect.dsl[0] ? prev.inspect.dsl : inspect.dsl; + const inspectResp = prev.inspect.response[0] + ? prev.inspect.response + : inspect.response; + + return { + ...prev, + ...rest, + inspect: { + dsl: inspectDsl, + response: inspectResp, + }, + }; + }); } + unsubscribeStream.current.next(); + } else if (isPartialResponse(res)) { + // TODO: Eql search strategy partial responses return a value under meta.params.body + // but the final/complete response does not, that's why the inspect values are set here + setResponse((prev) => ({ ...prev, inspect: formatInspect(res, index) })); } else if (isErrorResponse(res)) { setLoading(false); notifications.toasts.addWarning(i18n.EQL_PREVIEW_FETCH_FAILURE); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx index 01d95fa80ba59..3dc3213d65314 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx @@ -52,7 +52,7 @@ describe('PreviewCustomQueryHistogram', () => { expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); expect( wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').at(0).prop('subtitle') - ).toEqual(i18n.PREVIEW_SUBTITLE_LOADING); + ).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING); }); test('it configures data and subtitle', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx index 787e8dab393ca..77b6fbb938e20 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx @@ -54,7 +54,7 @@ export const PreviewCustomQueryHistogram = ({ const subtitle = useMemo( (): string => - isLoading ? i18n.PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), + isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), [isLoading, totalCount] ); @@ -67,7 +67,7 @@ export const PreviewCustomQueryHistogram = ({ barConfig={barConfig} title={i18n.QUERY_GRAPH_HITS_TITLE} subtitle={subtitle} - disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER} + disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER} isLoading={isLoading} data-test-subj="queryPreviewCustomHistogram" /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx index 16e71485de9a6..3e7807f423be9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx @@ -39,7 +39,6 @@ describe('PreviewEqlQueryHistogram', () => { { expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); expect( wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').at(0).prop('subtitle') - ).toEqual(i18n.PREVIEW_SUBTITLE_LOADING); + ).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING); }); test('it configures data and subtitle', () => { @@ -63,7 +62,6 @@ describe('PreviewEqlQueryHistogram', () => { { { refetch: mockRefetch, }); }); + + test('it displays histogram', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + + + ); + + expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); + expect( + wrapper.find('[data-test-subj="sharedPreviewQueryNoHistogramAvailable"]').exists() + ).toBeFalsy(); + expect(wrapper.find('[data-test-subj="sharedPreviewQueryHistogram"]').exists()).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx index 8f2774a1342b6..ed1fd5b7367d4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx @@ -15,7 +15,6 @@ import { } from '../../../../common/components/charts/common'; import { InspectQuery } from '../../../../common/store/inputs/model'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; -import { hasEqlSequenceQuery } from '../../../../../common/detection_engine/utils'; import { inputsModel } from '../../../../common/store'; import { PreviewHistogram } from './histogram'; @@ -26,7 +25,6 @@ interface PreviewEqlQueryHistogramProps { from: string; totalCount: number; isLoading: boolean; - query: string; data: ChartData[]; inspect: InspectQuery; refetch: inputsModel.Refetch; @@ -36,7 +34,6 @@ export const PreviewEqlQueryHistogram = ({ from, to, totalCount, - query, data, inspect, refetch, @@ -50,14 +47,11 @@ export const PreviewEqlQueryHistogram = ({ } }, [setQuery, inspect, isInitializing, refetch]); - const barConfig = useMemo( - (): ChartSeriesConfigs => getHistogramConfig(to, from, hasEqlSequenceQuery(query)), - [from, to, query] - ); + const barConfig = useMemo((): ChartSeriesConfigs => getHistogramConfig(to, from), [from, to]); const subtitle = useMemo( (): string => - isLoading ? i18n.PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), + isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), [isLoading, totalCount] ); @@ -70,7 +64,7 @@ export const PreviewEqlQueryHistogram = ({ barConfig={barConfig} title={i18n.QUERY_GRAPH_HITS_TITLE} subtitle={subtitle} - disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER_EQL} + disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER_EQL} isLoading={isLoading} data-test-subj="queryPreviewEqlHistogram" /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/helpers.ts index ed8994a4c44fd..89d017f4721f6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/helpers.ts @@ -16,13 +16,13 @@ import { FieldValueQueryBar } from '../query_bar'; import { ESQuery } from '../../../../../common/typed_json'; import { Filter } from '../../../../../../../../src/plugins/data/common/es_query'; -export const HITS_THRESHOLD: Record = { - h: 1, - d: 24, - M: 730, -}; - -export const isNoisy = (hits: number, timeframe: Unit) => { +/** + * Determines whether or not to display noise warning. + * Is considered noisy if alerts/hour rate > 1 + * @param hits Total query search hits + * @param timeframe Range selected by user (last hour, day...) + */ +export const isNoisy = (hits: number, timeframe: Unit): boolean => { if (timeframe === 'h') { return hits > 1; } else if (timeframe === 'd') { @@ -34,6 +34,12 @@ export const isNoisy = (hits: number, timeframe: Unit) => { return false; }; +/** + * Determines what timerange options to show. + * Eql sequence queries tend to be slower, so decided + * not to include the last month option. + * @param ruleType + */ export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => { if (ruleType === 'eql') { return [ @@ -49,6 +55,13 @@ export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => { } }; +/** + * Quick little helper to extract the query info from the + * queryBar object. + * @param queryBar Object containing all query info + * @param index Indices searched + * @param ruleType + */ export const getInfoFromQueryBar = ( queryBar: FieldValueQueryBar, index: string[], @@ -88,10 +101,15 @@ export const getInfoFromQueryBar = ( } }; +/** + * Config passed into elastic-charts settings. + * @param to + * @param from + */ export const getHistogramConfig = ( to: string, from: string, - showLegend: boolean = false + showLegend = false ): ChartSeriesConfigs => { return { series: { @@ -131,7 +149,11 @@ export const getHistogramConfig = ( }; }; -export const getThresholdHistogramConfig = (height: number | undefined): ChartSeriesConfigs => { +/** + * Threshold histogram is displayed a bit differently, + * x-axis is not time based, but ordinal. + */ +export const getThresholdHistogramConfig = (): ChartSeriesConfigs => { return { series: { xScaleType: ScaleType.Ordinal, @@ -165,6 +187,6 @@ export const getThresholdHistogramConfig = (height: number | undefined): ChartSe }, }, }, - customHeight: height ?? 200, + customHeight: 200, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx index 87436ad1e6d2b..26891dae1752a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx @@ -13,9 +13,13 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { TestProviders } from '../../../../common/mock'; import { useKibana } from '../../../../common/lib/kibana'; import { PreviewQuery } from './'; -import { getMockResponse } from '../../../../common/hooks/eql/helpers.test'; +import { getMockEqlResponse } from '../../../../common/hooks/eql/eql_search_response.mock'; +import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram'; +import { useEqlPreview } from '../../../../common/hooks/eql/'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/containers/matrix_histogram'); +jest.mock('../../../../common/hooks/eql/'); describe('PreviewQuery', () => { beforeEach(() => { @@ -23,7 +27,33 @@ describe('PreviewQuery', () => { useKibana().services.notifications.toasts.addWarning = jest.fn(); - (useKibana().services.data.search.search as jest.Mock).mockReturnValue(of(getMockResponse())); + (useMatrixHistogram as jest.Mock).mockReturnValue([ + false, + { + inspect: { dsl: [], response: [] }, + totalCount: 1, + refetch: jest.fn(), + data: [], + buckets: [], + }, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + ]); + + (useEqlPreview as jest.Mock).mockReturnValue([ + false, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + { + inspect: { dsl: [], response: [] }, + totalCount: 1, + refetch: jest.fn(), + data: [], + buckets: [], + }, + ]); }); afterEach(() => { @@ -121,6 +151,42 @@ describe('PreviewQuery', () => { expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy(); }); + test('it renders noise warning when rule type is query, timeframe is last hour and hit average is greater than 1/hour', async () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + + + ); + + (useMatrixHistogram as jest.Mock).mockReturnValue([ + false, + { + inspect: { dsl: [], response: [] }, + totalCount: 2, + refetch: jest.fn(), + data: [], + buckets: [], + }, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + ]); + + wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); + + expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy(); + }); + test('it renders query histogram when rule type is saved_query and preview button clicked', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -175,6 +241,42 @@ describe('PreviewQuery', () => { expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeTruthy(); }); + test('it renders noise warning when rule type is eql, timeframe is last hour and hit average is greater than 1/hour', async () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + + + ); + + (useEqlPreview as jest.Mock).mockReturnValue([ + false, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + { + inspect: { dsl: [], response: [] }, + totalCount: 2, + refetch: jest.fn(), + data: [], + buckets: [], + }, + ]); + + wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); + + expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy(); + }); + test('it renders threshold histogram when preview button clicked, rule type is threshold, and threshold field is defined', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -192,16 +294,70 @@ describe('PreviewQuery', () => { ); + (useMatrixHistogram as jest.Mock).mockReturnValue([ + false, + { + inspect: { dsl: [], response: [] }, + totalCount: 500, + refetch: jest.fn(), + data: [], + buckets: [{ key: 'siem-kibana', doc_count: 500 }], + }, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + ]); + wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls; expect(mockCalls.length).toEqual(1); + expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewThresholdQueryHistogram"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy(); }); + test('it renders noise warning when rule type is threshold, and threshold field is defined, timeframe is last hour and hit average is greater than 1/hour', async () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + + + ); + + (useMatrixHistogram as jest.Mock).mockReturnValue([ + false, + { + inspect: { dsl: [], response: [] }, + totalCount: 500, + refetch: jest.fn(), + data: [], + buckets: [ + { key: 'siem-kibana', doc_count: 200 }, + { key: 'siem-windows', doc_count: 300 }, + ], + }, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + ]); + + wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); + + expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy(); + }); + test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is not defined', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -255,4 +411,33 @@ describe('PreviewQuery', () => { expect(wrapper.find('[data-test-subj="previewThresholdQueryHistogram"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy(); }); + + test('it hides histogram when timeframe changes', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + + + ); + + wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); + + expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeTruthy(); + + wrapper + .find('[data-test-subj="queryPreviewTimeframeSelect"] select') + .at(0) + .simulate('change', { target: { value: 'd' } }); + + expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx index f1cb8e3ba9fdb..6669ea6d97969 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useCallback, useEffect, useReducer } from 'react'; +import React, { Fragment, useCallback, useEffect, useReducer, useRef } from 'react'; import { Unit } from '@elastic/datemath'; import styled from 'styled-components'; import { @@ -110,29 +110,31 @@ export const PreviewQuery = ({ { inspect, totalCount: matrixHistTotal, refetch, data: matrixHistoData, buckets }, startNonEql, ] = useMatrixHistogram({ - errorMessage: i18n.PREVIEW_QUERY_ERROR, + errorMessage: i18n.QUERY_PREVIEW_ERROR, endDate: fromTime, startDate: toTime, filterQuery: queryFilter, indexNames: index, histogramType: MatrixHistogramType.events, stackByField: 'event.category', - threshold, + threshold: ruleType === 'threshold' ? threshold : undefined, skip: true, }); const setQueryInfo = useCallback( - (queryBar: FieldValueQueryBar | undefined): void => { + (queryBar: FieldValueQueryBar | undefined, indices: string[], type: Type): void => { dispatch({ type: 'setQueryInfo', queryBar, - index, - ruleType, + index: indices, + ruleType: type, }); }, - [dispatch, index, ruleType] + [dispatch] ); + const debouncedSetQueryInfo = useRef(debounce(500, setQueryInfo)); + const setTimeframeSelect = useCallback( (selection: Unit): void => { dispatch({ @@ -190,11 +192,9 @@ export const PreviewQuery = ({ [dispatch] ); - useEffect((): void => { - const debounced = debounce(1000, setQueryInfo); - - debounced(query); - }, [setQueryInfo, query]); + useEffect(() => { + debouncedSetQueryInfo.current(query, index, ruleType); + }, [index, query, ruleType]); useEffect((): void => { setThresholdValues(threshold, ruleType); @@ -205,12 +205,32 @@ export const PreviewQuery = ({ }, [ruleType, setRuleTypeChange]); useEffect((): void => { - const totalHits = ruleType === 'eql' ? eqlQueryTotal : matrixHistTotal; - - if (isNoisy(totalHits, timeframe)) { - setNoiseWarning(); + switch (ruleType) { + case 'eql': + if (isNoisy(eqlQueryTotal, timeframe)) { + setNoiseWarning(); + } + break; + case 'threshold': + const totalHits = thresholdFieldExists ? buckets.length : matrixHistTotal; + if (isNoisy(totalHits, timeframe)) { + setNoiseWarning(); + } + break; + default: + if (isNoisy(matrixHistTotal, timeframe)) { + setNoiseWarning(); + } } - }, [timeframe, matrixHistTotal, eqlQueryTotal, ruleType, setNoiseWarning]); + }, [ + timeframe, + matrixHistTotal, + eqlQueryTotal, + ruleType, + setNoiseWarning, + thresholdFieldExists, + buckets.length, + ]); const handlePreviewEqlQuery = useCallback( (to: string, from: string): void => { @@ -263,8 +283,9 @@ export const PreviewQuery = ({ options={timeframeOptions} value={timeframe} onChange={handleSelectPreviewTimeframe} - aria-label={i18n.PREVIEW_SELECT_ARIA} + aria-label={i18n.QUERY_PREVIEW_SELECT_ARIA} disabled={isDisabled} + data-test-subj="queryPreviewTimeframeSelect" /> @@ -276,7 +297,7 @@ export const PreviewQuery = ({ onClick={handlePreviewClicked} data-test-subj="queryPreviewButton" > - {i18n.PREVIEW_LABEL} + {i18n.QUERY_PREVIEW_BUTTON} @@ -307,7 +328,6 @@ export const PreviewQuery = ({ { expect(update).toEqual(initialState); }); - test('should reset showHistogram and warnings if queryBar undefined', () => { + test('should reset showHistogram if queryBar undefined', () => { const update = reducer( - { ...initialState, showHistogram: true, warnings: ['uh oh'] }, + { ...initialState, showHistogram: true }, { type: 'setQueryInfo', queryBar: undefined, @@ -44,11 +44,10 @@ describe('queryPreviewReducer', () => { } ); - expect(update.warnings).toEqual([]); expect(update.showHistogram).toBeFalsy(); }); - test('should reset showHistogram and warnings if queryBar defined', () => { + test('should reset showHistogram if queryBar defined', () => { const update = reducer( { ...initialState, showHistogram: true, warnings: ['uh oh'] }, { @@ -62,7 +61,6 @@ describe('queryPreviewReducer', () => { } ); - expect(update.warnings).toEqual([]); expect(update.showHistogram).toBeFalsy(); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/reducer.ts b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/reducer.ts index 76047a0af5c54..ba27098a8c350 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/reducer.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/reducer.ts @@ -82,13 +82,11 @@ export const queryPreviewReducer = () => (state: State, action: Action): State = filters, queryFilter, showHistogram: false, - warnings: [], }; } return { ...state, - warnings: [], showHistogram: false, }; } diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/threshold_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/threshold_histogram.tsx index 1021c5b8ddcb7..a102c567a98e8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/threshold_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/threshold_histogram.tsx @@ -56,12 +56,12 @@ export const PreviewThresholdQueryHistogram = ({ }; }, [buckets]); - const barConfig = useMemo((): ChartSeriesConfigs => getThresholdHistogramConfig(200), []); + const barConfig = useMemo((): ChartSeriesConfigs => getThresholdHistogramConfig(), []); const subtitle = useMemo( (): string => isLoading - ? i18n.PREVIEW_SUBTITLE_LOADING + ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_THRESHOLD_WITH_FIELD_TITLE(totalCount), [isLoading, totalCount] ); @@ -73,7 +73,7 @@ export const PreviewThresholdQueryHistogram = ({ barConfig={barConfig} title={i18n.QUERY_GRAPH_HITS_TITLE} subtitle={subtitle} - disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER} + disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER} isLoading={isLoading} data-test-subj="thresholdQueryPreviewHistogram" /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/translations.ts index 7ae75c51dcf5a..0d080113aeae8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/translations.ts @@ -6,14 +6,14 @@ import { i18n } from '@kbn/i18n'; -export const PREVIEW_LABEL = i18n.translate( - 'xpack.securitySolution.stepDefineRule.previewQueryLabel', +export const QUERY_PREVIEW_BUTTON = i18n.translate( + 'xpack.securitySolution.stepDefineRule.previewQueryButton', { defaultMessage: 'Preview results', } ); -export const PREVIEW_SELECT_ARIA = i18n.translate( +export const QUERY_PREVIEW_SELECT_ARIA = i18n.translate( 'xpack.securitySolution.stepDefineRule.previewQueryAriaLabel', { defaultMessage: 'Query preview timeframe select', @@ -85,14 +85,14 @@ export const QUERY_PREVIEW_NO_HITS = i18n.translate( } ); -export const PREVIEW_QUERY_ERROR = i18n.translate( +export const QUERY_PREVIEW_ERROR = i18n.translate( 'xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewError', { defaultMessage: 'Error fetching preview', } ); -export const PREVIEW_QUERY_DISCLAIMER = i18n.translate( +export const QUERY_PREVIEW_DISCLAIMER = i18n.translate( 'xpack.securitySolution.detectionEngine.queryPreview.queryGraphDisclaimer', { defaultMessage: @@ -100,7 +100,7 @@ export const PREVIEW_QUERY_DISCLAIMER = i18n.translate( } ); -export const PREVIEW_QUERY_DISCLAIMER_EQL = i18n.translate( +export const QUERY_PREVIEW_DISCLAIMER_EQL = i18n.translate( 'xpack.securitySolution.detectionEngine.queryPreview.queryGraphDisclaimerEql', { defaultMessage: @@ -108,26 +108,24 @@ export const PREVIEW_QUERY_DISCLAIMER_EQL = i18n.translate( } ); -export const PREVIEW_WARNING_CAP_HIT = (cap: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphCapHitWarning', - { - values: { cap }, - defaultMessage: - 'Hit query cap size of {cap}. This query could produce more hits than the {cap} shown.', - } - ); +export const QUERY_PREVIEW_SUBTITLE_LOADING = i18n.translate( + 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewSubtitleLoading', + { + defaultMessage: '...loading', + } +); -export const PREVIEW_WARNING_TIMESTAMP = i18n.translate( - 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTimestampWarning', +export const QUERY_PREVIEW_EQL_SEQUENCE_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewEqlSequenceTitle', { - defaultMessage: 'Unable to find "@timestamp" field on events.', + defaultMessage: 'No histogram available', } ); -export const PREVIEW_SUBTITLE_LOADING = i18n.translate( - 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewSubtitleLoading', +export const QUERY_PREVIEW_EQL_SEQUENCE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewEqlSequenceDescription', { - defaultMessage: '...loading', + defaultMessage: + 'No histogram is available at this time for EQL sequence queries. You can use the inspect in the top right corner to view query details.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index bd3578bce6912..8a5966c71aa28 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonEmpty, EuiFormRow, EuiSpacer } from '@elastic/eui'; -import React, { FC, memo, useCallback, useState, useEffect } from 'react'; +import React, { FC, memo, useCallback, useState, useEffect, useMemo } from 'react'; import styled from 'styled-components'; // Prefer importing entire lodash library, e.g. import { get } from "lodash" // eslint-disable-next-line no-restricted-imports @@ -53,7 +53,7 @@ import { import { EqlQueryBar } from '../eql_query_bar'; import { ThreatMatchInput } from '../threatmatch_input'; import { useFetchIndex } from '../../../../common/containers/source'; -import { PreviewQuery } from '../query_preview'; +import { PreviewQuery, Threshold } from '../query_preview'; const CommonUseField = getUseField({ component: Field }); @@ -210,6 +210,12 @@ const StepDefineRuleComponent: FC = ({ setOpenTimelineSearch(false); }, []); + const thresholdFormValue = useMemo((): Threshold | undefined => { + return formThresholdValue != null && formThresholdField != null + ? { value: formThresholdValue, field: formThresholdField[0] } + : undefined; + }, [formThresholdField, formThresholdValue]); + const ThresholdInputChildren = useCallback( ({ thresholdField, thresholdValue }) => ( = ({ index={index} query={formQuery} isDisabled={queryBarQuery.trim() === '' || !isQueryBarValid || index.length === 0} - threshold={ - formThresholdValue != null && formThresholdField != null - ? { value: formThresholdValue, field: formThresholdField[0] } - : undefined - } + threshold={thresholdFormValue} /> )} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bfe62be564c60..f511dbc3b5874 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17356,9 +17356,7 @@ "xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewInspectTitle": "クエリプレビュー", "xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewNoiseWarning": "ノイズ警告:このルールではノイズが多く生じる可能性があります。クエリを絞り込むことを検討してください。これは1時間ごとに1アラートという線形進行に基づいています。", "xpack.securitySolution.detectionEngine.queryPreview.queryNoHits": "ヒットが見つかりませんでした。", - "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphCapHitWarning": "クエリの上限サイズ{cap}に達しました。このクエリは表示されている{cap}を超えるヒットを生成できませんでした。", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphThresholdWithFieldTitle": "{buckets} {buckets, plural, =1 {固有のヒット} other {固有のヒット}}", - "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTimestampWarning": "イベントで「@timestamp」フィールドが見つかりません", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTitle": "{hits} {hits, plural, =1 {ヒット} other {ヒット}}", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "クエリ結果をプレビューするデータのタイムフレームを選択します", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "クイッククエリプレビュー", @@ -18356,7 +18354,6 @@ "xpack.securitySolution.security.title": "セキュリティ", "xpack.securitySolution.source.destination.packetsLabel": "パケット", "xpack.securitySolution.stepDefineRule.previewQueryAriaLabel": "クエリプレビュータイムフレーム選択", - "xpack.securitySolution.stepDefineRule.previewQueryLabel": "結果を表示", "xpack.securitySolution.system.acceptedAConnectionViaDescription": "次の手段で接続を受け付けました。", "xpack.securitySolution.system.acceptedDescription": "以下を経由してユーザーを受け入れました。", "xpack.securitySolution.system.attemptedLoginDescription": "以下を経由してログインを試行しました:", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3f9726f83700b..6bd1129cd4388 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17374,9 +17374,7 @@ "xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewInspectTitle": "查询预览", "xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewNoiseWarning": "噪音警告:此规则可能会导致大量噪音。考虑缩小您的查询范围。这基于每小时 1 条告警的线性级数。", "xpack.securitySolution.detectionEngine.queryPreview.queryNoHits": "找不到任何命中。", - "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphCapHitWarning": "命中查询上限大小为 {cap}。此查询生成的命中数可能大于显示的 {cap}。", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphThresholdWithFieldTitle": "{buckets} 个{buckets, plural, =1 {唯一命中} other {唯一命中}}", - "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTimestampWarning": "在事件中找不到“@timestamp”字段。", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTitle": "{hits} 个{hits, plural, =1 {命中} other {命中}}", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "选择数据的时间范围以预览查询结果", "xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "快速查询预览", @@ -18375,7 +18373,6 @@ "xpack.securitySolution.security.title": "安全", "xpack.securitySolution.source.destination.packetsLabel": "pkts", "xpack.securitySolution.stepDefineRule.previewQueryAriaLabel": "查询预览时间范围选择", - "xpack.securitySolution.stepDefineRule.previewQueryLabel": "预览结果", "xpack.securitySolution.system.acceptedAConnectionViaDescription": "已接受连接 - 通过", "xpack.securitySolution.system.acceptedDescription": "已接受该用户 - 通过", "xpack.securitySolution.system.attemptedLoginDescription": "已尝试登录 - 通过",