diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock.ts index 777256ff961f9..c033e0adccf0f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock.ts @@ -60,7 +60,7 @@ export const getAddPrepackagedThreatMatchRulesSchemaMock = (): AddPrepackagedRul rule_id: 'rule-1', version: 1, threat_query: '*:*', - threat_index: 'list-index', + threat_index: ['list-index'], threat_mapping: [ { entries: [ @@ -118,7 +118,7 @@ export const getAddPrepackagedThreatMatchRulesSchemaDecodedMock = (): AddPrepack exceptions_list: [], rule_id: 'rule-1', threat_query: '*:*', - threat_index: 'list-index', + threat_index: ['list-index'], threat_mapping: [ { entries: [ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts index 3f338c57dd930..6ffbf4e4c8d4c 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts @@ -51,6 +51,7 @@ import { threat_query, threat_filters, threat_mapping, + threat_language, } from '../types/threat_mapping'; import { @@ -128,6 +129,7 @@ export const addPrepackagedRulesSchema = t.intersection([ threat_mapping, // defaults to "undefined" if not set during decode threat_query, // defaults to "undefined" if not set during decode threat_index, // defaults to "undefined" if not set during decode + threat_language, // defaults "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.mock.ts index 32299be500b45..94dd1215d8026 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.mock.ts @@ -66,7 +66,7 @@ export const getCreateThreatMatchRulesSchemaMock = (ruleId = 'rule-1'): CreateRu language: 'kuery', rule_id: ruleId, threat_query: '*:*', - threat_index: 'list-index', + threat_index: ['list-index'], threat_mapping: [ { entries: [ @@ -124,7 +124,7 @@ export const getCreateThreatMatchRulesSchemaDecodedMock = (): CreateRulesSchemaD exceptions_list: [], rule_id: 'rule-1', threat_query: '*:*', - threat_index: 'list-index', + threat_index: ['list-index'], threat_mapping: [ { entries: [ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts index 2489210a26c8f..d8e7614fcb840 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts @@ -52,6 +52,7 @@ import { threat_query, threat_filters, threat_mapping, + threat_language, } from '../types/threat_mapping'; import { @@ -124,6 +125,7 @@ export const createRulesSchema = t.intersection([ threat_query, // defaults to "undefined" if not set during decode threat_filters, // defaults to "undefined" if not set during decode threat_index, // defaults to "undefined" if not set during decode + threat_language, // defaults "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.mock.ts index 160dbb92b74cd..2eea9ac0f30c7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.mock.ts @@ -86,7 +86,7 @@ export const getImportThreatMatchRulesSchemaMock = (ruleId = 'rule-1'): ImportRu risk_score: 55, language: 'kuery', rule_id: ruleId, - threat_index: 'index-123', + threat_index: ['index-123'], threat_mapping: [{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }], threat_query: '*:*', threat_filters: [ @@ -136,7 +136,7 @@ export const getImportThreatMatchRulesSchemaDecodedMock = (): ImportRulesSchemaD rule_id: 'rule-1', immutable: false, threat_query: '*:*', - threat_index: 'index-123', + threat_index: ['index-123'], threat_mapping: [ { entries: [ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts index a411b3d439a1f..852394b74767b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts @@ -58,6 +58,7 @@ import { threat_query, threat_filters, threat_mapping, + threat_language, } from '../types/threat_mapping'; import { @@ -147,6 +148,7 @@ export const importRulesSchema = t.intersection([ threat_mapping, // defaults to "undefined" if not set during decode threat_query, // defaults to "undefined" if not set during decode threat_index, // defaults to "undefined" if not set during decode + threat_language, // defaults "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts index 40e79d96a9e6b..f4dce5c7ac05f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts @@ -48,6 +48,13 @@ import { severity_mapping, event_category_override, } from '../common/schemas'; +import { + threat_index, + threat_query, + threat_filters, + threat_mapping, + threat_language, +} from '../types/threat_mapping'; import { listArrayOrUndefined } from '../types/lists'; /** @@ -97,6 +104,11 @@ export const patchRulesSchema = t.exact( note, version, exceptions_list: listArrayOrUndefined, + threat_index, + threat_query, + threat_filters, + threat_mapping, + threat_language, }) ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts index 8a13dd2f4e908..b0cd8b1c53688 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts @@ -49,6 +49,13 @@ import { SeverityMapping, event_category_override, } from '../common/schemas'; +import { + threat_index, + threat_query, + threat_filters, + threat_mapping, + threat_language, +} from '../types/threat_mapping'; import { DefaultStringArray, @@ -122,6 +129,11 @@ export const updateRulesSchema = t.intersection([ note, // defaults to "undefined" if not set during decode version, // defaults to "undefined" if not set during decode exceptions_list: DefaultListArray, // defaults to empty array if not set during decode + threat_mapping, // defaults to "undefined" if not set during decode + threat_query, // defaults to "undefined" if not set during decode + threat_filters, // defaults to "undefined" if not set during decode + threat_index, // defaults to "undefined" if not set during decode + threat_language, // defaults "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index aaa246c82d9d7..340f93150ce5c 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -87,7 +87,7 @@ export const getThreatMatchingSchemaMock = (anchorDate: string = ANCHOR_DATE): R return { ...getRulesSchemaMock(anchorDate), type: 'threat_match', - threat_index: 'index-123', + threat_index: ['index-123'], threat_mapping: [{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }], threat_query: '*:*', threat_filters: [ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts index c5bad3c55066b..82675768a11b7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts @@ -626,7 +626,7 @@ describe('rules_schema', () => { const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'invalid keys "threat_index,threat_mapping,[{"entries":[{"field":"host.name","type":"mapping","value":"host.name"}]}],threat_query,threat_filters,[{"bool":{"must":[{"query_string":{"query":"host.name: linux","analyze_wildcard":true,"time_zone":"Zulu"}}],"filter":[],"should":[],"must_not":[]}}]"', + 'invalid keys "threat_index,["index-123"],threat_mapping,[{"entries":[{"field":"host.name","type":"mapping","value":"host.name"}]}],threat_query,threat_filters,[{"bool":{"must":[{"query_string":{"query":"host.name: linux","analyze_wildcard":true,"time_zone":"Zulu"}}],"filter":[],"should":[],"must_not":[]}}]"', ]); expect(message.schema).toEqual({}); }); @@ -764,7 +764,7 @@ describe('rules_schema', () => { test('should return 5 fields for a rule of type "threat_match"', () => { const fields = addThreatMatchFields({ type: 'threat_match' }); - expect(fields.length).toEqual(5); + expect(fields.length).toEqual(6); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts index 908425a7496d0..e85beddf0e51e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts @@ -66,6 +66,7 @@ import { threat_query, threat_filters, threat_mapping, + threat_language, } from '../types/threat_mapping'; import { DefaultListArray } from '../types/lists_default_array'; @@ -144,6 +145,7 @@ export const dependentRulesSchema = t.partial({ threat_index, threat_query, threat_mapping, + threat_language, }); /** @@ -277,6 +279,7 @@ export const addThreatMatchFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.exact(t.type({ threat_query: dependentRulesSchema.props.threat_query })), t.exact(t.type({ threat_index: dependentRulesSchema.props.threat_index })), t.exact(t.type({ threat_mapping: dependentRulesSchema.props.threat_mapping })), + t.exact(t.partial({ threat_language: dependentRulesSchema.props.threat_language })), t.exact(t.partial({ threat_filters: dependentRulesSchema.props.threat_filters })), t.exact(t.partial({ saved_id: dependentRulesSchema.props.saved_id })), ]; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts index aab9a550d25e7..28a66d2948a92 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts @@ -33,4 +33,5 @@ export * from './positive_integer'; export * from './positive_integer_greater_than_zero'; export * from './references_default_array'; export * from './risk_score'; +export * from './threat_mapping'; export * from './uuid'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts index f2b4754c2d113..a1be6485f596b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts @@ -7,6 +7,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import * as t from 'io-ts'; +import { language } from '../common/schemas'; import { NonEmptyString } from './non_empty_string'; export const threat_query = t.string; @@ -19,29 +20,38 @@ export type ThreatFilters = t.TypeOf; export const threatFiltersOrUndefined = t.union([threat_filters, t.undefined]); export type ThreatFiltersOrUndefined = t.TypeOf; -export const threatMappingEntries = t.array( - t.exact( - t.type({ - field: NonEmptyString, - type: t.keyof({ mapping: null }), - value: NonEmptyString, - }) - ) +export const threatMapEntry = t.exact( + t.type({ + field: NonEmptyString, + type: t.keyof({ mapping: null }), + value: NonEmptyString, + }) ); + +export type ThreatMapEntry = t.TypeOf; + +export const threatMappingEntries = t.array(threatMapEntry); export type ThreatMappingEntries = t.TypeOf; -export const threat_mapping = t.array( - t.exact( - t.type({ - entries: threatMappingEntries, - }) - ) +export const threatMap = t.exact( + t.type({ + entries: threatMappingEntries, + }) ); +export type ThreatMap = t.TypeOf; + +export const threat_mapping = t.array(threatMap); export type ThreatMapping = t.TypeOf; export const threatMappingOrUndefined = t.union([threat_mapping, t.undefined]); export type ThreatMappingOrUndefined = t.TypeOf; -export const threat_index = t.string; +export const threat_index = t.array(t.string); +export type ThreatIndex = t.TypeOf; export const threatIndexOrUndefined = t.union([threat_index, t.undefined]); export type ThreatIndexOrUndefined = t.TypeOf; + +export const threat_language = t.union([language, t.undefined]); +export type ThreatLanguage = t.TypeOf; +export const threatLanguageOrUndefined = t.union([threat_language, t.undefined]); +export type ThreatLanguageOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/and_badge.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/and_badge.test.tsx new file mode 100644 index 0000000000000..87d2b5ede7d67 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/and_badge.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { AndBadgeComponent } from './and_badge'; + +describe('AndBadgeComponent', () => { + test('it renders entryItemIndexItemEntryFirstRowAndBadge for very first item', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()).toBeTruthy(); + }); + + test('it renders entryItemEntryInvisibleAndBadge if "entriesLength" is 1 or less', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="entryItemEntryInvisibleAndBadge"]').exists() + ).toBeTruthy(); + }); + + test('it renders regular "and" badge if item is not the first one and includes more than one entry', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="entryItemEntryAndBadge"]').exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/and_badge.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/and_badge.tsx new file mode 100644 index 0000000000000..fd8d3f08e5de3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/and_badge.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { AndOrBadge } from '../and_or_badge'; + +const MyInvisibleAndBadge = styled(EuiFlexItem)` + visibility: hidden; +`; + +const MyFirstRowContainer = styled(EuiFlexItem)` + padding-top: 20px; +`; + +interface AndBadgeProps { + entriesLength: number; + entryItemIndex: number; +} + +export const AndBadgeComponent = React.memo(({ entriesLength, entryItemIndex }) => { + const badge = ; + + if (entriesLength > 1 && entryItemIndex === 0) { + return ( + + {badge} + + ); + } else if (entriesLength <= 1) { + return ( + + {badge} + + ); + } else { + return ( + + {badge} + + ); + } +}); + +AndBadgeComponent.displayName = 'AndBadge'; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_delete_button.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_delete_button.test.tsx new file mode 100644 index 0000000000000..063499902094c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_delete_button.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import { ThreatMappingEntries } from '../../../../common/detection_engine/schemas/types'; + +import { EntryDeleteButtonComponent } from './entry_delete_button'; + +const entries: ThreatMappingEntries = [ + { + field: 'field.one', + type: 'mapping', + value: 'field.one', + }, +]; + +describe('EntryDeleteButtonComponent', () => { + test('it renders firstRowDeleteButton for very first entry', () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="firstRowDeleteButton"] button')).toHaveLength(1); + }); + + test('it does not render firstRowDeleteButton if entryIndex is not 0', () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="firstRowDeleteButton"]')).toHaveLength(0); + expect(wrapper.find('[data-test-subj="deleteButton"] button')).toHaveLength(1); + }); + + test('it does not render firstRowDeleteButton if itemIndex is not 0', () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="firstRowDeleteButton"]')).toHaveLength(0); + expect(wrapper.find('[data-test-subj="deleteButton"] button')).toHaveLength(1); + }); + + test('it invokes "onDelete" when button is clicked', () => { + const onDelete = jest.fn(); + + const wrapper = mount( + + ); + wrapper.find('[data-test-subj="deleteButton"] button').simulate('click'); + + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onDelete).toHaveBeenCalledWith(0); + }); + + test('it disables button if it is the only entry left and no field has been selected', () => { + const emptyEntries: ThreatMappingEntries = [ + { + field: '', + type: 'mapping', + value: 'field.one', + }, + ]; + + const wrapper = mount( + + ); + + const button = wrapper.find('[data-test-subj="firstRowDeleteButton"] button').at(0); + + expect(button.prop('disabled')).toBeTruthy(); + }); + + test('it does not disable button if it is the only entry left and field has been selected', () => { + const wrapper = mount( + + ); + + const button = wrapper.find('[data-test-subj="deleteButton"] button').at(0); + + expect(button.prop('disabled')).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_delete_button.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_delete_button.tsx new file mode 100644 index 0000000000000..10a82855bb0a3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_delete_button.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { Entry } from './types'; + +const MyFirstRowContainer = styled(EuiFlexItem)` + padding-top: 20px; +`; + +interface EntryDeleteButtonProps { + entries: Entry[]; + isOnlyItem: boolean; + entryIndex: number; + itemIndex: number; + onDelete: (item: number) => void; +} + +export const EntryDeleteButtonComponent = React.memo( + ({ entries, isOnlyItem, entryIndex, itemIndex, onDelete }) => { + const isDisabled: boolean = + isOnlyItem && + entries.length === 1 && + itemIndex === 0 && + (entries[0].field == null || entries[0].field === ''); + + const handleDelete = useCallback((): void => { + onDelete(entryIndex); + }, [onDelete, entryIndex]); + + const button = ( + + ); + + if (entryIndex === 0 && itemIndex === 0) { + // This logic was added to work around it including the field + // labels in centering the delete icon for the first row + return ( + + {button} + + ); + } else { + return ( + + {button} + + ); + } + } +); + +EntryDeleteButtonComponent.displayName = 'EntryDeleteButton'; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx new file mode 100644 index 0000000000000..36033c358766d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { EntryItem } from './entry_item'; +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { IndexPattern } from 'src/plugins/data/public'; + +jest.mock('../../../common/lib/kibana'); + +describe('EntryItem', () => { + test('it renders field labels if "showLabel" is "true"', () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="threatFieldInputFormRow"]')).not.toEqual(0); + }); + + test('it invokes "onChange" when new field is selected and resets value fields', () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).at(0).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'machine.os' }]); + + expect(mockOnChange).toHaveBeenCalledWith( + { + field: 'machine.os', + type: 'mapping', + value: 'ip', + }, + 0 + ); + }); + + test('it invokes "onChange" when new value is selected', () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).at(1).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'is not' }]); + + expect(mockOnChange).toHaveBeenCalledWith({ field: 'ip', type: 'mapping', value: '' }, 0); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx new file mode 100644 index 0000000000000..c99e63ff4eda0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback, useMemo } from 'react'; +import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { IFieldType, IndexPattern } from '../../../../../../../src/plugins/data/common'; +import { FieldComponent } from '../autocomplete/field'; +import { FormattedEntry, Entry } from './types'; +import * as i18n from './translations'; +import { getEntryOnFieldChange, getEntryOnThreatFieldChange } from './helpers'; + +interface EntryItemProps { + entry: FormattedEntry; + indexPattern: IndexPattern; + threatIndexPatterns: IndexPattern; + showLabel: boolean; + onChange: (arg: Entry, i: number) => void; +} + +const FlexItemWithLabel = styled(EuiFlexItem)` + padding-top: 20px; + text-align: center; +`; + +const FlexItemWithoutLabel = styled(EuiFlexItem)` + text-align: center; +`; + +export const EntryItem: React.FC = ({ + entry, + indexPattern, + threatIndexPatterns, + showLabel, + onChange, +}): JSX.Element => { + const handleFieldChange = useCallback( + ([newField]: IFieldType[]): void => { + const { updatedEntry, index } = getEntryOnFieldChange(entry, newField); + onChange(updatedEntry, index); + }, + [onChange, entry] + ); + + const handleThreatFieldChange = useCallback( + ([newField]: IFieldType[]): void => { + const { updatedEntry, index } = getEntryOnThreatFieldChange(entry, newField); + onChange(updatedEntry, index); + }, + [onChange, entry] + ); + + const renderFieldInput = useMemo(() => { + const comboBox = ( + + ); + + if (showLabel) { + return ( + + {comboBox} + + ); + } else { + return comboBox; + } + }, [handleFieldChange, indexPattern, entry, showLabel]); + + const renderThreatFieldInput = useMemo(() => { + const comboBox = ( + + ); + + if (showLabel) { + return ( + + {comboBox} + + ); + } else { + return comboBox; + } + }, [handleThreatFieldChange, threatIndexPatterns, entry, showLabel]); + + return ( + + {renderFieldInput} + + + {showLabel ? ( + {i18n.MATCHES} + ) : ( + {i18n.MATCHES} + )} + + + {renderThreatFieldInput} + + ); +}; + +EntryItem.displayName = 'EntryItem'; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx new file mode 100644 index 0000000000000..7bab8e93ea9db --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx @@ -0,0 +1,225 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { Entry, EmptyEntry, ThreatMapEntries, FormattedEntry } from './types'; +import { IndexPattern } from '../../../../../../../src/plugins/data/common'; +import moment from 'moment-timezone'; + +import { + filterItems, + getEntryOnFieldChange, + getFormattedEntries, + getFormattedEntry, + getUpdatedEntriesOnDelete, +} from './helpers'; +import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types'; + +const getMockIndexPattern = (): IndexPattern => + ({ + id: '1234', + title: 'logstash-*', + fields, + } as IndexPattern); + +const getMockEntry = (): FormattedEntry => ({ + field: getField('ip'), + value: getField('ip'), + type: 'mapping', + entryIndex: 0, +}); + +describe('Helpers', () => { + beforeEach(() => { + moment.tz.setDefault('UTC'); + }); + + afterEach(() => { + moment.tz.setDefault('Browser'); + }); + + describe('#getFormattedEntry', () => { + test('it returns entry with a value when "item.field" is of type "text" and matching keyword field exists', () => { + const payloadIndexPattern: IndexPattern = { + ...getMockIndexPattern(), + fields: [ + ...fields, + { + name: 'machine.os.raw.text', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: true, + }, + ], + } as IndexPattern; + const payloadItem: Entry = { + field: 'machine.os.raw.text', + type: 'mapping', + value: 'some os', + }; + const output = getFormattedEntry(payloadIndexPattern, payloadItem, 0); + const expected: FormattedEntry = { + entryIndex: 0, + field: { + name: 'machine.os.raw.text', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: true, + }, + type: 'mapping', + value: undefined, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getFormattedEntries', () => { + test('it returns formatted entry with fields undefined if it unable to find a matching index pattern field', () => { + const payloadIndexPattern: IndexPattern = getMockIndexPattern(); + const payloadItems: Entry[] = [{ field: 'field.one', type: 'mapping', value: 'field.one' }]; + const output = getFormattedEntries(payloadIndexPattern, payloadItems); + const expected: FormattedEntry[] = [ + { + entryIndex: 0, + field: undefined, + value: undefined, + type: 'mapping', + }, + ]; + expect(output).toEqual(expected); + }); + + test('it returns formatted entries', () => { + const payloadIndexPattern: IndexPattern = getMockIndexPattern(); + const payloadItems: Entry[] = [ + { field: 'machine.os', type: 'mapping', value: 'machine.os' }, + { field: 'ip', type: 'mapping', value: 'ip' }, + ]; + const output = getFormattedEntries(payloadIndexPattern, payloadItems); + const expected: FormattedEntry[] = [ + { + field: { + name: 'machine.os', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + type: 'mapping', + value: { + name: 'machine.os', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + entryIndex: 0, + }, + { + field: { + name: 'ip', + type: 'ip', + esTypes: ['ip'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + type: 'mapping', + value: { + name: 'ip', + type: 'ip', + esTypes: ['ip'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + entryIndex: 1, + }, + ]; + expect(output).toEqual(expected); + }); + }); + + describe('#getUpdatedEntriesOnDelete', () => { + test('it removes entry corresponding to "entryIndex"', () => { + const payloadItem: ThreatMapEntries = { + entries: [ + { field: 'field.one', type: 'mapping', value: 'field.one' }, + { field: 'field.two', type: 'mapping', value: 'field.two' }, + ], + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0); + const expected: ThreatMapEntries = { + entries: [ + { + field: 'field.two', + type: 'mapping', + value: 'field.two', + }, + ], + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnFieldChange', () => { + test('it returns field of type "match" with updated field', () => { + const payloadItem = getMockEntry(); + const payloadIFieldType = getField('ip'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: Entry; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + type: 'mapping', + value: 'ip', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#filterItems', () => { + test('it removes entry items with "value" of "undefined"', () => { + const entry: ThreatMapEntry = { field: 'host.name', type: 'mapping', value: 'host.name' }; + const mockEmpty: EmptyEntry = { + field: 'host.name', + type: 'mapping', + value: undefined, + }; + const items = filterItems([ + { + entries: [entry], + }, + { + entries: [mockEmpty], + }, + ]); + expect(items).toEqual([{ entries: [entry] }]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx new file mode 100644 index 0000000000000..9b155e1d568a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ThreatMap, + threatMap, + ThreatMapping, +} from '../../../../common/detection_engine/schemas/types'; + +import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/common'; +import { Entry, FormattedEntry, ThreatMapEntries, EmptyEntry } from './types'; + +/** + * Formats the entry into one that is easily usable for the UI. + * + * @param patterns IndexPattern containing available fields on rule index + * @param item item entry + * @param itemIndex entry index + */ +export const getFormattedEntry = ( + indexPattern: IndexPattern, + item: Entry, + itemIndex: number +): FormattedEntry => { + const { fields } = indexPattern; + const field = item.field; + const threatField = item.value; + const [foundField] = fields.filter(({ name }) => field != null && field === name); + const [threatFoundField] = fields.filter( + ({ name }) => threatField != null && threatField === name + ); + return { + field: foundField, + type: 'mapping', + value: threatFoundField, + entryIndex: itemIndex, + }; +}; + +/** + * Formats the entries to be easily usable for the UI + * + * @param patterns IndexPattern containing available fields on rule index + * @param entries item entries + */ +export const getFormattedEntries = ( + indexPattern: IndexPattern, + entries: Entry[] +): FormattedEntry[] => { + return entries.reduce((acc, item, index) => { + const newItemEntry = getFormattedEntry(indexPattern, item, index); + return [...acc, newItemEntry]; + }, []); +}; + +/** + * Determines whether an entire entry or item need to be removed + * + * @param item + * @param entryIndex index of given entry + * + */ +export const getUpdatedEntriesOnDelete = ( + item: ThreatMapEntries, + entryIndex: number +): ThreatMapEntries => { + return { + ...item, + entries: [...item.entries.slice(0, entryIndex), ...item.entries.slice(entryIndex + 1)], + }; +}; + +/** + * Determines proper entry update when user selects new field + * + * @param item - current item entry values + * @param newField - newly selected field + * + */ +export const getEntryOnFieldChange = ( + item: FormattedEntry, + newField: IFieldType +): { updatedEntry: Entry; index: number } => { + const { entryIndex } = item; + return { + updatedEntry: { + field: newField != null ? newField.name : '', + type: 'mapping', + value: item.value != null ? item.value.name : '', + }, + index: entryIndex, + }; +}; + +/** + * Determines proper entry update when user selects new field + * + * @param item - current item entry values + * @param newField - newly selected field + * + */ +export const getEntryOnThreatFieldChange = ( + item: FormattedEntry, + newField: IFieldType +): { updatedEntry: Entry; index: number } => { + const { entryIndex } = item; + return { + updatedEntry: { + field: item.field != null ? item.field.name : '', + type: 'mapping', + value: newField != null ? newField.name : '', + }, + index: entryIndex, + }; +}; + +export const getDefaultEmptyEntry = (): EmptyEntry => ({ + field: '', + type: 'mapping', + value: '', +}); + +export const getNewItem = (): ThreatMap => { + return { + entries: [ + { + field: '', + type: 'mapping', + value: '', + }, + ], + }; +}; + +export const filterItems = (items: ThreatMapEntries[]): ThreatMapping => { + return items.reduce((acc, item) => { + const newItem = { ...item, entries: item.entries }; + if (threatMap.is(newItem)) { + return [...acc, newItem]; + } else { + return acc; + } + }, []); +}; + +/** + * Given a list of items checks each one to see if any of them have an empty field + * or an empty value. + * @param items The items to check if we have an empty entries. + */ +export const containsInvalidItems = (items: ThreatMapEntries[]): boolean => { + return items.some((item) => + item.entries.some((subEntry) => subEntry.field === '' || subEntry.value === '') + ); +}; + +/** + * Given a list of items checks if we have a single empty entry and if we do returns true. + * @param items The items to check if we have a single empty entry. + */ +export const singleEntryThreat = (items: ThreatMapEntries[]): boolean => { + return ( + items.length === 1 && + items[0].entries.length === 1 && + items[0].entries[0].field === '' && + items[0].entries[0].value === '' + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx new file mode 100644 index 0000000000000..14bc64c90a661 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx @@ -0,0 +1,304 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { waitFor } from '@testing-library/react'; + +import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; + +import { useKibana } from '../../../common/lib/kibana'; + +import { ThreatMatchComponent } from './'; +import { ThreatMapEntries } from './types'; +import { IndexPattern } from 'src/plugins/data/public'; + +jest.mock('../../../common/lib/kibana'); + +const getPayLoad = (): ThreatMapEntries[] => [ + { entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }, +]; + +const getDoublePayLoad = (): ThreatMapEntries[] => [ + { entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }, + { entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }, +]; + +describe('ThreatMatchComponent', () => { + const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); + + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + autocomplete: { + getValueSuggestions: getValueSuggestionsMock, + }, + }, + }, + }); + }); + + afterEach(() => { + getValueSuggestionsMock.mockClear(); + }); + + test('it displays empty entry if no "listItems" are passed in', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('EuiFlexGroup[data-test-subj="itemEntryContainer"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="entryField"]').text()).toEqual('Search'); + expect(wrapper.find('[data-test-subj="threatEntryField"]').text()).toEqual('Search'); + }); + + test('it displays "Search" for "listItems" that are passed in', async () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + expect(wrapper.find('EuiFlexGroup[data-test-subj="itemEntryContainer"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="entryField"]').at(0).text()).toEqual('Search'); + + wrapper.unmount(); + }); + + test('it displays "or", "and" enabled', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="andButton"] button').prop('disabled')).toBeFalsy(); + expect(wrapper.find('[data-test-subj="orButton"] button').prop('disabled')).toBeFalsy(); + }); + + test('it adds an entry when "and" clicked', async () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('EuiFlexGroup[data-test-subj="itemEntryContainer"]')).toHaveLength(1); + + wrapper.find('[data-test-subj="andButton"] button').simulate('click'); + + await waitFor(() => { + expect(wrapper.find('EuiFlexGroup[data-test-subj="itemEntryContainer"]')).toHaveLength(2); + expect(wrapper.find('[data-test-subj="entryField"]').at(0).text()).toEqual('Search'); + expect(wrapper.find('[data-test-subj="threatEntryField"]').at(0).text()).toEqual('Search'); + expect(wrapper.find('[data-test-subj="entryField"]').at(1).text()).toEqual('Search'); + expect(wrapper.find('[data-test-subj="threatEntryField"]').at(1).text()).toEqual('Search'); + }); + }); + + test('it adds an item when "or" clicked', async () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('EuiFlexGroup[data-test-subj="entriesContainer"]')).toHaveLength(1); + + wrapper.find('[data-test-subj="orButton"] button').simulate('click'); + + await waitFor(() => { + expect(wrapper.find('EuiFlexGroup[data-test-subj="entriesContainer"]')).toHaveLength(2); + expect(wrapper.find('[data-test-subj="entryField"]').at(0).text()).toEqual('Search'); + expect(wrapper.find('[data-test-subj="threatEntryField"]').at(0).text()).toEqual('Search'); + expect(wrapper.find('[data-test-subj="entryField"]').at(1).text()).toEqual('Search'); + expect(wrapper.find('[data-test-subj="threatEntryField"]').at(1).text()).toEqual('Search'); + }); + }); + + test('it removes one row if user deletes a row', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="entriesContainer"]').length).toEqual(4); + wrapper.find('[data-test-subj="firstRowDeleteButton"] button').simulate('click'); + expect(wrapper.find('[data-test-subj="entriesContainer"]').length).toEqual(2); + wrapper.unmount(); + }); + + test('it displays "and" badge if at least one item includes more than one entry', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()).toBeFalsy(); + + wrapper.find('[data-test-subj="andButton"] button').simulate('click'); + + expect(wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()).toBeTruthy(); + }); + + test('it does not display "and" badge if none of the items include more than one entry', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="orButton"] button').simulate('click'); + + expect(wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()).toBeFalsy(); + + wrapper.find('[data-test-subj="orButton"] button').simulate('click'); + + expect(wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx new file mode 100644 index 0000000000000..d3936e10bd877 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback, useEffect, useReducer } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { ThreatMapping } from '../../../../common/detection_engine/schemas/types'; +import { ListItemComponent } from './list_item'; +import { IndexPattern } from '../../../../../../../src/plugins/data/common'; +import { AndOrBadge } from '../and_or_badge'; +import { LogicButtons } from './logic_buttons'; +import { ThreatMapEntries } from './types'; +import { State, reducer } from './reducer'; +import { getDefaultEmptyEntry, getNewItem, filterItems } from './helpers'; + +const MyInvisibleAndBadge = styled(EuiFlexItem)` + visibility: hidden; +`; + +const MyAndBadge = styled(AndOrBadge)` + & > .euiFlexItem { + margin: 0; + } +`; + +const MyButtonsContainer = styled(EuiFlexItem)` + margin: 16px 0; +`; + +const initialState: State = { + andLogicIncluded: false, + entries: [], + entriesToDelete: [], +}; + +interface OnChangeProps { + entryItems: ThreatMapping; + entriesToDelete: ThreatMapEntries[]; +} + +interface ThreatMatchComponentProps { + listItems: ThreatMapEntries[]; + indexPatterns: IndexPattern; + threatIndexPatterns: IndexPattern; + onChange: (arg: OnChangeProps) => void; +} + +export const ThreatMatchComponent = ({ + listItems, + indexPatterns, + threatIndexPatterns, + onChange, +}: ThreatMatchComponentProps) => { + const [{ entries, entriesToDelete, andLogicIncluded }, dispatch] = useReducer(reducer(), { + ...initialState, + }); + + const setUpdateEntries = useCallback( + (items: ThreatMapEntries[]): void => { + dispatch({ + type: 'setEntries', + entries: items, + }); + }, + [dispatch] + ); + + const setDefaultEntries = useCallback( + (item: ThreatMapEntries): void => { + dispatch({ + type: 'setDefault', + initialState, + lastEntry: item, + }); + }, + [dispatch] + ); + + const handleEntryItemChange = useCallback( + (item: ThreatMapEntries, index: number): void => { + const updatedEntries = [ + ...entries.slice(0, index), + { + ...item, + }, + ...entries.slice(index + 1), + ]; + + setUpdateEntries(updatedEntries); + }, + [setUpdateEntries, entries] + ); + + const handleDeleteEntryItem = useCallback( + (item: ThreatMapEntries, itemIndex: number): void => { + if (item.entries.length === 0) { + const updatedEntries = [...entries.slice(0, itemIndex), ...entries.slice(itemIndex + 1)]; + // if it's the only item left, don't delete it just add a default entry to it + if (updatedEntries.length === 0) { + setDefaultEntries(item); + } else { + setUpdateEntries([...entries.slice(0, itemIndex), ...entries.slice(itemIndex + 1)]); + } + } else { + handleEntryItemChange(item, itemIndex); + } + }, + [handleEntryItemChange, setUpdateEntries, entries, setDefaultEntries] + ); + + const handleAddNewEntryItemEntry = useCallback((): void => { + const lastEntry = entries[entries.length - 1]; + const { entries: innerEntries } = lastEntry; + + const updatedEntry: ThreatMapEntries = { + ...lastEntry, + entries: [...innerEntries, getDefaultEmptyEntry()], + }; + + setUpdateEntries([...entries.slice(0, entries.length - 1), { ...updatedEntry }]); + }, [setUpdateEntries, entries]); + + const handleAddNewEntryItem = useCallback((): void => { + // There is a case where there are numerous list items, all with + // empty `entries` array. + const newItem = getNewItem(); + setUpdateEntries([...entries, { ...newItem }]); + }, [setUpdateEntries, entries]); + + const handleAddClick = useCallback((): void => { + handleAddNewEntryItemEntry(); + }, [handleAddNewEntryItemEntry]); + + // Bubble up changes to parent + useEffect(() => { + onChange({ entryItems: filterItems(entries), entriesToDelete }); + }, [onChange, entriesToDelete, entries]); + + // Defaults to never be sans entry, instead + // always falls back to an empty entry if user deletes all + useEffect(() => { + if ( + entries.length === 0 || + (entries.length === 1 && entries[0].entries != null && entries[0].entries.length === 0) + ) { + handleAddNewEntryItem(); + } + }, [entries, handleAddNewEntryItem]); + + useEffect(() => { + if (listItems.length > 0) { + setUpdateEntries(listItems); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + + {entries.map((entryListItem, index) => ( + + + {index !== 0 && + (andLogicIncluded ? ( + + + + + + + + + + + ) : ( + + + + ))} + + + + + + ))} + + + + {andLogicIncluded && ( + + + + )} + + + + + + + ); +}; + +ThreatMatchComponent.displayName = 'ThreatMatch'; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx new file mode 100644 index 0000000000000..90492bc46e2b0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx @@ -0,0 +1,382 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { useKibana } from '../../../common/lib/kibana'; +import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; + +import { ListItemComponent } from './list_item'; +import { ThreatMapEntries } from './types'; +import { IndexPattern } from 'src/plugins/data/public'; + +jest.mock('../../../common/lib/kibana'); + +const singlePayload = (): ThreatMapEntries => ({ + entries: [ + { + field: 'field.one', + type: 'mapping', + value: 'field.one', + }, + ], +}); + +const doublePayload = (): ThreatMapEntries => ({ + entries: [ + { + field: 'field.one', + type: 'mapping', + value: 'field.one', + }, + { + field: 'field.two', + type: 'mapping', + value: 'field.two', + }, + ], +}); + +describe('ListItemComponent', () => { + const getValueSuggestionsMock = jest.fn().mockResolvedValue(['field.one', 'field.two']); + + 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 item when "andLogicIncluded" is "true"', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists() + ).toBeTruthy(); + }); + + test('it renders "and" badge when more than one item entry exists and it is not the first item', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="entryItemEntryAndBadge"]').exists()).toBeTruthy(); + }); + + test('it renders indented "and" badge when "andLogicIncluded" is "true" and only one entry exists', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="entryItemEntryInvisibleAndBadge"]').exists() + ).toBeTruthy(); + }); + + test('it renders no "and" badge when "andLogicIncluded" is "false"', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="entryItemEntryInvisibleAndBadge"]').exists() + ).toBeFalsy(); + expect(wrapper.find('[data-test-subj="entryItemEntryAndBadge"]').exists()).toBeFalsy(); + expect( + wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists() + ).toBeFalsy(); + }); + }); + + describe('delete button logic', () => { + test('it renders delete button disabled when it is only entry left', () => { + const item: ThreatMapEntries = { + entries: [{ ...singlePayload(), field: '', type: 'mapping', value: '' }], + }; + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="itemEntryDeleteButton"] button').props().disabled + ).toBeTruthy(); + }); + + test('it does not render delete button disabled when it is not the only entry left', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="itemEntryDeleteButton"] button').props().disabled + ).toBeFalsy(); + }); + + test('it does not render delete button disabled when "entryItemIndex" is not "0"', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="itemEntryDeleteButton"] button').props().disabled + ).toBeFalsy(); + }); + + test('it does not render delete button disabled when more than one entry exists', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="itemEntryDeleteButton"] button').at(0).props().disabled + ).toBeFalsy(); + }); + + test('it invokes "onChangeEntryItem" when delete button clicked', () => { + const mockOnDeleteEntryItem = jest.fn(); + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="itemEntryDeleteButton"] button').at(0).simulate('click'); + + const expected: ThreatMapEntries = { + entries: [ + { + field: 'field.two', + type: 'mapping', + value: 'field.two', + }, + ], + }; + + expect(mockOnDeleteEntryItem).toHaveBeenCalledWith(expected, 0); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.tsx new file mode 100644 index 0000000000000..578986ccf17e4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { IndexPattern } from '../../../../../../../src/plugins/data/common'; +import { getFormattedEntries, getUpdatedEntriesOnDelete } from './helpers'; +import { FormattedEntry, ThreatMapEntries, Entry } from './types'; +import { EntryItem } from './entry_item'; +import { EntryDeleteButtonComponent } from './entry_delete_button'; +import { AndBadgeComponent } from './and_badge'; + +const MyOverflowContainer = styled(EuiFlexItem)` + overflow: hidden; + width: 100%; +`; + +interface ListItemProps { + listItem: ThreatMapEntries; + listId: string; + listItemIndex: number; + indexPattern: IndexPattern; + threatIndexPatterns: IndexPattern; + andLogicIncluded: boolean; + isOnlyItem: boolean; + onDeleteEntryItem: (item: ThreatMapEntries, index: number) => void; + onChangeEntryItem: (item: ThreatMapEntries, index: number) => void; +} + +export const ListItemComponent = React.memo( + ({ + listItem, + listId, + listItemIndex, + indexPattern, + threatIndexPatterns, + isOnlyItem, + andLogicIncluded, + onDeleteEntryItem, + onChangeEntryItem, + }) => { + const handleEntryChange = useCallback( + (entry: Entry, entryIndex: number): void => { + const updatedEntries: Entry[] = [ + ...listItem.entries.slice(0, entryIndex), + { ...entry }, + ...listItem.entries.slice(entryIndex + 1), + ]; + const updatedEntryItem: ThreatMapEntries = { + ...listItem, + entries: updatedEntries, + }; + onChangeEntryItem(updatedEntryItem, listItemIndex); + }, + [onChangeEntryItem, listItem, listItemIndex] + ); + + const handleDeleteEntry = useCallback( + (entryIndex: number): void => { + const updatedEntryItem = getUpdatedEntriesOnDelete(listItem, entryIndex); + + onDeleteEntryItem(updatedEntryItem, listItemIndex); + }, + [listItem, onDeleteEntryItem, listItemIndex] + ); + + const entries = useMemo( + (): FormattedEntry[] => + indexPattern != null && listItem.entries.length > 0 + ? getFormattedEntries(indexPattern, listItem.entries) + : [], + [listItem.entries, indexPattern] + ); + return ( + + + {andLogicIncluded && ( + + )} + + + {entries.map((item, index) => ( + + + + + + + + + ))} + + + + + ); + } +); + +ListItemComponent.displayName = 'ListItem'; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/logic_buttons.stories.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/logic_buttons.stories.tsx new file mode 100644 index 0000000000000..dc2fa79a7b8c1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/logic_buttons.stories.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { storiesOf, addDecorator } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { LogicButtons } from './logic_buttons'; + +addDecorator((storyFn) => ( + ({ eui: euiLightVars, darkMode: false })}>{storyFn()} +)); + +storiesOf('ThreatMatching|LogicButtons', module) + .add('and/or buttons', () => { + return ( + + ); + }) + .add('and disabled', () => { + return ( + + ); + }) + .add('or disabled', () => { + return ( + + ); + }); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/logic_buttons.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/logic_buttons.test.tsx new file mode 100644 index 0000000000000..cd2fe3dc8f550 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/logic_buttons.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import React from 'react'; + +import { LogicButtons } from './logic_buttons'; + +describe('LogicButtons', () => { + test('it renders "and" and "or" buttons', () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="andButton"] button')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="orButton"] button')).toHaveLength(1); + }); + + test('it invokes "onOrClicked" when "or" button is clicked', () => { + const onOrClicked = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="orButton"] button').simulate('click'); + + expect(onOrClicked).toHaveBeenCalledTimes(1); + }); + + test('it invokes "onAndClicked" when "and" button is clicked', () => { + const onAndClicked = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="andButton"] button').simulate('click'); + + expect(onAndClicked).toHaveBeenCalledTimes(1); + }); + + test('it disables "and" button if "isAndDisabled" is true', () => { + const wrapper = mount( + + ); + + const andButton = wrapper.find('[data-test-subj="andButton"] button').at(0); + + expect(andButton.prop('disabled')).toBeTruthy(); + }); + + test('it disables "or" button if "isOrDisabled" is "true"', () => { + const wrapper = mount( + + ); + + const orButton = wrapper.find('[data-test-subj="orButton"] button').at(0); + + expect(orButton.prop('disabled')).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/logic_buttons.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/logic_buttons.tsx new file mode 100644 index 0000000000000..abfbfecdb1baa --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/logic_buttons.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; +import styled from 'styled-components'; + +import * as i18n from './translations'; + +const MyEuiButton = styled(EuiButton)` + min-width: 95px; +`; + +interface LogicButtonsProps { + isOrDisabled: boolean; + isAndDisabled: boolean; + onAndClicked: () => void; + onOrClicked: () => void; +} + +export const LogicButtons: React.FC = ({ + isOrDisabled = false, + isAndDisabled = false, + onAndClicked, + onOrClicked, +}) => ( + + + + {i18n.AND} + + + + + {i18n.OR} + + + +); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/reducer.test.ts b/x-pack/plugins/security_solution/public/common/components/threat_match/reducer.test.ts new file mode 100644 index 0000000000000..6b2a443ec45a5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/reducer.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ThreatMapEntries } from './types'; +import { State, reducer } from './reducer'; +import { getDefaultEmptyEntry } from './helpers'; +import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types'; + +const initialState: State = { + andLogicIncluded: false, + entries: [], + entriesToDelete: [], +}; + +const getEntry = (): ThreatMapEntry => ({ + field: 'host.name', + type: 'mapping', + value: 'host.name', +}); + +describe('reducer', () => { + describe('#setEntries', () => { + test('should return "andLogicIncluded" ', () => { + const update = reducer()(initialState, { + type: 'setEntries', + entries: [], + }); + const expected: State = { + andLogicIncluded: false, + entries: [], + entriesToDelete: [], + }; + expect(update).toEqual(expected); + }); + + test('should set "andLogicIncluded" to true if any of the entries include entries with length greater than 1 ', () => { + const entries: ThreatMapEntries[] = [ + { + entries: [getEntry(), getEntry()], + }, + ]; + const { andLogicIncluded } = reducer()(initialState, { + type: 'setEntries', + entries, + }); + + expect(andLogicIncluded).toBeTruthy(); + }); + + test('should set "andLogicIncluded" to false if any of the entries include entries with length greater than 1 ', () => { + const entries: ThreatMapEntries[] = [ + { + entries: [getEntry()], + }, + ]; + const { andLogicIncluded } = reducer()(initialState, { + type: 'setEntries', + entries, + }); + + expect(andLogicIncluded).toBeFalsy(); + }); + }); + + describe('#setDefault', () => { + test('should restore initial state and add default empty entry to item" ', () => { + const entries: ThreatMapEntries[] = [ + { + entries: [getEntry()], + }, + ]; + + const update = reducer()( + { + andLogicIncluded: true, + entries, + entriesToDelete: [], + }, + { + type: 'setDefault', + initialState, + lastEntry: { + entries: [], + }, + } + ); + + expect(update).toEqual({ + ...initialState, + entries: [ + { + entries: [getDefaultEmptyEntry()], + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/reducer.ts b/x-pack/plugins/security_solution/public/common/components/threat_match/reducer.ts new file mode 100644 index 0000000000000..3fd19d40afa53 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/reducer.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ThreatMapEntries } from './types'; +import { getDefaultEmptyEntry } from './helpers'; + +export type ViewerModalName = 'addModal' | 'editModal' | null; + +export interface State { + andLogicIncluded: boolean; + entries: ThreatMapEntries[]; + entriesToDelete: ThreatMapEntries[]; +} + +export type Action = + | { + type: 'setEntries'; + entries: ThreatMapEntries[]; + } + | { + type: 'setDefault'; + initialState: State; + lastEntry: ThreatMapEntries; + }; + +export const reducer = () => (state: State, action: Action): State => { + switch (action.type) { + case 'setEntries': { + const isAndLogicIncluded = + action.entries.filter(({ entries }) => entries.length > 1).length > 0; + + const returnState = { + ...state, + andLogicIncluded: isAndLogicIncluded, + entries: action.entries, + }; + return returnState; + } + case 'setDefault': { + return { + ...state, + ...action.initialState, + entries: [{ ...action.lastEntry, entries: [getDefaultEmptyEntry()] }], + }; + } + default: + return state; + } +}; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/translations.ts b/x-pack/plugins/security_solution/public/common/components/threat_match/translations.ts new file mode 100644 index 0000000000000..ca9f6a13856cf --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/translations.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const FIELD = i18n.translate('xpack.securitySolution.threatMatch.fieldDescription', { + defaultMessage: 'Field', +}); + +export const THREAT_FIELD = i18n.translate( + 'xpack.securitySolution.threatMatch.threatFieldDescription', + { + defaultMessage: 'Threat index field', + } +); + +export const FIELD_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.threatMatch.fieldPlaceholderDescription', + { + defaultMessage: 'Search', + } +); + +export const MATCHES = i18n.translate('xpack.securitySolution.threatMatch.matchesLabel', { + defaultMessage: 'MATCHES', +}); + +export const AND = i18n.translate('xpack.securitySolution.threatMatch.andDescription', { + defaultMessage: 'AND', +}); + +export const OR = i18n.translate('xpack.securitySolution.threatMatch.orDescription', { + defaultMessage: 'OR', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts b/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts new file mode 100644 index 0000000000000..0cbd885db2d54 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ThreatMap, ThreatMapEntry } from '../../../../common/detection_engine/schemas/types'; +import { IFieldType } from '../../../../../../../src/plugins/data/common'; + +export interface FormattedEntry { + field: IFieldType | undefined; + type: 'mapping'; + value: IFieldType | undefined; + entryIndex: number; +} + +export interface EmptyEntry { + field: string | undefined; + type: 'mapping'; + value: string | undefined; +} + +export type Entry = ThreatMapEntry | EmptyEntry; + +export type ThreatMapEntries = Omit & { + entries: Entry[]; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx index 4d46d4dc86846..9ef1dd2bcb204 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx @@ -21,6 +21,8 @@ import { isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; +import { MATCHES, AND, OR } from '../../../../common/components/threat_match/translations'; +import { ThreatMapping } from '../../../../../common/detection_engine/schemas/types'; import { assertUnreachable } from '../../../../../common/utility_types'; import * as i18nSeverity from '../severity_mapping/translations'; import * as i18nRiskScore from '../risk_score_mapping/translations'; @@ -56,6 +58,7 @@ export const buildQueryBarDescription = ({ query, savedId, indexPatterns, + queryLabel, }: BuildQueryBarDescription): ListItems[] => { let items: ListItems[] = []; if (!isEmpty(filters)) { @@ -89,7 +92,7 @@ export const buildQueryBarDescription = ({ items = [ ...items, { - title: <>{i18n.QUERY_LABEL} , + title: <>{queryLabel ?? i18n.QUERY_LABEL} , description: <>{query} , }, ]; @@ -416,3 +419,40 @@ export const buildThresholdDescription = (label: string, threshold: Threshold): ), }, ]; + +export const buildThreatMappingDescription = ( + title: string, + threatMapping: ThreatMapping +): ListItems[] => { + const description = threatMapping.reduce( + (accumThreatMaps, threatMap, threatMapIndex, { length: threatMappingLength }) => { + const matches = threatMap.entries.reduce( + (accumItems, item, itemsIndex, { length: threatMapLength }) => { + if (threatMapLength === 1) { + return `${item.field} ${MATCHES} ${item.value}`; + } else if (itemsIndex === 0) { + return `(${item.field} ${MATCHES} ${item.value})`; + } else { + return `${accumItems} ${AND} (${item.field} ${MATCHES} ${item.value})`; + } + }, + '' + ); + + if (threatMappingLength === 1) { + return `${matches}`; + } else if (threatMapIndex === 0) { + return `(${matches})`; + } else { + return `${accumThreatMaps} ${OR} (${matches})`; + } + }, + '' + ); + return [ + { + title, + description, + }, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index 99e36669f78bb..83d087e60bc7d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -9,6 +9,7 @@ import { isEmpty, chunk, get, pick, isNumber } from 'lodash/fp'; import React, { memo, useState } from 'react'; import styled from 'styled-components'; +import { ThreatMapping } from '../../../../../common/detection_engine/schemas/types'; import { IIndexPattern, Filter, @@ -36,11 +37,13 @@ import { buildRiskScoreDescription, buildRuleTypeDescription, buildThresholdDescription, + buildThreatMappingDescription, } from './helpers'; import { buildMlJobDescription } from './ml_job_description'; import { buildActionsDescription } from './actions_description'; import { buildThrottleDescription } from './throttle_description'; import { Type } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { THREAT_QUERY_LABEL } from './translations'; const DescriptionListContainer = styled(EuiDescriptionList)` &.euiDescriptionList--column .euiDescriptionList__title { @@ -156,6 +159,7 @@ export const addFilterStateIfNotThere = (filters: Filter[]): Filter[] => { }); }; +/* eslint complexity: ["error", 21]*/ export const getDescriptionItem = ( field: string, label: string, @@ -189,7 +193,7 @@ export const getDescriptionItem = ( } else if (field === 'falsePositives') { const values: string[] = get(field, data); return buildUnorderedListArrayDescription(label, field, values); - } else if (Array.isArray(get(field, data))) { + } else if (Array.isArray(get(field, data)) && field !== 'threatMapping') { const values: string[] = get(field, data); return buildStringArrayDescription(label, field, values); } else if (field === 'riskScore') { @@ -214,6 +218,22 @@ export const getDescriptionItem = ( return buildRuleTypeDescription(label, ruleType); } else if (field === 'kibanaSiemAppUrl') { return []; + } else if (field === 'threatQueryBar') { + const filters = addFilterStateIfNotThere(get('threatQueryBar.filters', data) ?? []); + const query = get('threatQueryBar.query.query', data); + const savedId = get('threatQueryBar.saved_id', data); + return buildQueryBarDescription({ + field, + filters, + filterManager, + query, + savedId, + indexPatterns, + queryLabel: THREAT_QUERY_LABEL, + }); + } else if (field === 'threatMapping') { + const threatMap: ThreatMapping = get(field, data); + return buildThreatMappingDescription(label, threatMap); } const description: string = get(field, data); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx index d714f04f519d4..d9186c2da7225 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx @@ -20,6 +20,13 @@ export const QUERY_LABEL = i18n.translate( } ); +export const THREAT_QUERY_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.threatQueryLabel', + { + defaultMessage: 'Threat query', + } +); + export const SAVED_ID_LABEL = i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.savedIdLabel', { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/types.ts b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/types.ts index bcda5ff67a9a6..719c38689b722 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/types.ts @@ -24,6 +24,7 @@ export interface BuildQueryBarDescription { query: string; savedId: string; indexPatterns?: IIndexPattern; + queryLabel?: string; } export interface BuildThreatDescription { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx index 169e4f81d3498..9a1d11a2dfe42 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx @@ -12,6 +12,7 @@ import { isThresholdRule, isEqlRule, isQueryRule, + isThreatMatchRule, } from '../../../../../common/detection_engine/utils'; import { FieldHook } from '../../../../shared_imports'; import { useKibana } from '../../../../common/lib/kibana'; @@ -45,6 +46,7 @@ export const SelectRuleType: React.FC = ({ const setMl = useCallback(() => setType('machine_learning'), [setType]); const setQuery = useCallback(() => setType('query'), [setType]); const setThreshold = useCallback(() => setType('threshold'), [setType]); + const setThreatMatch = useCallback(() => setType('threat_match'), [setType]); const mlCardDisabled = isReadOnly || !hasValidLicense || !isMlAdmin; const licensingUrl = useKibana().services.application.getUrlForApp('kibana', { path: '#/management/stack/license_management', @@ -86,6 +88,15 @@ export const SelectRuleType: React.FC = ({ [isReadOnly, ruleType, setThreshold] ); + const threatMatchSelectableConfig = useMemo( + () => ({ + isDisabled: isReadOnly, + onClick: setThreatMatch, + isSelected: isThreatMatchRule(ruleType), + }), + [isReadOnly, ruleType, setThreatMatch] + ); + return ( = ({ selectable={eqlSelectableConfig} /> + + } + isDisabled={ + threatMatchSelectableConfig.isDisabled && !threatMatchSelectableConfig.isSelected + } + selectable={threatMatchSelectableConfig} + /> + ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts index e7b231ca74958..7043aa2d2f956 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts @@ -62,3 +62,17 @@ export const THRESHOLD_TYPE_DESCRIPTION = i18n.translate( defaultMessage: 'Aggregate query results to detect when number of matches exceeds threshold.', } ); + +export const THREAT_MATCH_TYPE_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.ruleTypeField.threatMatchTitle', + { + defaultMessage: 'Threat Match', + } +); + +export const THREAT_MATCH_TYPE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.ruleTypeField.threatMatchDescription', + { + defaultMessage: 'Upload value lists to write rules around a list of known bad attributes', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index dc31db76c3911..f728a508fef86 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -11,6 +11,7 @@ import styled from 'styled-components'; // eslint-disable-next-line no-restricted-imports import isEqual from 'lodash/isEqual'; +import { IndexPattern } from 'src/plugins/data/public'; import { DEFAULT_INDEX_KEY } from '../../../../../common/constants'; import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; @@ -47,8 +48,13 @@ import { } from '../../../../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; -import { isEqlRule, isThresholdRule } from '../../../../../common/detection_engine/utils'; +import { + isEqlRule, + isThreatMatchRule, + isThresholdRule, +} from '../../../../../common/detection_engine/utils'; import { EqlQueryBar } from '../eql_query_bar'; +import { ThreatMatchInput } from '../threatmatch_input'; import { useFetchIndex } from '../../../../common/containers/source'; const CommonUseField = getUseField({ component: Field }); @@ -62,11 +68,18 @@ const stepDefineDefaultValue: DefineStepRule = { index: [], machineLearningJobId: '', ruleType: 'query', + threatIndex: [], queryBar: { query: { query: '', language: 'kuery' }, filters: [], saved_id: undefined, }, + threatQueryBar: { + query: { query: '*:*', language: 'kuery' }, + filters: [], + saved_id: undefined, + }, + threatMapping: [], threshold: { field: [], value: '200', @@ -121,14 +134,22 @@ const StepDefineRuleComponent: FC = ({ schema, }); const { getFields, getFormData, reset, submit } = form; - const [{ index: formIndex, ruleType: formRuleType }] = (useFormData({ - form, - watch: ['index', 'ruleType'], - }) as unknown) as [Partial]; + const [{ index: formIndex, ruleType: formRuleType, threatIndex: formThreatIndex }] = (useFormData( + { + form, + watch: ['index', 'ruleType', 'threatIndex'], + } + ) as unknown) as [Partial]; const index = formIndex || initialState.index; + const threatIndex = formThreatIndex || initialState.threatIndex; const ruleType = formRuleType || initialState.ruleType; const [indexPatternsLoading, { browserFields, indexPatterns }] = useFetchIndex(index); + const [ + threatIndexPatternsLoading, + { browserFields: threatBrowserFields, indexPatterns: threatIndexPatterns }, + ] = useFetchIndex(threatIndex); + // reset form when rule type changes useEffect(() => { reset({ resetValues: false }); @@ -146,7 +167,7 @@ const StepDefineRuleComponent: FC = ({ const getData = useCallback(async () => { const result = await submit(); - return result?.isValid + return result.isValid ? result : { isValid: false, @@ -184,6 +205,19 @@ const StepDefineRuleComponent: FC = ({ [browserFields] ); + const ThreatMatchInputChildren = useCallback( + ({ threatMapping }) => ( + + ), + [threatBrowserFields, threatIndexPatternsLoading, threatIndexPatterns, indexPatterns] + ); + return isReadOnlyView ? ( = ({ + + <> + + {ThreatMatchInputChildren} + + + = { index: { @@ -219,4 +231,126 @@ export const schema: FormSchema = { ], }, }, + threatIndex: { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThreatIndexPatternsLabel', + { + defaultMessage: 'Threat index patterns', + } + ), + helpText: {THREAT_MATCH_INDEX_HELPER_TEXT}, + validations: [ + { + validator: ( + ...args: Parameters + ): ReturnType> | undefined => { + const [{ formData }] = args; + const needsValidation = isThreatMatchRule(formData.ruleType); + if (!needsValidation) { + return; + } + return fieldValidators.emptyField( + i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.threatMatchoutputIndiceNameFieldRequiredError', + { + defaultMessage: 'A minimum of one index pattern is required.', + } + ) + )(...args); + }, + }, + ], + }, + threatMapping: { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThreatMappingLabel', + { + defaultMessage: 'Threat Mapping', + } + ), + validations: [ + { + validator: ( + ...args: Parameters + ): ReturnType> | undefined => { + const [{ path, formData }] = args; + const needsValidation = isThreatMatchRule(formData.ruleType); + if (!needsValidation) { + return; + } + if (singleEntryThreat(formData.threatMapping)) { + return { + code: 'ERR_FIELD_MISSING', + path, + message: THREAT_MATCH_REQUIRED, + }; + } else if (containsInvalidItems(formData.threatMapping)) { + return { + code: 'ERR_FIELD_MISSING', + path, + message: THREAT_MATCH_EMPTIES, + }; + } else { + return undefined; + } + }, + }, + ], + }, + threatQueryBar: { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThreatQueryBarLabel', + { + defaultMessage: 'Threat index query', + } + ), + validations: [ + { + validator: ( + ...args: Parameters + ): ReturnType> | undefined => { + const [{ value, path, formData }] = args; + const needsValidation = isThreatMatchRule(formData.ruleType); + if (!needsValidation) { + return; + } + + const { query, filters } = value as FieldValueQueryBar; + + return isEmpty(query.query as string) && isEmpty(filters) + ? { + code: 'ERR_FIELD_MISSING', + path, + message: CUSTOM_QUERY_REQUIRED, + } + : undefined; + }, + }, + { + validator: ( + ...args: Parameters + ): ReturnType> | undefined => { + const [{ value, path, formData }] = args; + const needsValidation = isThreatMatchRule(formData.ruleType); + if (!needsValidation) { + return; + } + const { query } = value as FieldValueQueryBar; + + if (!isEmpty(query.query as string) && query.language === 'kuery') { + try { + esKuery.fromKueryExpression(query.query); + } catch (err) { + return { + code: 'ERR_FIELD_FORMAT', + path, + message: INVALID_CUSTOM_QUERY, + }; + } + } + }, + }, + ], + }, }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/translations.tsx index 860ed1831fdc6..8e0a3f9b8659e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/translations.tsx @@ -70,3 +70,24 @@ export const ENABLE_ML_JOB_WARNING = i18n.translate( 'This ML job is not currently running. Please set this job to run via "ML job settings" before activating this rule.', } ); + +export const THREAT_MATCH_INDEX_HELPER_TEXT = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.threatMatchingIcesHelperDescription', + { + defaultMessage: 'Select threat indices', + } +); + +export const THREAT_MATCH_REQUIRED = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredError', + { + defaultMessage: 'At least one threat match is required.', + } +); + +export const THREAT_MATCH_EMPTIES = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredEmptyError', + { + defaultMessage: 'All matches require both a field and threat index field.', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx new file mode 100644 index 0000000000000..2a4609a2f5e9e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiFormRow } from '@elastic/eui'; + +import { ThreatMapEntries } from '../../../../common/components/threat_match/types'; +import { ThreatMatchComponent } from '../../../../common/components/threat_match'; +import { BrowserField } from '../../../../common/containers/source'; +import { + FieldHook, + Field, + getUseField, + UseField, + getFieldValidityAndErrorMessage, +} from '../../../../shared_imports'; +import { schema } from '../step_define_rule/schema'; +import { QueryBarDefineRule } from '../query_bar'; +import { IndexPattern } from '../../../../../../../../src/plugins/data/public'; + +const CommonUseField = getUseField({ component: Field }); + +interface ThreatMatchInputProps { + threatMapping: FieldHook; + threatBrowserFields: Readonly>>; + threatIndexPatterns: IndexPattern; + indexPatterns: IndexPattern; + threatIndexPatternsLoading: boolean; +} + +const ThreatMatchInputComponent: React.FC = ({ + threatMapping, + indexPatterns, + threatIndexPatterns, + threatIndexPatternsLoading, + threatBrowserFields, +}: ThreatMatchInputProps) => { + const { setValue, value: threatItems } = threatMapping; + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(threatMapping); + const handleBuilderOnChange = useCallback( + ({ entryItems }: { entryItems: ThreatMapEntries[] }): void => { + setValue(entryItems); + }, + [setValue] + ); + return ( + <> + + + + + + + + + + + + + + + + ); +}; + +export const ThreatMatchInput = React.memo(ThreatMatchInputComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/translations.ts new file mode 100644 index 0000000000000..0aa268ae24ae1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const THREAT_MATCH_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.threatMatchField.threatMatchFieldPlaceholderText', + { + defaultMessage: 'All results', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 49579e893029b..e9c89130736c0 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -18,7 +18,14 @@ import { threshold, type, } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { listArray } from '../../../../../common/detection_engine/schemas/types'; +import { + listArray, + threat_query, + threat_index, + threat_mapping, + threat_language, + threat_filters, +} from '../../../../../common/detection_engine/schemas/types'; import { CreateRulesSchema, PatchRulesSchema, @@ -110,6 +117,11 @@ export const RuleSchema = t.intersection([ status: t.string, status_date: t.string, threshold, + threat_query, + threat_filters, + threat_index, + threat_mapping, + threat_language, timeline_id: t.string, timeline_title: t.string, timestamp_override, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 5a626ce0ff00a..5851177a4e4ab 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -212,10 +212,13 @@ export const mockDefineStepRule = (): DefineStepRule => ({ machineLearningJobId: '', index: ['filebeat-'], queryBar: mockQueryBar, + threatQueryBar: mockQueryBar, + threatMapping: [], timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Titled timeline', }, + threatIndex: [], threshold: { field: [''], value: '100', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 65a5c6aca0050..160809a2ba3cd 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -73,28 +73,77 @@ export interface RuleFields { index: unknown; ruleType: unknown; threshold?: unknown; + threatIndex?: unknown; + threatQueryBar?: unknown; + threatMapping?: unknown; + threatLanguage?: unknown; } -type QueryRuleFields = Omit; -type ThresholdRuleFields = Omit; -type MlRuleFields = Omit; + +type QueryRuleFields = Omit< + T, + | 'anomalyThreshold' + | 'machineLearningJobId' + | 'threshold' + | 'threatIndex' + | 'threatQueryBar' + | 'threatMapping' +>; +type ThresholdRuleFields = Omit< + T, + 'anomalyThreshold' | 'machineLearningJobId' | 'threatIndex' | 'threatQueryBar' | 'threatMapping' +>; +type MlRuleFields = Omit< + T, + 'queryBar' | 'index' | 'threshold' | 'threatIndex' | 'threatQueryBar' | 'threatMapping' +>; +type ThreatMatchRuleFields = Omit; const isMlFields = ( - fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields + fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields | ThreatMatchRuleFields ): fields is MlRuleFields => has('anomalyThreshold', fields); const isThresholdFields = ( - fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields + fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields | ThreatMatchRuleFields ): fields is ThresholdRuleFields => has('threshold', fields); -export const filterRuleFieldsForType = (fields: T, type: Type) => { +const isThreatMatchFields = ( + fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields | ThreatMatchRuleFields +): fields is ThreatMatchRuleFields => has('threatIndex', fields); + +export const filterRuleFieldsForType = ( + fields: T, + type: Type +): QueryRuleFields | MlRuleFields | ThresholdRuleFields | ThreatMatchRuleFields => { switch (type) { case 'machine_learning': - const { index, queryBar, threshold, ...mlRuleFields } = fields; + const { + index, + queryBar, + threshold, + threatIndex, + threatQueryBar, + threatMapping, + ...mlRuleFields + } = fields; return mlRuleFields; case 'threshold': - const { anomalyThreshold, machineLearningJobId, ...thresholdRuleFields } = fields; + const { + anomalyThreshold, + machineLearningJobId, + threatIndex: _removedThreatIndex, + threatQueryBar: _removedThreatQueryBar, + threatMapping: _removedThreatMapping, + ...thresholdRuleFields + } = fields; return thresholdRuleFields; case 'threat_match': + const { + anomalyThreshold: _removedAnomalyThreshold, + machineLearningJobId: _removedMachineLearningJobId, + threshold: _removedThreshold, + ...threatMatchRuleFields + } = fields; + return threatMatchRuleFields; case 'query': case 'saved_query': case 'eql': @@ -102,6 +151,9 @@ export const filterRuleFieldsForType = (fields: T, type: T anomalyThreshold: _a, machineLearningJobId: _m, threshold: _t, + threatIndex: __removedThreatIndex, + threatQueryBar: __removedThreatQueryBar, + threatMapping: __removedThreatMapping, ...queryRuleFields } = fields; return queryRuleFields; @@ -140,6 +192,18 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep }, }), } + : isThreatMatchFields(ruleFields) + ? { + index: ruleFields.index, + filters: ruleFields.queryBar?.filters, + language: ruleFields.queryBar?.query?.language, + query: ruleFields.queryBar?.query?.query as string, + saved_id: ruleFields.queryBar?.saved_id, + threat_index: ruleFields.threatIndex, + threat_query: ruleFields.threatQueryBar?.query?.query as string, + threat_mapping: ruleFields.threatMapping, + threat_language: ruleFields.threatQueryBar?.query?.language, + } : { index: ruleFields.index, filters: ruleFields.queryBar?.filters, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index 48247392dfe7f..542b7b1b84c3c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -183,10 +183,10 @@ const CreateRulePageComponent: React.FC = () => { if (nextStep != null) { goToStep(nextStep); } else { - const defineStep = await stepsData.current[RuleStep.defineRule]; - const aboutStep = await stepsData.current[RuleStep.aboutRule]; - const scheduleStep = await stepsData.current[RuleStep.scheduleRule]; - const actionsStep = await stepsData.current[RuleStep.ruleActions]; + const defineStep = stepsData.current[RuleStep.defineRule]; + const aboutStep = stepsData.current[RuleStep.aboutRule]; + const scheduleStep = stepsData.current[RuleStep.scheduleRule]; + const actionsStep = stepsData.current[RuleStep.ruleActions]; if ( stepIsValid(defineStep) && diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index 8545e5da512bb..0cd75506fa9f5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -82,6 +82,16 @@ describe('rule helpers', () => { field: ['host.name'], value: '50', }, + threatIndex: [], + threatMapping: [], + threatQueryBar: { + query: { + query: '', + language: '', + }, + filters: [], + saved_id: undefined, + }, timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Titled timeline', @@ -217,6 +227,16 @@ describe('rule helpers', () => { field: [], value: '100', }, + threatIndex: [], + threatMapping: [], + threatQueryBar: { + query: { + query: '', + language: '', + }, + filters: [], + saved_id: undefined, + }, timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Untitled timeline', @@ -249,6 +269,16 @@ describe('rule helpers', () => { field: [], value: '100', }, + threatIndex: [], + threatMapping: [], + threatQueryBar: { + query: { + query: '', + language: '', + }, + filters: [], + saved_id: undefined, + }, timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Untitled timeline', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index 42fbe40d690ea..456bf8419a1f7 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -79,6 +79,13 @@ export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ anomalyThreshold: rule.anomaly_threshold ?? 50, machineLearningJobId: rule.machine_learning_job_id ?? '', index: rule.index ?? [], + threatIndex: rule.threat_index ?? [], + threatQueryBar: { + query: { query: rule.threat_query ?? '', language: rule.threat_language ?? '' }, + filters: (rule.threat_filters ?? []) as Filter[], + saved_id: undefined, + }, + threatMapping: rule.threat_mapping ?? [], queryBar: { query: { query: rule.query ?? '', language: rule.language ?? '' }, filters: (rule.filters ?? []) as Filter[], @@ -341,7 +348,6 @@ export const getActionMessageRuleParams = (ruleType: Type): string[] => { 'threat', 'type', 'version', - // 'lists', ]; const ruleParamsKeys = [ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index e3d0ea123872a..f2afe32b1e12c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -22,7 +22,11 @@ import { Type, Severity, } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { List } from '../../../../../common/detection_engine/schemas/types'; +import { + List, + ThreatIndex, + ThreatMapping, +} from '../../../../../common/detection_engine/schemas/types'; export interface EuiBasicTableSortTypes { field: string; @@ -124,6 +128,9 @@ export interface DefineStepRule { ruleType: Type; timeline: FieldValueTimeline; threshold: FieldValueThreshold; + threatIndex: ThreatIndex; + threatQueryBar: FieldValueQueryBar; + threatMapping: ThreatMapping; } export interface ScheduleStepRule { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 5d9cfb4bb4492..aea50b682f017 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -399,6 +399,7 @@ export const getResult = (): RuleAlertType => ({ timestampOverride: undefined, threatFilters: undefined, threatMapping: undefined, + threatLanguage: undefined, threatIndex: undefined, threatQuery: undefined, references: ['http://www.example.com', 'https://ww.example.com'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 067a4352e1080..09e161166dddf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -96,6 +96,7 @@ export const createRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => threat_index: threatIndex, threat_mapping: threatMapping, threat_query: threatQuery, + threat_language: threatLanguage, threshold, throttle, timestamp_override: timestampOverride, @@ -186,6 +187,7 @@ export const createRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => threatMapping, threatQuery, threatIndex, + threatLanguage, threshold, timestampOverride, references, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts index 54df87ca17787..9940a56988c77 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -84,6 +84,7 @@ export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void threat_index: threatIndex, threat_query: threatQuery, threat_mapping: threatMapping, + threat_language: threatLanguage, throttle, timestamp_override: timestampOverride, to, @@ -175,6 +176,7 @@ export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void threatIndex, threatQuery, threatMapping, + threatLanguage, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 4dbca5df0041c..fb9d9c4ea72cf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -163,6 +163,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP threat_index: threatIndex, threat_query: threatQuery, threat_mapping: threatMapping, + threat_language: threatLanguage, threshold, timestamp_override: timestampOverride, to, @@ -223,11 +224,12 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP to, type, threat, + threshold, threatFilters, threatIndex, threatQuery, - threshold, threatMapping, + threatLanguage, timestampOverride, references, note, @@ -272,6 +274,11 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP type, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, references, note, version, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index 39bbe9ee686a4..081e804cf7356 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -87,6 +87,11 @@ export const patchRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => type, threat, threshold, + threat_filters: threatFilters, + threat_index: threatIndex, + threat_query: threatQuery, + threat_mapping: threatMapping, + threat_language: threatLanguage, timestamp_override: timestampOverride, throttle, references, @@ -147,6 +152,11 @@ export const patchRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => type, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts index 879bd8d5b8a1d..baa5468f862c3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -78,6 +78,11 @@ export const patchRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { type, threat, threshold, + threat_filters: threatFilters, + threat_index: threatIndex, + threat_query: threatQuery, + threat_mapping: threatMapping, + threat_language: threatLanguage, timestamp_override: timestampOverride, throttle, references, @@ -146,6 +151,11 @@ export const patchRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { type, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 4df0773f86317..8828bbe6c9826 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -91,6 +91,11 @@ export const updateRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => type, threat, threshold, + threat_filters: threatFilters, + threat_index: threatIndex, + threat_query: threatQuery, + threat_mapping: threatMapping, + threat_language: threatLanguage, throttle, timestamp_override: timestampOverride, references, @@ -158,6 +163,11 @@ export const updateRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => type, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts index ef698db008d80..1fa3bb8c9bc82 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -81,6 +81,11 @@ export const updateRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { type, threat, threshold, + threat_filters: threatFilters, + threat_index: threatIndex, + threat_query: threatQuery, + threat_mapping: threatMapping, + threat_language: threatLanguage, throttle, timestamp_override: timestampOverride, references, @@ -148,6 +153,11 @@ export const updateRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { type, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts index 2159245f2f735..13eb7495a898a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts @@ -156,7 +156,7 @@ describe('utils', () => { ], }, ]; - threatRule.params.threatIndex = 'index-123'; + threatRule.params.threatIndex = ['index-123']; threatRule.params.threatFilters = threatFilters; threatRule.params.threatMapping = threatMapping; threatRule.params.threatQuery = '*:*'; @@ -164,7 +164,7 @@ describe('utils', () => { const rule = transformAlertToRule(threatRule); expect(rule).toEqual( expect.objectContaining({ - threat_index: 'index-123', + threat_index: ['index-123'], threat_filters: threatFilters, threat_mapping: threatMapping, threat_query: '*:*', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts index c75b32b614e07..fb4ba855f6536 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts @@ -150,6 +150,7 @@ export const transformAlertToRule = ( threat_index: alert.params.threatIndex, threat_query: alert.params.threatQuery, threat_mapping: alert.params.threatMapping, + threat_language: alert.params.threatLanguage, throttle: ruleActions?.ruleThrottle || 'no_actions', timestamp_override: alert.params.timestampOverride, note: alert.params.note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts index a6034f3d7b7b3..271b1043ea568 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts @@ -42,6 +42,7 @@ export const getCreateRulesOptionsMock = (): CreateRulesOptions => ({ threat: [], threatFilters: undefined, threatMapping: undefined, + threatLanguage: undefined, threatQuery: undefined, threatIndex: undefined, threshold: undefined, @@ -92,6 +93,7 @@ export const getCreateMlRulesOptionsMock = (): CreateRulesOptions => ({ threatIndex: undefined, threatMapping: undefined, threatQuery: undefined, + threatLanguage: undefined, threshold: undefined, timestampOverride: undefined, to: 'now', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index 3a311d03e3c89..776882d0f8494 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -45,6 +45,7 @@ export const createRules = async ({ threat, threatFilters, threatIndex, + threatLanguage, threatQuery, threatMapping, threshold, @@ -96,6 +97,7 @@ export const createRules = async ({ threatIndex, threatQuery, threatMapping, + threatLanguage, timestampOverride, to, type, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts index 38adc03c00d50..0a43c652234d0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts @@ -50,6 +50,7 @@ export const installPrepackagedRules = ( threat, threat_filters: threatFilters, threat_mapping: threatMapping, + threat_language: threatLanguage, threat_query: threatQuery, threat_index: threatIndex, threshold, @@ -101,6 +102,7 @@ export const installPrepackagedRules = ( threat, threatFilters, threatMapping, + threatLanguage, threatQuery, threatIndex, threshold, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index aeb136a969aa1..4099a4d2469d0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -145,6 +145,11 @@ export const getPatchRulesOptionsMock = (): PatchRulesOptions => ({ tags: [], threat: [], threshold: undefined, + threatFilters: undefined, + threatIndex: undefined, + threatQuery: undefined, + threatMapping: undefined, + threatLanguage: undefined, timestampOverride: undefined, to: 'now', type: 'query', @@ -189,6 +194,11 @@ export const getPatchMlRulesOptionsMock = (): PatchRulesOptions => ({ tags: [], threat: [], threshold: undefined, + threatFilters: undefined, + threatIndex: undefined, + threatQuery: undefined, + threatMapping: undefined, + threatLanguage: undefined, timestampOverride: undefined, to: 'now', type: 'machine_learning', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index 852ff06bdc736..1982dcf9dd9b6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -44,6 +44,11 @@ export const patchRules = async ({ tags, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, timestampOverride, to, type, @@ -87,6 +92,11 @@ export const patchRules = async ({ tags, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, timestampOverride, to, type, @@ -126,6 +136,11 @@ export const patchRules = async ({ severityMapping, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, timestampOverride, to, type, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index d688e1b338e21..8af622e6a128b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -91,6 +91,7 @@ import { ThreatQueryOrUndefined, ThreatMappingOrUndefined, ThreatFiltersOrUndefined, + ThreatLanguageOrUndefined, } from '../../../../common/detection_engine/schemas/types/threat_mapping'; import { AlertsClient, PartialAlert } from '../../../../../alerts/server'; @@ -219,6 +220,7 @@ export interface CreateRulesOptions { threatIndex: ThreatIndexOrUndefined; threatQuery: ThreatQueryOrUndefined; threatMapping: ThreatMappingOrUndefined; + threatLanguage: ThreatLanguageOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: To; type: Type; @@ -264,6 +266,11 @@ export interface UpdateRulesOptions { tags: Tags; threat: Threat; threshold: ThresholdOrUndefined; + threatFilters: ThreatFiltersOrUndefined; + threatIndex: ThreatIndexOrUndefined; + threatQuery: ThreatQueryOrUndefined; + threatMapping: ThreatMappingOrUndefined; + threatLanguage: ThreatLanguageOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: To; type: Type; @@ -307,6 +314,11 @@ export interface PatchRulesOptions { tags: TagsOrUndefined; threat: ThreatOrUndefined; threshold: ThresholdOrUndefined; + threatFilters: ThreatFiltersOrUndefined; + threatIndex: ThreatIndexOrUndefined; + threatQuery: ThreatQueryOrUndefined; + threatMapping: ThreatMappingOrUndefined; + threatLanguage: ThreatLanguageOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: ToOrUndefined; type: TypeOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index 01a481ed7b2d9..c685c4198c119 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -47,6 +47,11 @@ export const updatePrepackagedRules = async ( type, threat, threshold, + threat_filters: threatFilters, + threat_index: threatIndex, + threat_query: threatQuery, + threat_mapping: threatMapping, + threat_language: threatLanguage, timestamp_override: timestampOverride, references, version, @@ -97,6 +102,11 @@ export const updatePrepackagedRules = async ( type, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, references, version, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts index 8cdc904a861c7..a33651580ef22 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts @@ -43,6 +43,11 @@ export const getUpdateRulesOptionsMock = (): UpdateRulesOptions => ({ tags: [], threat: [], threshold: undefined, + threatFilters: undefined, + threatIndex: undefined, + threatQuery: undefined, + threatMapping: undefined, + threatLanguage: undefined, timestampOverride: undefined, to: 'now', type: 'query', @@ -88,6 +93,11 @@ export const getUpdateMlRulesOptionsMock = (): UpdateRulesOptions => ({ tags: [], threat: [], threshold: undefined, + threatFilters: undefined, + threatIndex: undefined, + threatQuery: undefined, + threatMapping: undefined, + threatLanguage: undefined, timestampOverride: undefined, to: 'now', type: 'machine_learning', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index 08df785884b76..3da921ed47f26 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -45,6 +45,11 @@ export const updateRules = async ({ tags, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, timestampOverride, to, type, @@ -89,6 +94,11 @@ export const updateRules = async ({ tags, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, timestampOverride, to, type, @@ -134,6 +144,11 @@ export const updateRules = async ({ severityMapping, threat, threshold, + threatFilters, + threatIndex, + threatQuery, + threatMapping, + threatLanguage, timestampOverride, to, type, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts index 227f574bc4e4b..654383ff97c7a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts @@ -55,6 +55,11 @@ describe('utils', () => { tags: undefined, threat: undefined, threshold: undefined, + threatFilters: undefined, + threatIndex: undefined, + threatQuery: undefined, + threatMapping: undefined, + threatLanguage: undefined, to: undefined, timestampOverride: undefined, type: undefined, @@ -98,6 +103,11 @@ describe('utils', () => { tags: undefined, threat: undefined, threshold: undefined, + threatFilters: undefined, + threatIndex: undefined, + threatQuery: undefined, + threatMapping: undefined, + threatLanguage: undefined, to: undefined, timestampOverride: undefined, type: undefined, @@ -141,6 +151,11 @@ describe('utils', () => { tags: undefined, threat: undefined, threshold: undefined, + threatFilters: undefined, + threatIndex: undefined, + threatQuery: undefined, + threatMapping: undefined, + threatLanguage: undefined, to: undefined, timestampOverride: undefined, type: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index d9f953f2803a6..a9a100543b528 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -42,7 +42,14 @@ import { EventCategoryOverrideOrUndefined, } from '../../../../common/detection_engine/schemas/common/schemas'; import { PartialFilter } from '../types'; -import { ListArrayOrUndefined } from '../../../../common/detection_engine/schemas/types'; +import { + ListArrayOrUndefined, + ThreatFiltersOrUndefined, + ThreatIndexOrUndefined, + ThreatLanguageOrUndefined, + ThreatMappingOrUndefined, + ThreatQueryOrUndefined, +} from '../../../../common/detection_engine/schemas/types'; export const calculateInterval = ( interval: string | undefined, @@ -86,6 +93,11 @@ export interface UpdateProperties { tags: TagsOrUndefined; threat: ThreatOrUndefined; threshold: ThresholdOrUndefined; + threatFilters: ThreatFiltersOrUndefined; + threatIndex: ThreatIndexOrUndefined; + threatQuery: ThreatQueryOrUndefined; + threatMapping: ThreatMappingOrUndefined; + threatLanguage: ThreatLanguageOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: ToOrUndefined; type: TypeOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/query_with_threat_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/query_with_threat_mapping.json index c914e568048a1..1e2f217751e96 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/query_with_threat_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/query_with_threat_mapping.json @@ -7,7 +7,7 @@ "type": "threat_match", "query": "*:*", "tags": ["tag_1", "tag_2"], - "threat_index": "mock-threat-list", + "threat_index": ["mock-threat-list"], "threat_query": "*:*", "threat_mapping": [ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 501cd1fa6ecfb..cbf70f3119b31 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -60,6 +60,7 @@ export const sampleRuleAlertParams = ( threatQuery: undefined, threatMapping: undefined, threatIndex: undefined, + threatLanguage: undefined, timelineId: undefined, timelineTitle: undefined, timestampOverride: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts index 344f705c4af24..6a76c7842e451 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts @@ -168,6 +168,7 @@ export const buildRuleWithoutOverrides = ( threat_index: ruleParams.threatIndex, threat_query: ruleParams.threatQuery, threat_mapping: ruleParams.threatMapping, + threat_language: ruleParams.threatLanguage, }; return removeInternalTagsFromRule(rule); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts index 4006345b24385..cfe71f66395b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts @@ -50,9 +50,10 @@ const signalSchema = schema.object({ exceptions_list: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), // For backwards compatibility with customers that had a data bug in 7.8. Once we use a migration script please remove this. exceptionsList: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), threatFilters: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - threatIndex: schema.maybe(schema.string()), + threatIndex: schema.maybe(schema.arrayOf(schema.string())), threatQuery: schema.maybe(schema.string()), threatMapping: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), + threatLanguage: schema.maybe(schema.string()), }); /** diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 95348808bb58f..9436dc9cf8a8e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -112,6 +112,7 @@ export const signalRulesAlertType = ({ threatQuery, threatIndex, threatMapping, + threatLanguage, type, exceptionsList, } = params; @@ -389,6 +390,7 @@ export const signalRulesAlertType = ({ throttle, threatFilters: threatFilters ?? [], threatQuery, + threatLanguage, buildRuleMessage, threatIndex, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index 560e7ad7fe2cb..09ddfb342496d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -45,6 +45,7 @@ export const createThreatSignal = async ({ throttle, threatFilters, threatQuery, + threatLanguage, buildRuleMessage, threatIndex, name, @@ -105,8 +106,9 @@ export const createThreatSignal = async ({ callCluster: services.callCluster, exceptionItems, query: threatQuery, + language: threatLanguage, threatFilters, - index: [threatIndex], + index: threatIndex, searchAfter, sortField: undefined, sortOrder: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index f44c7a9684457..eeace508c9bfe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -41,6 +41,7 @@ export const createThreatSignals = async ({ throttle, threatFilters, threatQuery, + threatLanguage, buildRuleMessage, threatIndex, name, @@ -59,7 +60,8 @@ export const createThreatSignals = async ({ exceptionItems, threatFilters, query: threatQuery, - index: [threatIndex], + language: threatLanguage, + index: threatIndex, searchAfter: undefined, sortField: undefined, sortOrder: undefined, @@ -99,6 +101,7 @@ export const createThreatSignals = async ({ threatQuery, buildRuleMessage, threatIndex, + threatLanguage, name, currentThreatList: threatList, currentResult: results, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts index 8b381ca0d96dc..3c3f5b544bb17 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts @@ -21,6 +21,7 @@ export const MAX_PER_PAGE = 9000; export const getThreatList = async ({ callCluster, query, + language, index, perPage, searchAfter, @@ -33,7 +34,13 @@ export const getThreatList = async ({ if (calculatedPerPage > 10000) { throw new TypeError('perPage cannot exceed the size of 10000'); } - const queryFilter = getQueryFilter(query, 'kuery', threatFilters, index, exceptionItems); + const queryFilter = getQueryFilter( + query, + language ?? 'kuery', + threatFilters, + index, + exceptionItems + ); const response: SearchResponse = await callCluster('search', { body: { query: queryFilter, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index 7cd6e5196ea68..06c9c4c13c5f3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -15,6 +15,8 @@ import { ThreatQuery, ThreatMapping, ThreatMappingEntries, + ThreatIndex, + ThreatLanguageOrUndefined, } from '../../../../../common/detection_engine/schemas/types/threat_mapping'; import { PartialFilter, RuleTypeParams } from '../../types'; import { AlertServices } from '../../../../../../alerts/server'; @@ -57,7 +59,8 @@ export interface CreateThreatSignalsOptions { threatFilters: PartialFilter[]; threatQuery: ThreatQuery; buildRuleMessage: BuildRuleMessage; - threatIndex: string; + threatIndex: ThreatIndex; + threatLanguage: ThreatLanguageOrUndefined; name: string; } @@ -93,7 +96,8 @@ export interface CreateThreatSignalOptions { threatFilters: PartialFilter[]; threatQuery: ThreatQuery; buildRuleMessage: BuildRuleMessage; - threatIndex: string; + threatIndex: ThreatIndex; + threatLanguage: ThreatLanguageOrUndefined; name: string; currentThreatList: SearchResponse; currentResult: SearchAfterAndBulkCreateReturnType; @@ -138,6 +142,7 @@ export interface BooleanFilter { export interface GetThreatListOptions { callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; query: string; + language: ThreatLanguageOrUndefined; index: string[]; perPage?: number; searchAfter: string[] | undefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts index 728f5b1dd867f..cf4d989c1f4c8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts @@ -43,6 +43,7 @@ import { ThreatIndexOrUndefined, ThreatQueryOrUndefined, ThreatMappingOrUndefined, + ThreatLanguageOrUndefined, } from '../../../common/detection_engine/schemas/types/threat_mapping'; import { LegacyCallAPIOptions } from '../../../../../../src/core/server'; @@ -85,6 +86,7 @@ export interface RuleTypeParams { threatIndex: ThreatIndexOrUndefined; threatQuery: ThreatQueryOrUndefined; threatMapping: ThreatMappingOrUndefined; + threatLanguage: ThreatLanguageOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: To; type: Type;