From 482faae7995fd6febf3ce9ae200ab79476a36c26 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Thu, 9 Jan 2020 17:52:57 -0700 Subject: [PATCH] [SIEM] Adds Signals Histogram (#53742) ## Summary Detection Engine Meta Issue: #50405 This PR adds the `Signals Histogram` component for use on the main `Detection Engine` page, `Rule Details` page, and the newly designed `Overview` page. Out of the box configuration includes an `EuiSelect` for stacking by the following: * Risk Scores * Severities * Event Actions * Event Categories * Host Names * Rule Types * Rules * Users * Destination IPs * Source IPs Additional configuration properties are available to configure the component as needed depending on where it will be displayed (e.g. no `Stack By` option on `Overview`, filter to specific `rule_id` on `Rule Details`, etc): ``` ts interface SignalsHistogramPanelProps { defaultStackByOption?: SignalsHistogramOption; filters?: esFilters.Filter[]; from: number; query?: Query; legendPosition?: 'left' | 'right' | 'bottom' | 'top'; loadingInitial?: boolean; showLinkToSignals?: boolean; showTotalSignalsCount?: boolean; stackByOptions?: SignalsHistogramOption[]; title?: string; to: number; updateDateRange: (min: number, max: number) => void; } ``` ##### Light Theme: ![de_hist_light](https://user-images.githubusercontent.com/2946766/71299977-41685800-234e-11ea-93bd-05a0c4cb6ee1.gif) ##### Dark Theme: ![de_histogram_dark](https://user-images.githubusercontent.com/2946766/71299980-45947580-234e-11ea-9d26-380bae5c4aa6.gif) ##### Overview: Example props for overview impl: ``` jsx ``` ![image](https://user-images.githubusercontent.com/2946766/72030438-2fd7e900-3246-11ea-8404-40905ca5f85c.png) Note @andrew-goldstein @angorayc @MichaelMarcialis -- looks like the MITRE ATT&CK Tactics are stored as a nested object in `signal.rule.threat`, so we may have to do some finangling to get it to show on the histogram. e.g. format: ``` json { "framework": "MITRE ATT&CK", "tactic": { "id": "TA0010", "reference": "https://attack.mitre.org/tactics/TA0010", "name": "Exfiltration" }, "techniques": [ { "id": "T1002", "name": "Data Compressed", "reference": "https://attack.mitre.org/techniques/T1002" } ] } ``` ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. - [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials * Will work with @benskelker on any specific documentation - [ ] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios - [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~ ### For maintainers - [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ - [ ] ~This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ --- .../__snapshots__/index.test.tsx.snap | 3 - .../histogram_signals/index.test.tsx | 23 - .../histogram_signals/index.tsx | 79 ---- .../detection_engine/signals/api.ts | 2 +- .../detection_engine/signals/types.ts | 5 +- .../detection_engine/signals/use_query.tsx | 15 +- .../components/signals_chart/index.tsx | 41 -- .../signals_histogram_panel/config.ts | 21 + .../signals_histogram_panel/index.tsx | 112 +++++ .../signals_histogram/helpers.tsx | 73 +++ .../signals_histogram/index.tsx | 122 +++++ .../signals_histogram_panel/translations.ts | 116 +++++ .../signals_histogram_panel/types.ts | 40 ++ .../components/signals_info/index.tsx | 11 +- .../components/signals_info/query.dsl.ts | 2 +- .../detection_engine/detection_engine.tsx | 111 ++++- .../public/pages/detection_engine/index.tsx | 8 +- .../detection_engine/rules/details/index.tsx | 415 ++++++++++-------- 18 files changed, 836 insertions(+), 363 deletions(-) delete mode 100644 x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts diff --git a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap deleted file mode 100644 index d2579f427debea..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`HistogramSignals it renders 1`] = ``; diff --git a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx deleted file mode 100644 index 5d2f3256ef5091..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 { shallow } from 'enzyme'; -import React from 'react'; - -import { TestProviders } from '../../../../mock'; -import { HistogramSignals } from './index'; - -describe('HistogramSignals', () => { - test('it renders', () => { - const wrapper = shallow( - - - - ); - - expect(wrapper.find('HistogramSignals')).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx deleted file mode 100644 index 35fe8a2d905095..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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 { - Axis, - Chart, - HistogramBarSeries, - Settings, - niceTimeFormatByDay, - timeFormatter, -} from '@elastic/charts'; -import React from 'react'; -import { npStart } from 'ui/new_platform'; - -export const HistogramSignals = React.memo(() => { - const sampleChartData = [ - { x: 1571090784000, y: 2, a: 'a' }, - { x: 1571090784000, y: 2, b: 'b' }, - { x: 1571093484000, y: 7, a: 'a' }, - { x: 1571096184000, y: 3, a: 'a' }, - { x: 1571098884000, y: 2, a: 'a' }, - { x: 1571101584000, y: 7, a: 'a' }, - { x: 1571104284000, y: 3, a: 'a' }, - { x: 1571106984000, y: 2, a: 'a' }, - { x: 1571109684000, y: 7, a: 'a' }, - { x: 1571112384000, y: 3, a: 'a' }, - { x: 1571115084000, y: 2, a: 'a' }, - { x: 1571117784000, y: 7, a: 'a' }, - { x: 1571120484000, y: 3, a: 'a' }, - { x: 1571123184000, y: 2, a: 'a' }, - { x: 1571125884000, y: 7, a: 'a' }, - { x: 1571128584000, y: 3, a: 'a' }, - { x: 1571131284000, y: 2, a: 'a' }, - { x: 1571133984000, y: 7, a: 'a' }, - { x: 1571136684000, y: 3, a: 'a' }, - { x: 1571139384000, y: 2, a: 'a' }, - { x: 1571142084000, y: 7, a: 'a' }, - { x: 1571144784000, y: 3, a: 'a' }, - { x: 1571147484000, y: 2, a: 'a' }, - { x: 1571150184000, y: 7, a: 'a' }, - { x: 1571152884000, y: 3, a: 'a' }, - { x: 1571155584000, y: 2, a: 'a' }, - { x: 1571158284000, y: 7, a: 'a' }, - { x: 1571160984000, y: 3, a: 'a' }, - { x: 1571163684000, y: 2, a: 'a' }, - { x: 1571166384000, y: 7, a: 'a' }, - { x: 1571169084000, y: 3, a: 'a' }, - { x: 1571171784000, y: 2, a: 'a' }, - { x: 1571174484000, y: 7, a: 'a' }, - ]; - - return ( - - - - - - - - - - ); -}); -HistogramSignals.displayName = 'HistogramSignals'; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts index e7641fd37678e2..8754d73637e7c7 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts @@ -42,7 +42,7 @@ export const fetchQuerySignals = async ({ 'content-type': 'application/json', 'kbn-xsrf': 'true', }, - body: query, + body: JSON.stringify(query), signal, }); await throwIfNotOk(response); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts index 32f53691bae874..34cb7684a03993 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts @@ -10,7 +10,7 @@ export interface BasicSignals { signal: AbortSignal; } export interface QuerySignals extends BasicSignals { - query: string; + query: object; } export interface SignalsResponse { @@ -18,7 +18,8 @@ export interface SignalsResponse { timeout: boolean; } -export interface SignalSearchResponse extends SignalsResponse { +export interface SignalSearchResponse + extends SignalsResponse { _shards: { total: number; successful: number; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx index 65a5ac866e68d7..fa88a84fb11871 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx @@ -4,20 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useState } from 'react'; +import React, { SetStateAction, useEffect, useState } from 'react'; import { fetchQuerySignals } from './api'; import { SignalSearchResponse } from './types'; -type Return = [boolean, SignalSearchResponse | null]; +type Return = [ + boolean, + SignalSearchResponse | null, + React.Dispatch> +]; /** * Hook for using to get a Signals from the Detection Engine API * - * @param query convert a dsl into string + * @param initialQuery query dsl object * */ -export const useQuerySignals = (query: string): Return => { +export const useQuerySignals = (initialQuery: object): Return => { + const [query, setQuery] = useState(initialQuery); const [signals, setSignals] = useState | null>(null); const [loading, setLoading] = useState(true); @@ -53,5 +58,5 @@ export const useQuerySignals = (query: string): Return => }; }, [query]); - return [loading, signals]; + return [loading, signals, setQuery]; }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx deleted file mode 100644 index 01ebafdccfefdb..00000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 { EuiPanel, EuiSelect } from '@elastic/eui'; -import { noop } from 'lodash/fp'; -import React, { memo } from 'react'; - -import { HeaderSection } from '../../../../components/header_section'; -import { HistogramSignals } from '../../../../components/page/detection_engine/histogram_signals'; - -export const sampleChartOptions = [ - { text: 'Risk scores', value: 'risk_scores' }, - { text: 'Severities', value: 'severities' }, - { text: 'Top destination IPs', value: 'destination_ips' }, - { text: 'Top event actions', value: 'event_actions' }, - { text: 'Top event categories', value: 'event_categories' }, - { text: 'Top host names', value: 'host_names' }, - { text: 'Top rule types', value: 'rule_types' }, - { text: 'Top rules', value: 'rules' }, - { text: 'Top source IPs', value: 'source_ips' }, - { text: 'Top users', value: 'users' }, -]; - -const SignalsChartsComponent = () => ( - - - noop} - prepend="Stack by" - value={sampleChartOptions[0].value} - /> - - - - -); - -export const SignalsCharts = memo(SignalsChartsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.ts new file mode 100644 index 00000000000000..f329780b075e3f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.ts @@ -0,0 +1,21 @@ +/* + * 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 * as i18n from './translations'; +import { SignalsHistogramOption } from './types'; + +export const signalsHistogramOptions: SignalsHistogramOption[] = [ + { text: i18n.STACK_BY_RISK_SCORES, value: 'signal.rule.risk_score' }, + { text: i18n.STACK_BY_SEVERITIES, value: 'signal.rule.severity' }, + { text: i18n.STACK_BY_DESTINATION_IPS, value: 'destination.ip' }, + { text: i18n.STACK_BY_ACTIONS, value: 'event.action' }, + { text: i18n.STACK_BY_CATEGORIES, value: 'event.category' }, + { text: i18n.STACK_BY_HOST_NAMES, value: 'host.name' }, + { text: i18n.STACK_BY_RULE_TYPES, value: 'signal.rule.type' }, + { text: i18n.STACK_BY_RULE_NAMES, value: 'signal.rule.name' }, + { text: i18n.STACK_BY_SOURCE_IPS, value: 'source.ip' }, + { text: i18n.STACK_BY_USERS, value: 'user.name' }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx new file mode 100644 index 00000000000000..fda40f5f9fa5db --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx @@ -0,0 +1,112 @@ +/* + * 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 { Position } from '@elastic/charts'; +import { EuiButton, EuiPanel, EuiSelect } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import React, { memo, useCallback, useMemo, useState } from 'react'; + +import { HeaderSection } from '../../../../components/header_section'; +import { SignalsHistogram } from './signals_histogram'; + +import * as i18n from './translations'; +import { Query } from '../../../../../../../../../src/plugins/data/common/query'; +import { esFilters } from '../../../../../../../../../src/plugins/data/common/es_query'; +import { SignalsHistogramOption, SignalsTotal } from './types'; +import { signalsHistogramOptions } from './config'; +import { getDetectionEngineUrl } from '../../../../components/link_to'; +import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants'; +import { useUiSetting$ } from '../../../../lib/kibana'; + +const defaultTotalSignalsObj: SignalsTotal = { + value: 0, + relation: 'eq', +}; + +interface SignalsHistogramPanelProps { + defaultStackByOption?: SignalsHistogramOption; + filters?: esFilters.Filter[]; + from: number; + query?: Query; + legendPosition?: Position; + loadingInitial?: boolean; + showLinkToSignals?: boolean; + showTotalSignalsCount?: boolean; + stackByOptions?: SignalsHistogramOption[]; + title?: string; + to: number; + updateDateRange: (min: number, max: number) => void; +} + +export const SignalsHistogramPanel = memo( + ({ + defaultStackByOption = signalsHistogramOptions[0], + filters, + query, + from, + legendPosition = 'bottom', + loadingInitial = false, + showLinkToSignals = false, + showTotalSignalsCount = false, + stackByOptions, + to, + title = i18n.HISTOGRAM_HEADER, + updateDateRange, + }) => { + const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const [totalSignalsObj, setTotalSignalsObj] = useState(defaultTotalSignalsObj); + const [selectedStackByOption, setSelectedStackByOption] = useState( + defaultStackByOption + ); + + const totalSignals = useMemo( + () => + i18n.SHOWING_SIGNALS( + numeral(totalSignalsObj.value).format(defaultNumberFormat), + totalSignalsObj.value, + totalSignalsObj.relation === 'gte' ? '>' : totalSignalsObj.relation === 'lte' ? '<' : '' + ), + [totalSignalsObj] + ); + + const setSelectedOptionCallback = useCallback((event: React.ChangeEvent) => { + setSelectedStackByOption( + stackByOptions?.find(co => co.value === event.target.value) ?? defaultStackByOption + ); + }, []); + + return ( + + + {stackByOptions && ( + + )} + {showLinkToSignals && ( + {i18n.VIEW_SIGNALS} + )} + + + + + ); + } +); + +SignalsHistogramPanel.displayName = 'SignalsHistogramPanel'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx new file mode 100644 index 00000000000000..ed503e9872f0af --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx @@ -0,0 +1,73 @@ +/* + * 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 { HistogramData, SignalsAggregation, SignalsBucket, SignalsGroupBucket } from '../types'; +import { SignalSearchResponse } from '../../../../../containers/detection_engine/signals/types'; +import * as i18n from '../translations'; + +export const formatSignalsData = ( + signalsData: SignalSearchResponse<{}, SignalsAggregation> | null +) => { + const groupBuckets: SignalsGroupBucket[] = + signalsData?.aggregations?.signalsByGrouping?.buckets ?? []; + return groupBuckets.reduce((acc, { key: group, signals }) => { + const signalsBucket: SignalsBucket[] = signals.buckets ?? []; + + return [ + ...acc, + ...signalsBucket.map(({ key, doc_count }: SignalsBucket) => ({ + x: key, + y: doc_count, + g: group, + })), + ]; + }, []); +}; + +export const getSignalsHistogramQuery = ( + stackByField: string, + from: number, + to: number, + additionalFilters: Array<{ + bool: { filter: unknown[]; should: unknown[]; must_not: unknown[]; must: unknown[] }; + }> +) => ({ + aggs: { + signalsByGrouping: { + terms: { + field: stackByField, + missing: stackByField.endsWith('.ip') ? '0.0.0.0' : i18n.ALL_OTHERS, + order: { + _count: 'desc', + }, + size: 10, + }, + aggs: { + signals: { + auto_date_histogram: { + field: '@timestamp', + buckets: 36, + }, + }, + }, + }, + }, + query: { + bool: { + filter: [ + ...additionalFilters, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx new file mode 100644 index 00000000000000..218fcc3a70f79f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx @@ -0,0 +1,122 @@ +/* + * 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 { + Axis, + Chart, + getAxisId, + getSpecId, + HistogramBarSeries, + niceTimeFormatByDay, + Position, + Settings, + timeFormatter, +} from '@elastic/charts'; +import React, { useEffect, useMemo } from 'react'; +import { EuiLoadingContent } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import { useQuerySignals } from '../../../../../containers/detection_engine/signals/use_query'; +import { Query } from '../../../../../../../../../../src/plugins/data/common/query'; +import { esFilters, esQuery } from '../../../../../../../../../../src/plugins/data/common/es_query'; +import { SignalsAggregation, SignalsTotal } from '../types'; +import { formatSignalsData, getSignalsHistogramQuery } from './helpers'; +import { useTheme } from '../../../../../components/charts/common'; +import { useKibana } from '../../../../../lib/kibana'; + +interface HistogramSignalsProps { + filters?: esFilters.Filter[]; + from: number; + legendPosition?: Position; + loadingInitial: boolean; + query?: Query; + setTotalSignalsCount: React.Dispatch; + stackByField: string; + to: number; + updateDateRange: (min: number, max: number) => void; +} + +export const SignalsHistogram = React.memo( + ({ + to, + from, + query, + filters, + legendPosition = 'bottom', + loadingInitial, + setTotalSignalsCount, + stackByField, + updateDateRange, + }) => { + const [isLoadingSignals, signalsData, setQuery] = useQuerySignals<{}, SignalsAggregation>( + getSignalsHistogramQuery(stackByField, from, to, []) + ); + const theme = useTheme(); + const kibana = useKibana(); + + const formattedSignalsData = useMemo(() => formatSignalsData(signalsData), [signalsData]); + + useEffect(() => { + setTotalSignalsCount( + signalsData?.hits.total ?? { + value: 0, + relation: 'eq', + } + ); + }, [signalsData]); + + useEffect(() => { + const converted = esQuery.buildEsQuery( + undefined, + query != null ? [query] : [], + filters?.filter(f => f.meta.disabled === false) ?? [], + { + ...esQuery.getEsQueryConfig(kibana.services.uiSettings), + dateFormatTZ: undefined, + } + ); + + setQuery( + getSignalsHistogramQuery(stackByField, from, to, !isEmpty(converted) ? [converted] : []) + ); + }, [stackByField, from, to, query, filters]); + + return ( + <> + {loadingInitial || isLoadingSignals ? ( + + ) : ( + + + + + + + + + + )} + + ); + } +); +SignalsHistogram.displayName = 'SignalsHistogram'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts new file mode 100644 index 00000000000000..0245b9968cc360 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts @@ -0,0 +1,116 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const STACK_BY_LABEL = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.stackByLabel', + { + defaultMessage: 'Stack by', + } +); + +export const STACK_BY_RISK_SCORES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.riskScoresDropDown', + { + defaultMessage: 'Risk scores', + } +); + +export const STACK_BY_SEVERITIES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.severitiesDropDown', + { + defaultMessage: 'Severities', + } +); + +export const STACK_BY_DESTINATION_IPS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.destinationIpsDropDown', + { + defaultMessage: 'Top destination IPs', + } +); + +export const STACK_BY_SOURCE_IPS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.sourceIpsDropDown', + { + defaultMessage: 'Top source IPs', + } +); + +export const STACK_BY_ACTIONS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.eventActionsDropDown', + { + defaultMessage: 'Top event actions', + } +); + +export const STACK_BY_CATEGORIES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.eventCategoriesDropDown', + { + defaultMessage: 'Top event categories', + } +); + +export const STACK_BY_HOST_NAMES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.hostNamesDropDown', + { + defaultMessage: 'Top host names', + } +); + +export const STACK_BY_RULE_TYPES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.ruleTypesDropDown', + { + defaultMessage: 'Top rule types', + } +); + +export const STACK_BY_RULE_NAMES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.rulesDropDown', + { + defaultMessage: 'Top rules', + } +); + +export const STACK_BY_USERS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.usersDropDown', + { + defaultMessage: 'Top users', + } +); + +export const HISTOGRAM_HEADER = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.headerTitle', + { + defaultMessage: 'Signal detection frequency', + } +); + +export const ALL_OTHERS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.allOthersGroupingLabel', + { + defaultMessage: 'All others', + } +); + +export const VIEW_SIGNALS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.viewSignalsButtonLabel', + { + defaultMessage: 'View signals', + } +); + +export const SHOWING_SIGNALS = ( + totalSignalsFormatted: string, + totalSignals: number, + modifier: string +) => + i18n.translate('xpack.siem.detectionEngine.signals.histogram.showingSignalsTitle', { + values: { totalSignalsFormatted, totalSignals, modifier }, + defaultMessage: + 'Showing: {modifier}{totalSignalsFormatted} {totalSignals, plural, =1 {signal} other {signals}}', + }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts new file mode 100644 index 00000000000000..4eb10852450ade --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts @@ -0,0 +1,40 @@ +/* + * 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. + */ + +export interface SignalsHistogramOption { + text: string; + value: string; +} + +export interface HistogramData { + x: number; + y: number; + g: string; +} + +export interface SignalsAggregation { + signalsByGrouping: { + buckets: SignalsGroupBucket[]; + }; +} + +export interface SignalsBucket { + key_as_string: string; + key: number; + doc_count: number; +} + +export interface SignalsGroupBucket { + key: string; + signals: { + buckets: SignalsBucket[]; + }; +} + +export interface SignalsTotal { + value: number; + relation: string; +} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx index a90022d4a34cea..fc1110e3828473 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx @@ -9,7 +9,7 @@ import { FormattedRelative } from '@kbn/i18n/react'; import React, { useState, useEffect } from 'react'; import { useQuerySignals } from '../../../../containers/detection_engine/signals/use_query'; -import { buildlastSignalsQuery } from './query.dsl'; +import { buildLastSignalsQuery } from './query.dsl'; import { Aggs } from './types'; interface SignalInfo { @@ -26,14 +26,7 @@ export const useSignalInfo = ({ ruleId = null }: SignalInfo): Return => { ); - let query = ''; - try { - query = JSON.stringify(buildlastSignalsQuery(ruleId)); - } catch { - query = ''; - } - - const [loading, signals] = useQuerySignals(query); + const [loading, signals] = useQuerySignals(buildLastSignalsQuery(ruleId)); useEffect(() => { if (signals != null) { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts index 0b14aa17a94500..8cb07a4f8e6b55 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export const buildlastSignalsQuery = (ruleId: string | undefined | null) => { +export const buildLastSignalsQuery = (ruleId: string | undefined | null) => { const queryFilter = [ { bool: { should: [{ match: { 'signal.status': 'open' } }], minimum_should_match: 1 }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx index 8e5c3e9f131180..2a91a559ec0e4a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiSpacer, EuiPanel, EuiLoadingContent } from '@elastic/eui'; -import React from 'react'; +import { EuiButton, EuiLoadingContent, EuiPanel, EuiSpacer } from '@elastic/eui'; +import React, { useCallback } from 'react'; import { StickyContainer } from 'react-sticky'; +import { connect } from 'react-redux'; +import { ActionCreator } from 'typescript-fsa'; import { FiltersGlobal } from '../../components/filters_global'; import { HeaderPage } from '../../components/header_page'; import { SiemSearchBar } from '../../components/search_bar'; @@ -18,24 +20,63 @@ import { SpyRoute } from '../../utils/route/spy_routes'; import { SignalsTable } from './components/signals'; import * as signalsI18n from './components/signals/translations'; -import { SignalsCharts } from './components/signals_chart'; +import { SignalsHistogramPanel } from './components/signals_histogram_panel'; +import { Query } from '../../../../../../../src/plugins/data/common/query'; +import { esFilters } from '../../../../../../../src/plugins/data/common/es_query'; +import { inputsSelectors } from '../../store/inputs'; +import { State } from '../../store'; +import { InputsRange } from '../../store/inputs/model'; +import { signalsHistogramOptions } from './components/signals_histogram_panel/config'; import { useSignalInfo } from './components/signals_info'; import { DetectionEngineEmptyPage } from './detection_engine_empty_page'; import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated'; import * as i18n from './translations'; import { HeaderSection } from '../../components/header_section'; +import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions'; +import { InputsModelId } from '../../store/inputs/constants'; -interface DetectionEngineComponentProps { +interface OwnProps { loading: boolean; isSignalIndexExists: boolean | null; isUserAuthenticated: boolean | null; signalsIndex: string | null; } +interface ReduxProps { + filters: esFilters.Filter[]; + query: Query; +} + +export interface DispatchProps { + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: number; + to: number; + }>; +} + +type DetectionEngineComponentProps = OwnProps & ReduxProps & DispatchProps; + export const DetectionEngineComponent = React.memo( - ({ loading, isSignalIndexExists, isUserAuthenticated, signalsIndex }) => { + ({ + filters, + loading, + isSignalIndexExists, + isUserAuthenticated, + query, + setAbsoluteRangeDatePicker, + signalsIndex, + }) => { const [lastSignals] = useSignalInfo({}); + + const updateDateRangeCallback = useCallback( + (min: number, max: number) => { + setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); + }, + [setAbsoluteRangeDatePicker] + ); + if (isUserAuthenticated != null && !isUserAuthenticated && !loading) { return ( @@ -81,22 +122,33 @@ export const DetectionEngineComponent = React.memo - - - - {({ to, from }) => - !loading ? ( - isSignalIndexExists && ( - - ) - ) : ( - - - - - ) - } + {({ to, from }) => ( + <> + + + + + {!loading ? ( + isSignalIndexExists && ( + + ) + ) : ( + + + + + )} + + )} @@ -115,3 +167,22 @@ export const DetectionEngineComponent = React.memo { + const getGlobalInputs = inputsSelectors.globalSelector(); + return (state: State) => { + const globalInputs: InputsRange = getGlobalInputs(state); + const { query, filters } = globalInputs; + + return { + query, + filters, + }; + }; +}; + +export const DetectionEngine = connect(makeMapStateToProps, { + setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker, +})(DetectionEngineComponent); + +DetectionEngine.displayName = 'DetectionEngine'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx index 9c95c74cd62a54..c32cab7f933b2f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx @@ -11,9 +11,9 @@ import { useSignalIndex } from '../../containers/detection_engine/signals/use_si import { usePrivilegeUser } from '../../containers/detection_engine/signals/use_privilege_user'; import { CreateRuleComponent } from './rules/create'; -import { DetectionEngineComponent } from './detection_engine'; +import { DetectionEngine } from './detection_engine'; import { EditRuleComponent } from './rules/edit'; -import { RuleDetailsComponent } from './rules/details'; +import { RuleDetails } from './rules/details'; import { RulesComponent } from './rules'; const detectionEnginePath = `/:pageName(detection-engine)`; @@ -44,7 +44,7 @@ export const DetectionEngineContainer = React.memo(() => { return ( - (() => { - + diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 4d887c7cb5b6ec..9b6998ab4a1327 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -6,10 +6,12 @@ import { EuiButton, EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { memo, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { StickyContainer } from 'react-sticky'; +import { ActionCreator } from 'typescript-fsa'; +import { connect } from 'react-redux'; import { FiltersGlobal } from '../../../../components/filters_global'; import { FormattedDate } from '../../../../components/formatted_date'; import { HeaderPage } from '../../../../components/header_page'; @@ -24,7 +26,7 @@ import { } from '../../../../containers/source'; import { SpyRoute } from '../../../../utils/route/spy_routes'; -import { SignalsCharts } from '../../components/signals_chart'; +import { SignalsHistogramPanel } from '../../components/signals_histogram_panel'; import { SignalsTable } from '../../components/signals'; import { DetectionEngineEmptyPage } from '../../detection_engine_empty_page'; import { useSignalInfo } from '../../components/signals_info'; @@ -39,198 +41,261 @@ import { getStepsData } from '../helpers'; import * as ruleI18n from '../translations'; import * as i18n from './translations'; import { GlobalTime } from '../../../../containers/global_time'; +import { signalsHistogramOptions } from '../../components/signals_histogram_panel/config'; +import { InputsModelId } from '../../../../store/inputs/constants'; +import { esFilters } from '../../../../../../../../../src/plugins/data/common/es_query'; +import { Query } from '../../../../../../../../../src/plugins/data/common/query'; +import { inputsSelectors } from '../../../../store/inputs'; +import { State } from '../../../../store'; +import { InputsRange } from '../../../../store/inputs/model'; +import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../store/inputs/actions'; -interface RuleDetailsComponentProps { +interface OwnProps { signalsIndex: string | null; } -export const RuleDetailsComponent = memo(({ signalsIndex }) => { - const { ruleId } = useParams(); - const [loading, rule] = useRule(ruleId); - const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ - rule, - detailsView: true, - }); - const [lastSignals] = useSignalInfo({ ruleId }); - - const title = loading === true || rule === null ? : rule.name; - const subTitle = useMemo( - () => - loading === true || rule === null ? ( - - ) : ( - [ - - ), - }} - />, - rule?.updated_by != null ? ( +interface ReduxProps { + filters: esFilters.Filter[]; + query: Query; +} + +export interface DispatchProps { + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: number; + to: number; + }>; +} + +type RuleDetailsComponentProps = OwnProps & ReduxProps & DispatchProps; + +const RuleDetailsComponent = memo( + ({ filters, query, setAbsoluteRangeDatePicker, signalsIndex }) => { + const { ruleId } = useParams(); + const [loading, rule] = useRule(ruleId); + const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ + rule, + detailsView: true, + }); + const [lastSignals] = useSignalInfo({ ruleId }); + + const title = loading === true || rule === null ? : rule.name; + const subTitle = useMemo( + () => + loading === true || rule === null ? ( + + ) : ( + [ ), }} - /> - ) : ( - '' - ), - ] - ), - [loading, rule] - ); - - const signalDefaultFilters = useMemo( - () => (ruleId != null ? buildSignalsRuleIdFilter(ruleId) : []), - [ruleId] - ); - return ( - <> - - {({ indicesExist, indexPattern }) => { - return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - - {({ to, from }) => ( - - - - - - - - {detectionI18n.LAST_SIGNAL} - {': '} - {lastSignals} - - ) : null, - 'Status: Comming Soon', - ]} - title={title} - > - - - - + />, + rule?.updated_by != null ? ( + + ), + }} + /> + ) : ( + '' + ), + ] + ), + [loading, rule] + ); - - - - - {ruleI18n.EDIT_RULE_SETTINGS} - - - - - - - - - - - - - {defineRuleData != null && ( - - )} - - - - - - {aboutRuleData != null && ( - - )} - - - - - - {scheduleRuleData != null && ( - (ruleId != null ? buildSignalsRuleIdFilter(ruleId) : []), + [ruleId] + ); + + const signalMergedFilters = useMemo(() => [...signalDefaultFilters, ...filters], [ + signalDefaultFilters, + filters, + ]); + + const updateDateRangeCallback = useCallback( + (min: number, max: number) => { + setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); + }, + [setAbsoluteRangeDatePicker] + ); + + return ( + <> + + {({ indicesExist, indexPattern }) => { + return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( + + {({ to, from }) => ( + + + + + + + + {detectionI18n.LAST_SIGNAL} + {': '} + {lastSignals} + + ) : null, + 'Status: Comming Soon', + ]} + title={title} + > + + + - )} - - - + - + + + + + {ruleI18n.EDIT_RULE_SETTINGS} + + + + + + - + - + + + + {defineRuleData != null && ( + + )} + + + + + + {aboutRuleData != null && ( + + )} + + - {ruleId != null && ( - + + {scheduleRuleData != null && ( + + )} + + + + + + - )} - - - )} - - ) : ( - - - - - - ); - }} - - - - - ); -}); + + + + {ruleId != null && ( + + )} + + + )} + + ) : ( + + + + + + ); + }} + + + + + ); + } +); RuleDetailsComponent.displayName = 'RuleDetailsComponent'; + +const makeMapStateToProps = () => { + const getGlobalInputs = inputsSelectors.globalSelector(); + return (state: State) => { + const globalInputs: InputsRange = getGlobalInputs(state); + const { query, filters } = globalInputs; + + return { + query, + filters, + }; + }; +}; + +export const RuleDetails = connect(makeMapStateToProps, { + setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker, +})(RuleDetailsComponent); + +RuleDetails.displayName = 'RuleDetails';