From 85a9c22c8c397d0a365b020e232586bccf3f749b Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 30 Jul 2020 17:05:43 -0400 Subject: [PATCH] [Security Solution][Exceptions] Adds autocomplete workaround for .text fields (#73761) ## Summary This PR provides a workaround for the autocomplete service not providing suggestions when the selected field is `someField.text`. As endpoint exceptions will be largely using `.text` for now, wanted to still provide the autocomplete service. Updates to the autocomplete components were done after seeing some React errors that were popping up related to memory leaks. This is due to the use of `debounce` I believe. The calls were still executed even after the builder component was unmounted. This resulted in the subsequent calls from the autocomplete service not always going through (sometimes being canceled) when reopening the builder. Moved the filtering of endpoint fields to occur in the existing helper function so that we would still have access to the corresponding `keyword` field of `text` fields when formatting the entries for the builder. --- .../autocomplete/field_value_match.test.tsx | 1 - .../autocomplete/field_value_match.tsx | 15 +- .../field_value_match_any.test.tsx | 1 - .../autocomplete/field_value_match_any.tsx | 15 +- .../use_field_value_autocomplete.test.ts | 17 +- .../hooks/use_field_value_autocomplete.ts | 92 +++-- .../builder/builder_entry_item.test.tsx | 70 ++++ .../exceptions/builder/builder_entry_item.tsx | 16 +- .../builder/builder_exception_item.test.tsx | 24 +- .../exceptions/builder/helpers.test.tsx | 343 +++++++++++++++--- .../components/exceptions/builder/helpers.tsx | 70 +++- .../components/exceptions/builder/index.tsx | 17 +- .../common/components/exceptions/types.ts | 1 + 13 files changed, 540 insertions(+), 142 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx index 72467a62f57c1a..998ed1f3351c80 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx @@ -232,7 +232,6 @@ describe('AutocompleteFieldMatchComponent', () => { fields, }, value: 'value 1', - signal: new AbortController().signal, }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx index 137f6803dc54e7..dfb3761bb34978 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx @@ -72,14 +72,13 @@ export const AutocompleteFieldMatchComponent: React.FC { - const signal = new AbortController().signal; - - updateSuggestions({ - fieldSelected: selectedField, - value: `${searchVal}`, - patterns: indexPattern, - signal, - }); + if (updateSuggestions != null) { + updateSuggestions({ + fieldSelected: selectedField, + value: searchVal, + patterns: indexPattern, + }); + } }; const isValid = useMemo( diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx index f3f0f2e2a44b1f..0a0281a9c4a51a 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx @@ -232,7 +232,6 @@ describe('AutocompleteFieldMatchAnyComponent', () => { fields, }, value: 'value 1', - signal: new AbortController().signal, }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx index 5a15c1f7238dea..1952ef865e045a 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx @@ -65,14 +65,13 @@ export const AutocompleteFieldMatchAnyComponent: React.FC { - const signal = new AbortController().signal; - - updateSuggestions({ - fieldSelected: selectedField, - value: `${searchVal}`, - patterns: indexPattern, - signal, - }); + if (updateSuggestions != null) { + updateSuggestions({ + fieldSelected: selectedField, + value: searchVal, + patterns: indexPattern, + }); + } }; const onCreateOption = (option: string) => onChange([...(selectedValue || []), option]); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts index def2a303f6038a..a76b50d11a8757 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts @@ -199,12 +199,17 @@ describe('useFieldValueAutocomplete', () => { await waitForNextUpdate(); await waitForNextUpdate(); - result.current[2]({ - fieldSelected: getField('@tags'), - value: 'hello', - patterns: stubIndexPatternWithFields, - signal: new AbortController().signal, - }); + expect(result.current[2]).not.toBeNull(); + + // Added check for typescripts sake, if null, + // would not reach below logic as test would stop above + if (result.current[2] != null) { + result.current[2]({ + fieldSelected: getField('@tags'), + value: 'hello', + patterns: stubIndexPatternWithFields, + }); + } await waitForNextUpdate(); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts index 541c0a8d3fbae5..a53914da93f279 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts @@ -11,16 +11,13 @@ import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/d import { useKibana } from '../../../../common/lib/kibana'; import { OperatorTypeEnum } from '../../../../lists_plugin_deps'; -export type UseFieldValueAutocompleteReturn = [ - boolean, - string[], - (args: { - fieldSelected: IFieldType | undefined; - value: string | string[] | undefined; - patterns: IIndexPattern | undefined; - signal: AbortSignal; - }) => void -]; +type Func = (args: { + fieldSelected: IFieldType | undefined; + value: string | string[] | undefined; + patterns: IIndexPattern | undefined; +}) => void; + +export type UseFieldValueAutocompleteReturn = [boolean, string[], Func | null]; export interface UseFieldValueAutocompleteProps { selectedField: IFieldType | undefined; @@ -41,62 +38,77 @@ export const useFieldValueAutocomplete = ({ const { services } = useKibana(); const [isLoading, setIsLoading] = useState(false); const [suggestions, setSuggestions] = useState([]); - const updateSuggestions = useRef( - debounce( + const updateSuggestions = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchSuggestions = debounce( async ({ fieldSelected, value, patterns, - signal, }: { fieldSelected: IFieldType | undefined; value: string | string[] | undefined; patterns: IIndexPattern | undefined; - signal: AbortSignal; }) => { - if (fieldSelected == null || patterns == null) { - return; - } + const inputValue: string | string[] = value ?? ''; + const userSuggestion: string = Array.isArray(inputValue) + ? inputValue[inputValue.length - 1] ?? '' + : inputValue; - setIsLoading(true); + try { + if (isSubscribed) { + if (fieldSelected == null || patterns == null) { + return; + } - // Fields of type boolean should only display two options - if (fieldSelected.type === 'boolean') { - setIsLoading(false); - setSuggestions(['true', 'false']); - return; - } + setIsLoading(true); - const newSuggestions = await services.data.autocomplete.getValueSuggestions({ - indexPattern: patterns, - field: fieldSelected, - query: '', - signal, - }); + // Fields of type boolean should only display two options + if (fieldSelected.type === 'boolean') { + setIsLoading(false); + setSuggestions(['true', 'false']); + return; + } - setIsLoading(false); - setSuggestions(newSuggestions); + const newSuggestions = await services.data.autocomplete.getValueSuggestions({ + indexPattern: patterns, + field: fieldSelected, + query: userSuggestion.trim(), + signal: abortCtrl.signal, + }); + + setIsLoading(false); + setSuggestions([...newSuggestions]); + } + } catch (error) { + if (isSubscribed) { + setSuggestions([]); + setIsLoading(false); + } + } }, 500 - ) - ); - - useEffect(() => { - const abortCtrl = new AbortController(); + ); if (operatorType !== OperatorTypeEnum.EXISTS) { - updateSuggestions.current({ + fetchSuggestions({ fieldSelected: selectedField, value: fieldValue, patterns: indexPattern, - signal: abortCtrl.signal, }); } + updateSuggestions.current = fetchSuggestions; + return (): void => { + isSubscribed = false; abortCtrl.abort(); }; - }, [updateSuggestions, selectedField, operatorType, fieldValue, indexPattern]); + }, [services.data.autocomplete, selectedField, operatorType, fieldValue, indexPattern]); return [isLoading, suggestions, updateSuggestions.current]; }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx index 3dcc3eb5a8329f..0f54ec29cc5400 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx @@ -55,6 +55,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -81,6 +82,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -111,6 +113,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -143,6 +146,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -175,6 +179,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -207,6 +212,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -239,6 +245,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -271,6 +278,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -306,6 +314,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -331,6 +340,62 @@ describe('BuilderEntryItem', () => { ).toBeTruthy(); }); + test('it uses "correspondingKeywordField" if it exists', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="exceptionBuilderEntryFieldMatchAny"]').prop('selectedField') + ).toEqual({ + name: 'extension', + type: 'string', + esTypes: ['keyword'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }); + }); + test('it invokes "onChange" when new field is selected and resets operator and value fields', () => { const mockOnChange = jest.fn(); const wrapper = mount( @@ -342,6 +407,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -376,6 +442,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -410,6 +477,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -444,6 +512,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -478,6 +547,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx index 5939a5a1b576eb..3883a2fad2cf2c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx @@ -95,7 +95,7 @@ export const BuilderEntryItem: React.FC = ({ const renderFieldInput = useCallback( (isFirst: boolean): JSX.Element => { - const filteredIndexPatterns = getFilteredIndexPatterns(indexPattern, entry); + const filteredIndexPatterns = getFilteredIndexPatterns(indexPattern, entry, listType); const comboBox = ( = ({ return comboBox; } }, - [handleFieldChange, indexPattern, entry] + [handleFieldChange, indexPattern, entry, listType] ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { @@ -170,7 +170,11 @@ export const BuilderEntryItem: React.FC = ({ return ( = ({ return ( { + const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); + + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + autocomplete: { + getValueSuggestions: getValueSuggestionsMock, + }, + }, + }, + }); + }); + + afterEach(() => { + getValueSuggestionsMock.mockClear(); + }); + describe('and badge logic', () => { test('it renders "and" badge with extra top padding for the first exception item when "andLogicIncluded" is "true"', () => { const exceptionItem = { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx index 17c94adf42648f..e8a5196a418d69 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -41,6 +41,7 @@ import { getOperatorOptions, getUpdatedEntriesOnDelete, isEntryNested, + getCorrespondingKeywordField, } from './helpers'; import { OperatorOption } from '../../autocomplete/types'; @@ -57,6 +58,7 @@ const getMockBuilderEntry = (): FormattedBuilderEntry => ({ nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }); const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ @@ -73,6 +75,7 @@ const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ parentIndex: 0, }, entryIndex: 0, + correspondingKeywordField: undefined, }); const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ @@ -82,69 +85,305 @@ const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ nested: 'parent', parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }); +const mockEndpointFields = [ + { + name: 'file.path.text', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + name: 'file.Ext.code_signature.status', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + subType: { nested: { path: 'file.Ext.code_signature' } }, + }, +]; + +export const getEndpointField = (name: string) => + mockEndpointFields.find((field) => field.name === name) as IFieldType; + describe('Exception builder helpers', () => { + describe('#getCorrespondingKeywordField', () => { + test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: 'machine.os.raw.text', + }); + + expect(output).toEqual(getField('machine.os.raw')); + }); + + test('it returns undefined if "selectedFieldIsTextType" is false', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: 'machine.os.raw', + }); + + expect(output).toEqual(undefined); + }); + + test('it returns undefined if "selectedField" is empty string', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: '', + }); + + expect(output).toEqual(undefined); + }); + + test('it returns undefined if "selectedField" is undefined', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: undefined, + }); + + expect(output).toEqual(undefined); + }); + }); + describe('#getFilteredIndexPatterns', () => { - test('it returns nested fields that match parent value when "item.nested" is "child"', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); - const expected: IIndexPattern = { - fields: [ - { ...getField('nestedField.child') }, - { ...getField('nestedField.nestedChild.doublyNestedChild') }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); + describe('list type detections', () => { + test('it returns nested fields that match parent value when "item.nested" is "child"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [ + { ...getField('nestedField.child') }, + { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [{ ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: undefined, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [ + { ...getField('nestedField.child') }, + { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [...fields], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); }); - test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); - const expected: IIndexPattern = { - fields: [{ ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); + describe('list type endpoint', () => { + let payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + + beforeAll(() => { + payloadIndexPattern = { + ...payloadIndexPattern, + fields: [...payloadIndexPattern.fields, ...mockEndpointFields], + }; + }); + + test('it returns nested fields that match parent value when "item.nested" is "child"', () => { + const payloadItem: FormattedBuilderEntry = { + field: getEndpointField('file.Ext.code_signature.status'), + operator: isOperator, + value: 'some value', + nested: 'child', + parent: { + parent: { + ...getEntryNestedMock(), + field: 'file.Ext.code_signature', + entries: [{ ...getEntryMatchMock(), field: 'child' }], + }, + parentIndex: 0, + }, + entryIndex: 0, + correspondingKeywordField: undefined, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [getEndpointField('file.Ext.code_signature.status')], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: { + ...getEndpointField('file.Ext.code_signature.status'), + name: 'file.Ext.code_signature', + esTypes: ['nested'], + }, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [ + { + aggregatable: false, + count: 0, + esTypes: ['nested'], + name: 'file.Ext.code_signature', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'file.Ext.code_signature', + }, + }, + type: 'string', + }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: undefined, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [getEndpointField('file.Ext.code_signature.status')], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns all fields that matched those in "exceptionable_fields.json" with no further filtering if "item.nested" is not "child" or "parent"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [ + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'file.path.text', + readFromDocValues: false, + scripted: false, + searchable: true, + type: 'string', + }, + { + name: 'file.Ext.code_signature.status', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + subType: { nested: { path: 'file.Ext.code_signature' } }, + }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); }); + }); - test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); - const expected: IIndexPattern = { + describe('#getFormattedBuilderEntry', () => { + test('it returns entry with a value for "correspondingKeywordField" when "item.field" is of type "text" and matching keyword field exists', () => { + const payloadIndexPattern: IIndexPattern = { + ...getMockIndexPattern(), fields: [ - { ...getField('nestedField.child') }, - { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ...fields, + { + name: 'machine.os.raw.text', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: true, + }, ], - id: '1234', - title: 'logstash-*', }; - expect(output).toEqual(expected); - }); - - test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); - const expected: IIndexPattern = { - fields: [...fields], - id: '1234', - title: 'logstash-*', + const payloadItem: BuilderEntry = { + ...getEntryMatchMock(), + field: 'machine.os.raw.text', + value: 'some os', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined + ); + const expected: FormattedBuilderEntry = { + entryIndex: 0, + field: { + name: 'machine.os.raw.text', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: true, + }, + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some os', + correspondingKeywordField: getField('machine.os.raw'), }; expect(output).toEqual(expected); }); - }); - describe('#getFormattedBuilderEntry', () => { test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'child' }; @@ -188,6 +427,7 @@ describe('Exception builder helpers', () => { parentIndex: 1, }, value: 'some host name', + correspondingKeywordField: undefined, }; expect(output).toEqual(expected); }); @@ -218,6 +458,7 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: undefined, value: 'some ip', + correspondingKeywordField: undefined, }; expect(output).toEqual(expected); }); @@ -252,6 +493,7 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: undefined, value: 'some host name', + correspondingKeywordField: undefined, }, ]; expect(output).toEqual(expected); @@ -281,6 +523,7 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: undefined, value: 'some ip', + correspondingKeywordField: undefined, }, { entryIndex: 1, @@ -298,6 +541,7 @@ describe('Exception builder helpers', () => { operator: isOneOfOperator, parent: undefined, value: ['some extension'], + correspondingKeywordField: undefined, }, ]; expect(output).toEqual(expected); @@ -333,6 +577,7 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: undefined, value: 'some ip', + correspondingKeywordField: undefined, }, { entryIndex: 1, @@ -347,6 +592,7 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: undefined, value: undefined, + correspondingKeywordField: undefined, }, { entryIndex: 0, @@ -383,6 +629,7 @@ describe('Exception builder helpers', () => { parentIndex: 1, }, value: 'some host name', + correspondingKeywordField: undefined, }, ]; expect(output).toEqual(expected); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index 93bae091885c14..8585f58504e313 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -33,6 +33,8 @@ import { EmptyNestedEntry, } from '../types'; import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import exceptionableFields from '../exceptionable_fields.json'; /** * Returns filtered index patterns based on the field - if a user selects to @@ -45,13 +47,21 @@ import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; */ export const getFilteredIndexPatterns = ( patterns: IIndexPattern, - item: FormattedBuilderEntry + item: FormattedBuilderEntry, + type: ExceptionListType ): IIndexPattern => { + const indexPatterns = { + ...patterns, + fields: patterns.fields.filter(({ name }) => + type === 'endpoint' ? exceptionableFields.includes(name) : true + ), + }; + if (item.nested === 'child' && item.parent != null) { // when user has selected a nested entry, only fields with the common parent are shown return { - ...patterns, - fields: patterns.fields.filter( + ...indexPatterns, + fields: indexPatterns.fields.filter( (field) => field.subType != null && field.subType.nested != null && @@ -61,20 +71,53 @@ export const getFilteredIndexPatterns = ( }; } else if (item.nested === 'parent' && item.field != null) { // when user has selected a nested entry, right above it we show the common parent - return { ...patterns, fields: [item.field] }; + return { ...indexPatterns, fields: [item.field] }; } else if (item.nested === 'parent' && item.field == null) { // when user selects to add a nested entry, only nested fields are shown as options return { - ...patterns, - fields: patterns.fields.filter( + ...indexPatterns, + fields: indexPatterns.fields.filter( (field) => field.subType != null && field.subType.nested != null ), }; } else { - return patterns; + return indexPatterns; } }; +/** + * Fields of type 'text' do not generate autocomplete values, we want + * to find it's corresponding keyword type (if available) which does + * generate autocomplete values + * + * @param fields IFieldType fields + * @param selectedField the field name that was selected + * @param isTextType we only want a corresponding keyword field if + * the selected field is of type 'text' + * + */ +export const getCorrespondingKeywordField = ({ + fields, + selectedField, +}: { + fields: IFieldType[]; + selectedField: string | undefined; +}): IFieldType | undefined => { + const selectedFieldBits = + selectedField != null && selectedField !== '' ? selectedField.split('.') : []; + const selectedFieldIsTextType = selectedFieldBits.slice(-1)[0] === 'text'; + + if (selectedFieldIsTextType && selectedFieldBits.length > 0) { + const keywordField = selectedFieldBits.slice(0, selectedFieldBits.length - 1).join('.'); + const [foundKeywordField] = fields.filter( + ({ name }) => keywordField !== '' && keywordField === name + ); + return foundKeywordField; + } + + return undefined; +}; + /** * Formats the entry into one that is easily usable for the UI, most of the * complexity was introduced with nested fields @@ -95,11 +138,16 @@ export const getFormattedBuilderEntry = ( ): FormattedBuilderEntry => { const { fields } = indexPattern; const field = parent != null ? `${parent.field}.${item.field}` : item.field; - const [selectedField] = fields.filter(({ name }) => field != null && field === name); + const [foundField] = fields.filter(({ name }) => field != null && field === name); + const correspondingKeywordField = getCorrespondingKeywordField({ + fields, + selectedField: field, + }); if (parent != null && parentIndex != null) { return { - field: selectedField, + field: foundField, + correspondingKeywordField, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), nested: 'child', @@ -108,7 +156,8 @@ export const getFormattedBuilderEntry = ( }; } else { return { - field: selectedField, + field: foundField, + correspondingKeywordField, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), nested: undefined, @@ -167,6 +216,7 @@ export const getFormattedBuilderEntries = ( value: undefined, entryIndex: index, parent: undefined, + correspondingKeywordField: undefined, }; // User has selected to add a nested field, but not yet selected the field diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx index 734434484fb4cc..b82607a541aaac 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; +import React, { useCallback, useEffect, useReducer } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; @@ -29,8 +29,6 @@ import { getDefaultEmptyEntry, getDefaultNestedEmptyEntry, } from './helpers'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import exceptionableFields from '../exceptionable_fields.json'; const MyInvisibleAndBadge = styled(EuiFlexItem)` visibility: hidden; @@ -244,17 +242,6 @@ export const ExceptionBuilder = ({ setUpdateExceptions([...exceptions, { ...newException }]); }, [setUpdateExceptions, exceptions, listType, listId, listNamespaceType, ruleName]); - // Filters index pattern fields by exceptionable fields if list type is endpoint - const filterIndexPatterns = useMemo((): IIndexPattern => { - if (listType === 'endpoint') { - return { - ...indexPatterns, - fields: indexPatterns.fields.filter(({ name }) => exceptionableFields.includes(name)), - }; - } - return indexPatterns; - }, [indexPatterns, listType]); - // The builder can have existing exception items, or new exception items that have yet // to be created (and thus lack an id), this was creating some React bugs with relying // on the index, as a result, created a temporary id when new exception items are first @@ -368,7 +355,7 @@ export const ExceptionBuilder = ({ key={getExceptionListItemId(exceptionListItem, index)} exceptionItem={exceptionListItem} exceptionId={getExceptionListItemId(exceptionListItem, index)} - indexPattern={filterIndexPatterns} + indexPattern={indexPatterns} listType={listType} addNested={addNested} exceptionItemIndex={index} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index 83367e5b9e7399..9b7c68848c41fb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -62,6 +62,7 @@ export interface FormattedBuilderEntry { nested: 'parent' | 'child' | undefined; entryIndex: number; parent: { parent: EntryNested; parentIndex: number } | undefined; + correspondingKeywordField: IFieldType | undefined; } export interface EmptyEntry {