From 3da4c6bb2c58a2b61c1fca7afc4d3c0741de4cb5 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 2 Feb 2021 18:30:45 -0500 Subject: [PATCH] [Secutiy Solution] Timeline kpis (#89210) * Stub kpi component * search strategy scheleton timeline KPI * search strategy scheleton timeline KPI * Add timeline kpis component and search strategy container * Use getEmptyValue in timeline kpis * Prevent request from being made for blank timeline properly * Add kpi search strategy api integration test * Add jest tests for timeline kpis * Clear mocks in afterAll * Decouple some tests from EUI structure * Combine some selector calls, change types to be more appropriate * Simplify hook logic * Set loading and response on blank timeline * Only render kpi component when query is active tab * Use TimelineTabs enum for query tab string Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../search_strategy/timeline/events/index.ts | 1 + .../timeline/events/last_event_time/index.ts | 9 ++ .../common/search_strategy/timeline/index.ts | 5 + .../components/flyout/header/index.test.tsx | 117 ++++++++++++++ .../components/flyout/header/index.tsx | 153 ++++++++++++++---- .../components/flyout/header/kpis.tsx | 68 ++++++++ .../components/flyout/header/translations.ts | 29 ++++ .../timelines/containers/kpis/index.tsx | 129 +++++++++++++++ .../timeline/factory/events/index.ts | 2 + .../timeline/factory/events/kpi/index.ts | 39 +++++ .../factory/events/kpi/query.kpi.dsl.ts | 86 ++++++++++ .../security_solution/timeline_details.ts | 27 ++++ 12 files changed, 631 insertions(+), 34 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/containers/kpis/index.tsx create mode 100644 x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/kpi/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/kpi/query.kpi.dsl.ts diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts index 6bb946199597..9dca1558d5a6 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts @@ -11,5 +11,6 @@ export * from './last_event_time'; export enum TimelineEventsQueries { all = 'eventsAll', details = 'eventsDetails', + kpi = 'eventsKpi', lastEventTime = 'eventsLastEventTime', } diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts index 10750503fc80..d6c1be0594c0 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts @@ -25,6 +25,15 @@ export interface TimelineEventsLastEventTimeStrategyResponse extends IEsSearchRe inspect?: Maybe; } +export interface TimelineKpiStrategyResponse extends IEsSearchResponse { + destinationIpCount: number; + inspect?: Maybe; + hostCount: number; + processCount: number; + sourceIpCount: number; + userCount: number; +} + export interface TimelineEventsLastEventTimeRequestOptions extends Omit { indexKey: LastEventIndexKey; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts index d3ec2763f939..f6b937b516fd 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts @@ -13,6 +13,7 @@ import { TimelineEventsDetailsStrategyResponse, TimelineEventsLastEventTimeRequestOptions, TimelineEventsLastEventTimeStrategyResponse, + TimelineKpiStrategyResponse, } from './events'; import { DocValueFields, PaginationInputPaginated, TimerangeInput, SortField } from '../common'; @@ -44,6 +45,8 @@ export type TimelineStrategyResponseType< ? TimelineEventsAllStrategyResponse : T extends TimelineEventsQueries.details ? TimelineEventsDetailsStrategyResponse + : T extends TimelineEventsQueries.kpi + ? TimelineKpiStrategyResponse : T extends TimelineEventsQueries.lastEventTime ? TimelineEventsLastEventTimeStrategyResponse : never; @@ -54,6 +57,8 @@ export type TimelineStrategyRequestType< ? TimelineEventsAllRequestOptions : T extends TimelineEventsQueries.details ? TimelineEventsDetailsRequestOptions + : T extends TimelineEventsQueries.kpi + ? TimelineRequestBasicOptions : T extends TimelineEventsQueries.lastEventTime ? TimelineEventsLastEventTimeRequestOptions : never; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx new file mode 100644 index 000000000000..3a2f96e42025 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx @@ -0,0 +1,117 @@ +/* + * 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 React from 'react'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock'; +import { useTimelineKpis } from '../../../containers/kpis'; +import { FlyoutHeader } from '.'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { mockBrowserFields, mockDocValueFields } from '../../../../common/containers/source/mock'; +import { useMountAppended } from '../../../../common/utils/use_mount_appended'; +import { getEmptyValue } from '../../../../common/components/empty_value'; + +const mockUseSourcererScope: jest.Mock = useSourcererScope as jest.Mock; +jest.mock('../../../../common/containers/sourcerer'); + +const mockUseTimelineKpis: jest.Mock = useTimelineKpis as jest.Mock; +jest.mock('../../../containers/kpis', () => ({ + useTimelineKpis: jest.fn(), +})); +const useKibanaMock = useKibana as jest.Mocked; +jest.mock('../../../../common/lib/kibana'); + +const mockUseTimelineKpiResponse = { + processCount: 1, + userCount: 1, + sourceIpCount: 1, + hostCount: 1, + destinationIpCount: 1, +}; +const defaultMocks = { + browserFields: mockBrowserFields, + docValueFields: mockDocValueFields, + indexPattern: mockIndexPattern, + loading: false, + selectedPatterns: mockIndexNames, +}; +describe('Timeline KPIs', () => { + const mount = useMountAppended(); + + beforeEach(() => { + // Mocking these services is required for the header component to render. + mockUseSourcererScope.mockImplementation(() => defaultMocks); + useKibanaMock().services.application.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + actions: { show: true, crud: true }, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when the data is not loading and the response contains data', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]); + }); + it('renders the component, labels and values succesfully', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true); + // label + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('Processes') + ); + // value + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('1') + ); + }); + }); + + describe('when the data is loading', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]); + }); + it('renders a loading indicator for values', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('--') + ); + }); + }); + + describe('when the response is null and timeline is blank', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, null]); + }); + it('renders labels and the default empty string', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('Processes') + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining(getEmptyValue()) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index 0e948afd5d7c..6e77971b8553 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -15,25 +15,42 @@ import { } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { isEmpty, get, pick } from 'lodash/fp'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import { FormattedRelative } from '@kbn/i18n/react'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { TimelineStatus, TimelineTabs, TimelineType } from '../../../../../common/types/timeline'; +import { + TimelineStatus, + TimelineTabs, + TimelineType, + TimelineId, +} from '../../../../../common/types/timeline'; +import { State } from '../../../../common/store'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; import { AddToFavoritesButton } from '../../timeline/properties/helpers'; - +import { TimerangeInput } from '../../../../../common/search_strategy'; import { AddToCaseButton } from '../add_to_case_button'; import { AddTimelineButton } from '../add_timeline_button'; import { SaveTimelineButton } from '../../timeline/header/save_timeline_button'; +import { useKibana } from '../../../../common/lib/kibana'; import { InspectButton } from '../../../../common/components/inspect'; +import { useTimelineKpis } from '../../../containers/kpis'; +import { esQuery } from '../../../../../../../../src/plugins/data/public'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { TimelineModel } from '../../../../timelines/store/timeline/model'; +import { + startSelector, + endSelector, +} from '../../../../common/components/super_date_picker/selectors'; +import { combineQueries, focusActiveTimelineButton } from '../../timeline/helpers'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { ActiveTimelines } from './active_timelines'; import * as i18n from './translations'; import * as commonI18n from '../../timeline/properties/translations'; import { getTimelineStatusByIdSelector } from './selectors'; -import { focusActiveTimelineButton } from '../../timeline/helpers'; +import { TimelineKPIs } from './kpis'; // to hide side borders const StyledPanel = styled(EuiPanel)` @@ -227,38 +244,106 @@ const TimelineStatusInfoComponent: React.FC = ({ timelineId } const TimelineStatusInfo = React.memo(TimelineStatusInfoComponent); -const FlyoutHeaderComponent: React.FC = ({ timelineId }) => ( - - - - - - - - - - - - - - - - +const FlyoutHeaderComponent: React.FC = ({ timelineId }) => { + const { selectedPatterns, indexPattern, docValueFields, browserFields } = useSourcererScope( + SourcererScopeName.timeline + ); + const getStartSelector = useMemo(() => startSelector(), []); + const getEndSelector = useMemo(() => endSelector(), []); + const isActive = useMemo(() => timelineId === TimelineId.active, [timelineId]); + const timerange: TimerangeInput = useDeepEqualSelector((state) => { + if (isActive) { + return { + from: getStartSelector(state.inputs.timeline), + to: getEndSelector(state.inputs.timeline), + interval: '', + }; + } else { + return { + from: getStartSelector(state.inputs.global), + to: getEndSelector(state.inputs.global), + interval: '', + }; + } + }); + const { uiSettings } = useKibana().services; + const esQueryConfig = useMemo(() => esQuery.getEsQueryConfig(uiSettings), [uiSettings]); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timeline: TimelineModel = useSelector( + (state: State) => getTimeline(state, timelineId) ?? timelineDefaults + ); + const { dataProviders, filters, timelineType, kqlMode, activeTab } = timeline; + const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterQuerySelector(), []); + const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId)!); - {/* KPIs PLACEHOLDER */} + const kqlQueryExpression = + isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template' + ? ' ' + : kqlQueryTimeline; + const kqlQuery = useMemo(() => ({ query: kqlQueryExpression, language: 'kuery' }), [ + kqlQueryExpression, + ]); - - - - - - - - - - - -); + const isBlankTimeline: boolean = useMemo( + () => isEmpty(dataProviders) && isEmpty(filters) && isEmpty(kqlQuery.query), + [dataProviders, filters, kqlQuery] + ); + const combinedQueries = useMemo( + () => + combineQueries({ + config: esQueryConfig, + dataProviders, + indexPattern, + browserFields, + filters: filters ? filters : [], + kqlQuery, + kqlMode, + }), + [browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQuery] + ); + const [loading, kpis] = useTimelineKpis({ + defaultIndex: selectedPatterns, + docValueFields, + timerange, + isBlankTimeline, + filterQuery: combinedQueries?.filterQuery ?? '', + }); + + return ( + + + + + + + + + + + + + + + + + + + {activeTab === TimelineTabs.query ? : null} + + + + + + + + + + + + + + ); +}; FlyoutHeaderComponent.displayName = 'FlyoutHeaderComponent'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx new file mode 100644 index 000000000000..b8dc10a878f8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/kpis.tsx @@ -0,0 +1,68 @@ +/* + * 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 React from 'react'; + +import { EuiStat, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { TimelineKpiStrategyResponse } from '../../../../../common/search_strategy'; +import { getEmptyValue } from '../../../../common/components/empty_value'; +import * as i18n from './translations'; + +export const TimelineKPIs = React.memo( + ({ kpis, isLoading }: { kpis: TimelineKpiStrategyResponse | null; isLoading: boolean }) => { + return ( + + + + + + + + + + + + + + + + + + ); + } +); + +TimelineKPIs.displayName = 'TimelineKPIs'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts index 6492731cdeba..8c4a0aa12ef8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts @@ -31,6 +31,35 @@ export const INSPECT_TIMELINE_TITLE = i18n.translate( } ); +export const PROCESS_KPI_TITLE = i18n.translate( + 'xpack.securitySolution.timeline.kpis.processKpiTitle', + { + defaultMessage: 'Processes', + } +); + +export const HOST_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.hostKpiTitle', { + defaultMessage: 'Hosts', +}); + +export const SOURCE_IP_KPI_TITLE = i18n.translate( + 'xpack.securitySolution.timeline.kpis.sourceIpKpiTitle', + { + defaultMessage: 'Source IPs', + } +); + +export const DESTINATION_IP_KPI_TITLE = i18n.translate( + 'xpack.securitySolution.timeline.kpis.destinationKpiTitle', + { + defaultMessage: 'Destination IPs', + } +); + +export const USER_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.userKpiTitle', { + defaultMessage: 'Users', +}); + export const TIMELINE_TOGGLE_BUTTON_ARIA_LABEL = ({ isOpen, title, diff --git a/x-pack/plugins/security_solution/public/timelines/containers/kpis/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/kpis/index.tsx new file mode 100644 index 000000000000..03d448f22827 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/containers/kpis/index.tsx @@ -0,0 +1,129 @@ +/* + * 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 { noop } from 'lodash/fp'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; + +import { inputsModel } from '../../../common/store'; +import { useKibana } from '../../../common/lib/kibana'; +import { + DocValueFields, + TimelineEventsQueries, + TimelineRequestBasicOptions, + TimelineKpiStrategyResponse, + TimerangeInput, +} from '../../../../common/search_strategy'; +import { ESQuery } from '../../../../common/typed_json'; +import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/public'; +import { AbortError } from '../../../../../../../src/plugins/kibana_utils/common'; + +export interface UseTimelineKpiProps { + timerange: TimerangeInput; + filterQuery?: ESQuery | string | undefined; + defaultIndex: string[]; + docValueFields?: DocValueFields[]; + isBlankTimeline: boolean; +} + +export const useTimelineKpis = ({ + timerange, + filterQuery, + docValueFields, + defaultIndex, + isBlankTimeline, +}: UseTimelineKpiProps): [boolean, TimelineKpiStrategyResponse | null] => { + const { data, notifications } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const didCancel = useRef(false); + const [loading, setLoading] = useState(false); + const [timelineKpiRequest, setTimelineKpiRequest] = useState( + null + ); + const [ + timelineKpiResponse, + setTimelineKpiResponse, + ] = useState(null); + const timelineKpiSearch = useCallback( + (request: TimelineRequestBasicOptions | null) => { + if (request == null) { + return; + } + didCancel.current = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search(request, { + strategy: 'securitySolutionTimelineSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel.current) { + setLoading(false); + setTimelineKpiResponse(response); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel.current) { + setLoading(false); + } + notifications.toasts.addWarning('An error has occurred'); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!didCancel.current) { + setLoading(false); + } + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger('Failed to load KPIs'); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [data.search, notifications.toasts] + ); + + useEffect(() => { + setTimelineKpiRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + docValueFields, + defaultIndex, + timerange, + filterQuery, + factoryQueryType: TimelineEventsQueries.kpi, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [docValueFields, defaultIndex, timerange, filterQuery]); + + useEffect(() => { + if (!isBlankTimeline) { + timelineKpiSearch(timelineKpiRequest); + } else { + setLoading(false); + setTimelineKpiResponse(null); + } + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + }; + }, [isBlankTimeline, timelineKpiRequest, timelineKpiSearch]); + return [loading, timelineKpiResponse]; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/index.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/index.ts index a4e2c168a41d..6d652d2721ea 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/index.ts @@ -12,6 +12,7 @@ import { import { SecuritySolutionTimelineFactory } from '../types'; import { timelineEventsAll } from './all'; import { timelineEventsDetails } from './details'; +import { timelineKpi } from './kpi'; import { timelineEventsLastEventTime } from './last_event_time'; export const timelineEventsFactory: Record< @@ -20,5 +21,6 @@ export const timelineEventsFactory: Record< > = { [TimelineEventsQueries.all]: timelineEventsAll, [TimelineEventsQueries.details]: timelineEventsDetails, + [TimelineEventsQueries.kpi]: timelineKpi, [TimelineEventsQueries.lastEventTime]: timelineEventsLastEventTime, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/kpi/index.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/kpi/index.ts new file mode 100644 index 000000000000..ee84a0faab2c --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/kpi/index.ts @@ -0,0 +1,39 @@ +/* + * 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 { getOr } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { + TimelineEventsQueries, + TimelineRequestBasicOptions, + TimelineKpiStrategyResponse, +} from '../../../../../../common/search_strategy/timeline'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { SecuritySolutionTimelineFactory } from '../../types'; +import { buildTimelineKpiQuery } from './query.kpi.dsl'; + +export const timelineKpi: SecuritySolutionTimelineFactory = { + buildDsl: (options: TimelineRequestBasicOptions) => buildTimelineKpiQuery(options), + parse: async ( + options: TimelineRequestBasicOptions, + response: IEsSearchResponse + ): Promise => { + const inspect = { + dsl: [inspectStringifyObject(buildTimelineKpiQuery(options))], + }; + + return { + ...response, + destinationIpCount: getOr(0, 'aggregations.destinationIpCount.value', response.rawResponse), + inspect, + hostCount: getOr(0, 'aggregations.hostCount.value', response.rawResponse), + processCount: getOr(0, 'aggregations.processCount.value', response.rawResponse), + sourceIpCount: getOr(0, 'aggregations.sourceIpCount.value', response.rawResponse), + userCount: getOr(0, 'aggregations.userCount.value', response.rawResponse), + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/kpi/query.kpi.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/kpi/query.kpi.dsl.ts new file mode 100644 index 000000000000..b0333411bdee --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/kpi/query.kpi.dsl.ts @@ -0,0 +1,86 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; + +import { + TimerangeFilter, + TimerangeInput, + TimelineRequestBasicOptions, +} from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +export const buildTimelineKpiQuery = ({ + defaultIndex, + filterQuery, + timerange, +}: TimelineRequestBasicOptions) => { + const filterClause = [...createQueryFilterClauses(filterQuery)]; + + const getTimerangeFilter = (timerangeOption: TimerangeInput | undefined): TimerangeFilter[] => { + if (timerangeOption) { + const { to, from } = timerangeOption; + return !isEmpty(to) && !isEmpty(from) + ? [ + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ] + : []; + } + return []; + }; + + const filter = [...filterClause, ...getTimerangeFilter(timerange), { match_all: {} }]; + + const dslQuery = { + allowNoIndices: true, + index: defaultIndex, + ignoreUnavailable: true, + body: { + aggs: { + userCount: { + cardinality: { + field: 'user.id', + }, + }, + destinationIpCount: { + cardinality: { + field: 'destination.ip', + }, + }, + hostCount: { + cardinality: { + field: 'host.id', + }, + }, + processCount: { + cardinality: { + field: 'process.entity_id', + }, + }, + sourceIpCount: { + cardinality: { + field: 'source.ip', + }, + }, + }, + query: { + bool: { + filter, + }, + }, + track_total_hits: true, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts index aec9a896afc7..da104e5124b3 100644 --- a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts +++ b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts @@ -558,6 +558,14 @@ const EXPECTED_DATA = [ }, ]; +const EXPECTED_KPI_COUNTS = { + destinationIpCount: 154, + hostCount: 1, + processCount: 0, + sourceIpCount: 121, + userCount: 0, +}; + export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); @@ -587,5 +595,24 @@ export default function ({ getService }: FtrProviderContext) { }) ).to.eql(sortBy(EXPECTED_DATA, 'name')); }); + + it('Make sure that we get kpi data', async () => { + const { + body: { destinationIpCount, hostCount, processCount, sourceIpCount, userCount }, + } = await supertest + .post('/internal/search/securitySolutionTimelineSearchStrategy/') + .set('kbn-xsrf', 'true') + .send({ + factoryQueryType: TimelineEventsQueries.kpi, + docValueFields: [], + indexName: INDEX_NAME, + inspect: false, + eventId: ID, + }) + .expect(200); + expect({ destinationIpCount, hostCount, processCount, sourceIpCount, userCount }).to.eql( + EXPECTED_KPI_COUNTS + ); + }); }); }