From 60d907daf634eb492fea7f9175c747f89cf718ce Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Fri, 17 Dec 2021 16:18:38 -0700 Subject: [PATCH] [Security Solution] Fixes issues with the Raw events Top N view (#121562) ## [Security Solution] Fixes issues with the Raw events Top N view This PR fixes the following issues with the Raw events Top N view: - Fixes an issue where the Sourcerer context included detection alerts in the Raw events view, per the before screenshot below: ### Before ![image](https://user-images.githubusercontent.com/61860752/145980440-0945a01c-d257-434e-8d94-4231feadff5b.png) _Above: Before - the Raw events view includes detection alerts_ ### After ![after_no_detection_alerts_in_raw_events](https://user-images.githubusercontent.com/4459398/146592973-36e51997-86a4-4982-a8c3-fa0c4ee3e99f.png) _Above: After - The Raw events view does NOT include detection alerts_ - Fixes an issue where when inspecting Raw events, `Sorry about that, something went wrong` is displayed when the Sourcerer context does not match the current selection: ### Before ![image](https://user-images.githubusercontent.com/59917825/146342313-7b0afcd5-31c9-4139-9011-cb85af303deb.png) _Above: Before - When users `Inspect` the Raw events view, `Sorry about that, something went wrong` is displayed_ ### After ![after_inspect_raw_events](https://user-images.githubusercontent.com/4459398/146595397-89aa65d0-9055-4511-81bd-670b20449610.png) _Above: After - When users `Inspect` the raw events view, the expected Index pattern reflects the current Sourcerer selection_ - Fixes an issue where the following filters in the `Security > Alerts` and `Security > Rule > Details` views: - `kibana.alert.building_block_type`: an "Additional filters" option on the alerts table - `kibana.alert.rule.rule_id`: filters alerts to a single rule on the `Security > Rules > Details` views - `kibana.alert.rule.name`: not a built-in view filter, but frequently applied via the `Filter In` and `Filter Out` actions - `kibana.alert.rule.threat_mapping`: an "Additional filters" option on the alerts table - `kibana.alert.workflow_status`: The `open | acknowledged | closed` status filter were incorrectly applied to the Raw events view, per the screenshots below: ### Before Inspecting the Raw events query reveals the alert filters are applied as filter criteria, per the screenshot below: ![before_alert_filters_applied_to_raw_events_query](https://user-images.githubusercontent.com/4459398/146596292-eb2f52a2-adf4-47a3-bb96-3f39019df725.png) _Above: Before - The alert filters are applied to the Raw events view_ ### After After the fix, the alert filters are NOT applied to the raw events view, per the screenshot below: ![after_alert_filters_NOT_applied_to_raw_events_query](https://user-images.githubusercontent.com/4459398/146596252-d5ec1512-5514-48f5-aff3-e18a69572e6f.png) _Above: After - The alert filters are NOT applied to the Raw events view_ --- .../common/components/top_n/helpers.test.tsx | 270 +++++++++++++++++- .../public/common/components/top_n/helpers.ts | 178 +++++++++++- .../common/components/top_n/top_n.test.tsx | 60 +++- .../public/common/components/top_n/top_n.tsx | 45 ++- 4 files changed, 520 insertions(+), 33 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/helpers.test.tsx index bd1a2892262a..c13be767f9be 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/helpers.test.tsx @@ -5,7 +5,155 @@ * 2.0. */ -import { allEvents, defaultOptions, getOptions, rawEvents, alertEvents } from './helpers'; +import type { Filter } from '@kbn/es-query'; + +import { TimelineId } from '../../../../common/types/timeline'; +import { + alertEvents, + allEvents, + defaultOptions, + getOptions, + getSourcererScopeName, + isDetectionsAlertsTable, + rawEvents, + removeIgnoredAlertFilters, + shouldIgnoreAlertFilters, +} from './helpers'; +import { SourcererScopeName } from '../../store/sourcerer/model'; + +/** the following `TimelineId`s are detection alert tables */ +const detectionAlertsTimelines = [TimelineId.detectionsPage, TimelineId.detectionsRulesDetailsPage]; + +/** the following `TimelineId`s are NOT detection alert tables */ +const otherTimelines = [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + TimelineId.networkPageExternalAlerts, + TimelineId.uebaPageExternalAlerts, + TimelineId.active, + TimelineId.casePage, + TimelineId.test, + TimelineId.alternateTest, +]; + +const othersWithoutActive = otherTimelines.filter((x) => x !== TimelineId.active); + +const hostNameFilter: Filter = { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'host.name', + params: { + query: 'Host-abcd', + }, + }, + query: { + match_phrase: { + 'host.name': { + query: 'Host-abcd', + }, + }, + }, +}; + +const buildingBlockTypeFilter: Filter = { + meta: { + alias: null, + negate: true, + disabled: false, + type: 'exists', + key: 'kibana.alert.building_block_type', + value: 'exists', + }, + query: { + exists: { + field: 'kibana.alert.building_block_type', + }, + }, +}; + +const ruleIdFilter: Filter = { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'kibana.alert.rule.rule_id', + params: { + query: '32a4aefa-80fb-4716-bc0f-3f7bb1f14929', + }, + }, + query: { + match_phrase: { + 'kibana.alert.rule.rule_id': '32a4aefa-80fb-4716-bc0f-3f7bb1f14929', + }, + }, +}; + +const ruleNameFilter: Filter = { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'kibana.alert.rule.name', + params: { + query: 'baz', + }, + }, + query: { + match_phrase: { + 'kibana.alert.rule.name': { + query: 'baz', + }, + }, + }, +}; + +const threatMappingFilter: Filter = { + meta: { + alias: null, + negate: true, + disabled: false, + type: 'exists', + key: 'kibana.alert.rule.threat_mapping', + value: 'exists', + }, + query: { + exists: { + field: 'kibana.alert.rule.threat_mapping', + }, + }, +}; + +const workflowStatusFilter: Filter = { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'kibana.alert.workflow_status', + params: { + query: 'open', + }, + }, + query: { + term: { + 'kibana.alert.workflow_status': 'open', + }, + }, +}; + +const allFilters = [ + hostNameFilter, + buildingBlockTypeFilter, + ruleIdFilter, + ruleNameFilter, + threatMappingFilter, + workflowStatusFilter, +]; describe('getOptions', () => { test(`it returns the default options when 'activeTimelineEventType' is undefined`, () => { @@ -24,3 +172,123 @@ describe('getOptions', () => { expect(getOptions('alert')).toEqual(alertEvents); }); }); + +describe('isDetectionsAlertsTable', () => { + detectionAlertsTimelines.forEach((timelineId) => + test(`it returns true for detections alerts table '${timelineId}'`, () => { + expect(isDetectionsAlertsTable(timelineId)).toEqual(true); + }) + ); + + otherTimelines.forEach((timelineId) => + test(`it returns false for (NON alert table) timeline '${timelineId}'`, () => { + expect(isDetectionsAlertsTable(timelineId)).toEqual(false); + }) + ); +}); + +describe('shouldIgnoreAlertFilters', () => { + detectionAlertsTimelines.forEach((timelineId) => { + test(`it returns true when the view is 'raw' for detections alerts table '${timelineId}'`, () => { + const view = 'raw'; + expect(shouldIgnoreAlertFilters({ timelineId, view })).toEqual(true); + }); + + test(`it returns false when the view is NOT 'raw' for detections alerts table '${timelineId}'`, () => { + const view = 'alert'; // the default selection for detection alert tables + expect(shouldIgnoreAlertFilters({ timelineId, view })).toEqual(false); + }); + }); + + otherTimelines.forEach((timelineId) => { + test(`it returns false when the view is 'raw' for (NON alert table) timeline'${timelineId}'`, () => { + const view = 'raw'; + expect(shouldIgnoreAlertFilters({ timelineId, view })).toEqual(false); + }); + + test(`it returns false when the view is NOT 'raw' for (NON alert table) timeline '${timelineId}'`, () => { + const view = 'alert'; + expect(shouldIgnoreAlertFilters({ timelineId, view })).toEqual(false); + }); + }); +}); + +describe('removeIgnoredAlertFilters', () => { + detectionAlertsTimelines.forEach((timelineId) => { + test(`it removes the ignored alert filters when the view is 'raw' for detections alerts table '${timelineId}'`, () => { + const view = 'raw'; + expect(removeIgnoredAlertFilters({ filters: allFilters, timelineId, view })).toEqual([ + hostNameFilter, + ]); + }); + + test(`it does NOT remove any filters when the view is NOT 'raw' for detections alerts table '${timelineId}'`, () => { + const view = 'alert'; + expect(removeIgnoredAlertFilters({ filters: allFilters, timelineId, view })).toEqual( + allFilters + ); + }); + }); + + otherTimelines.forEach((timelineId) => { + test(`it does NOT remove any filters when the view is 'raw' for (NON alert table) '${timelineId}'`, () => { + const view = 'alert'; + expect(removeIgnoredAlertFilters({ filters: allFilters, timelineId, view })).toEqual( + allFilters + ); + }); + + test(`it does NOT remove any filters when the view is NOT 'raw' for (NON alert table '${timelineId}'`, () => { + const view = 'alert'; + expect(removeIgnoredAlertFilters({ filters: allFilters, timelineId, view })).toEqual( + allFilters + ); + }); + }); +}); + +describe('getSourcererScopeName', () => { + detectionAlertsTimelines.forEach((timelineId) => { + test(`it returns the 'default' SourcererScopeName when the view is 'raw' for detections alerts table '${timelineId}'`, () => { + const view = 'raw'; + expect(getSourcererScopeName({ timelineId, view })).toEqual(SourcererScopeName.default); + }); + + test(`it returns the 'detections' SourcererScopeName when the view is NOT 'raw' for detections alerts table '${timelineId}'`, () => { + const view = 'alert'; + expect(getSourcererScopeName({ timelineId, view })).toEqual(SourcererScopeName.detections); + }); + }); + + test(`it returns the 'default' SourcererScopeName when timelineId is undefined'`, () => { + const timelineId = undefined; + const view = 'raw'; + expect(getSourcererScopeName({ timelineId, view })).toEqual(SourcererScopeName.default); + }); + + test(`it returns the 'timeline' SourcererScopeName when the view is 'raw' for the active timeline '${TimelineId.active}'`, () => { + const view = 'raw'; + expect(getSourcererScopeName({ timelineId: TimelineId.active, view })).toEqual( + SourcererScopeName.timeline + ); + }); + + test(`it returns the 'timeline' SourcererScopeName when the view is NOT 'raw' for the active timeline '${TimelineId.active}'`, () => { + const view = 'all'; + expect(getSourcererScopeName({ timelineId: TimelineId.active, view })).toEqual( + SourcererScopeName.timeline + ); + }); + + othersWithoutActive.forEach((timelineId) => { + test(`it returns the 'default' SourcererScopeName when the view is 'raw' for (NON alert table) timeline '${timelineId}'`, () => { + const view = 'raw'; + expect(getSourcererScopeName({ timelineId, view })).toEqual(SourcererScopeName.default); + }); + + test(`it returns the 'default' SourcererScopeName when the view is NOT 'raw' for detections alerts table '${timelineId}'`, () => { + const view = 'alert'; + expect(getSourcererScopeName({ timelineId, view })).toEqual(SourcererScopeName.default); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/helpers.ts b/x-pack/plugins/security_solution/public/common/components/top_n/helpers.ts index c7a931030826..37eeac326afd 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/top_n/helpers.ts @@ -5,7 +5,60 @@ * 2.0. */ -import { TimelineEventsType } from '../../../../common/types/timeline'; +import type { Filter } from '@kbn/es-query'; +import { + ALERT_ACTION_GROUP, + ALERT_BUILDING_BLOCK_TYPE, + ALERT_DURATION, + ALERT_END, + ALERT_EVALUATION_THRESHOLD, + ALERT_EVALUATION_VALUE, + ALERT_INSTANCE_ID, + ALERT_NAMESPACE, + ALERT_REASON, + ALERT_RISK_SCORE, + ALERT_RULE_AUTHOR, + ALERT_RULE_CATEGORY, + ALERT_RULE_CONSUMER, + ALERT_RULE_CREATED_AT, + ALERT_RULE_CREATED_BY, + ALERT_RULE_DESCRIPTION, + ALERT_RULE_ENABLED, + ALERT_RULE_FROM, + ALERT_RULE_INTERVAL, + ALERT_RULE_LICENSE, + ALERT_RULE_NAME, + ALERT_RULE_NAMESPACE, + ALERT_RULE_NOTE, + ALERT_RULE_PARAMETERS, + ALERT_RULE_PRODUCER, + ALERT_RULE_REFERENCES, + ALERT_RULE_RISK_SCORE, + ALERT_RULE_RISK_SCORE_MAPPING, + ALERT_RULE_RULE_ID, + ALERT_RULE_RULE_NAME_OVERRIDE, + ALERT_RULE_SEVERITY, + ALERT_RULE_SEVERITY_MAPPING, + ALERT_RULE_TAGS, + ALERT_RULE_TO, + ALERT_RULE_TYPE, + ALERT_RULE_TYPE_ID, + ALERT_RULE_UPDATED_AT, + ALERT_RULE_UPDATED_BY, + ALERT_RULE_UUID, + ALERT_RULE_VERSION, + ALERT_SEVERITY, + ALERT_START, + ALERT_STATUS, + ALERT_SYSTEM_STATUS, + ALERT_UUID, + ALERT_WORKFLOW_REASON, + ALERT_WORKFLOW_STATUS, + ALERT_WORKFLOW_USER, +} from '@kbn/rule-data-utils'; + +import { TimelineEventsType, TimelineId } from '../../../../common/types/timeline'; +import { SourcererScopeName } from '../../store/sourcerer/model'; import * as i18n from './translations'; @@ -65,3 +118,126 @@ export const getOptions = (activeTimelineEventsType?: TimelineEventsType): TopNO return defaultOptions; } }; + +/** returns true if the specified timelineId is a detections alert table */ +export const isDetectionsAlertsTable = (timelineId: string | undefined): boolean => + timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage; + +/** + * The following fields are used to filter alerts tables, (i.e. tables in the + * `Security > Alert` and `Security > Rule > Details` pages). These fields, + * MUST be ignored when showing Top N alerts for `raw` documents, because + * the raw documents don't include them. + */ +export const IGNORED_ALERT_FILTERS = [ + ALERT_ACTION_GROUP, + ALERT_BUILDING_BLOCK_TYPE, // an "Additional filters" option on the alerts table + ALERT_DURATION, + ALERT_END, + ALERT_EVALUATION_THRESHOLD, + ALERT_EVALUATION_VALUE, + ALERT_INSTANCE_ID, + ALERT_NAMESPACE, + ALERT_RULE_NAMESPACE, + ALERT_RULE_CONSUMER, + ALERT_RULE_PRODUCER, + ALERT_REASON, + ALERT_RISK_SCORE, + ALERT_STATUS, + ALERT_WORKFLOW_REASON, + ALERT_WORKFLOW_STATUS, // open | acknowledged | closed filter + ALERT_WORKFLOW_USER, + ALERT_RULE_AUTHOR, + ALERT_RULE_CREATED_AT, + ALERT_RULE_CREATED_BY, + ALERT_RULE_DESCRIPTION, + ALERT_RULE_ENABLED, + ALERT_RULE_FROM, + ALERT_RULE_INTERVAL, + ALERT_RULE_LICENSE, + ALERT_RULE_NAME, // not a built-in view filter, but frequently applied via the `Filter In` and `Filter Out` actions + ALERT_RULE_NOTE, + ALERT_RULE_PARAMETERS, + ALERT_RULE_REFERENCES, + ALERT_RULE_RISK_SCORE, + ALERT_RULE_RISK_SCORE_MAPPING, + ALERT_RULE_RULE_ID, // filters alerts to a single rule on the Security > Rules > details pages + ALERT_RULE_RULE_NAME_OVERRIDE, + ALERT_RULE_SEVERITY_MAPPING, + ALERT_RULE_TAGS, + 'kibana.alert.rule.threat_mapping', // an "Additional filters" option on the alerts table + ALERT_RULE_TO, + ALERT_RULE_TYPE, + ALERT_RULE_TYPE_ID, + ALERT_RULE_UPDATED_AT, + ALERT_RULE_UPDATED_BY, + ALERT_RULE_UUID, + ALERT_RULE_CATEGORY, + ALERT_RULE_VERSION, + ALERT_RULE_SEVERITY, + ALERT_SEVERITY, + ALERT_START, + ALERT_SYSTEM_STATUS, + ALERT_UUID, +]; + +/** + * returns true if the Top N query should ignore filters specific to alerts + * when querying raw documents + * + * @see IGNORED_ALERT_FILTERS + */ +export const shouldIgnoreAlertFilters = ({ + timelineId, + view, +}: { + timelineId: string | undefined; + view: TimelineEventsType; +}): boolean => view === 'raw' && isDetectionsAlertsTable(timelineId); + +/** + * returns a new set of `filters` that don't contain the fields specified in + * `IGNORED_ALERT_FILTERS` when they should be ignored + * + * @see IGNORED_ALERT_FILTERS + */ +export const removeIgnoredAlertFilters = ({ + filters, + timelineId, + view, +}: { + filters: Filter[]; + timelineId: string | undefined; + view: TimelineEventsType; +}): Filter[] => { + if (!shouldIgnoreAlertFilters({ timelineId, view })) { + return filters; // unmodified filters + } + + return filters.filter((x) => !IGNORED_ALERT_FILTERS.includes(`${x.meta.key}`)); +}; + +/** returns the SourcererScopeName applicable to the specified timelineId and view */ +export const getSourcererScopeName = ({ + timelineId, + view, +}: { + timelineId: string | undefined; + view: TimelineEventsType; +}): SourcererScopeName => { + // When alerts should be ignored, use the `default` Sourcerer scope, + // because it does NOT include alert indexes: + if (shouldIgnoreAlertFilters({ timelineId, view })) { + return SourcererScopeName.default; // no alerts in this scope + } + + if (isDetectionsAlertsTable(timelineId)) { + return SourcererScopeName.detections; + } + + if (timelineId === TimelineId.active) { + return SourcererScopeName.timeline; + } + + return SourcererScopeName.default; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx index 8cb56d7581b3..c5a8e9314535 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx @@ -8,6 +8,8 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; import { waitFor } from '@testing-library/react'; + +import { TimelineId } from '../../../../common/types'; import '../../mock/match_media'; import { TestProviders, mockIndexPattern } from '../../mock'; @@ -126,9 +128,59 @@ describe('TopN', () => { expect(toggleTopN).toHaveBeenCalled(); }); + }); + + describe('view selection', () => { + const detectionAlertsTimelines = [ + TimelineId.detectionsPage, + TimelineId.detectionsRulesDetailsPage, + ]; + + const nonDetectionAlertTables = [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + TimelineId.networkPageExternalAlerts, + TimelineId.casePage, + ]; - test('it enables the view select by default', () => { - expect(wrapper.find('[data-test-subj="view-select"]').first().props().disabled).toBe(false); + test('it disables view selection when timelineId is undefined', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="view-select"]').first().props().disabled).toBe(true); + }); + + test('it disables view selection when timelineId is `active`', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="view-select"]').first().props().disabled).toBe(true); + }); + + detectionAlertsTimelines.forEach((timelineId) => { + test(`it enables view selection for detection alert table '${timelineId}'`, () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="view-select"]').first().props().disabled).toBe(false); + }); + }); + + nonDetectionAlertTables.forEach((timelineId) => { + test(`it disables view selection for NON detection alert table '${timelineId}'`, () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="view-select"]').first().props().disabled).toBe(true); + }); }); }); @@ -203,10 +255,6 @@ describe('TopN', () => { ); }); - test(`it disables the view select when 'options' contains only one entry`, () => { - expect(wrapper.find('[data-test-subj="view-select"]').first().props().disabled).toBe(true); - }); - test(`it renders EventsByDataset when defaultView is 'all'`, () => { expect( wrapper.find('[data-test-subj="eventsByDatasetOverview-uuid.v4()Panel"]').exists() diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx index f0d7000e4ed9..a450b56d70a9 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx @@ -6,9 +6,7 @@ */ import { EuiButtonIcon, EuiSuperSelect } from '@elastic/eui'; -import deepEqual from 'fast-deep-equal'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useSelector } from 'react-redux'; import styled from 'styled-components'; import type { DataViewBase, Filter, Query } from '@kbn/es-query'; @@ -17,13 +15,15 @@ import { EventsByDataset } from '../../../overview/components/events_by_dataset' import { SignalsByCategory } from '../../../overview/components/signals_by_category'; import { InputsModelId } from '../../store/inputs/constants'; import { TimelineEventsType } from '../../../../common/types/timeline'; - -import { TopNOption } from './helpers'; +import { useSourcererDataView } from '../../containers/sourcerer'; +import { + isDetectionsAlertsTable, + getSourcererScopeName, + removeIgnoredAlertFilters, + TopNOption, +} from './helpers'; import * as i18n from './translations'; -import { getIndicesSelector, IndicesSelector } from './selectors'; -import { State } from '../../store'; import { AlertsStackByField } from '../../../detections/components/alerts_kpis/common/types'; -import { SourcererScopeName } from '../../store/sourcerer/model'; const TopNContainer = styled.div` min-width: 600px; @@ -89,19 +89,7 @@ const TopNComponent: React.FC = ({ (value: string) => setView(value as TimelineEventsType), [setView] ); - const indicesSelector = useMemo(getIndicesSelector, []); - const { all: allIndices, raw: rawIndices } = useSelector( - (state) => - indicesSelector( - state, - timelineId != null - ? defaultView === 'alert' - ? SourcererScopeName.detections - : SourcererScopeName.timeline - : SourcererScopeName.default - ), - deepEqual - ); + const { selectedPatterns } = useSourcererDataView(getSourcererScopeName({ timelineId, view })); useEffect(() => { setView(defaultView); @@ -111,13 +99,20 @@ const TopNComponent: React.FC = ({ () => ( ), - [onViewSelected, options, view] + [onViewSelected, options, timelineId, view] + ); + + // alert workflow statuses (e.g. open | closed) and other alert-specific + // filters must be ignored when viewing raw alerts + const applicableFilters = useMemo( + () => removeIgnoredAlertFilters({ filters, timelineId, view }), + [filters, timelineId, view] ); return ( @@ -134,11 +129,11 @@ const TopNComponent: React.FC = ({ = ({ ) : (