diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 706283288f58..b5923bb4f3b3 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -57,7 +57,11 @@ const SavedColumnHeaderRuntimeType = runtimeTypes.partial({ const SavedDataProviderQueryMatchBasicRuntimeType = runtimeTypes.partial({ field: unionWithNullType(runtimeTypes.string), displayField: unionWithNullType(runtimeTypes.string), - value: unionWithNullType(runtimeTypes.string), + value: runtimeTypes.union([ + runtimeTypes.null, + runtimeTypes.string, + runtimeTypes.array(runtimeTypes.string), + ]), displayValue: unionWithNullType(runtimeTypes.string), operator: unionWithNullType(runtimeTypes.string), }); @@ -652,7 +656,7 @@ export interface DataProviderResult { export interface QueryMatchResult { field?: Maybe; displayField?: Maybe; - value?: Maybe; + value?: Maybe; displayValue?: Maybe; operator?: Maybe; } diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap index a0ef37259aaa..5ec3b5e15813 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap @@ -625,6 +625,26 @@ exports[`DragDropContextWrapper rendering it renders against the snapshot 1`] = }, "type": "string", }, + "nestedField.thirdAttributes": Object { + "aggregatable": false, + "category": "nestedField", + "description": "", + "example": "", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "nestedField.thirdAttributes", + "searchable": true, + "subType": Object { + "nested": Object { + "path": "nestedField", + }, + }, + "type": "date", + }, }, }, "source": Object { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts b/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts index 794ec46b7a22..18c1bcc422c0 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts @@ -25,10 +25,11 @@ import { } from '../../../../timelines/components/timeline/body/renderers/constants'; import { BYTES_FORMAT } from '../../../../timelines/components/timeline/body/renderers/bytes'; import { EVENT_DURATION_FIELD_NAME } from '../../../../timelines/components/duration'; +import { getDisplayValue } from '../../../../timelines/components/timeline/data_providers/helpers'; import { PORT_NAMES } from '../../../../network/components/port/helpers'; import { INDICATOR_REFERENCE } from '../../../../../common/cti/constants'; import type { BrowserField } from '../../../containers/source'; -import type { DataProvider } from '../../../../../common/types'; +import type { DataProvider, QueryOperator } from '../../../../../common/types'; import { IS_OPERATOR } from '../../../../../common/types'; export interface UseActionCellDataProvider { @@ -48,7 +49,12 @@ export interface ActionCellValuesAndDataProvider { dataProviders: DataProvider[]; } -export const getDataProvider = (field: string, id: string, value: string): DataProvider => ({ +export const getDataProvider = ( + field: string, + id: string, + value: string | string[], + operator: QueryOperator = IS_OPERATOR +): DataProvider => ({ and: [], enabled: true, id: escapeDataProviderId(id), @@ -58,7 +64,8 @@ export const getDataProvider = (field: string, id: string, value: string): DataP queryMatch: { field, value, - operator: IS_OPERATOR, + operator, + displayValue: getDisplayValue(value), }, }); diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_actions.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_actions.tsx index 584830395702..bb780af23c82 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_actions.tsx @@ -80,6 +80,20 @@ export const useHoverActions = ({ const { closeTopN, toggleTopN, isShowingTopN } = useTopNPopOver(handleClosePopOverTrigger); + const values = useMemo(() => { + const val = dataProvider.queryMatch.value; + + if (typeof val === 'number') { + return val.toString(); + } + + if (Array.isArray(val)) { + return val.map((item) => String(item)); + } + + return val; + }, [dataProvider.queryMatch.value]); + const hoverContent = useMemo(() => { // display links as additional content in the hover menu to enable keyboard // navigation of links (when the draggable contains them): @@ -110,11 +124,7 @@ export const useHoverActions = ({ showTopN={isShowingTopN} scopeId={id} toggleTopN={toggleTopN} - values={ - typeof dataProvider.queryMatch.value !== 'number' - ? dataProvider.queryMatch.value - : `${dataProvider.queryMatch.value}` - } + values={values} /> ); }, [ @@ -131,6 +141,7 @@ export const useHoverActions = ({ onFilterAdded, id, toggleTopN, + values, ]); const setContainerRef = useCallback((e: HTMLDivElement) => { diff --git a/x-pack/plugins/security_solution/public/common/containers/source/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/containers/source/__snapshots__/index.test.tsx.snap index 37e6a9b6ba0a..26b23380f15c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/containers/source/__snapshots__/index.test.tsx.snap @@ -639,5 +639,25 @@ Array [ }, "type": "string", }, + Object { + "aggregatable": false, + "category": "nestedField", + "description": "", + "example": "", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "nestedField.thirdAttributes", + "searchable": true, + "subType": Object { + "nested": Object { + "path": "nestedField", + }, + }, + "type": "date", + }, ] `; diff --git a/x-pack/plugins/security_solution/public/common/containers/source/mock.ts b/x-pack/plugins/security_solution/public/common/containers/source/mock.ts index 956275d43bac..bd29838952ba 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/mock.ts +++ b/x-pack/plugins/security_solution/public/common/containers/source/mock.ts @@ -439,6 +439,22 @@ export const mocksSource = { }, }, }, + { + aggregatable: false, + category: 'nestedField', + description: '', + example: '', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'nestedField.thirdAttributes', + searchable: true, + type: 'date', + subType: { + nested: { + path: 'nestedField', + }, + }, + }, ], }; @@ -952,6 +968,22 @@ export const mockBrowserFields: BrowserFields = { }, }, }, + 'nestedField.thirdAttributes': { + aggregatable: false, + category: 'nestedField', + description: '', + example: '', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'nestedField.thirdAttributes', + searchable: true, + type: 'date', + subType: { + nested: { + path: 'nestedField', + }, + }, + }, }, }, }; diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/mock_data.ts b/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/mock_data.ts index 4aa8c1766c2f..a18546a77fe2 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/mock_data.ts +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/mock_data.ts @@ -30,13 +30,14 @@ export const dataProviderWithOneFilter = [ { and: [], enabled: true, - id: '', + id: 'mock-id', name: 'host.hostname', excluded: false, kqlQuery: '', queryMatch: { field: 'host.hostname', value: 'Host-u6ou715rzy', + displayValue: 'Host-u6ou715rzy', operator: ':' as QueryOperator, }, }, @@ -49,25 +50,27 @@ export const dataProviderWithAndFilters = [ and: [], enabled: true, excluded: false, - id: '', + id: 'mock-id', kqlQuery: '', name: 'kibana.alerts.workflow_status', queryMatch: { field: 'kibana.alerts.workflow_status', operator: ':' as QueryOperator, value: 'open', + displayValue: 'open', }, }, ], enabled: true, - id: '', + id: 'mock-id', name: 'host.hostname', excluded: false, kqlQuery: '', queryMatch: { field: 'host.hostname', value: 'Host-u6ou715rzy', + displayValue: 'Host-u6ou715rzy', operator: ':' as QueryOperator, }, }, @@ -79,25 +82,27 @@ export const dataProviderWithOrFilters = [ { and: [], enabled: true, - id: '', + id: 'mock-id', name: 'kibana.alerts.workflow_status', excluded: false, kqlQuery: '', queryMatch: { field: 'kibana.alerts.workflow_status', value: 'open', + displayValue: 'open', operator: ':' as QueryOperator, }, }, ], enabled: true, - id: '', + id: 'mock-id', name: 'host.hostname', excluded: false, kqlQuery: '', queryMatch: { field: 'host.hostname', value: 'Host-u6ou715rzy', + displayValue: 'Host-u6ou715rzy', operator: ':' as QueryOperator, }, }, @@ -106,25 +111,27 @@ export const dataProviderWithOrFilters = [ { and: [], enabled: true, - id: '', + id: 'mock-id', name: 'kibana.alerts.workflow_status', excluded: false, kqlQuery: '', queryMatch: { field: 'kibana.alerts.workflow_status', value: 'closed', + displayValue: 'closed', operator: ':' as QueryOperator, }, }, ], enabled: true, - id: '', + id: 'mock-id', name: 'host.hostname', excluded: false, kqlQuery: '', queryMatch: { field: 'host.hostname', value: 'Host-u6ou715rzy', + displayValue: 'Host-u6ou715rzy', operator: ':' as QueryOperator, }, }, @@ -133,25 +140,27 @@ export const dataProviderWithOrFilters = [ { and: [], enabled: true, - id: '', + id: 'mock-id', name: 'kibana.alerts.workflow_status', excluded: false, kqlQuery: '', queryMatch: { field: 'kibana.alerts.workflow_status', value: 'acknowledged', + displayValue: 'acknowledged', operator: ':' as QueryOperator, }, }, ], enabled: true, - id: '', + id: 'mock-id', name: 'host.hostname', excluded: false, kqlQuery: '', queryMatch: { field: 'host.hostname', value: 'Host-u6ou715rzy', + displayValue: 'Host-u6ou715rzy', operator: ':' as QueryOperator, }, }, diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/use_navigate_to_timeline.test.ts b/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/use_navigate_to_timeline.test.ts index 25f070b6d14f..5e451f6c44cc 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/use_navigate_to_timeline.test.ts +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/use_navigate_to_timeline.test.ts @@ -33,6 +33,10 @@ jest.mock('react-redux', () => { }; }); +jest.mock('uuid', () => ({ + v4: () => 'mock-id', +})); + const id = 'timeline-1'; const renderUseNavigatgeToTimeline = () => renderHook(() => useNavigateToTimeline()); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/use_navigate_to_timeline.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/use_navigate_to_timeline.tsx index 705375d48ec3..24c9968c7cff 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/use_navigate_to_timeline.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/hooks/use_navigate_to_timeline.tsx @@ -7,12 +7,13 @@ import { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; +import { v4 as uuid } from 'uuid'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { sourcererActions } from '../../../../common/store/sourcerer'; import { getDataProvider } from '../../../../common/components/event_details/table/use_action_cell_data_provider'; -import type { DataProvider } from '../../../../../common/types/timeline'; +import type { DataProvider, QueryOperator } from '../../../../../common/types/timeline'; import { TimelineId, TimelineType } from '../../../../../common/types/timeline'; import { useCreateTimeline } from '../../../../timelines/components/timeline/properties/use_create_timeline'; import { updateProviders } from '../../../../timelines/store/timeline/actions'; @@ -21,7 +22,8 @@ import type { TimeRange } from '../../../../common/store/inputs/model'; export interface Filter { field: string; - value: string; + value: string | string[]; + operator?: QueryOperator; } export const useNavigateToTimeline = () => { @@ -79,10 +81,17 @@ export const useNavigateToTimeline = () => { const mainFilter = orFilterGroup[0]; if (mainFilter) { - const dataProvider = getDataProvider(mainFilter.field, '', mainFilter.value); + const dataProvider = getDataProvider( + mainFilter.field, + uuid(), + mainFilter.value, + mainFilter.operator + ); for (const filter of orFilterGroup.slice(1)) { - dataProvider.and.push(getDataProvider(filter.field, '', filter.value)); + dataProvider.and.push( + getDataProvider(filter.field, uuid(), filter.value, filter.operator) + ); } dataProviders.push(dataProvider); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/components.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/components.test.tsx new file mode 100644 index 000000000000..ff9c524a7673 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/components.test.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ControlledComboboxInput, ControlledDefaultInput } from '.'; +import { + convertComboboxValuesToStringArray, + convertValuesToComboboxValueArray, +} from './controlled_combobox_input'; +import { getDefaultValue } from './controlled_default_input'; + +const onChangeCallbackMock = jest.fn(); + +const renderControlledComboboxInput = (badOverrideValue?: string) => + render( + + ); + +const renderControlledDefaultInput = (badOverrideValue?: string[]) => + render( + + ); + +describe('ControlledComboboxInput', () => { + afterEach(jest.clearAllMocks); + + it('renders the current value', () => { + renderControlledComboboxInput(); + expect(screen.getByText('test')); + }); + + it('calls onChangeCallback, and disabledButtonCallback when value is removed', () => { + renderControlledComboboxInput(); + const removeButton = screen.getByTestId('is-one-of-combobox-input').querySelector('button'); + + userEvent.click(removeButton as HTMLButtonElement); + expect(onChangeCallbackMock).toHaveBeenLastCalledWith([]); + }); + + it('handles non arrays by defaulting to an empty state', () => { + renderControlledComboboxInput('nonArray'); + + expect(onChangeCallbackMock).toHaveBeenLastCalledWith([]); + }); +}); + +describe('ControlledDefaultInput', () => { + afterEach(jest.clearAllMocks); + + it('renders the current value', () => { + renderControlledDefaultInput(); + expect(screen.getByDisplayValue('test')); + }); + + it('calls onChangeCallback, and disabledButtonCallback when value is changed', () => { + renderControlledDefaultInput([]); + const inputBox = screen.getByPlaceholderText('value'); + + userEvent.type(inputBox, 'new value'); + + expect(onChangeCallbackMock).toHaveBeenLastCalledWith('new value'); + }); + + it('handles arrays by defaulting to the first value', () => { + renderControlledDefaultInput(['testing']); + + expect(onChangeCallbackMock).toHaveBeenLastCalledWith('testing'); + }); + + describe('getDefaultValue', () => { + it('Returns a provided value if the value is a string', () => { + expect(getDefaultValue('test')).toBe('test'); + }); + + it('Returns a provided value if the value is a number', () => { + expect(getDefaultValue(0)).toBe(0); + }); + + it('Returns the first value of a string array', () => { + expect(getDefaultValue(['a', 'b'])).toBe('a'); + }); + + it('Returns the first value of a number array', () => { + expect(getDefaultValue([0, 1])).toBe(0); + }); + + it('Returns an empty string if given an empty array', () => { + expect(getDefaultValue([])).toBe(''); + }); + }); + + describe('convertValuesToComboboxValueArray', () => { + it('returns an empty array if not provided correct input', () => { + expect(convertValuesToComboboxValueArray('test')).toEqual([]); + expect(convertValuesToComboboxValueArray(1)).toEqual([]); + }); + + it('returns an empty array if provided non array input', () => { + expect(convertValuesToComboboxValueArray('test')).toEqual([]); + expect(convertValuesToComboboxValueArray(1)).toEqual([]); + }); + + it('Returns a comboboxoption array when provided the correct input', () => { + expect(convertValuesToComboboxValueArray(['a', 'b'])).toEqual([ + { label: 'a' }, + { label: 'b' }, + ]); + expect(convertValuesToComboboxValueArray([1, 2])).toEqual([{ label: '1' }, { label: '2' }]); + }); + }); + + describe('convertComboboxValuesToStringArray', () => { + it('correctly converts combobox values to a string array ', () => { + expect(convertComboboxValuesToStringArray([])).toEqual([]); + expect(convertComboboxValuesToStringArray([{ label: '1' }, { label: '2' }])).toEqual([ + '1', + '2', + ]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/controlled_combobox_input.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/controlled_combobox_input.tsx new file mode 100644 index 000000000000..0be933113e99 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/controlled_combobox_input.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect, useCallback } from 'react'; + +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { EuiComboBox } from '@elastic/eui'; + +import { isStringOrNumberArray } from '../../timeline/helpers'; +import * as i18n from '../translations'; + +interface ControlledDataProviderInput { + onChangeCallback: (value: string | number | string[]) => void; + value: string | number | Array; +} + +export const ControlledComboboxInput = ({ + value, + onChangeCallback, +}: ControlledDataProviderInput) => { + const [includeValues, setIncludeValues] = useState(convertValuesToComboboxValueArray(value)); + + useEffect(() => { + onChangeCallback(convertComboboxValuesToStringArray(includeValues)); + }, [includeValues, onChangeCallback]); + + const onCreateOption = useCallback( + (searchValue: string, flattenedOptions: EuiComboBoxOptionOption[] = includeValues) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + if ( + flattenedOptions.findIndex( + (option) => option.label.trim().toLowerCase() === normalizedSearchValue + ) === -1 + // add the option, because it wasn't found in the current set of `includeValues` + ) { + setIncludeValues([ + ...includeValues, + { + label: searchValue, + }, + ]); + } + }, + [includeValues] + ); + + const onIncludeValueChanged = useCallback((updatedIncludesValues: EuiComboBoxOptionOption[]) => { + setIncludeValues(updatedIncludesValues); + }, []); + + return ( + + ); +}; + +export const convertValuesToComboboxValueArray = ( + values: string | number | Array +): EuiComboBoxOptionOption[] => + isStringOrNumberArray(values) ? values.map((item) => ({ label: String(item) })) : []; + +export const convertComboboxValuesToStringArray = (values: EuiComboBoxOptionOption[]): string[] => + values.map((item) => item.label); diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/controlled_default_input.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/controlled_default_input.tsx new file mode 100644 index 000000000000..9734e1c51de1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/controlled_default_input.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect, useCallback } from 'react'; + +import { EuiFieldText } from '@elastic/eui'; + +import { isStringOrNumberArray } from '../../timeline/helpers'; +import { sanatizeValue } from '../helpers'; +import * as i18n from '../translations'; + +interface ControlledDataProviderInput { + onChangeCallback: (value: string | number | string[]) => void; + value: string | number | Array; +} + +const VALUE_INPUT_CLASS_NAME = 'edit-data-provider-value'; + +export const ControlledDefaultInput = ({ + value, + onChangeCallback, +}: ControlledDataProviderInput) => { + const [primitiveValue, setPrimitiveValue] = useState(getDefaultValue(value)); + + useEffect(() => { + onChangeCallback(sanatizeValue(primitiveValue)); + }, [primitiveValue, onChangeCallback]); + + const onValueChange = useCallback((e: React.ChangeEvent) => { + setPrimitiveValue(e.target.value); + }, []); + + return ( + + ); +}; + +export const getDefaultValue = ( + value: string | number | Array +): string | number => { + if (isStringOrNumberArray(value)) { + return value[0] ?? ''; + } else return value; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/index.ts b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/index.ts new file mode 100644 index 000000000000..6d841723bfdf --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/components/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ControlledComboboxInput } from './controlled_combobox_input'; +export { ControlledDefaultInput } from './controlled_default_input'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx index e890b724f78b..1f4a50a46a62 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx @@ -5,14 +5,21 @@ * 2.0. */ +import { DataProviderType } from '@kbn/timelines-plugin/common'; + import { mockBrowserFields } from '../../../common/containers/source/mock'; -import { EXISTS_OPERATOR, IS_OPERATOR } from '../timeline/data_providers/data_provider'; +import { + EXISTS_OPERATOR, + IS_OPERATOR, + IS_ONE_OF_OPERATOR, +} from '../timeline/data_providers/data_provider'; import { getCategorizedFieldNames, getExcludedFromSelection, getFieldNames, getQueryOperatorFromSelection, + sanatizeValue, selectionsAreValid, } from './helpers'; @@ -106,6 +113,9 @@ describe('helpers', () => { { label: 'nestedField.secondAttributes', }, + { + label: 'nestedField.thirdAttributes', + }, ], }, { label: 'source', options: [{ label: 'source.ip' }, { label: 'source.port' }] }, @@ -132,6 +142,7 @@ describe('helpers', () => { label: 'is', }, ], + type: DataProviderType.default, }) ).toBe(true); }); @@ -150,6 +161,7 @@ describe('helpers', () => { label: 'is', }, ], + type: DataProviderType.default, }) ).toBe(false); }); @@ -165,9 +177,10 @@ describe('helpers', () => { ], selectedOperator: [ { - label: 'is', + label: 'is one of', }, ], + type: DataProviderType.default, }) ).toBe(false); }); @@ -186,6 +199,7 @@ describe('helpers', () => { label: '', }, ], + type: DataProviderType.default, }) ).toBe(false); }); @@ -204,6 +218,45 @@ describe('helpers', () => { label: 'invalid-operator', }, ], + type: DataProviderType.default, + }) + ).toBe(false); + }); + + test('it should return false when the selected operator is "is one of", and the DataProviderType is template', () => { + expect( + selectionsAreValid({ + browserFields: mockBrowserFields, + selectedField: [ + { + label: 'destination.bytes', + }, + ], + selectedOperator: [ + { + label: 'is one of', + }, + ], + type: DataProviderType.template, + }) + ).toBe(false); + }); + + test('it should return false when the selected operator is "is not one of", and the DataProviderType is template', () => { + expect( + selectionsAreValid({ + browserFields: mockBrowserFields, + selectedField: [ + { + label: 'destination.bytes', + }, + ], + selectedOperator: [ + { + label: 'is not one of', + }, + ], + type: DataProviderType.template, }) ).toBe(false); }); @@ -219,6 +272,14 @@ describe('helpers', () => { operator: i18n.IS_NOT, expected: IS_OPERATOR, }, + { + operator: i18n.IS_ONE_OF, + expected: IS_ONE_OF_OPERATOR, + }, + { + operator: i18n.IS_NOT_ONE_OF, + expected: IS_ONE_OF_OPERATOR, + }, { operator: i18n.EXISTS, expected: EXISTS_OPERATOR, @@ -230,7 +291,7 @@ describe('helpers', () => { ]; validSelections.forEach(({ operator, expected }) => { - test(`it should the expected operator given "${operator}", a valid selection`, () => { + test(`it should use the expected operator given "${operator}", a valid selection`, () => { expect( getQueryOperatorFromSelection([ { @@ -282,6 +343,15 @@ describe('helpers', () => { ]) ).toBe(false); }); + test('it returns false when the "is one of" operator is selected', () => { + expect( + getExcludedFromSelection([ + { + label: i18n.IS_ONE_OF, + }, + ]) + ).toBe(false); + }); test('it returns false when the "exists" operator is selected', () => { expect( @@ -313,6 +383,16 @@ describe('helpers', () => { ).toBe(true); }); + test('it returns true when "is not one of" is selected', () => { + expect( + getExcludedFromSelection([ + { + label: i18n.IS_NOT_ONE_OF, + }, + ]) + ).toBe(true); + }); + test('it returns true when "does not exist" is selected', () => { expect( getExcludedFromSelection([ @@ -323,4 +403,19 @@ describe('helpers', () => { ).toBe(true); }); }); + + describe('sanatizeValue', () => { + it("returns a provided value if it's a string or number as a string", () => { + expect(sanatizeValue('a string')).toBe('a string'); + expect(sanatizeValue(1)).toBe('1'); + }); + + it('returns the string interpretation of the first value of an array', () => { + expect(sanatizeValue(['a string', 'another value'])).toBe('a string'); + expect(sanatizeValue([1, 'another value'])).toBe('1'); + expect(sanatizeValue([])).toBe(''); + expect(sanatizeValue([null])).toBe('null'); + expect(sanatizeValue([undefined])).toBe('undefined'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.tsx index bcda8c7167bf..c2ad001dde1a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.tsx @@ -6,12 +6,18 @@ */ import { findIndex } from 'lodash/fp'; + import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { DataProviderType } from '@kbn/timelines-plugin/common'; import type { BrowserField, BrowserFields } from '../../../common/containers/source'; import { getAllFieldsByName } from '../../../common/containers/source'; import type { QueryOperator } from '../timeline/data_providers/data_provider'; -import { EXISTS_OPERATOR, IS_OPERATOR } from '../timeline/data_providers/data_provider'; +import { + EXISTS_OPERATOR, + IS_OPERATOR, + IS_ONE_OF_OPERATOR, +} from '../timeline/data_providers/data_provider'; import * as i18n from './translations'; @@ -23,6 +29,12 @@ export const operatorLabels: EuiComboBoxOptionOption[] = [ { label: i18n.IS_NOT, }, + { + label: i18n.IS_ONE_OF, + }, + { + label: i18n.IS_NOT_ONE_OF, + }, { label: i18n.EXISTS, }, @@ -57,18 +69,23 @@ export const selectionsAreValid = ({ browserFields, selectedField, selectedOperator, + type, }: { browserFields: BrowserFields; selectedField: EuiComboBoxOptionOption[]; selectedOperator: EuiComboBoxOptionOption[]; + type: DataProviderType; }): boolean => { const fieldId = selectedField.length > 0 ? selectedField[0].label : ''; const operator = selectedOperator.length > 0 ? selectedOperator[0].label : ''; const fieldIsValid = browserFields && getAllFieldsByName(browserFields)[fieldId] != null; const operatorIsValid = findIndex((o) => o.label === operator, operatorLabels) !== -1; + const isOneOfOperatorSelectionWithTemplate = + type === DataProviderType.template && + (operator === i18n.IS_ONE_OF || operator === i18n.IS_NOT_ONE_OF); - return fieldIsValid && operatorIsValid; + return fieldIsValid && operatorIsValid && !isOneOfOperatorSelectionWithTemplate; }; /** Returns a `QueryOperator` based on the user's Operator selection */ @@ -81,6 +98,9 @@ export const getQueryOperatorFromSelection = ( case i18n.IS: // fall through case i18n.IS_NOT: return IS_OPERATOR; + case i18n.IS_ONE_OF: // fall through + case i18n.IS_NOT_ONE_OF: + return IS_ONE_OF_OPERATOR; case i18n.EXISTS: // fall through case i18n.DOES_NOT_EXIST: return EXISTS_OPERATOR; @@ -97,9 +117,19 @@ export const getExcludedFromSelection = (selectedOperator: EuiComboBoxOptionOpti switch (selection) { case i18n.IS_NOT: // fall through + case i18n.IS_NOT_ONE_OF: case i18n.DOES_NOT_EXIST: return true; default: return false; } }; + +/** Ensure that a value passed to ControlledDefaultInput is not an array */ +export const sanatizeValue = (value: string | number | unknown[]): string => { + if (Array.isArray(value)) { + // fun fact: value should never be an array + return value.length ? `${value[0]}` : ''; + } + return `${value}`; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.test.tsx index 4a92bf323f2e..8fd5dce2da0d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.test.tsx @@ -15,6 +15,7 @@ import { DataProviderType, IS_OPERATOR, EXISTS_OPERATOR, + IS_ONE_OF_OPERATOR, } from '../timeline/data_providers/data_provider'; import { StatefulEditDataProvider } from '.'; @@ -144,6 +145,46 @@ describe('StatefulEditDataProvider', () => { expect(screen.getByText('does not exist')).toBeInTheDocument(); }); + test('it renders the "is one of" operator in human-readable format', () => { + render( + + + + ); + + expect(screen.getByText('is one of')).toBeInTheDocument(); + }); + + test('it renders the negated "is one of" operator in a humanized format when isExcluded is true', () => { + render( + + + + ); + + expect(screen.getByText('is not one of')).toBeInTheDocument(); + }); + test('it renders the current value when the operator is "is"', () => { render( @@ -186,6 +227,48 @@ describe('StatefulEditDataProvider', () => { expect(screen.getByDisplayValue(value)).toBeInTheDocument(); }); + test('it handles bad values when the operator is "is one of" by showing default placholder', () => { + const reallyAnArrayOfBadValues = [undefined, null] as unknown as string[]; + render( + + + + ); + expect(screen.getByText('enter one or more values')).toBeInTheDocument(); + }); + + test('it renders selected values when the type of value is an array and the operator is "is one of"', () => { + const values = ['apple', 'banana', 'cherry']; + render( + + + + ); + expect(screen.getByText(values[0])).toBeInTheDocument(); + expect(screen.getByText(values[1])).toBeInTheDocument(); + expect(screen.getByText(values[2])).toBeInTheDocument(); + }); + test('it does NOT render the current value when the operator is "is not" (isExcluded is true)', () => { render( @@ -226,6 +309,27 @@ describe('StatefulEditDataProvider', () => { expect(screen.getByPlaceholderText('value')).toBeInTheDocument(); }); + test('it renders the expected placeholder when value is empty and operator is "is one of"', () => { + render( + + + + ); + + // EuiCombobox does not render placeholder text with placeholder tag + expect(screen.getByText('enter one or more values')).toBeInTheDocument(); + }); + test('it does NOT render value when the operator is "exists"', () => { render( @@ -244,6 +348,7 @@ describe('StatefulEditDataProvider', () => { ); expect(screen.queryByPlaceholderText('value')).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue('Value')).not.toBeInTheDocument(); }); test('it does NOT render value when the operator is "not exists" (isExcluded is true)', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.tsx index 38c27ba4a0d5..29b78e73f7b9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.tsx @@ -5,12 +5,12 @@ * 2.0. */ -import { noop, startsWith, endsWith } from 'lodash/fp'; +import { noop } from 'lodash/fp'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiButton, EuiComboBox, - EuiFieldText, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -33,6 +33,8 @@ import { selectionsAreValid, } from './helpers'; +import { ControlledComboboxInput, ControlledDefaultInput } from './components'; + import * as i18n from './translations'; const EDIT_DATA_PROVIDER_WIDTH = 400; @@ -55,19 +57,18 @@ interface Props { operator: QueryOperator; providerId: string; timelineId: string; - value: string | number; + value: string | number | Array; type?: DataProviderType; } -const sanatizeValue = (value: string | number): string => - Array.isArray(value) ? `${value[0]}` : `${value}`; // fun fact: value should never be an array - export const getInitialOperatorLabel = ( isExcluded: boolean, operator: QueryOperator ): EuiComboBoxOptionOption[] => { if (operator === ':') { return isExcluded ? [{ label: i18n.IS_NOT }] : [{ label: i18n.IS }]; + } else if (operator === 'includes') { + return isExcluded ? [{ label: i18n.IS_NOT_ONE_OF }] : [{ label: i18n.IS_ONE_OF }]; } else { return isExcluded ? [{ label: i18n.DOES_NOT_EXIST }] : [{ label: i18n.EXISTS }]; } @@ -90,7 +91,33 @@ export const StatefulEditDataProvider = React.memo( const [updatedOperator, setUpdatedOperator] = useState( getInitialOperatorLabel(isExcluded, operator) ); - const [updatedValue, setUpdatedValue] = useState(value); + + const [updatedValue, setUpdatedValue] = useState>( + value + ); + + const showComboBoxInput = useMemo( + () => + updatedOperator.length > 0 && + (updatedOperator[0].label === i18n.IS_ONE_OF || + updatedOperator[0].label === i18n.IS_NOT_ONE_OF), + [updatedOperator] + ); + + const showValueInput = useMemo( + () => + type !== DataProviderType.template && + updatedOperator.length > 0 && + updatedOperator[0].label !== i18n.EXISTS && + updatedOperator[0].label !== i18n.DOES_NOT_EXIST && + !showComboBoxInput, + [showComboBoxInput, type, updatedOperator] + ); + + const disableSave = useMemo( + () => showComboBoxInput && Array.isArray(updatedValue) && !updatedValue.length, + [showComboBoxInput, updatedValue] + ); /** Focuses the Value input if it is visible, falling back to the Save button if it's not */ const focusInput = () => { @@ -126,8 +153,8 @@ export const StatefulEditDataProvider = React.memo( focusInput(); }, []); - const onValueChange = useCallback((e: React.ChangeEvent) => { - setUpdatedValue(e.target.value); + const onValueChange = useCallback((changedValue: string | number | string[]) => { + setUpdatedValue(changedValue); }, []); const disableScrolling = () => { @@ -170,14 +197,6 @@ export const StatefulEditDataProvider = React.memo( type, ]); - const isValueFieldInvalid = useMemo( - () => - type !== DataProviderType.template && - (startsWith('{', sanatizeValue(updatedValue)) || - endsWith('}', sanatizeValue(updatedValue))), - [type, updatedValue] - ); - useEffect(() => { disableScrolling(); return () => { @@ -224,22 +243,6 @@ export const StatefulEditDataProvider = React.memo( /> - {type !== DataProviderType.template && - updatedOperator.length > 0 && - updatedOperator[0].label !== i18n.EXISTS && - updatedOperator[0].label !== i18n.DOES_NOT_EXIST ? ( - - - - - - ) : null} @@ -248,6 +251,35 @@ export const StatefulEditDataProvider = React.memo( + {showValueInput && ( + + + + )} + + {showComboBoxInput && type !== DataProviderType.template && ( + + + + )} + + + + + + + + {type === DataProviderType.template && showComboBoxInput && ( + <> + + + + )} ( fill={true} isDisabled={ !selectionsAreValid({ + type, browserFields, selectedField: updatedField, selectedOperator: updatedOperator, - }) || isValueFieldInvalid + }) || disableSave } onClick={handleSave} size="m" diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/translations.ts index 44d8ee8087ac..562a69eeb9d0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/translations.ts @@ -33,10 +33,28 @@ export const IS = i18n.translate('xpack.securitySolution.editDataProvider.isLabe defaultMessage: 'is', }); +export const IS_ONE_OF = i18n.translate('xpack.securitySolution.editDataProvider.isOneOfLabel', { + defaultMessage: 'is one of', +}); + export const IS_NOT = i18n.translate('xpack.securitySolution.editDataProvider.isNotLabel', { defaultMessage: 'is not', }); +export const IS_NOT_ONE_OF = i18n.translate( + 'xpack.securitySolution.editDataProvider.isNotOneOfLabel', + { + defaultMessage: 'is not one of', + } +); + +export const ENTER_ONE_OR_MORE_VALUES = i18n.translate( + 'xpack.securitySolution.editDataProvider.includesPlaceholder', + { + defaultMessage: 'enter one or more values', + } +); + export const OPERATOR = i18n.translate('xpack.securitySolution.editDataProvider.operatorLabel', { defaultMessage: 'Operator', }); @@ -59,3 +77,9 @@ export const SELECT_AN_OPERATOR = i18n.translate( defaultMessage: 'Select an operator', } ); + +export const UNAVAILABLE_OPERATOR = (operator: string) => + i18n.translate('xpack.securitySolution.editDataProvider.unavailableOperator', { + values: { operator }, + defaultMessage: '{operator} operator is unavailable with templates', + }); 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 367a145774d7..59967eda95cd 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 @@ -111,6 +111,7 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline () => !isEmpty(dataProviders) || !isEmpty(get('filterQuery.kuery.expression', kqlQuery)), [dataProviders, kqlQuery] ); + const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterQuerySelector(), []); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId)!); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index a7475bab09f1..12e78df1e49e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -626,6 +626,26 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` }, "type": "string", }, + "nestedField.thirdAttributes": Object { + "aggregatable": false, + "category": "nestedField", + "description": "", + "example": "", + "format": "", + "indexes": Array [ + "auditbeat", + "filebeat", + "packetbeat", + ], + "name": "nestedField.thirdAttributes", + "searchable": true, + "subType": Object { + "nested": Object { + "path": "nestedField", + }, + }, + "type": "date", + }, }, }, "source": Object { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/__snapshots__/netflow_row_renderer.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/__snapshots__/netflow_row_renderer.test.tsx.snap index 082ed270ada2..4a4c047d4cb1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/__snapshots__/netflow_row_renderer.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/__snapshots__/netflow_row_renderer.test.tsx.snap @@ -2,12 +2,7 @@ exports[`netflowRowRenderer renders correctly against snapshot 1`] = ` - .c15 svg { - position: relative; - top: -1px; -} - -.c0 { + .c0 { display: inline-block; font-size: 12px; line-height: 1.5; @@ -21,6 +16,11 @@ exports[`netflowRowRenderer renders correctly against snapshot 1`] = ` border-radius: 4px; } +.c15 svg { + position: relative; + top: -1px; +} + .c13, .c13 * { display: inline-block; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap index acc48bdc6c04..5af5d50dd6f4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap @@ -3,6 +3,7 @@ exports[`Provider rendering renders correctly against snapshot 1`] = ` = ( onAddedToTimeline: handleClosePopover, providerToAdd: { id: providerId, - name: value, + name: field, enabled: true, excluded, kqlQuery: '', type, queryMatch: { displayField: undefined, - displayValue: undefined, + displayValue: getDisplayValue(value), field, value, operator, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts index b96fe3c0db0f..204583c9eaba 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts @@ -13,8 +13,11 @@ export const IS_OPERATOR = ':'; /** The `exists` operator in a KQL query */ export const EXISTS_OPERATOR = ':*'; +/** The `is one of` faux operator in a KQL query */ +export const IS_ONE_OF_OPERATOR = 'includes'; + /** The operator applied to a field */ -export type QueryOperator = ':' | ':*'; +export type QueryOperator = typeof IS_OPERATOR | typeof EXISTS_OPERATOR | typeof IS_ONE_OF_OPERATOR; export enum DataProviderType { default = 'default', @@ -24,7 +27,7 @@ export enum DataProviderType { export interface QueryMatch { field: string; displayField?: string; - value: string | number; + value: string | number | Array; displayValue?: string | number; operator: QueryOperator; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.test.tsx index faafb8eca7ec..e490cd631f11 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.test.tsx @@ -23,6 +23,7 @@ import { reorder, sourceAndDestinationAreSameDroppable, unFlattenGroups, + getDisplayValue, } from './helpers'; import { providerA, @@ -1103,4 +1104,18 @@ describe('helpers', () => { }); }); }); + + describe('getDisplayValue', () => { + it('converts an array (is one of query) to correct format for a string array', () => { + expect(getDisplayValue(['a', 'b', 'c'])).toBe('( a OR b OR c )'); + expect(getDisplayValue([1, 2, 3])).toBe('( 1 OR 2 OR 3 )'); + }); + it('handles an empty array', () => { + expect(getDisplayValue([])).toBe(''); + }); + it('returns a provided value if not an array', () => { + expect(getDisplayValue(1)).toBe(1); + expect(getDisplayValue('text')).toBe('text'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.tsx index 2af72c36d26e..03bd1d84c970 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.tsx @@ -10,6 +10,7 @@ import type { DraggableLocation } from 'react-beautiful-dnd'; import type { Dispatch } from 'redux'; import { updateProviders } from '../../../store/timeline/actions'; +import { isStringOrNumberArray } from '../helpers'; import type { DataProvider, DataProvidersAnd } from './data_provider'; @@ -342,3 +343,15 @@ export const addContentToTimeline = ({ }); } }; + +export const getDisplayValue = ( + value: string | number | Array +): string | number => { + if (isStringOrNumberArray(value)) { + if (value.length) { + return `( ${value.join(' OR ')} )`; + } + return ''; + } + return value; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx index dcfd4337af80..5b26cff4f6d8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx @@ -32,7 +32,8 @@ export const Provider = React.memo(({ dataProvider }) => { toggleExcludedProvider={noop} toggleEnabledProvider={noop} toggleTypeProvider={noop} - val={dataProvider.queryMatch.displayValue || dataProvider.queryMatch.value} + displayValue={String(dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value)} + val={dataProvider.queryMatch.value} operator={dataProvider.queryMatch.operator || IS_OPERATOR} type={dataProvider.type || DataProviderType.default} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_badge.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_badge.tsx index 7e48b4b5c781..951caa426e2c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_badge.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_badge.tsx @@ -16,7 +16,7 @@ import { getEmptyString } from '../../../../common/components/empty_value'; import { ProviderContainer } from '../../../../common/components/drag_and_drop/provider_container'; import type { QueryOperator } from './data_provider'; -import { DataProviderType, EXISTS_OPERATOR } from './data_provider'; +import { DataProviderType, EXISTS_OPERATOR, IS_ONE_OF_OPERATOR } from './data_provider'; import * as i18n from './translations'; @@ -102,7 +102,8 @@ interface ProviderBadgeProps { providerId: string; togglePopover: () => void; toggleType: () => void; - val: string | number; + displayValue: string; + val: string | number | Array; operator: QueryOperator; type: DataProviderType; timelineType: TimelineType; @@ -124,6 +125,7 @@ export const ProviderBadge = React.memo( providerId, togglePopover, toggleType, + displayValue, val, type, timelineType, @@ -160,7 +162,9 @@ export const ProviderBadge = React.memo( <> {prefix} {operator !== EXISTS_OPERATOR ? ( - {`${field}: "${formattedValue}"`} + {`${field}: "${ + operator === 'includes' ? displayValue : formattedValue + }"`} ) : ( {field} {i18n.EXISTS_LABEL} @@ -168,7 +172,7 @@ export const ProviderBadge = React.memo( )} ), - [field, formattedValue, operator, prefix] + [displayValue, field, formattedValue, operator, prefix] ); const ariaLabel = useMemo( @@ -196,7 +200,10 @@ export const ProviderBadge = React.memo( {content} - {timelineType === TimelineType.template && ( + {/* Add a UI feature to let users know the is one of operator doesnt work with timeline templates: + https://github.com/elastic/kibana/issues/142437 */} + + {timelineType === TimelineType.template && operator !== IS_ONE_OF_OPERATOR && ( )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx index b78866ceb82a..d37720f7218c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx @@ -16,7 +16,7 @@ import type { BrowserFields } from '../../../../common/containers/source'; import type { OnDataProviderEdited } from '../events'; import type { QueryOperator } from './data_provider'; -import { DataProviderType, EXISTS_OPERATOR } from './data_provider'; +import { DataProviderType, EXISTS_OPERATOR, IS_ONE_OF_OPERATOR } from './data_provider'; import { StatefulEditDataProvider } from '../../edit_data_provider'; import * as i18n from './translations'; @@ -48,7 +48,7 @@ interface OwnProps { toggleEnabledProvider: () => void; toggleExcludedProvider: () => void; toggleTypeProvider: () => void; - value: string | number; + value: string | number | Array; type: DataProviderType; } @@ -80,7 +80,7 @@ interface GetProviderActionsProps { toggleEnabled: () => void; toggleExcluded: () => void; toggleType: () => void; - value: string | number; + value: string | number | Array; type: DataProviderType; } @@ -138,7 +138,7 @@ export const getProviderActions = ({ timelineType === TimelineType.template ? { className: CONVERT_TO_FIELD_CLASS_NAME, - disabled: isLoading, + disabled: isLoading || operator === IS_ONE_OF_OPERATOR, icon: 'visText', name: type === DataProviderType.template diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx index 17d7dc1a6438..dfe598177bc5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx @@ -43,7 +43,8 @@ interface ProviderItemBadgeProps { toggleEnabledProvider: () => void; toggleExcludedProvider: () => void; toggleTypeProvider: () => void; - val: string | number; + displayValue?: string; + val: string | number | Array; type?: DataProviderType; wrapperRef?: React.MutableRefObject; } @@ -67,6 +68,7 @@ export const ProviderItemBadge = React.memo( toggleEnabledProvider, toggleExcludedProvider, toggleTypeProvider, + displayValue, val, type = DataProviderType.default, wrapperRef, @@ -144,6 +146,7 @@ export const ProviderItemBadge = React.memo( providerId={providerId} togglePopover={togglePopover} toggleType={onToggleTypeProvider} + displayValue={displayValue ?? String(val)} val={val} operator={operator} type={type} @@ -152,6 +155,7 @@ export const ProviderItemBadge = React.memo( ), [ deleteProvider, + displayValue, field, isEnabled, isExcluded, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx index eb3e9a2c280b..d6423b334583 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx @@ -110,9 +110,6 @@ const ParensContainer = styled(EuiFlexItem)` align-self: center; `; -const getDataProviderValue = (dataProvider: DataProvidersAnd) => - dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value; - /** * Renders an interactive card representation of the data providers. It also * affords uniform UI controls for the following actions: @@ -264,6 +261,10 @@ export const DataProvidersGroupItem = React.memo( [onKeyDown] ); + const displayValue = String( + dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value + ); + const DraggableContent = useCallback( (provided, snapshot) => (
( toggleEnabledProvider={handleToggleEnabledProvider} toggleExcludedProvider={handleToggleExcludedProvider} toggleTypeProvider={handleToggleTypeProvider} - val={getDataProviderValue(dataProvider)} + displayValue={displayValue} + val={dataProvider.queryMatch.value} type={dataProvider.type} wrapperRef={keyboardHandlerRef} /> @@ -323,6 +325,7 @@ export const DataProvidersGroupItem = React.memo( [ browserFields, dataProvider, + displayValue, group, handleDataProviderEdited, handleDeleteProvider, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts index 5aff599670dc..46ed0839a0e2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts @@ -36,7 +36,7 @@ export type OnDataProviderEdited = ({ id: string; operator: QueryOperator; providerId: string; - value: string | number; + value: string | number | Array; type: DataProvider['type']; }) => void; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx index dc7ed0affa46..c2df23cd69c4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx @@ -7,9 +7,19 @@ import { cloneDeep } from 'lodash/fp'; -import { DataProviderType } from './data_providers/data_provider'; +import { DataProviderType, EXISTS_OPERATOR, IS_OPERATOR } from './data_providers/data_provider'; import { mockDataProviders } from './data_providers/mock/mock_data_providers'; -import { buildGlobalQuery, showGlobalFilters } from './helpers'; + +import { + buildExistsQueryMatch, + buildGlobalQuery, + buildIsOneOfQueryMatch, + buildIsQueryMatch, + handleIsOperator, + isStringOrNumberArray, + showGlobalFilters, +} from './helpers'; + import { mockBrowserFields } from '../../../common/containers/source/mock'; const cleanUpKqlQuery = (str: string) => str.replace(/\n/g, '').replace(/\s\s+/g, ' '); @@ -96,6 +106,24 @@ describe('Build KQL Query', () => { expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 2"'); }); + test('Build KQL query with "includes" operator', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].enabled = true; + dataProviders[0].queryMatch.operator = 'includes'; + dataProviders[0].queryMatch.value = ['a', 'b', 'c']; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual(`name : (\"a\" OR \"b\" OR \"c\")`); + }); + + test('Handles bad inputs to buildKQLQuery', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].enabled = true; + dataProviders[0].queryMatch.operator = 'includes'; + dataProviders[0].queryMatch.value = [undefined] as unknown as string[]; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : [null]'); + }); + test('Build KQL query with two data provider and second is disabled', () => { const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); dataProviders[1].enabled = false; @@ -243,3 +271,123 @@ describe('Build KQL Query', () => { }); }); }); + +describe('isStringOrNumberArray', () => { + test('it returns false when value is not an array', () => { + expect(isStringOrNumberArray('just a string')).toBe(false); + }); + + test('it returns false when value is an array of mixed types', () => { + expect(isStringOrNumberArray(['mixed', 123, 'types'])).toBe(false); + }); + test('it returns false when value is an array of bad types', () => { + const badValues = [undefined, null, {}] as unknown as string[]; + expect(isStringOrNumberArray(badValues)).toBe(false); + }); + + test('it returns true when value is an empty array', () => { + expect(isStringOrNumberArray([])).toBe(true); + }); + + test('it returns true when value is an array of all strings', () => { + expect(isStringOrNumberArray(['all', 'string', 'values'])).toBe(true); + }); + + test('it returns true when value is an array of all numbers', () => { + expect(isStringOrNumberArray([123, 456, 789])).toBe(true); + }); + + describe('queryHandlerFunctions', () => { + describe('handleIsOperator', () => { + it('returns the entire query unchanged, if value is an array', () => { + expect( + handleIsOperator({ + browserFields: {}, + field: 'host.name', + isExcluded: '', + isFieldTypeNested: false, + type: undefined, + value: ['some', 'values'], + }) + ).toBe('host.name : ["some","values"]'); + }); + }); + }); + + describe('buildExistsQueryMatch', () => { + it('correcty computes EXISTS query with no nested field', () => { + expect( + buildExistsQueryMatch({ isFieldTypeNested: false, field: 'host', browserFields: {} }) + ).toBe(`host ${EXISTS_OPERATOR}`); + }); + it('correcty computes EXISTS query with nested field', () => { + expect( + buildExistsQueryMatch({ + isFieldTypeNested: true, + field: 'nestedField.firstAttributes', + browserFields: mockBrowserFields, + }) + ).toBe(`nestedField: { firstAttributes: * }`); + }); + }); + + describe('buildIsQueryMatch', () => { + it('correcty computes IS query with no nested field', () => { + expect( + buildIsQueryMatch({ + isFieldTypeNested: false, + field: 'nestedField.thirdAttributes', + value: 100000, + browserFields: {}, + }) + ).toBe(`nestedField.thirdAttributes ${IS_OPERATOR} 100000`); + }); + it('correcty computes IS query with nested date field', () => { + expect( + buildIsQueryMatch({ + isFieldTypeNested: true, + browserFields: mockBrowserFields, + field: 'nestedField.thirdAttributes', + value: 100000, + }) + ).toBe(`nestedField: { thirdAttributes${IS_OPERATOR} \"100000\" }`); + }); + it('correcty computes IS query with nested string field', () => { + expect( + buildIsQueryMatch({ + isFieldTypeNested: true, + browserFields: mockBrowserFields, + field: 'nestedField.secondAttributes', + value: 'text', + }) + ).toBe(`nestedField: { secondAttributes${IS_OPERATOR} text }`); + }); + }); + + describe('buildIsOneOfQueryMatch', () => { + it('correcty computes IS ONE OF query with numbers', () => { + expect( + buildIsOneOfQueryMatch({ + field: 'kibana.alert.worflow_status', + value: [1, 2, 3], + }) + ).toBe('kibana.alert.worflow_status : (1 OR 2 OR 3)'); + }); + it('correcty computes IS ONE OF query with strings', () => { + expect( + buildIsOneOfQueryMatch({ + field: 'kibana.alert.worflow_status', + value: ['a', 'b', 'c'], + }) + ).toBe(`kibana.alert.worflow_status : (\"a\" OR \"b\" OR \"c\")`); + }); + it('correcty computes IS ONE OF query if value is an empty array', () => { + expect( + buildIsOneOfQueryMatch({ + field: 'kibana.alert.worflow_status', + value: [], + }) + ).toBe("kibana.alert.worflow_status : ''"); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index 9cb0e3d6e60b..1760693b4187 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -9,21 +9,26 @@ import { isEmpty, get } from 'lodash/fp'; import memoizeOne from 'memoize-one'; import { - handleSkipFocus, elementOrChildrenHasFocus, getFocusedAriaColindexCell, getTableSkipFocus, + handleSkipFocus, stopPropagationAndPreventDefault, } from '@kbn/timelines-plugin/public'; -import { escapeQueryValue } from '../../../common/lib/kuery'; -import type { DataProvider, DataProvidersAnd } from './data_providers/data_provider'; -import { DataProviderType, EXISTS_OPERATOR } from './data_providers/data_provider'; +import { assertUnreachable } from '../../../../common/utility_types'; import type { BrowserFields } from '../../../common/containers/source'; - +import { escapeQueryValue } from '../../../common/lib/kuery'; +import type { DataProvider, DataProvidersAnd } from './data_providers/data_provider'; +import { + DataProviderType, + EXISTS_OPERATOR, + IS_ONE_OF_OPERATOR, + IS_OPERATOR, +} from './data_providers/data_provider'; import { EVENTS_TABLE_CLASS_NAME } from './styles'; -const isNumber = (value: string | number) => !isNaN(Number(value)); +const isNumber = (value: string | number): value is number => !isNaN(Number(value)); const convertDateFieldToQuery = (field: string, value: string | number) => `${field}: ${isNumber(value) ? value : new Date(value).valueOf()}`; @@ -86,27 +91,30 @@ const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) = const buildQueryMatch = ( dataProvider: DataProvider | DataProvidersAnd, browserFields: BrowserFields -) => - `${dataProvider.excluded ? 'NOT ' : ''}${ - dataProvider.queryMatch.operator !== EXISTS_OPERATOR && - dataProvider.type !== DataProviderType.template - ? checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields) - ? convertNestedFieldToQuery( - dataProvider.queryMatch.field, - dataProvider.queryMatch.value, - browserFields - ) - : checkIfFieldTypeIsDate(dataProvider.queryMatch.field, browserFields) - ? convertDateFieldToQuery(dataProvider.queryMatch.field, dataProvider.queryMatch.value) - : `${dataProvider.queryMatch.field} : ${ - isNumber(dataProvider.queryMatch.value) - ? dataProvider.queryMatch.value - : escapeQueryValue(dataProvider.queryMatch.value) - }` - : checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields) - ? convertNestedFieldToExistQuery(dataProvider.queryMatch.field, browserFields) - : `${dataProvider.queryMatch.field} ${EXISTS_OPERATOR}` - }`.trim(); +) => { + const { + excluded, + type, + queryMatch: { field, operator, value }, + } = dataProvider; + + const isFieldTypeNested = checkIfFieldTypeIsNested(field, browserFields); + const isExcluded = excluded ? 'NOT ' : ''; + + switch (operator) { + case IS_OPERATOR: + return handleIsOperator({ browserFields, field, isExcluded, isFieldTypeNested, type, value }); + + case EXISTS_OPERATOR: + return `${isExcluded}${buildExistsQueryMatch({ browserFields, field, isFieldTypeNested })}`; + + case IS_ONE_OF_OPERATOR: + return handleIsOneOfOperator({ field, isExcluded, value }); + + default: + assertUnreachable(operator); + } +}; export const buildGlobalQuery = (dataProviders: DataProvider[], browserFields: BrowserFields) => dataProviders @@ -253,3 +261,94 @@ export const focusUtilityBarAction = (containerElement: HTMLElement | null) => { export const resetKeyboardFocus = () => { document.querySelector('header.headerGlobalNav a.euiHeaderLogo')?.focus(); }; + +interface OperatorHandler { + field: string; + isExcluded: string; + value: string | number | Array; +} + +export const handleIsOperator = ({ + browserFields, + field, + isExcluded, + isFieldTypeNested, + type, + value, +}: OperatorHandler & { + browserFields: BrowserFields; + isFieldTypeNested: boolean; + type?: DataProviderType; +}) => { + if (!isStringOrNumberArray(value)) { + return `${isExcluded}${ + type !== DataProviderType.template + ? buildIsQueryMatch({ browserFields, field, isFieldTypeNested, value }) + : buildExistsQueryMatch({ browserFields, field, isFieldTypeNested }) + }`; + } else { + return `${isExcluded}${field} : ${JSON.stringify(value)}`; + } +}; + +const handleIsOneOfOperator = ({ field, isExcluded, value }: OperatorHandler) => { + if (isStringOrNumberArray(value)) { + return `${isExcluded}${buildIsOneOfQueryMatch({ field, value })}`; + } else { + return `${isExcluded}${field} : ${JSON.stringify(value)}`; + } +}; + +export const buildIsQueryMatch = ({ + browserFields, + field, + isFieldTypeNested, + value, +}: { + browserFields: BrowserFields; + field: string; + isFieldTypeNested: boolean; + value: string | number; +}): string => { + if (isFieldTypeNested) { + return convertNestedFieldToQuery(field, value, browserFields); + } else if (checkIfFieldTypeIsDate(field, browserFields)) { + return convertDateFieldToQuery(field, value); + } else { + return `${field} : ${isNumber(value) ? value : escapeQueryValue(value)}`; + } +}; + +export const buildExistsQueryMatch = ({ + browserFields, + field, + isFieldTypeNested, +}: { + browserFields: BrowserFields; + field: string; + isFieldTypeNested: boolean; +}): string => { + return isFieldTypeNested + ? convertNestedFieldToExistQuery(field, browserFields).trim() + : `${field} ${EXISTS_OPERATOR}`.trim(); +}; + +export const buildIsOneOfQueryMatch = ({ + field, + value, +}: { + field: string; + value: Array; +}): string => { + const trimmedField = field.trim(); + if (value.length) { + return `${trimmedField} : (${value + .map((item) => (isNumber(item) ? Number(item) : `${escapeQueryValue(item.trim())}`)) + .join(' OR ')})`; + } + return `${trimmedField} : ''`; +}; + +export const isStringOrNumberArray = (value: unknown): value is Array => + Array.isArray(value) && + (value.every((x) => typeof x === 'string') || value.every((x) => typeof x === 'number')); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index 7ed71e3b6a4a..39bd6044879b 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -114,7 +114,7 @@ export const dataProviderEdited = actionCreator<{ id: string; operator: QueryOperator; providerId: string; - value: string | number; + value: string | number | Array; }>('DATA_PROVIDER_EDITED'); export const updateDataProviderType = actionCreator<{ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index cfe0e860852b..3f82606e5b6d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -50,6 +50,7 @@ import { import { activeTimeline } from '../../containers/active_timeline_context'; import type { ResolveTimelineConfig } from '../../components/open_timeline/types'; import type { SessionViewConfig } from '../../components/timeline/session_tab_content/use_session_view'; +import { getDisplayValue } from '../../components/timeline/data_providers/helpers'; export const isNotNull = (value: T | null): value is T => value !== null; interface AddTimelineNoteParams { @@ -850,7 +851,7 @@ const updateProviderProperties = ({ operator: QueryOperator; providerId: string; timeline: TimelineModel; - value: string | number; + value: string | number | Array; }) => timeline.dataProviders.map((provider) => provider.id === providerId @@ -862,7 +863,7 @@ const updateProviderProperties = ({ field, displayField: field, value, - displayValue: value, + displayValue: getDisplayValue(value), operator, }, } @@ -884,7 +885,7 @@ const updateAndProviderProperties = ({ operator: QueryOperator; providerId: string; timeline: TimelineModel; - value: string | number; + value: string | number | Array; }) => timeline.dataProviders.map((provider) => provider.id === providerId @@ -900,7 +901,7 @@ const updateAndProviderProperties = ({ field, displayField: field, value, - displayValue: value, + displayValue: getDisplayValue(value), operator, }, } @@ -918,7 +919,7 @@ interface UpdateTimelineProviderEditPropertiesParams { operator: QueryOperator; providerId: string; timelineById: TimelineById; - value: string | number; + value: string | number | Array; } export const updateTimelineProviderProperties = ({ diff --git a/x-pack/plugins/timelines/common/types/timeline/data_provider/index.ts b/x-pack/plugins/timelines/common/types/timeline/data_provider/index.ts index d706aff6f6aa..4aaa675137fc 100644 --- a/x-pack/plugins/timelines/common/types/timeline/data_provider/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/data_provider/index.ts @@ -13,8 +13,11 @@ export const IS_OPERATOR = ':'; /** The `exists` operator in a KQL query */ export const EXISTS_OPERATOR = ':*'; +/** The `is one of` operator in a KQL query */ +export const IS_ONE_OF_OPERATOR = 'includes'; + /** The operator applied to a field */ -export type QueryOperator = ':' | ':*'; +export type QueryOperator = typeof IS_OPERATOR | typeof EXISTS_OPERATOR | typeof IS_ONE_OF_OPERATOR; export enum DataProviderType { default = 'default', @@ -24,7 +27,7 @@ export enum DataProviderType { export interface QueryMatch { field: string; displayField?: string; - value: string | number; + value: string | number | Array; displayValue?: string | number; operator: QueryOperator; } diff --git a/x-pack/plugins/timelines/public/components/inspect/index.tsx b/x-pack/plugins/timelines/public/components/inspect/index.tsx index a174cc08a83e..304dd8cdfcf8 100644 --- a/x-pack/plugins/timelines/public/components/inspect/index.tsx +++ b/x-pack/plugins/timelines/public/components/inspect/index.tsx @@ -95,7 +95,7 @@ const InspectButtonComponent: React.FC = ({ data-test-subj="inspect-icon-button" iconSize="m" iconType="inspect" - isDisabled={loading || isDisabled || false} + isDisabled={loading || isDisabled} title={i18n.INSPECT} onClick={handleClick} /> diff --git a/x-pack/plugins/timelines/public/components/t_grid/helpers.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/helpers.test.tsx index 3ddebe460a55..f68d67339d19 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/helpers.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/helpers.test.tsx @@ -8,12 +8,21 @@ import { cloneDeep } from 'lodash/fp'; import { Filter, EsQueryConfig, FilterStateStore } from '@kbn/es-query'; -import { DataProviderType } from '../../../common/types/timeline'; import { + DataProviderType, + EXISTS_OPERATOR, + IS_ONE_OF_OPERATOR, + IS_OPERATOR, +} from '../../../common/types/timeline'; +import { + buildExistsQueryMatch, buildGlobalQuery, + buildIsOneOfQueryMatch, + buildIsQueryMatch, combineQueries, getDefaultViewSelection, isSelectableView, + isStringOrNumberArray, isViewSelection, resolverIsShowing, } from './helpers'; @@ -682,7 +691,7 @@ describe('Combined Queries', () => { }); invalidViewSelections.forEach((value) => { - test(`it returns false when value is INvalid: ${value}`, () => { + test(`it returns false when value is invalid: ${value}`, () => { expect(isViewSelection(value)).toBe(false); }); }); @@ -699,9 +708,9 @@ describe('Combined Queries', () => { }); }); - describe('given INvalid values', () => { + describe('given invalid values', () => { invalidViewSelections.forEach((value) => { - test(`it ALWAYS returns 'gridView' for NON-selectable timelineId ${timelineId}, with INvalid value: ${value}`, () => { + test(`it ALWAYS returns 'gridView' for NON-selectable timelineId ${timelineId}, with invalid value: ${value}`, () => { expect(getDefaultViewSelection({ timelineId, value })).toEqual('gridView'); }); }); @@ -722,7 +731,7 @@ describe('Combined Queries', () => { describe('given INvalid values', () => { invalidViewSelections.forEach((value) => { - test(`it ALWAYS returns 'gridView' for selectable timelineId ${timelineId}, with INvalid value: ${value}`, () => { + test(`it ALWAYS returns 'gridView' for selectable timelineId ${timelineId}, with invalid value: ${value}`, () => { expect(getDefaultViewSelection({ timelineId, value })).toEqual('gridView'); }); }); @@ -730,4 +739,290 @@ describe('Combined Queries', () => { }); }); }); + describe('DataProvider yields same result as kqlQuery equivolent with each operator', () => { + describe('IS ONE OF operator', () => { + test('dataprovider matches kql equivolent', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.operator = IS_ONE_OF_OPERATOR; + dataProviders[0].queryMatch.value = ['a', 'b', 'c']; + const { filterQuery: filterQueryWithDataProvider } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + const { filterQuery: filterQueryWithKQLQuery } = combineQueries({ + config, + dataProviders: [], + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'name: ("a" OR "b" OR "c")', language: 'kuery' }, + kqlMode: 'search', + })!; + + expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery); + }); + test('dataprovider with negated IS ONE OF operator matches kql equivolent', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.operator = IS_ONE_OF_OPERATOR; + dataProviders[0].queryMatch.value = ['a', 'b', 'c']; + dataProviders[0].excluded = true; + const { filterQuery: filterQueryWithDataProvider } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + const { filterQuery: filterQueryWithKQLQuery } = combineQueries({ + config, + dataProviders: [], + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'NOT name: ("a" OR "b" OR "c")', language: 'kuery' }, + kqlMode: 'search', + })!; + + expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery); + }); + }); + describe('IS operator', () => { + test('dataprovider matches kql equivolent', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.operator = IS_OPERATOR; + dataProviders[0].queryMatch.value = 'a'; + const { filterQuery: filterQueryWithDataProvider } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + const { filterQuery: filterQueryWithKQLQuery } = combineQueries({ + config, + dataProviders: [], + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'name: "a"', language: 'kuery' }, + kqlMode: 'search', + })!; + + expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery); + }); + test('dataprovider with negated IS operator matches kql equivolent', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.operator = IS_OPERATOR; + dataProviders[0].queryMatch.value = 'a'; + dataProviders[0].excluded = true; + const { filterQuery: filterQueryWithDataProvider } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + const { filterQuery: filterQueryWithKQLQuery } = combineQueries({ + config, + dataProviders: [], + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'NOT name: "a"', language: 'kuery' }, + kqlMode: 'search', + })!; + + expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery); + }); + }); + describe('Exists operator', () => { + test('dataprovider matches kql equivolent', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.operator = EXISTS_OPERATOR; + const { filterQuery: filterQueryWithDataProvider } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + const { filterQuery: filterQueryWithKQLQuery } = combineQueries({ + config, + dataProviders: [], + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'name : *', language: 'kuery' }, + kqlMode: 'search', + })!; + + expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery); + }); + test('dataprovider with negated EXISTS operator matches kql equivolent', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].queryMatch.operator = EXISTS_OPERATOR; + dataProviders[0].excluded = true; + const { filterQuery: filterQueryWithDataProvider } = combineQueries({ + config, + dataProviders, + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: '', language: 'kuery' }, + kqlMode: 'search', + })!; + const { filterQuery: filterQueryWithKQLQuery } = combineQueries({ + config, + dataProviders: [], + indexPattern: mockIndexPattern, + browserFields: mockBrowserFields, + filters: [], + kqlQuery: { query: 'NOT name : *', language: 'kuery' }, + kqlMode: 'search', + })!; + + expect(filterQueryWithDataProvider).toEqual(filterQueryWithKQLQuery); + }); + }); + }); +}); + +describe('isStringOrNumberArray', () => { + test('it returns false when value is not an array', () => { + expect(isStringOrNumberArray('just a string')).toBe(false); + }); + + test('it returns false when value is an array of mixed types', () => { + expect(isStringOrNumberArray(['mixed', 123, 'types'])).toBe(false); + }); + + test('it returns false when value is an array of bad values', () => { + const badValues = [undefined, null, {}] as unknown as string[]; + expect(isStringOrNumberArray(badValues)).toBe(false); + }); + + test('it returns true when value is an empty array', () => { + expect(isStringOrNumberArray([])).toBe(true); + }); + + test('it returns true when value is an array of all strings', () => { + expect(isStringOrNumberArray(['all', 'string', 'values'])).toBe(true); + }); + + test('it returns true when value is an array of all numbers', () => { + expect(isStringOrNumberArray([123, 456, 789])).toBe(true); + }); +}); + +describe('buildExistsQueryMatch', () => { + it('correcty computes EXISTS query with no nested field', () => { + expect( + buildExistsQueryMatch({ isFieldTypeNested: false, field: 'host', browserFields: {} }) + ).toBe(`host ${EXISTS_OPERATOR}`); + }); + + it('correcty computes EXISTS query with nested field', () => { + expect( + buildExistsQueryMatch({ + isFieldTypeNested: true, + field: 'nestedField.firstAttributes', + browserFields: mockBrowserFields, + }) + ).toBe(`nestedField: { firstAttributes: * }`); + }); +}); + +describe('buildIsQueryMatch', () => { + it('correcty computes IS query with no nested field', () => { + expect( + buildIsQueryMatch({ + isFieldTypeNested: false, + field: 'nestedField.thirdAttributes', + value: 100000, + browserFields: {}, + }) + ).toBe(`nestedField.thirdAttributes ${IS_OPERATOR} 100000`); + }); + + it('correcty computes IS query with nested date field', () => { + expect( + buildIsQueryMatch({ + isFieldTypeNested: true, + browserFields: mockBrowserFields, + field: 'nestedField.thirdAttributes', + value: 1668521970232, + }) + ).toBe(`nestedField: { thirdAttributes${IS_OPERATOR} \"1668521970232\" }`); + }); + + it('correcty computes IS query with nested string field', () => { + expect( + buildIsQueryMatch({ + isFieldTypeNested: true, + browserFields: mockBrowserFields, + field: 'nestedField.secondAttributes', + value: 'text', + }) + ).toBe(`nestedField: { secondAttributes${IS_OPERATOR} text }`); + }); +}); + +describe('buildIsOneOfQueryMatch', () => { + it('correcty computes IS ONE OF query with numbers', () => { + expect( + buildIsOneOfQueryMatch({ + field: 'kibana.alert.worflow_status', + value: [1, 2, 3], + }) + ).toBe('kibana.alert.worflow_status : (1 OR 2 OR 3)'); + }); + + it('correcty computes IS ONE OF query with strings', () => { + expect( + buildIsOneOfQueryMatch({ + field: 'kibana.alert.worflow_status', + value: ['a', 'b', 'c'], + }) + ).toBe(`kibana.alert.worflow_status : (\"a\" OR \"b\" OR \"c\")`); + }); + + it('correcty computes IS ONE OF query if value is an empty array', () => { + expect( + buildIsOneOfQueryMatch({ + field: 'kibana.alert.worflow_status', + value: [], + }) + ).toBe("kibana.alert.worflow_status : ''"); + }); + + it('correcty computes IS ONE OF query if given a single string value', () => { + expect( + buildIsOneOfQueryMatch({ + field: 'kibana.alert.worflow_status', + value: ['a'], + }) + ).toBe(`kibana.alert.worflow_status : (\"a\")`); + }); + + it('correcty computes IS ONE OF query if given a single numeric value', () => { + expect( + buildIsOneOfQueryMatch({ + field: 'kibana.alert.worflow_status', + value: [1], + }) + ).toBe(`kibana.alert.worflow_status : (1)`); + }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/helpers.tsx b/x-pack/plugins/timelines/public/components/t_grid/helpers.tsx index cb925fff4d39..64043dbefcef 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/helpers.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/helpers.tsx @@ -12,13 +12,14 @@ import memoizeOne from 'memoize-one'; import { elementOrChildrenHasFocus } from '../../../common/utils/accessibility'; import type { BrowserFields } from '../../../common/search_strategy/index_fields'; import { - DataProvider, - DataProvidersAnd, DataProviderType, EXISTS_OPERATOR, + IS_ONE_OF_OPERATOR, + IS_OPERATOR, } from '../../../common/types/timeline'; +import type { DataProvider, DataProvidersAnd } from '../../../common/types/timeline'; +import { assertUnreachable } from '../../../common/utility_types'; import { convertToBuildEsQuery, escapeQueryValue } from '../utils/keury'; - import { EVENTS_TABLE_CLASS_NAME } from './styles'; import { TableId } from '../../types'; import { ViewSelection } from './event_rendered_view/selector'; @@ -33,7 +34,7 @@ interface CombineQueries { kqlMode: string; } -const isNumber = (value: string | number) => !isNaN(Number(value)); +const isNumber = (value: string | number): value is number => !isNaN(Number(value)); const convertDateFieldToQuery = (field: string, value: string | number) => `${field}: ${isNumber(value) ? value : new Date(value).valueOf()}`; @@ -96,27 +97,41 @@ const checkIfFieldTypeIsNested = (field: string, browserFields: BrowserFields) = const buildQueryMatch = ( dataProvider: DataProvider | DataProvidersAnd, browserFields: BrowserFields -) => - `${dataProvider.excluded ? 'NOT ' : ''}${ - dataProvider.queryMatch.operator !== EXISTS_OPERATOR && - dataProvider.type !== DataProviderType.template - ? checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields) - ? convertNestedFieldToQuery( - dataProvider.queryMatch.field, - dataProvider.queryMatch.value, - browserFields - ) - : checkIfFieldTypeIsDate(dataProvider.queryMatch.field, browserFields) - ? convertDateFieldToQuery(dataProvider.queryMatch.field, dataProvider.queryMatch.value) - : `${dataProvider.queryMatch.field} : ${ - isNumber(dataProvider.queryMatch.value) - ? dataProvider.queryMatch.value - : escapeQueryValue(dataProvider.queryMatch.value) - }` - : checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields) - ? convertNestedFieldToExistQuery(dataProvider.queryMatch.field, browserFields) - : `${dataProvider.queryMatch.field} ${EXISTS_OPERATOR}` - }`.trim(); +) => { + const { + excluded, + type, + queryMatch: { field, operator, value }, + } = dataProvider; + + const isFieldTypeNested = checkIfFieldTypeIsNested(field, browserFields); + const isExcluded = excluded ? 'NOT ' : ''; + + switch (operator) { + case IS_OPERATOR: + if (!isStringOrNumberArray(value)) { + return `${isExcluded}${ + type !== DataProviderType.template + ? buildIsQueryMatch({ browserFields, field, isFieldTypeNested, value }) + : buildExistsQueryMatch({ browserFields, field, isFieldTypeNested }) + }`; + } else { + return `${isExcluded}${field} : ${JSON.stringify(value[0])}`; + } + + case EXISTS_OPERATOR: + return `${isExcluded}${buildExistsQueryMatch({ browserFields, field, isFieldTypeNested })}`; + + case IS_ONE_OF_OPERATOR: + if (isStringOrNumberArray(value)) { + return `${isExcluded}${buildIsOneOfQueryMatch({ field, value })}`; + } else { + return `${isExcluded}${field} : ${JSON.stringify(value)}`; + } + default: + assertUnreachable(operator); + } +}; export const buildGlobalQuery = (dataProviders: DataProvider[], browserFields: BrowserFields) => dataProviders @@ -276,3 +291,57 @@ export const getDefaultViewSelection = ({ /** This local storage key stores the `Grid / Event rendered view` selection */ export const ALERTS_TABLE_VIEW_SELECTION_KEY = 'securitySolution.alerts.table.view-selection'; + +export const buildIsQueryMatch = ({ + browserFields, + field, + isFieldTypeNested, + value, +}: { + browserFields: BrowserFields; + field: string; + isFieldTypeNested: boolean; + value: string | number; +}): string => { + if (isFieldTypeNested) { + return convertNestedFieldToQuery(field, value, browserFields); + } else if (checkIfFieldTypeIsDate(field, browserFields)) { + return convertDateFieldToQuery(field, value); + } else { + return `${field} : ${isNumber(value) ? value : escapeQueryValue(value)}`; + } +}; + +export const buildExistsQueryMatch = ({ + browserFields, + field, + isFieldTypeNested, +}: { + browserFields: BrowserFields; + field: string; + isFieldTypeNested: boolean; +}): string => { + return isFieldTypeNested + ? convertNestedFieldToExistQuery(field, browserFields) + : `${field} ${EXISTS_OPERATOR}`; +}; + +export const buildIsOneOfQueryMatch = ({ + field, + value, +}: { + field: string; + value: Array; +}): string => { + const trimmedField = field.trim(); + if (value.length) { + return `${trimmedField} : (${value + .map((item) => (isNumber(item) ? Number(item) : `${escapeQueryValue(item.trim())}`)) + .join(' OR ')})`; + } + return `${trimmedField} : ''`; +}; + +export const isStringOrNumberArray = (value: unknown): value is Array => + Array.isArray(value) && + (value.every((x) => typeof x === 'string') || value.every((x) => typeof x === 'number')); diff --git a/x-pack/plugins/timelines/public/mock/browser_fields.ts b/x-pack/plugins/timelines/public/mock/browser_fields.ts index 1e6afa11fa13..d852c0002e83 100644 --- a/x-pack/plugins/timelines/public/mock/browser_fields.ts +++ b/x-pack/plugins/timelines/public/mock/browser_fields.ts @@ -810,6 +810,22 @@ export const mockBrowserFields: BrowserFields = { }, }, }, + 'nestedField.thirdAttributes': { + aggregatable: false, + category: 'nestedField', + description: '', + example: '', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'nestedField.thirdAttributes', + searchable: true, + type: 'date', + subType: { + nested: { + path: 'nestedField', + }, + }, + }, }, }, };