diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 136e5f7bc95b1..68bd793b3d5c9 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -479,6 +479,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D }, privileges: `${SECURITY_SOLUTION_DOCS}endpoint-management-req.html`, manageDetectionRules: `${SECURITY_SOLUTION_DOCS}rules-ui-management.html`, + createDetectionRules: `${SECURITY_SOLUTION_DOCS}rules-ui-create.html`, createEsqlRuleType: `${SECURITY_SOLUTION_DOCS}rules-ui-create.html#create-esql-rule`, ruleUiAdvancedParams: `${SECURITY_SOLUTION_DOCS}rules-ui-create.html#rule-ui-advanced-params`, entityAnalytics: { diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 29e09d8b25672..fb59c867cff9d 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -354,6 +354,7 @@ export interface DocLinks { }; readonly privileges: string; readonly manageDetectionRules: string; + readonly createDetectionRules: string; readonly createEsqlRuleType: string; readonly ruleUiAdvancedParams: string; readonly entityAnalytics: { diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts index 6b6bc018c8e5c..ac6eb3dd18a7e 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts @@ -303,13 +303,40 @@ export const TimestampOverride = z.string(); export type TimestampOverrideFallbackDisabled = z.infer; export const TimestampOverrideFallbackDisabled = z.boolean(); +/** + * Describes an Elasticsearch field that is needed for the rule to function + */ export type RequiredField = z.infer; export const RequiredField = z.object({ + /** + * Name of an Elasticsearch field + */ name: NonEmptyString, + /** + * Type of the Elasticsearch field + */ type: NonEmptyString, + /** + * Whether the field is an ECS field + */ ecs: z.boolean(), }); +/** + * Input parameters to create a RequiredField. Does not include the `ecs` field, because `ecs` is calculated on the backend based on the field name and type. + */ +export type RequiredFieldInput = z.infer; +export const RequiredFieldInput = z.object({ + /** + * Name of an Elasticsearch field + */ + name: NonEmptyString, + /** + * Type of an Elasticsearch field + */ + type: NonEmptyString, +}); + export type RequiredFieldArray = z.infer; export const RequiredFieldArray = z.array(RequiredField); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml index fadb38ef1ce5f..cd5e238723f6a 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml @@ -315,18 +315,36 @@ components: RequiredField: type: object + description: Describes an Elasticsearch field that is needed for the rule to function properties: name: $ref: '../../../model/primitives.schema.yaml#/components/schemas/NonEmptyString' + description: Name of an Elasticsearch field type: $ref: '../../../model/primitives.schema.yaml#/components/schemas/NonEmptyString' + description: Type of the Elasticsearch field ecs: type: boolean + description: Whether the field is an ECS field required: - name - type - ecs + RequiredFieldInput: + type: object + description: Input parameters to create a RequiredField. Does not include the `ecs` field, because `ecs` is calculated on the backend based on the field name and type. + properties: + name: + $ref: '../../../model/primitives.schema.yaml#/components/schemas/NonEmptyString' + description: Name of an Elasticsearch field + type: + $ref: '../../../model/primitives.schema.yaml#/components/schemas/NonEmptyString' + description: Type of an Elasticsearch field + required: + - name + - type + RequiredFieldArray: type: array items: diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts index d05a272337534..d2523a9a5c557 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts @@ -54,6 +54,7 @@ import { ThreatArray, SetupGuide, RelatedIntegrationArray, + RequiredFieldInput, RuleObjectId, RuleSignatureId, IsRuleImmutable, @@ -137,6 +138,7 @@ export const BaseDefaultableFields = z.object({ threat: ThreatArray.optional(), setup: SetupGuide.optional(), related_integrations: RelatedIntegrationArray.optional(), + required_fields: z.array(RequiredFieldInput).optional(), }); export type BaseCreateProps = z.infer; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml index f8998624f99b1..ae1a5657d2ab4 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml @@ -117,12 +117,15 @@ components: author: $ref: './common_attributes.schema.yaml#/components/schemas/RuleAuthorArray' + # False positive examples false_positives: $ref: './common_attributes.schema.yaml#/components/schemas/RuleFalsePositiveArray' + # Reference URLs references: $ref: './common_attributes.schema.yaml#/components/schemas/RuleReferenceArray' + # Max alerts per run max_signals: $ref: './common_attributes.schema.yaml#/components/schemas/MaxSignals' threat: @@ -130,9 +133,16 @@ components: setup: $ref: './common_attributes.schema.yaml#/components/schemas/SetupGuide' + # Related integrations related_integrations: $ref: './common_attributes.schema.yaml#/components/schemas/RelatedIntegrationArray' + # Required fields + required_fields: + type: array + items: + $ref: './common_attributes.schema.yaml#/components/schemas/RequiredFieldInput' + BaseCreateProps: x-inline: true allOf: diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.ts index 9634d773b121d..9dc1e218f6ceb 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/rule_to_import.ts @@ -9,6 +9,7 @@ import * as z from 'zod'; import { BaseCreateProps, ResponseFields, + RequiredFieldInput, RuleSignatureId, TypeSpecificCreateProps, } from '../../model/rule_schema'; @@ -29,5 +30,13 @@ export const RuleToImport = BaseCreateProps.and(TypeSpecificCreateProps).and( ResponseFields.partial().extend({ rule_id: RuleSignatureId, immutable: z.literal(false).default(false), + /* + Overriding `required_fields` from ResponseFields because + in ResponseFields `required_fields` has the output type, + but for importing rules, we need to use the input type. + Otherwise importing rules without the "ecs" property in + `required_fields` will fail. + */ + required_fields: z.array(RequiredFieldInput).optional(), }) ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field.tsx index c24220923441b..fb6e89fb44acc 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field.tsx @@ -24,7 +24,6 @@ import type { FieldHook } from '../../../../shared_imports'; import type { Integration, RelatedIntegration } from '../../../../../common/api/detection_engine'; import { useIntegrations } from '../../../../detections/components/rules/related_integrations/use_integrations'; import { IntegrationStatusBadge } from './integration_status_badge'; -import { DEFAULT_RELATED_INTEGRATION } from './default_related_integration'; import * as i18n from './translations'; interface RelatedIntegrationItemFormProps { @@ -95,16 +94,6 @@ export function RelatedIntegrationField({ ); const hasError = Boolean(packageErrorMessage) || Boolean(versionErrorMessage); - const isLastField = relatedIntegrations.length === 1; - const isLastEmptyField = isLastField && field.value.package === ''; - const handleRemove = useCallback(() => { - if (isLastField) { - field.setValue(DEFAULT_RELATED_INTEGRATION); - return; - } - - onRemove(); - }, [onRemove, field, isLastField]); return ( ({ docLinks: { links: { securitySolution: { - ruleUiAdvancedParams: 'http://link-to-docs', + createDetectionRules: 'http://link-to-docs', }, }, }, @@ -669,82 +669,6 @@ describe('RelatedIntegrations form part', () => { }); }); }); - - describe('sticky last form row', () => { - it('does not remove the last item', async () => { - render(, { wrapper: createReactQueryWrapper() }); - - await addRelatedIntegrationRow(); - await removeLastRelatedIntegrationRow(); - - expect(screen.getAllByTestId(RELATED_INTEGRATION_ROW)).toHaveLength(1); - }); - - it('disables remove button after clicking remove button on the last item', async () => { - render(, { wrapper: createReactQueryWrapper() }); - - await addRelatedIntegrationRow(); - await removeLastRelatedIntegrationRow(); - - expect(screen.getByTestId(REMOVE_INTEGRATION_ROW_BUTTON_TEST_ID)).toBeDisabled(); - }); - - it('clears selected integration when clicking remove the last form row button', async () => { - render(, { wrapper: createReactQueryWrapper() }); - - await addRelatedIntegrationRow(); - await selectFirstEuiComboBoxOption({ - comboBoxToggleButton: getLastByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), - }); - await removeLastRelatedIntegrationRow(); - - expect(screen.queryByTestId(COMBO_BOX_SELECTION_TEST_ID)).not.toBeInTheDocument(); - }); - - it('submits an empty integration after clicking remove the last form row button', async () => { - const handleSubmit = jest.fn(); - - render(, { wrapper: createReactQueryWrapper() }); - - await addRelatedIntegrationRow(); - await selectFirstEuiComboBoxOption({ - comboBoxToggleButton: getLastByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), - }); - await removeLastRelatedIntegrationRow(); - await submitForm(); - await waitFor(() => { - expect(handleSubmit).toHaveBeenCalled(); - }); - - expect(handleSubmit).toHaveBeenCalledWith({ - data: [{ package: '', version: '' }], - isValid: true, - }); - }); - - it('submits an empty integration after previously saved integrations were removed', async () => { - const initialRelatedIntegrations: RelatedIntegration[] = [ - { package: 'package-a', version: '^1.2.3' }, - ]; - const handleSubmit = jest.fn(); - - render(, { - wrapper: createReactQueryWrapper(), - }); - - await waitForIntegrationsToBeLoaded(); - await removeLastRelatedIntegrationRow(); - await submitForm(); - await waitFor(() => { - expect(handleSubmit).toHaveBeenCalled(); - }); - - expect(handleSubmit).toHaveBeenCalledWith({ - data: [{ package: '', version: '' }], - isValid: true, - }); - }); - }); }); }); @@ -778,11 +702,6 @@ function TestForm({ initialState, onSubmit }: TestFormProps): JSX.Element { ); } -function getLastByTestId(testId: string): HTMLElement { - // getAllByTestId throws an error when there are no `testId` elements found - return screen.getAllByTestId(testId).at(-1)!; -} - function waitForIntegrationsToBeLoaded(): Promise { return waitForElementToBeRemoved(screen.queryAllByRole('progressbar')); } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx index b694d17a80435..08c4a8e22edfd 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx @@ -40,7 +40,7 @@ export function RelatedIntegrationsHelpInfo(): JSX.Element { defaultMessage="Choose the {integrationsDocLink} this rule depends on, and correct if necessary each integration’s version constraint in {semverLink} format. Only tilde, caret, and plain versions are supported, such as ~1.2.3, ^1.2.3, or 1.2.3." values={{ integrationsDocLink: ( - + > + ): ReturnType> | undefined { + const [{ value, path, form }] = args; + + const formData = form.getFormData(); + const parentFieldData: RequiredFieldInput[] = formData[parentFieldPath]; + + const isFieldNameUsedMoreThanOnce = + parentFieldData.filter((field) => field.name === value.name).length > 1; + + if (isFieldNameUsedMoreThanOnce) { + return { + code: 'ERR_FIELD_FORMAT', + path: `${path}.name`, + message: i18n.FIELD_NAME_USED_MORE_THAN_ONCE(value.name), + }; + } + + /* Allow empty rows. They are going to be removed before submission. */ + if (value.name.trim().length === 0 && value.type.trim().length === 0) { + return; + } + + if (value.name.trim().length === 0) { + return { + code: 'ERR_FIELD_MISSING', + path: `${path}.name`, + message: i18n.FIELD_NAME_REQUIRED, + }; + } + + if (value.type.trim().length === 0) { + return { + code: 'ERR_FIELD_MISSING', + path: `${path}.type`, + message: i18n.FIELD_TYPE_REQUIRED, + }; + } + }; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/name_combobox.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/name_combobox.tsx new file mode 100644 index 0000000000000..848e3c9a3c558 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/name_combobox.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import { EuiComboBox, EuiIcon } from '@elastic/eui'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; +import type { FieldHook } from '../../../../shared_imports'; +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; +import { pickTypeForName } from './utils'; +import * as i18n from './translations'; + +interface NameComboBoxProps { + field: FieldHook; + itemId: string; + availableFieldNames: string[]; + typesByFieldName: Record; + nameWarning: string; + nameError: { message: string } | undefined; +} + +export function NameComboBox({ + field, + itemId, + availableFieldNames, + typesByFieldName, + nameWarning, + nameError, +}: NameComboBoxProps) { + const { value, setValue } = field; + + const selectableNameOptions: Array> = useMemo( + () => + /* Not adding an empty string to the list of selectable field names */ + (value.name ? [value.name] : []).concat(availableFieldNames).map((name) => ({ + label: name, + value: name, + })), + [availableFieldNames, value.name] + ); + + /* + Using a state for `selectedNameOptions` instead of using the field value directly + to fix the issue where pressing the backspace key in combobox input would clear the field value + and trigger a validation error. By using a separate state, we can clear the selected option + without clearing the field value. + */ + const [selectedNameOption, setSelectedNameOption] = useState< + EuiComboBoxOptionOption | undefined + >(selectableNameOptions.find((option) => option.label === value.name)); + + useEffect(() => { + /* Re-computing the new selected name option when the field value changes */ + setSelectedNameOption(selectableNameOptions.find((option) => option.label === value.name)); + }, [value.name, selectableNameOptions]); + + const handleNameChange = useCallback( + (selectedOptions: Array>) => { + const newlySelectedOption: EuiComboBoxOptionOption | undefined = selectedOptions[0]; + + if (!newlySelectedOption) { + /* This occurs when the user hits backspace in combobox */ + setSelectedNameOption(undefined); + return; + } + + const updatedName = newlySelectedOption?.value || ''; + + const updatedType = pickTypeForName({ + name: updatedName, + type: value.type, + typesByFieldName, + }); + + const updatedFieldValue: RequiredFieldInput = { + name: updatedName, + type: updatedType, + }; + + setValue(updatedFieldValue); + }, + [setValue, value.type, typesByFieldName] + ); + + const handleAddCustomName = useCallback( + (newName: string) => { + const updatedFieldValue: RequiredFieldInput = { + name: newName, + type: pickTypeForName({ name: newName, type: value.type, typesByFieldName }), + }; + + setValue(updatedFieldValue); + }, + [setValue, value.type, typesByFieldName] + ); + + return ( + + ) : undefined + } + /> + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields.test.tsx new file mode 100644 index 0000000000000..2812c147d9c2d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields.test.tsx @@ -0,0 +1,724 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { screen, render, act, fireEvent, waitFor } from '@testing-library/react'; +import { Form, useForm } from '../../../../shared_imports'; + +import type { DataViewFieldBase } from '@kbn/es-query'; +import { RequiredFields } from './required_fields'; +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine'; + +const ADD_REQUIRED_FIELD_BUTTON_TEST_ID = 'addRequiredFieldButton'; +const REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID = 'requiredFieldsGeneralWarning'; + +describe('RequiredFields form part', () => { + it('displays the required fields label', () => { + render(); + + expect(screen.getByText('Required fields')); + }); + + it('displays previously saved required fields', () => { + const initialState = [ + { name: 'field1', type: 'string' }, + { name: 'field2', type: 'number' }, + ]; + + render(); + + expect(screen.getByDisplayValue('field1')).toBeVisible(); + expect(screen.getByDisplayValue('string')).toBeVisible(); + + expect(screen.getByDisplayValue('field2')).toBeVisible(); + expect(screen.getByDisplayValue('number')).toBeVisible(); + }); + + it('user can add a new required field to an empty form', async () => { + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + await addRequiredFieldRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + }); + + expect(screen.getByDisplayValue('field1')).toBeVisible(); + expect(screen.getByDisplayValue('string')).toBeVisible(); + }); + + it('user can add a new required field to a previously saved form', async () => { + const initialState = [{ name: 'field1', type: 'string' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field2', esTypes: ['keyword'] }), + ]; + + render(); + + await addRequiredFieldRow(); + + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + }); + + expect(screen.getByDisplayValue('field2')).toBeVisible(); + expect(screen.getByDisplayValue('keyword')).toBeVisible(); + }); + + it('user can select any field name that is available in index patterns', async () => { + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + createIndexPatternField({ name: 'field2', esTypes: ['keyword'] }), + ]; + + render(); + + await addRequiredFieldRow(); + + await selectEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + optionIndex: 0, + }); + + expect(screen.getByDisplayValue('field1')).toBeVisible(); + expect(screen.getByDisplayValue('string')).toBeVisible(); + + await addRequiredFieldRow(); + + await selectEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + optionIndex: 0, + }); + + expect(screen.getByDisplayValue('field2')).toBeVisible(); + expect(screen.getByDisplayValue('keyword')).toBeVisible(); + }); + + it('user can add his own custom field name and type', async () => { + render(); + + await addRequiredFieldRow(); + + await typeInCustomComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + optionText: 'customField', + }); + + expect(screen.getByDisplayValue('customField')).toBeVisible(); + + await typeInCustomComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForType('empty'), + optionText: 'customType', + }); + + expect(screen.getByDisplayValue('customType')).toBeVisible(); + expect(screen.queryByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeVisible(); + }); + + it('field type dropdown allows to choose from options if multiple types are available', async () => { + const initialState = [{ name: 'field1', type: 'string' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string', 'keyword'] }), + ]; + + render(); + + await selectEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForType('string'), + optionText: 'keyword', + }); + + expect(screen.getByDisplayValue('keyword')).toBeVisible(); + }); + + it('user can remove a required field', async () => { + const initialState = [{ name: 'field1', type: 'string' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('removeRequiredFieldButton-field1')); + }); + + expect(screen.queryByDisplayValue('field1')).toBeNull(); + }); + + it('user can not select the same field twice', async () => { + const initialState = [{ name: 'field1', type: 'string' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + createIndexPatternField({ name: 'field2', esTypes: ['keyword'] }), + createIndexPatternField({ name: 'field3', esTypes: ['date'] }), + ]; + + render(); + + await addRequiredFieldRow(); + + const emptyRowOptions = await getDropdownOptions(getSelectToggleButtonForName('empty')); + expect(emptyRowOptions).toEqual(['field2', 'field3']); + + await selectEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + optionText: 'field2', + }); + + const firstRowNameOptions = await getDropdownOptions(getSelectToggleButtonForName('field1')); + expect(firstRowNameOptions).toEqual(['field1', 'field3']); + }); + + it('adding a new required field is disabled when index patterns are loading', async () => { + render(); + + expect(screen.getByTestId(ADD_REQUIRED_FIELD_BUTTON_TEST_ID)).toBeDisabled(); + }); + + it('adding a new required field is disabled when an empty row is already displayed', async () => { + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + expect(screen.getByTestId(ADD_REQUIRED_FIELD_BUTTON_TEST_ID)).toBeEnabled(); + + await addRequiredFieldRow(); + + expect(screen.getByTestId(ADD_REQUIRED_FIELD_BUTTON_TEST_ID)).toBeDisabled(); + }); + + describe('warnings', () => { + it('displays a warning when a selected field name is not found within index patterns', async () => { + const initialState = [{ name: 'field-that-does-not-exist', type: 'keyword' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + expect(screen.getByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeVisible(); + + expect( + screen.getByText( + `Field "field-that-does-not-exist" is not found within the rule's specified index patterns` + ) + ).toBeVisible(); + + const nameWarningIcon = screen + .getByTestId(`requiredFieldNameSelect-field-that-does-not-exist`) + .querySelector('[data-euiicon-type="warning"]'); + + expect(nameWarningIcon).toBeVisible(); + + /* Make sure only one warning icon is displayed - the one for name */ + expect(document.querySelectorAll('[data-test-subj="warningIcon"]')).toHaveLength(1); + }); + + it('displays a warning when a selected field type is not found within index patterns', async () => { + const initialState = [{ name: 'field1', type: 'type-that-does-not-exist' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + expect(screen.getByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeVisible(); + + expect( + screen.getByText( + `Field "field1" with type "type-that-does-not-exist" is not found within the rule's specified index patterns` + ) + ).toBeVisible(); + + const typeWarningIcon = screen + .getByTestId(`requiredFieldTypeSelect-type-that-does-not-exist`) + .querySelector('[data-euiicon-type="warning"]'); + + expect(typeWarningIcon).toBeVisible(); + + /* Make sure only one warning icon is displayed - the one for type */ + expect(document.querySelectorAll('[data-test-subj="warningIcon"]')).toHaveLength(1); + }); + + it('displays a warning only for field name when both field name and type are not found within index patterns', async () => { + const initialState = [ + { name: 'field-that-does-not-exist', type: 'type-that-does-not-exist' }, + ]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + expect(screen.getByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeVisible(); + + expect( + screen.getByText( + `Field "field-that-does-not-exist" is not found within the rule's specified index patterns` + ) + ).toBeVisible(); + + const nameWarningIcon = screen + .getByTestId(`requiredFieldNameSelect-field-that-does-not-exist`) + .querySelector('[data-euiicon-type="warning"]'); + + expect(nameWarningIcon).toBeVisible(); + + /* Make sure only one warning icon is displayed - the one for name */ + expect(document.querySelectorAll('[data-test-subj="warningIcon"]')).toHaveLength(1); + }); + + it(`doesn't display a warning when all selected fields are found within index patterns`, async () => { + const initialState = [{ name: 'field1', type: 'string' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + expect(screen.queryByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeNull(); + }); + + it(`doesn't display a warning for an empty row`, async () => { + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + await addRequiredFieldRow(); + + expect(screen.queryByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeNull(); + }); + + it(`doesn't display a warning when field is invalid`, async () => { + render(); + + await addRequiredFieldRow(); + + await typeInCustomComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + optionText: 'customField', + }); + + expect(screen.getByText('Field type is required')).toBeVisible(); + + expect(screen.queryByTestId(`customField-warningText`)).toBeNull(); + }); + }); + + describe('validation', () => { + it('form is invalid when only field name is empty', async () => { + render(); + + await addRequiredFieldRow(); + + await typeInCustomComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForType('empty'), + optionText: 'customType', + }); + + expect(screen.getByText('Field name is required')).toBeVisible(); + + await typeInCustomComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + optionText: 'customField', + }); + + expect(screen.queryByText('Field name is required')).toBeNull(); + }); + + it('form is invalid when only field type is empty', async () => { + render(); + + await addRequiredFieldRow(); + + await typeInCustomComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + optionText: 'customField', + }); + + expect(screen.getByText('Field type is required')).toBeVisible(); + + await typeInCustomComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForType('empty'), + optionText: 'customType', + }); + + expect(screen.queryByText('Field type is required')).toBeNull(); + }); + + it('form is invalid when same field name is selected more than once', async () => { + const initialState = [{ name: 'field1', type: 'string' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + createIndexPatternField({ name: 'field2', esTypes: ['string'] }), + ]; + + render(); + + await addRequiredFieldRow(); + + await typeInCustomComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + optionText: 'field1', + }); + + expect(screen.getByText('Field name "field1" is already used')).toBeVisible(); + + await typeInCustomComboBoxOption({ + comboBoxToggleButton: getLastSelectToggleButtonForName(), + optionText: 'field2', + }); + + expect(screen.queryByText('Field name "field1" is already used')).toBeNull(); + }); + + it('form is valid when both field name and type are empty', async () => { + const handleSubmit = jest.fn(); + + render(); + + await addRequiredFieldRow(); + + await submitForm(); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ name: '', type: '' }], + isValid: true, + }); + }); + }); + + describe('form submission', () => { + it('submits undefined when no required fields are selected', async () => { + const handleSubmit = jest.fn(); + + render(); + + await submitForm(); + + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalledWith({ + data: undefined, + isValid: true, + }); + }); + }); + + it('submits undefined when all selected fields were removed', async () => { + const initialState = [{ name: 'field1', type: 'string' }]; + + const handleSubmit = jest.fn(); + + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('removeRequiredFieldButton-field1')); + }); + + await submitForm(); + + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalledWith({ + data: undefined, + isValid: true, + }); + }); + }); + + it('submits newly added required fields', async () => { + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + const handleSubmit = jest.fn(); + + render(); + + await addRequiredFieldRow(); + + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + }); + + await submitForm(); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ name: 'field1', type: 'string' }], + isValid: true, + }); + }); + + it('submits previously saved required fields', async () => { + const initialState = [{ name: 'field1', type: 'string' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + const handleSubmit = jest.fn(); + + render( + + ); + + await submitForm(); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ name: 'field1', type: 'string' }], + isValid: true, + }); + }); + + it('submits updated required fields', async () => { + const initialState = [{ name: 'field1', type: 'string' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + createIndexPatternField({ name: 'field2', esTypes: ['keyword', 'date'] }), + ]; + + const handleSubmit = jest.fn(); + + render( + + ); + + await selectEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('field1'), + optionText: 'field2', + }); + + await selectEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForType('keyword'), + optionText: 'date', + }); + + await submitForm(); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ name: 'field2', type: 'date' }], + isValid: true, + }); + }); + + it('submits a form with warnings', async () => { + const initialState = [{ name: 'name-that-does-not-exist', type: 'type-that-does-not-exist' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + const handleSubmit = jest.fn(); + + render( + + ); + + expect(screen.queryByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeVisible(); + + await submitForm(); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ name: 'name-that-does-not-exist', type: 'type-that-does-not-exist' }], + isValid: true, + }); + }); + }); +}); + +export function createIndexPatternField(overrides: Partial): DataViewFieldBase { + return { + name: 'one', + type: 'string', + esTypes: [], + ...overrides, + }; +} + +async function getDropdownOptions(dropdownToggleButton: HTMLElement): Promise { + await showEuiComboBoxOptions(dropdownToggleButton); + + const options = screen.getAllByRole('option').map((option) => option.textContent) as string[]; + + fireEvent.click(dropdownToggleButton); + + return options; +} + +export function addRequiredFieldRow(): Promise { + return act(async () => { + fireEvent.click(screen.getByText('Add required field')); + }); +} + +function showEuiComboBoxOptions(comboBoxToggleButton: HTMLElement): Promise { + fireEvent.click(comboBoxToggleButton); + + return waitFor(() => { + const listWithOptionsElement = document.querySelector('[role="listbox"]'); + const emptyListElement = document.querySelector('.euiComboBoxOptionsList__empty'); + + expect(listWithOptionsElement || emptyListElement).toBeInTheDocument(); + }); +} + +type SelectEuiComboBoxOptionParameters = + | { + comboBoxToggleButton: HTMLElement; + optionIndex: number; + optionText?: undefined; + } + | { + comboBoxToggleButton: HTMLElement; + optionText: string; + optionIndex?: undefined; + }; + +function selectEuiComboBoxOption({ + comboBoxToggleButton, + optionIndex, + optionText, +}: SelectEuiComboBoxOptionParameters): Promise { + return act(async () => { + await showEuiComboBoxOptions(comboBoxToggleButton); + + const options = Array.from( + document.querySelectorAll('[data-test-subj*="comboBoxOptionsList"] [role="option"]') + ); + + if (typeof optionText === 'string') { + const optionToSelect = options.find((option) => option.textContent === optionText); + + if (!optionToSelect) { + throw new Error( + `Could not find option with text "${optionText}". Available options: ${options + .map((option) => option.textContent) + .join(', ')}` + ); + } + + fireEvent.click(optionToSelect); + } else { + fireEvent.click(options[optionIndex]); + } + }); +} + +function selectFirstEuiComboBoxOption({ + comboBoxToggleButton, +}: { + comboBoxToggleButton: HTMLElement; +}): Promise { + return selectEuiComboBoxOption({ comboBoxToggleButton, optionIndex: 0 }); +} + +function typeInCustomComboBoxOption({ + comboBoxToggleButton, + optionText, +}: { + comboBoxToggleButton: HTMLElement; + optionText: string; +}) { + return act(async () => { + await showEuiComboBoxOptions(comboBoxToggleButton); + + fireEvent.change(document.activeElement as HTMLInputElement, { target: { value: optionText } }); + fireEvent.keyDown(document.activeElement as HTMLInputElement, { key: 'Enter' }); + }); +} + +function getLastSelectToggleButtonForName(): HTMLElement { + const allNameSelects = screen.getAllByTestId(/requiredFieldNameSelect-.*/); + const lastNameSelect = allNameSelects[allNameSelects.length - 1]; + + return lastNameSelect.querySelector('[data-test-subj="comboBoxToggleListButton"]') as HTMLElement; +} + +export function getSelectToggleButtonForName(value: string): HTMLElement { + return screen + .getByTestId(`requiredFieldNameSelect-${value}`) + .querySelector('[data-test-subj="comboBoxToggleListButton"]') as HTMLElement; +} + +function getSelectToggleButtonForType(value: string): HTMLElement { + return screen + .getByTestId(`requiredFieldTypeSelect-${value}`) + .querySelector('[data-test-subj="comboBoxToggleListButton"]') as HTMLElement; +} + +function submitForm(): Promise { + return act(async () => { + fireEvent.click(screen.getByText('Submit')); + }); +} + +interface TestFormProps { + initialState?: RequiredFieldInput[]; + onSubmit?: (args: { data: RequiredFieldInput[]; isValid: boolean }) => void; + indexPatternFields?: DataViewFieldBase[]; + isIndexPatternLoading?: boolean; +} + +function TestForm({ + indexPatternFields, + initialState = [], + isIndexPatternLoading, + onSubmit, +}: TestFormProps): JSX.Element { + const { form } = useForm({ + defaultValue: { + requiredFieldsField: initialState, + }, + onSubmit: async (formData, isValid) => + onSubmit?.({ data: formData.requiredFieldsField, isValid }), + }); + + return ( + +
+ + + +
+ ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields.tsx new file mode 100644 index 0000000000000..f53c41ce98d00 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields.tsx @@ -0,0 +1,213 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiButtonEmpty, EuiCallOut, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { DataViewFieldBase } from '@kbn/es-query'; +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine'; +import { UseArray, useFormData } from '../../../../shared_imports'; +import type { FormHook, ArrayItem } from '../../../../shared_imports'; +import { RequiredFieldsHelpInfo } from './required_fields_help_info'; +import { RequiredFieldRow } from './required_fields_row'; +import * as defineRuleI18n from '../../../rule_creation_ui/components/step_define_rule/translations'; +import * as i18n from './translations'; + +interface RequiredFieldsComponentProps { + path: string; + indexPatternFields?: DataViewFieldBase[]; + isIndexPatternLoading?: boolean; +} + +const RequiredFieldsComponent = ({ + path, + indexPatternFields = [], + isIndexPatternLoading = false, +}: RequiredFieldsComponentProps) => { + return ( + + {({ items, addItem, removeItem, form }) => ( + + )} + + ); +}; + +interface RequiredFieldsListProps { + items: ArrayItem[]; + addItem: () => void; + removeItem: (id: number) => void; + indexPatternFields: DataViewFieldBase[]; + isIndexPatternLoading: boolean; + path: string; + form: FormHook; +} + +const RequiredFieldsList = ({ + items, + addItem, + removeItem, + indexPatternFields, + isIndexPatternLoading, + path, + form, +}: RequiredFieldsListProps) => { + /* + This component should only re-render when either the "index" form field (index patterns) or the required fields change. + + By default, the `useFormData` hook triggers a re-render whenever any form field changes. + It also allows optimization by passing a "watch" array of field names. The component then only re-renders when these specified fields change. + + However, it doesn't work with fields created using the `UseArray` component. + In `useFormData`, these array fields are stored as "flattened" objects with numbered keys, like { "requiredFields[0]": { ... }, "requiredFields[1]": { ... } }. + The "watch" feature of `useFormData` only works if you pass these "flattened" field names, such as ["requiredFields[0]", "requiredFields[1]", ...], not just "requiredFields". + + To work around this, we manually construct a list of "flattened" field names to watch, based on the current state of the form. + This is a temporary solution and ideally, `useFormData` should be updated to handle this scenario. + */ + + const internalField = form.getFields()[`${path}__array__`] ?? {}; + const internalFieldValue = (internalField?.value ?? []) as ArrayItem[]; + const flattenedFieldNames = internalFieldValue.map((item) => item.path); + + /* + Not using "watch" for the initial render, to let row components render and initialize form fields. + Then we can use the "watch" feature to track their changes. + */ + const hasRenderedInitially = flattenedFieldNames.length > 0; + const fieldsToWatch = hasRenderedInitially ? ['index', ...flattenedFieldNames] : []; + + const [formData] = useFormData({ watch: fieldsToWatch }); + + const fieldValue: RequiredFieldInput[] = formData[path] ?? []; + + const typesByFieldName: Record = useMemo( + () => + indexPatternFields.reduce((accumulator, field) => { + if (field.esTypes?.length) { + accumulator[field.name] = field.esTypes; + } + return accumulator; + }, {} as Record), + [indexPatternFields] + ); + + const allFieldNames = useMemo(() => Object.keys(typesByFieldName), [typesByFieldName]); + + const selectedFieldNames = fieldValue.map(({ name }) => name); + + const availableFieldNames = allFieldNames.filter((name) => !selectedFieldNames.includes(name)); + + const nameWarnings = fieldValue.reduce>((warnings, { name }) => { + if ( + !isIndexPatternLoading && + /* Creating a warning only if "name" value is filled in */ + name !== '' && + !allFieldNames.includes(name) + ) { + warnings[name] = i18n.FIELD_NAME_NOT_FOUND_WARNING(name); + } + return warnings; + }, {}); + + const typeWarnings = fieldValue.reduce>((warnings, { name, type }) => { + if ( + !isIndexPatternLoading && + /* Creating a warning for "type" only if "name" value is filled in */ + name !== '' && + typesByFieldName[name] && + !typesByFieldName[name].includes(type) + ) { + warnings[`${name}-${type}`] = i18n.FIELD_TYPE_NOT_FOUND_WARNING(name, type); + } + return warnings; + }, {}); + + const getWarnings = ({ name, type }: { name: string; type: string }) => ({ + nameWarning: nameWarnings[name] || '', + typeWarning: typeWarnings[`${name}-${type}`] || '', + }); + + const hasEmptyFieldName = fieldValue.some(({ name }) => name === ''); + + const hasWarnings = Object.keys(nameWarnings).length > 0 || Object.keys(typeWarnings).length > 0; + + return ( + <> + {hasWarnings && ( + +

+ {defineRuleI18n.SOURCE}, + }} + /> +

+
+ )} + + + {i18n.REQUIRED_FIELDS_LABEL} + + + } + labelAppend={ + + {i18n.OPTIONAL} + + } + hasChildLabel={false} + labelType="legend" + > + <> + {items.map((item) => ( + + ))} + + + + {i18n.ADD_REQUIRED_FIELD} + + + + + ); +}; + +export const RequiredFields = React.memo(RequiredFieldsComponent); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_help_info.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_help_info.tsx new file mode 100644 index 0000000000000..187f05880d205 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_help_info.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useToggle } from 'react-use'; +import { EuiPopover, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import * as defineRuleI18n from '../../../rule_creation_ui/components/step_define_rule/translations'; +import * as i18n from './translations'; + +/** + * Theme doesn't expose width variables. Using provided size variables will require + * multiplying it by another magic constant. + * + * 320px width looks + * like a [commonly used width in EUI](https://github.com/search?q=repo%3Aelastic%2Feui%20320&type=code). + */ +const POPOVER_WIDTH = 320; + +export function RequiredFieldsHelpInfo(): JSX.Element { + const [isPopoverOpen, togglePopover] = useToggle(false); + + const button = ( + + ); + + return ( + + + {defineRuleI18n.SOURCE}, + }} + /> + + + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_row.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_row.tsx new file mode 100644 index 0000000000000..755f1de413760 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_row.tsx @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiTextColor } from '@elastic/eui'; +import { UseField } from '../../../../shared_imports'; +import { NameComboBox } from './name_combobox'; +import { TypeComboBox } from './type_combobox'; +import { makeValidateRequiredField } from './make_validate_required_field'; +import * as i18n from './translations'; + +import type { ArrayItem, FieldConfig, FieldHook } from '../../../../shared_imports'; +import type { + RequiredField, + RequiredFieldInput, +} from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; + +interface RequiredFieldRowProps { + item: ArrayItem; + removeItem: (id: number) => void; + typesByFieldName: Record; + availableFieldNames: string[]; + getWarnings: ({ name, type }: { name: string; type: string }) => { + nameWarning: string; + typeWarning: string; + }; + parentFieldPath: string; +} + +export const RequiredFieldRow = ({ + item, + removeItem, + typesByFieldName, + availableFieldNames, + getWarnings, + parentFieldPath, +}: RequiredFieldRowProps) => { + const handleRemove = useCallback(() => removeItem(item.id), [removeItem, item.id]); + + const rowFieldConfig: FieldConfig = + useMemo( + () => ({ + deserializer: (value) => { + const rowValueWithoutEcs: RequiredFieldInput = { + name: value.name, + type: value.type, + }; + + return rowValueWithoutEcs; + }, + validations: [{ validator: makeValidateRequiredField(parentFieldPath) }], + defaultValue: { name: '', type: '' }, + }), + [parentFieldPath] + ); + + return ( + + ); +}; + +interface RequiredFieldFieldProps { + field: FieldHook; + onRemove: () => void; + typesByFieldName: Record; + availableFieldNames: string[]; + getWarnings: ({ name, type }: { name: string; type: string }) => { + nameWarning: string; + typeWarning: string; + }; + itemId: string; +} + +const RequiredFieldField = ({ + field, + typesByFieldName, + onRemove, + availableFieldNames, + getWarnings, + itemId, +}: RequiredFieldFieldProps) => { + const { nameWarning, typeWarning } = getWarnings(field.value); + const warningMessage = nameWarning || typeWarning; + + const [nameError, typeError] = useMemo(() => { + return [ + field.errors.find((error) => 'path' in error && error.path === `${field.path}.name`), + field.errors.find((error) => 'path' in error && error.path === `${field.path}.type`), + ]; + }, [field.path, field.errors]); + const hasError = Boolean(nameError) || Boolean(typeError); + const errorMessage = nameError?.message || typeError?.message; + + return ( + + {warningMessage} + + ) : ( + '' + ) + } + color="warning" + > + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/translations.ts new file mode 100644 index 0000000000000..bed9a4ea0024c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/translations.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const REQUIRED_FIELDS_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.requiredFieldsLabel', + { + defaultMessage: 'Required fields', + } +); + +export const FIELD_NAME = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.fieldNameLabel', + { + defaultMessage: 'Field name', + } +); + +export const FIELD_TYPE = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.fieldTypeLabel', + { + defaultMessage: 'Field type', + } +); + +export const OPEN_HELP_POPOVER_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.openHelpPopoverAriaLabel', + { + defaultMessage: 'Open help popover', + } +); + +export const REQUIRED_FIELDS_GENERAL_WARNING_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.generalWarningTitle', + { + defaultMessage: `Some fields aren't found within the rule's specified index patterns.`, + } +); + +export const OPTIONAL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.optionalText', + { + defaultMessage: 'Optional', + } +); + +export const REMOVE_REQUIRED_FIELD_BUTTON_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.removeRequiredFieldButtonAriaLabel', + { + defaultMessage: 'Remove required field', + } +); + +export const ADD_REQUIRED_FIELD = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.addRequiredFieldButtonLabel', + { + defaultMessage: 'Add required field', + } +); + +export const FIELD_NAME_NOT_FOUND_WARNING = (name: string) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.fieldNameNotFoundWarning', + { + values: { name }, + defaultMessage: `Field "{name}" is not found within the rule's specified index patterns`, + } + ); + +export const FIELD_TYPE_NOT_FOUND_WARNING = (name: string, type: string) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.fieldTypeNotFoundWarning', + { + values: { name, type }, + defaultMessage: `Field "{name}" with type "{type}" is not found within the rule's specified index patterns`, + } + ); + +export const FIELD_NAME_REQUIRED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.validation.fieldNameRequired', + { + defaultMessage: 'Field name is required', + } +); + +export const FIELD_TYPE_REQUIRED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.validation.fieldTypeRequired', + { + defaultMessage: 'Field type is required', + } +); + +export const FIELD_NAME_USED_MORE_THAN_ONCE = (name: string) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.validation.fieldNameUsedMoreThanOnce', + { + values: { name }, + defaultMessage: 'Field name "{name}" is already used', + } + ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/type_combobox.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/type_combobox.tsx new file mode 100644 index 0000000000000..48d5a009c4abe --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/type_combobox.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import { EuiComboBox, EuiIcon } from '@elastic/eui'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; +import type { FieldHook } from '../../../../shared_imports'; +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; +import * as i18n from './translations'; + +interface TypeComboBoxProps { + field: FieldHook; + itemId: string; + typesByFieldName: Record; + typeWarning: string; + typeError: { message: string } | undefined; +} + +export function TypeComboBox({ + field, + itemId, + typesByFieldName, + typeWarning, + typeError, +}: TypeComboBoxProps) { + const { value, setValue } = field; + + const selectableTypeOptions: Array> = useMemo(() => { + const typesAvailableForSelectedName = typesByFieldName[value.name]; + const isSelectedTypeAvailable = (typesAvailableForSelectedName || []).includes(value.type); + + if (typesAvailableForSelectedName && isSelectedTypeAvailable) { + /* + Case: name is available, type is not available + Selected field name is present in index patterns, so it has one or more types available for it. + Allowing the user to select from them. + */ + + return typesAvailableForSelectedName.map((type) => ({ + label: type, + value: type, + })); + } else if (typesAvailableForSelectedName) { + /* + Case: name is available, type is not available + Selected field name is present in index patterns, but the selected type doesn't exist for it. + Adding the selected type to the list of selectable options since it was selected before. + */ + return typesAvailableForSelectedName + .map((type) => ({ + label: type, + value: type, + })) + .concat({ label: value.type, value: value.type }); + } else if (value.name) { + /* + Case: name is not available (so the type is also not available) + Field name is set (not an empty string), but it's not present in index patterns. + In such case the only selectable type option is the currenty selected type. + */ + return [ + { + label: value.type, + value: value.type, + }, + ]; + } + + return []; + }, [value.name, value.type, typesByFieldName]); + + /* + Using a state for `selectedTypeOptions` instead of using the field value directly + to fix the issue where pressing the backspace key in combobox input would clear the field value + and trigger a validation error. By using a separate state, we can clear the selected option + without clearing the field value. + */ + const [selectedTypeOption, setSelectedTypeOption] = useState< + EuiComboBoxOptionOption | undefined + >(selectableTypeOptions.find((option) => option.value === value.type)); + + useEffect(() => { + /* Re-computing the new selected type option when the field value changes */ + setSelectedTypeOption(selectableTypeOptions.find((option) => option.value === value.type)); + }, [value.type, selectableTypeOptions]); + + const handleTypeChange = useCallback( + (selectedOptions: Array>) => { + const newlySelectedOption: EuiComboBoxOptionOption | undefined = selectedOptions[0]; + + if (!newlySelectedOption) { + /* This occurs when the user hits backspace in combobox */ + setSelectedTypeOption(undefined); + return; + } + + const updatedType = newlySelectedOption?.value || ''; + + const updatedFieldValue: RequiredFieldInput = { + name: value.name, + type: updatedType, + }; + + setValue(updatedFieldValue); + }, + [value.name, setValue] + ); + + const handleAddCustomType = useCallback( + (newType: string) => { + const updatedFieldValue: RequiredFieldInput = { + name: value.name, + type: newType, + }; + + setValue(updatedFieldValue); + }, + [value.name, setValue] + ); + + return ( + + ) : undefined + } + /> + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.test.tsx new file mode 100644 index 0000000000000..235da2208a43b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pickTypeForName } from './utils'; + +describe('pickTypeForName', () => { + it('returns the current type if it is available for the current name', () => { + const typesByFieldName = { + name1: ['text', 'keyword'], + }; + + expect( + pickTypeForName({ + name: 'name1', + type: 'keyword', + typesByFieldName, + }) + ).toEqual('keyword'); + }); + + it('returns the first available type if the current type is not available for the current name', () => { + const typesByFieldName = { + name1: ['text', 'keyword'], + }; + + expect( + pickTypeForName({ + name: 'name1', + type: 'long', + typesByFieldName, + }) + ).toEqual('text'); + }); + + it('returns the current type if no types are available for the current name', () => { + expect( + pickTypeForName({ + name: 'name1', + type: 'keyword', + typesByFieldName: {}, + }) + ).toEqual('keyword'); + + expect( + pickTypeForName({ + name: 'name1', + type: 'keyword', + typesByFieldName: { + name1: [], + }, + }) + ).toEqual('keyword'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.ts new file mode 100644 index 0000000000000..55beca264e120 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +interface PickTypeForNameParameters { + name: string; + type: string; + typesByFieldName?: Record; +} + +export function pickTypeForName({ name, type, typesByFieldName = {} }: PickTypeForNameParameters) { + const typesAvailableForName = typesByFieldName[name] || []; + const isCurrentTypeAvailableForNewName = typesAvailableForName.includes(type); + + /* First try to keep the type if it's available for the name */ + if (isCurrentTypeAvailableForNewName) { + return type; + } + + /* + If current type is not available, pick the first available type. + If no type is available, use the current type. + */ + return typesAvailableForName[0] ?? type; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/helpers.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/helpers.tsx index aaf588edd3099..8ef2a3751a036 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/helpers.tsx @@ -18,12 +18,9 @@ import { } from '@elastic/eui'; import { ALERT_RISK_SCORE } from '@kbn/rule-data-utils'; -import { castEsToKbnFieldTypeName } from '@kbn/field-types'; - import { isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; -import { FieldIcon } from '@kbn/react-field'; import type { ThreatMapping, Type, Threats } from '@kbn/securitysolution-io-ts-alerting-types'; import { FilterBadgeGroup } from '@kbn/unified-search-plugin/public'; @@ -50,8 +47,10 @@ import type { } from '../../../../detections/pages/detection_engine/rules/types'; import { GroupByOptions } from '../../../../detections/pages/detection_engine/rules/types'; import { defaultToEmptyTag } from '../../../../common/components/empty_value'; +import { RequiredFieldIcon } from '../../../rule_management/components/rule_details/required_field_icon'; import { ThreatEuiFlexGroup } from './threat_description'; import { AlertSuppressionLabel } from './alert_suppression_label'; + const NoteDescriptionContainer = styled(EuiFlexItem)` height: 105px; overflow-y: hidden; @@ -566,11 +565,7 @@ export const buildRequiredFieldsDescription = ( - + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx index de34718ef050f..be1306d706357 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx @@ -8,6 +8,7 @@ import React, { useEffect, useState } from 'react'; import { screen, fireEvent, render, within, act, waitFor } from '@testing-library/react'; import type { Type as RuleType } from '@kbn/securitysolution-io-ts-alerting-types'; +import type { DataViewBase } from '@kbn/es-query'; import { StepDefineRule, aggregatableFields } from '.'; import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { useRuleFromTimeline } from '../../../../detections/containers/detection_engine/rules/use_rule_from_timeline'; @@ -18,6 +19,11 @@ import type { FormSubmitHandler } from '../../../../shared_imports'; import { useForm } from '../../../../shared_imports'; import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types'; import { fleetIntegrationsApi } from '../../../fleet_integrations/api/__mocks__'; +import { + addRequiredFieldRow, + createIndexPatternField, + getSelectToggleButtonForName, +} from '../../../rule_creation/components/required_fields/required_fields.test'; // Mocks integrations jest.mock('../../../fleet_integrations/api'); @@ -410,6 +416,116 @@ describe('StepDefineRule', () => { }); }); + describe('required fields', () => { + it('submits a form without selected required fields', async () => { + const initialState = { + index: ['test-index'], + queryBar: { + query: { query: '*:*', language: 'kuery' }, + filters: [], + saved_id: null, + }, + }; + const handleSubmit = jest.fn(); + + render(, { + wrapper: TestProviders, + }); + + await submitForm(); + + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith( + expect.not.objectContaining({ + requiredFields: expect.anything(), + }), + true + ); + }); + + it('submits saved early required fields without the "ecs" property', async () => { + const initialState = { + index: ['test-index'], + queryBar: { + query: { query: '*:*', language: 'kuery' }, + filters: [], + saved_id: null, + }, + requiredFields: [{ name: 'host.name', type: 'string', ecs: false }], + }; + + const handleSubmit = jest.fn(); + + render(, { + wrapper: TestProviders, + }); + + await submitForm(); + + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + requiredFields: [{ name: 'host.name', type: 'string' }], + }), + true + ); + }); + + it('submits newly added required fields', async () => { + const initialState = { + index: ['test-index'], + queryBar: { + query: { query: '*:*', language: 'kuery' }, + filters: [], + saved_id: null, + }, + }; + + const indexPattern: DataViewBase = { + fields: [createIndexPatternField({ name: 'host.name', esTypes: ['string'] })], + title: '', + }; + + const handleSubmit = jest.fn(); + + render( + , + { + wrapper: TestProviders, + } + ); + + await addRequiredFieldRow(); + + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + }); + + await submitForm(); + + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + requiredFields: [{ name: 'host.name', type: 'string' }], + }), + true + ); + }); + }); + describe('handleSetRuleFromTimeline', () => { it('updates KQL query correctly', () => { const kqlQuery = { @@ -497,14 +613,16 @@ describe('StepDefineRule', () => { }); interface TestFormProps { - ruleType?: RuleType; initialState?: Partial; + ruleType?: RuleType; + indexPattern?: DataViewBase; onSubmit?: FormSubmitHandler; } function TestForm({ - ruleType = stepDefineDefaultValue.ruleType, initialState, + ruleType = stepDefineDefaultValue.ruleType, + indexPattern = { fields: [], title: '' }, onSubmit, }: TestFormProps): JSX.Element { const [selectedEqlOptions, setSelectedEqlOptions] = useState(stepDefineDefaultValue.eqlOptions); @@ -524,7 +642,7 @@ function TestForm({ threatIndicesConfig={[]} optionsSelected={selectedEqlOptions} setOptionsSelected={setSelectedEqlOptions} - indexPattern={{ fields: [], title: '' }} + indexPattern={indexPattern} isIndexPatternLoading={false} browserFields={{}} isQueryBarValid={true} @@ -560,32 +678,71 @@ function addRelatedIntegrationRow(): Promise { }); } +function setVersion({ input, value }: { input: HTMLInputElement; value: string }): Promise { + return act(async () => { + fireEvent.input(input, { + target: { value }, + }); + }); +} + function showEuiComboBoxOptions(comboBoxToggleButton: HTMLElement): Promise { fireEvent.click(comboBoxToggleButton); return waitFor(() => { - expect(screen.getByRole('listbox')).toBeInTheDocument(); + const listWithOptionsElement = document.querySelector('[role="listbox"]'); + const emptyListElement = document.querySelector('.euiComboBoxOptionsList__empty'); + + expect(listWithOptionsElement || emptyListElement).toBeInTheDocument(); }); } +type SelectEuiComboBoxOptionParameters = + | { + comboBoxToggleButton: HTMLElement; + optionIndex: number; + optionText?: undefined; + } + | { + comboBoxToggleButton: HTMLElement; + optionText: string; + optionIndex?: undefined; + }; + function selectEuiComboBoxOption({ comboBoxToggleButton, optionIndex, -}: { - comboBoxToggleButton: HTMLElement; - optionIndex: number; -}): Promise { + optionText, +}: SelectEuiComboBoxOptionParameters): Promise { return act(async () => { await showEuiComboBoxOptions(comboBoxToggleButton); - fireEvent.click(within(screen.getByRole('listbox')).getAllByRole('option')[optionIndex]); + const options = Array.from( + document.querySelectorAll('[data-test-subj*="comboBoxOptionsList"] [role="option"]') + ); + + if (typeof optionText === 'string') { + const optionToSelect = options.find((option) => option.textContent === optionText); + + if (optionToSelect) { + fireEvent.click(optionToSelect); + } else { + throw new Error( + `Could not find option with text "${optionText}". Available options: ${options + .map((option) => option.textContent) + .join(', ')}` + ); + } + } else { + fireEvent.click(options[optionIndex]); + } }); } -function setVersion({ input, value }: { input: HTMLInputElement; value: string }): Promise { - return act(async () => { - fireEvent.input(input, { - target: { value }, - }); - }); +function selectFirstEuiComboBoxOption({ + comboBoxToggleButton, +}: { + comboBoxToggleButton: HTMLElement; +}): Promise { + return selectEuiComboBoxOption({ comboBoxToggleButton, optionIndex: 0 }); } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx index f0dcadc056315..56073e2a6af59 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx @@ -90,6 +90,7 @@ import type { BrowserField } from '../../../../common/containers/source'; import { useFetchIndex } from '../../../../common/containers/source'; import { NewTermsFields } from '../new_terms_fields'; import { ScheduleItem } from '../../../rule_creation/components/schedule_item_form'; +import { RequiredFields } from '../../../rule_creation/components/required_fields'; import { DocLink } from '../../../../common/components/links_to_docs/doc_link'; import { defaultCustomQuery } from '../../../../detections/pages/detection_engine/rules/utils'; import { MultiSelectFieldsAutocomplete } from '../multi_select_fields'; @@ -915,7 +916,6 @@ const StepDefineRuleComponent: FC = ({ )} - {isQueryRule(ruleType) && ( <> @@ -940,7 +940,6 @@ const StepDefineRuleComponent: FC = ({ )} - <> = ({ /> - <> @@ -1116,6 +1114,19 @@ const StepDefineRuleComponent: FC = ({ + + + {!isMlRule(ruleType) && ( + <> + + + + )} + = { }, relatedIntegrations: { type: FIELD_TYPES.JSON, + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel', + { + defaultMessage: 'Related integrations', + } + ), }, requiredFields: { label: i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_esql_index.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_esql_index.test.ts index dc4394be257e5..2f5065eb113be 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_esql_index.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_esql_index.test.ts @@ -10,29 +10,28 @@ import { useEsqlIndex } from './use_esql_index'; const validEsqlQuery = 'from auditbeat* metadata _id, _index, _version'; describe('useEsqlIndex', () => { - it('should return empty array if isQueryReadEnabled is undefined', () => { - const { result } = renderHook(() => useEsqlIndex(validEsqlQuery, 'esql', undefined)); + it('should return parsed index array from a valid query', async () => { + const { result } = renderHook(() => useEsqlIndex(validEsqlQuery, 'esql')); - expect(result.current).toEqual([]); + expect(result.current).toEqual(['auditbeat*']); }); - it('should return empty array if isQueryReadEnabled is false', () => { - const { result } = renderHook(() => useEsqlIndex(validEsqlQuery, 'esql', false)); + it('should return empty array if rule type is not esql', async () => { + const { result } = renderHook(() => useEsqlIndex(validEsqlQuery, 'query')); expect(result.current).toEqual([]); }); - it('should return empty array if rule type is not esql', () => { - const { result } = renderHook(() => useEsqlIndex(validEsqlQuery, 'query', true)); - expect(result.current).toEqual([]); - }); - it('should return empty array if query is empty', () => { - const { result } = renderHook(() => useEsqlIndex('', 'esql', true)); + it('should return empty array if query is empty', async () => { + const { result } = renderHook(() => useEsqlIndex('', 'esql')); expect(result.current).toEqual([]); }); - it('should return parsed index array from a valid query', () => { - const { result } = renderHook(() => useEsqlIndex(validEsqlQuery, 'esql', true)); - expect(result.current).toEqual(['auditbeat*']); + it('should return empty array if invalid query is causing a TypeError in ES|QL parser', async () => { + const typeErrorCausingQuery = 'from auditbeat* []'; + + const { result } = renderHook(() => useEsqlIndex(typeErrorCausingQuery, 'esql')); + + expect(result.current).toEqual([]); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_esql_index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_esql_index.ts index 508f2272b69ab..358c1fc70a945 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_esql_index.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_esql_index.ts @@ -4,7 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; import { getIndexListFromIndexString } from '@kbn/securitysolution-utils'; @@ -15,23 +16,39 @@ import { isEsqlRule } from '../../../../common/detection_engine/utils'; /** * parses ES|QL query and returns memoized array of indices - * @param query - ES|QL query to retrieve index from + * @param query - ES|QL query to retrieve indices from * @param ruleType - rule type value - * @param isQueryReadEnabled - if not enabled, return empty array. Useful if we know form or query is not valid and we don't want to retrieve index - * @returns + * @returns string[] - array of indices. Array is empty if query is invalid or ruleType is not 'esql'. */ -export const useEsqlIndex = ( - query: Query['query'], - ruleType: Type, - isQueryReadEnabled: boolean | undefined -) => { +export const useEsqlIndex = (query: Query['query'], ruleType: Type): string[] => { + const [debouncedQuery, setDebouncedQuery] = useState(query); + + useDebounce( + () => { + /* + Triggerring the ES|QL parser a few moments after the user has finished typing + to avoid unnecessary calls to the parser. + */ + setDebouncedQuery(query); + }, + 300, + [query] + ); + const indexString = useMemo(() => { - if (!isQueryReadEnabled) { + const esqlQuery = + typeof debouncedQuery === 'string' && isEsqlRule(ruleType) ? debouncedQuery : undefined; + + try { + return getIndexPatternFromESQLQuery(esqlQuery); + } catch (error) { + /* + Some invalid queries cause ES|QL parser to throw a TypeError. + Treating such cases as if parser returned an empty string. + */ return ''; } - const esqlQuery = typeof query === 'string' && isEsqlRule(ruleType) ? query : undefined; - return getIndexPatternFromESQLQuery(esqlQuery); - }, [query, isQueryReadEnabled, ruleType]); + }, [debouncedQuery, ruleType]); const index = useMemo(() => getIndexListFromIndexString(indexString), [indexString]); return index; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts index 20a432cdc1420..b61cdbc386ee1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts @@ -140,6 +140,7 @@ describe('helpers', () => { version: '^1.2.3', }, ], + required_fields: [{ name: 'host.name', type: 'keyword' }], }; expect(result).toEqual(expected); @@ -178,6 +179,20 @@ describe('helpers', () => { }); }); + test('filters out empty required fields', () => { + const result = formatDefineStepData({ + ...mockData, + requiredFields: [ + { name: 'host.name', type: 'keyword' }, + { name: '', type: '' }, + ], + }); + + expect(result).toMatchObject({ + required_fields: [{ name: 'host.name', type: 'keyword' }], + }); + }); + describe('saved_query and query rule types', () => { test('returns query rule if savedId provided but shouldLoadQueryDynamically != true', () => { const mockStepData: DefineStepRule = { @@ -567,6 +582,7 @@ describe('helpers', () => { version: '^1.2.3', }, ], + required_fields: [{ name: 'host.name', type: 'keyword' }], }; expect(result).toEqual(expected); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts index 3054a894d0df1..11da431e3e602 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts @@ -48,6 +48,7 @@ import { import type { RuleCreateProps, AlertSuppression, + RequiredFieldInput, } from '../../../../../common/api/detection_engine/model/rule_schema'; import { stepActionsDefaultValue } from '../../../rule_creation/components/step_rule_actions'; import { DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY } from '../../../../../common/detection_engine/constants'; @@ -395,6 +396,12 @@ export const getStepDataDataSource = ( return copiedStepData; }; +/** + * Strips away form rows that were not filled out by the user + */ +const removeEmptyRequiredFields = (requiredFields: RequiredFieldInput[]): RequiredFieldInput[] => + requiredFields.filter((field) => field.name !== '' && field.type !== ''); + export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { const stepData = getStepDataDataSource(defineStepData); @@ -426,6 +433,8 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep } : {}; + const requiredFields = removeEmptyRequiredFields(defineStepData.requiredFields ?? []); + const typeFields = isMlFields(ruleFields) ? { anomaly_threshold: ruleFields.anomalyThreshold, @@ -438,6 +447,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep language: ruleFields.queryBar?.query?.language, query: ruleFields.queryBar?.query?.query as string, saved_id: ruleFields.queryBar?.saved_id ?? undefined, + required_fields: requiredFields, ...(ruleType === 'threshold' && { threshold: { field: ruleFields.threshold?.field ?? [], @@ -465,6 +475,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep language: ruleFields.queryBar?.query?.language, query: ruleFields.queryBar?.query?.query as string, saved_id: ruleFields.queryBar?.saved_id ?? undefined, + required_fields: requiredFields, threat_index: ruleFields.threatIndex, threat_query: ruleFields.threatQueryBar?.query?.query as string, threat_filters: ruleFields.threatQueryBar?.filters, @@ -479,6 +490,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep language: ruleFields.queryBar?.query?.language, query: ruleFields.queryBar?.query?.query as string, saved_id: ruleFields.queryBar?.saved_id ?? undefined, + required_fields: requiredFields, timestamp_field: ruleFields.eqlOptions?.timestampField, event_category_override: ruleFields.eqlOptions?.eventCategoryField, tiebreaker_field: ruleFields.eqlOptions?.tiebreakerField, @@ -490,6 +502,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep filters: ruleFields.queryBar?.filters, language: ruleFields.queryBar?.query?.language, query: ruleFields.queryBar?.query?.query as string, + required_fields: requiredFields, new_terms_fields: ruleFields.newTermsFields, history_window_start: `now-${ruleFields.historyWindowSize}`, ...alertSuppressionFields, @@ -498,6 +511,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep ? { language: ruleFields.queryBar?.query?.language, query: ruleFields.queryBar?.query?.query as string, + required_fields: requiredFields, } : { ...alertSuppressionFields, @@ -506,6 +520,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep language: ruleFields.queryBar?.query?.language, query: ruleFields.queryBar?.query?.query as string, saved_id: undefined, + required_fields: requiredFields, type: 'query' as const, // rule only be updated as saved_query type if it has saved_id and shouldLoadQueryDynamically checkbox checked ...(['query', 'saved_query'].includes(ruleType) && diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx index d57198522b388..806ea9f336bd5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx @@ -210,11 +210,9 @@ const CreateRulePageComponent: React.FC = () => { const [isThreatQueryBarValid, setIsThreatQueryBarValid] = useState(false); const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep }); - const esqlIndex = useEsqlIndex( - defineStepData.queryBar.query.query, - ruleType, - defineStepForm.isValid - ); + + const esqlIndex = useEsqlIndex(defineStepData.queryBar.query.query, ruleType); + const memoizedIndex = useMemo( () => (isEsqlRuleValue ? esqlIndex : defineStepData.index), [defineStepData.index, esqlIndex, isEsqlRuleValue] diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx index 768b5a9903691..47b67c8ed720a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx @@ -150,13 +150,9 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { }); const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep }); - const esqlIndex = useEsqlIndex( - defineStepData.queryBar.query.query, - defineStepData.ruleType, - // allow to compute index from query only when query is valid or user switched to another tab - // to prevent multiple data view initiations with partly typed index names - defineStepForm.isValid || activeStep !== RuleStep.defineRule - ); + + const esqlIndex = useEsqlIndex(defineStepData.queryBar.query.query, defineStepData.ruleType); + const memoizedIndex = useMemo( () => (isEsqlRule(defineStepData.ruleType) ? esqlIndex : defineStepData.index), [defineStepData.index, esqlIndex, defineStepData.ruleType] diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/required_field_icon.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/required_field_icon.tsx new file mode 100644 index 0000000000000..0001bae25dd89 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/required_field_icon.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ES_FIELD_TYPES } from '@kbn/field-types'; +import { FieldIcon } from '@kbn/field-utils'; +import type { FieldIconProps } from '@kbn/field-utils'; + +function mapEsTypesToIconProps(type: string) { + switch (type) { + case ES_FIELD_TYPES._ID: + case ES_FIELD_TYPES._INDEX: + /* In Discover "_id" and "_index" have the "keyword" icon. Doing same here for consistency */ + return { type: 'keyword' }; + case ES_FIELD_TYPES.OBJECT: + return { type, iconType: 'tokenObject' }; + case ES_FIELD_TYPES.DATE_NANOS: + return { type: 'date' }; + case ES_FIELD_TYPES.FLOAT: + case ES_FIELD_TYPES.HALF_FLOAT: + case ES_FIELD_TYPES.SCALED_FLOAT: + case ES_FIELD_TYPES.DOUBLE: + case ES_FIELD_TYPES.INTEGER: + case ES_FIELD_TYPES.LONG: + case ES_FIELD_TYPES.SHORT: + case ES_FIELD_TYPES.UNSIGNED_LONG: + case ES_FIELD_TYPES.AGGREGATE_METRIC_DOUBLE: + case ES_FIELD_TYPES.FLOAT_RANGE: + case ES_FIELD_TYPES.DOUBLE_RANGE: + case ES_FIELD_TYPES.INTEGER_RANGE: + case ES_FIELD_TYPES.LONG_RANGE: + case ES_FIELD_TYPES.BYTE: + case ES_FIELD_TYPES.TOKEN_COUNT: + return { type: 'number' }; + default: + return { type }; + } +} + +interface RequiredFieldIconProps extends FieldIconProps { + type: string; + label?: string; + 'data-test-subj': string; +} + +/** + * `FieldIcon` component with addtional icons for types that are not handled by the `FieldIcon` component. + */ +export function RequiredFieldIcon({ + type, + label = type, + 'data-test-subj': dataTestSubj, + ...props +}: RequiredFieldIconProps) { + return ( + + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx index 912af1b0c4589..35f429f776fab 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx @@ -24,8 +24,6 @@ import type { Filter } from '@kbn/es-query'; import type { SavedQuery } from '@kbn/data-plugin/public'; import { mapAndFlattenFilters } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; -import { FieldIcon } from '@kbn/react-field'; -import { castEsToKbnFieldTypeName } from '@kbn/field-types'; import { FilterItems } from '@kbn/unified-search-plugin/public'; import type { AlertSuppressionMissingFieldsStrategy, @@ -53,6 +51,7 @@ import { BadgeList } from './badge_list'; import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants'; import * as i18n from './translations'; import { useAlertSuppression } from '../../logic/use_alert_suppression'; +import { RequiredFieldIcon } from './required_field_icon'; import { filtersStyles, queryStyles, @@ -254,17 +253,14 @@ interface RequiredFieldsProps { const RequiredFields = ({ requiredFields }: RequiredFieldsProps) => { const styles = useRequiredFieldsStyles(); + return ( {requiredFields.map((rF, index) => ( - + ({ dataViewId: undefined, queryBar: mockQueryBar, threatQueryBar: mockQueryBar, - requiredFields: [], + requiredFields: [{ name: 'host.name', type: 'keyword' }], relatedIntegrations: [ { package: 'aws', 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 4757c9f29dfdc..1bf915e1a122f 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 @@ -26,7 +26,6 @@ import type { FieldValueThreshold } from '../../../../detection_engine/rule_crea import type { BuildingBlockType, RelatedIntegrationArray, - RequiredFieldArray, RuleAuthorArray, RuleLicense, RuleNameOverride, @@ -38,6 +37,7 @@ import type { AlertSuppression, ThresholdAlertSuppression, RelatedIntegration, + RequiredFieldInput, } from '../../../../../common/api/detection_engine/model/rule_schema'; import type { SortOrder } from '../../../../../common/api/detection_engine'; import type { EqlOptionsSelected } from '../../../../../common/search_strategy'; @@ -147,7 +147,7 @@ export interface DefineStepRule { dataViewId?: string; dataViewTitle?: string; relatedIntegrations?: RelatedIntegrationArray; - requiredFields: RequiredFieldArray; + requiredFields?: RequiredFieldInput[]; ruleType: Type; timeline: FieldValueTimeline; threshold: FieldValueThreshold; @@ -226,6 +226,7 @@ export interface DefineStepRuleJson { tiebreaker_field?: string; alert_suppression?: AlertSuppression | ThresholdAlertSuppression; related_integrations?: RelatedIntegration[]; + required_fields?: RequiredFieldInput[]; } export interface AboutStepRuleJson { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/normalization/convert_rule_to_diffable.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/normalization/convert_rule_to_diffable.ts index 95a8687324d1c..c08edbc0c5cc5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/normalization/convert_rule_to_diffable.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/normalization/convert_rule_to_diffable.ts @@ -53,6 +53,7 @@ import { extractRuleNameOverrideObject } from './extract_rule_name_override_obje import { extractRuleSchedule } from './extract_rule_schedule'; import { extractTimelineTemplateReference } from './extract_timeline_template_reference'; import { extractTimestampOverrideObject } from './extract_timestamp_override_object'; +import { addEcsToRequiredFields } from '../../../../rule_management/utils/utils'; /** * Normalizes a given rule to the form which is suitable for passing to the diff algorithm. @@ -133,7 +134,7 @@ const extractDiffableCommonFields = ( note: rule.note ?? '', setup: rule.setup ?? '', related_integrations: rule.related_integrations ?? [], - required_fields: rule.required_fields ?? [], + required_fields: addEcsToRequiredFields(rule.required_fields), author: rule.author ?? [], license: rule.license ?? '', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts index 40a3d048fb518..38ead608a3b75 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts @@ -7,7 +7,6 @@ import * as z from 'zod'; import { - RequiredFieldArray, RuleSignatureId, RuleVersion, BaseCreateProps, @@ -33,6 +32,5 @@ export const PrebuiltRuleAsset = BaseCreateProps.and(TypeSpecificCreateProps).an z.object({ rule_id: RuleSignatureId, version: RuleVersion, - required_fields: RequiredFieldArray.optional(), }) ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts index d36f0ab4ad66e..ec790f9f6f71b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts @@ -14,6 +14,7 @@ import { transformRuleToAlertAction } from '../../../../../../common/detection_e import type { InternalRuleUpdate, RuleParams, RuleAlertType } from '../../../rule_schema'; import { transformToActionFrequency } from '../../normalization/rule_actions'; import { typeSpecificSnakeToCamel } from '../../normalization/rule_converters'; +import { addEcsToRequiredFields } from '../../utils/utils'; export interface UpdateRulesOptions { rulesClient: RulesClient; @@ -38,6 +39,7 @@ export const updateRules = async ({ const typeSpecificParams = typeSpecificSnakeToCamel(ruleUpdate); const enabled = ruleUpdate.enabled ?? true; + const newInternalRule: InternalRuleUpdate = { name: ruleUpdate.name, tags: ruleUpdate.tags ?? [], @@ -58,7 +60,7 @@ export const updateRules = async ({ meta: ruleUpdate.meta, maxSignals: ruleUpdate.max_signals ?? DEFAULT_MAX_SIGNALS, relatedIntegrations: ruleUpdate.related_integrations ?? [], - requiredFields: existingRule.params.requiredFields, + requiredFields: addEcsToRequiredFields(ruleUpdate.required_fields), riskScore: ruleUpdate.risk_score, riskScoreMapping: ruleUpdate.risk_score_mapping ?? [], ruleNameOverride: ruleUpdate.rule_name_override, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts index 50a1cecce88c8..355fa626f7848 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts @@ -22,7 +22,6 @@ import { import type { PatchRuleRequestBody } from '../../../../../common/api/detection_engine/rule_management'; import type { RelatedIntegrationArray, - RequiredFieldArray, RuleCreateProps, TypeSpecificCreateProps, TypeSpecificResponse, @@ -78,6 +77,7 @@ import type { } from '../../rule_schema'; import { transformFromAlertThrottle, transformToActionFrequency } from './rule_actions'; import { + addEcsToRequiredFields, convertAlertSuppressionToCamel, convertAlertSuppressionToSnake, migrateLegacyInvestigationFields, @@ -431,7 +431,6 @@ export const patchTypeSpecificSnakeToCamel = ( export const convertPatchAPIToInternalSchema = ( nextParams: PatchRuleRequestBody & { related_integrations?: RelatedIntegrationArray; - required_fields?: RequiredFieldArray; }, existingRule: SanitizedRule ): InternalRuleUpdate => { @@ -462,7 +461,7 @@ export const convertPatchAPIToInternalSchema = ( meta: nextParams.meta ?? existingParams.meta, maxSignals: nextParams.max_signals ?? existingParams.maxSignals, relatedIntegrations: nextParams.related_integrations ?? existingParams.relatedIntegrations, - requiredFields: nextParams.required_fields ?? existingParams.requiredFields, + requiredFields: addEcsToRequiredFields(nextParams.required_fields), riskScore: nextParams.risk_score ?? existingParams.riskScore, riskScoreMapping: nextParams.risk_score_mapping ?? existingParams.riskScoreMapping, ruleNameOverride: nextParams.rule_name_override ?? existingParams.ruleNameOverride, @@ -487,11 +486,9 @@ export const convertPatchAPIToInternalSchema = ( }; }; -// eslint-disable-next-line complexity export const convertCreateAPIToInternalSchema = ( input: RuleCreateProps & { related_integrations?: RelatedIntegrationArray; - required_fields?: RequiredFieldArray; }, immutable = false, defaultEnabled = true @@ -537,7 +534,7 @@ export const convertCreateAPIToInternalSchema = ( version: input.version ?? 1, exceptionsList: input.exceptions_list ?? [], relatedIntegrations: input.related_integrations ?? [], - requiredFields: input.required_fields ?? [], + requiredFields: addEcsToRequiredFields(input.required_fields), setup: input.setup ?? '', ...typeSpecificParams, }, @@ -776,6 +773,7 @@ export const convertPrebuiltRuleAssetToRuleResponse = ( return RuleResponse.parse({ ...prebuiltRuleAssetDefaults, ...prebuiltRuleAsset, + required_fields: addEcsToRequiredFields(prebuiltRuleAsset.required_fields), ...ruleResponseSpecificFields, }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts index bda8cd7a688ca..66fa635e768ad 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts @@ -9,6 +9,8 @@ import { partition } from 'lodash/fp'; import pMap from 'p-map'; import { v4 as uuidv4 } from 'uuid'; +import { ecsFieldMap } from '@kbn/alerts-as-data-utils'; + import type { ActionsClient, FindActionResult } from '@kbn/actions-plugin/server'; import type { FindResult, PartialRule } from '@kbn/alerting-plugin/server'; import type { SavedObjectsClientContract } from '@kbn/core/server'; @@ -18,6 +20,8 @@ import type { AlertSuppression, AlertSuppressionCamel, InvestigationFields, + RequiredField, + RequiredFieldInput, RuleResponse, } from '../../../../../common/api/detection_engine/model/rule_schema'; import type { @@ -388,3 +392,19 @@ export const migrateLegacyInvestigationFields = ( return investigationFields; }; + +/* + Computes the boolean "ecs" property value for each required field based on the ECS field map. + "ecs" property indicates whether the required field is an ECS field or not. +*/ +export const addEcsToRequiredFields = (requiredFields?: RequiredFieldInput[]): RequiredField[] => + (requiredFields ?? []).map((requiredFieldWithoutEcs) => { + const isEcsField = Boolean( + ecsFieldMap[requiredFieldWithoutEcs.name]?.type === requiredFieldWithoutEcs.type + ); + + return { + ...requiredFieldWithoutEcs, + ecs: isEcsField, + }; + }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/strip_non_ecs_fields.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/strip_non_ecs_fields.ts index 86a3dc4ff6c1f..08e5fa5fd879d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/strip_non_ecs_fields.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/strip_non_ecs_fields.ts @@ -67,9 +67,9 @@ const getIsEcsFieldObject = (path: string) => { /** * checks if path is in Ecs mapping */ -const getIsEcsField = (path: string) => { +export const getIsEcsField = (path: string): boolean => { const ecsField = ecsFieldMap[path as keyof typeof ecsFieldMap]; - const isEcsField = !!ecsField || ecsObjectFields[path]; + const isEcsField = Boolean(!!ecsField || ecsObjectFields[path]); return isEcsField; }; diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index b5778dbf20e39..bb26581356fa1 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -200,6 +200,7 @@ "@kbn/security-plugin-types-server", "@kbn/deeplinks-security", "@kbn/react-kibana-context-render", - "@kbn/search-types" + "@kbn/search-types", + "@kbn/field-utils" ] } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts index f728b011b6801..22ac52dc2a333 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts @@ -142,6 +142,10 @@ export default ({ getService }: FtrProviderContext): void => { ], max_signals: 100, setup: '# some setup markdown', + required_fields: [ + { name: '@timestamp', type: 'date' }, + { name: 'my-non-ecs-field', type: 'keyword' }, + ], }; const mockRule = getCustomQueryRuleParams(defaultableFields); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules.ts index 63b1a4dfecdc7..925961c5a3720 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules.ts @@ -9,8 +9,8 @@ import expect from 'expect'; import { RuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { - getSimpleRule, getCustomQueryRuleParams, + getSimpleRule, getSimpleRuleOutputWithoutRuleId, getSimpleRuleWithoutRuleId, removeServerGeneratedProperties, @@ -70,7 +70,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('should create a rule with defaultable fields', async () => { - const expectedRule = getCustomQueryRuleParams({ + const ruleCreateProperties = getCustomQueryRuleParams({ rule_id: 'rule-1', max_signals: 200, setup: '# some setup markdown', @@ -78,10 +78,22 @@ export default ({ getService }: FtrProviderContext) => { { package: 'package-a', version: '^1.2.3' }, { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], + required_fields: [ + { name: '@timestamp', type: 'date' }, + { name: 'my-non-ecs-field', type: 'keyword' }, + ], }); + const expectedRule = { + ...ruleCreateProperties, + required_fields: [ + { name: '@timestamp', type: 'date', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ], + }; + const { body: createdRuleResponse } = await securitySolutionApi - .createRule({ body: expectedRule }) + .createRule({ body: ruleCreateProperties }) .expect(200); expect(createdRuleResponse).toMatchObject(expectedRule); @@ -207,6 +219,32 @@ export default ({ getService }: FtrProviderContext) => { ); }); }); + + describe('required_fields', () => { + it('creates a rule with required_fields defaulted to an empty array when not present', async () => { + const customQueryRuleParams = getCustomQueryRuleParams({ + rule_id: 'rule-without-required-fields', + }); + + expect(customQueryRuleParams.required_fields).toBeUndefined(); + + const { body } = await securitySolutionApi + .createRule({ + body: customQueryRuleParams, + }) + .expect(200); + + expect(body.required_fields).toEqual([]); + + const { body: createdRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-without-required-fields' }, + }) + .expect(200); + + expect(createdRule.required_fields).toEqual([]); + }); + }); }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules_bulk.ts index dc02f8450f411..4356c8b82b8b4 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules_bulk.ts @@ -69,7 +69,7 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should create a rule with defaultable fields', async () => { - const expectedRule = getCustomQueryRuleParams({ + const ruleCreateProperties = getCustomQueryRuleParams({ rule_id: 'rule-1', max_signals: 200, setup: '# some setup markdown', @@ -77,10 +77,22 @@ export default ({ getService }: FtrProviderContext): void => { { package: 'package-a', version: '^1.2.3' }, { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], + required_fields: [ + { name: '@timestamp', type: 'date' }, + { name: 'my-non-ecs-field', type: 'keyword' }, + ], }); + const expectedRule = { + ...ruleCreateProperties, + required_fields: [ + { name: '@timestamp', type: 'date', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ], + }; + const { body: createdRulesBulkResponse } = await securitySolutionApi - .bulkCreateRules({ body: [expectedRule] }) + .bulkCreateRules({ body: [ruleCreateProperties] }) .expect(200); expect(createdRulesBulkResponse[0]).toMatchObject(expectedRule); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/export_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/export_rules.ts index 5986e4d40fe3a..783d0bb42fd87 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/export_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/export_rules.ts @@ -57,9 +57,22 @@ export default ({ getService }: FtrProviderContext): void => { { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], setup: '# some setup markdown', + required_fields: [ + { name: '@timestamp', type: 'date' }, + { name: 'my-non-ecs-field', type: 'keyword' }, + ], }; + const ruleToExport = getCustomQueryRuleParams(defaultableFields); + const expectedRule = { + ...ruleToExport, + required_fields: [ + { name: '@timestamp', type: 'date', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ], + }; + await securitySolutionApi.createRule({ body: ruleToExport }); const { body } = await securitySolutionApi @@ -69,7 +82,7 @@ export default ({ getService }: FtrProviderContext): void => { const exportedRule = JSON.parse(body.toString().split(/\n/)[0]); - expect(exportedRule).toMatchObject(defaultableFields); + expect(exportedRule).toMatchObject(expectedRule); }); it('should have export summary reflecting a number of rules', async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/import_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/import_rules.ts index 71f40086a29f6..fb02b47067f8e 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/import_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/import_rules.ts @@ -125,11 +125,25 @@ export default ({ getService }: FtrProviderContext): void => { { package: 'package-a', version: '^1.2.3' }, { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], + required_fields: [ + { name: '@timestamp', type: 'date' }, + { name: 'my-non-ecs-field', type: 'keyword' }, + ], }; + const ruleToImport = getCustomQueryRuleParams({ ...defaultableFields, rule_id: 'rule-1', }); + + const expectedRule = { + ...ruleToImport, + required_fields: [ + { name: '@timestamp', type: 'date', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ], + }; + const ndjson = combineToNdJson(ruleToImport); await securitySolutionApi @@ -143,7 +157,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(importedRule).toMatchObject(ruleToImport); + expect(importedRule).toMatchObject(expectedRule); }); it('should be able to import two rules', async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts index bdbbc271c26e5..a2658ed2fb285 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts @@ -61,7 +61,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('should patch defaultable fields', async () => { - const expectedRule = getCustomQueryRuleParams({ + const rulePatchProperties = getCustomQueryRuleParams({ rule_id: 'rule-1', max_signals: 200, setup: '# some setup markdown', @@ -69,8 +69,14 @@ export default ({ getService }: FtrProviderContext) => { { package: 'package-a', version: '^1.2.3' }, { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], + required_fields: [{ name: '@timestamp', type: 'date' }], }); + const expectedRule = { + ...rulePatchProperties, + required_fields: [{ name: '@timestamp', type: 'date', ecs: true }], + }; + await securitySolutionApi.createRule({ body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), }); @@ -78,10 +84,7 @@ export default ({ getService }: FtrProviderContext) => { const { body: patchedRuleResponse } = await securitySolutionApi .patchRule({ body: { - rule_id: 'rule-1', - max_signals: expectedRule.max_signals, - setup: expectedRule.setup, - related_integrations: expectedRule.related_integrations, + ...rulePatchProperties, }, }) .expect(200); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts index 539c39061aa5f..a04245eac5517 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts @@ -60,7 +60,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('should patch defaultable fields', async () => { - const expectedRule = getCustomQueryRuleParams({ + const rulePatchProperties = getCustomQueryRuleParams({ rule_id: 'rule-1', max_signals: 200, setup: '# some setup markdown', @@ -68,8 +68,14 @@ export default ({ getService }: FtrProviderContext) => { { package: 'package-a', version: '^1.2.3' }, { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], + required_fields: [{ name: '@timestamp', type: 'date' }], }); + const expectedRule = { + ...rulePatchProperties, + required_fields: [{ name: '@timestamp', type: 'date', ecs: true }], + }; + await securitySolutionApi.createRule({ body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), }); @@ -78,10 +84,7 @@ export default ({ getService }: FtrProviderContext) => { .bulkPatchRules({ body: [ { - rule_id: 'rule-1', - max_signals: expectedRule.max_signals, - setup: expectedRule.setup, - related_integrations: expectedRule.related_integrations, + ...rulePatchProperties, }, ], }) diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts index ccf598a00da2e..33505f9d150d6 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts @@ -66,7 +66,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('should update a rule with defaultable fields', async () => { - const expectedRule = getCustomQueryRuleParams({ + const ruleUpdateProperties = getCustomQueryRuleParams({ rule_id: 'rule-1', max_signals: 200, setup: '# some setup markdown', @@ -74,15 +74,21 @@ export default ({ getService }: FtrProviderContext) => { { package: 'package-a', version: '^1.2.3' }, { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], + required_fields: [{ name: '@timestamp', type: 'date' }], }); + const expectedRule = { + ...ruleUpdateProperties, + required_fields: [{ name: '@timestamp', type: 'date', ecs: true }], + }; + await securitySolutionApi.createRule({ body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), }); const { body: updatedRuleResponse } = await securitySolutionApi .updateRule({ - body: expectedRule, + body: ruleUpdateProperties, }) .expect(200); @@ -273,6 +279,33 @@ export default ({ getService }: FtrProviderContext) => { ); }); }); + + describe('required_fields', () => { + it('should reset required fields field to default value on update when not present', async () => { + const expectedRule = getCustomQueryRuleParams({ + rule_id: 'required-fields-default-value-test', + required_fields: [], + }); + + await securitySolutionApi.createRule({ + body: getCustomQueryRuleParams({ + rule_id: 'required-fields-default-value-test', + required_fields: [{ name: 'host.name', type: 'keyword' }], + }), + }); + + const { body: updatedRuleResponse } = await securitySolutionApi + .updateRule({ + body: getCustomQueryRuleParams({ + rule_id: 'required-fields-default-value-test', + required_fields: undefined, + }), + }) + .expect(200); + + expect(updatedRuleResponse).toMatchObject(expectedRule); + }); + }); }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts index fc7c7229ef107..effc64a241cc5 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts @@ -65,7 +65,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('should update a rule with defaultable fields', async () => { - const expectedRule = getCustomQueryRuleParams({ + const ruleUpdateProperties = getCustomQueryRuleParams({ rule_id: 'rule-1', max_signals: 200, setup: '# some setup markdown', @@ -73,15 +73,21 @@ export default ({ getService }: FtrProviderContext) => { { package: 'package-a', version: '^1.2.3' }, { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], + required_fields: [{ name: '@timestamp', type: 'date' }], }); + const expectedRule = { + ...ruleUpdateProperties, + required_fields: [{ name: '@timestamp', type: 'date', ecs: true }], + }; + await securitySolutionApi.createRule({ body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), }); const { body: updatedRulesBulkResponse } = await securitySolutionApi .bulkUpdateRules({ - body: [expectedRule], + body: [ruleUpdateProperties], }) .expect(200); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts index 6bf3ea611dc7f..abf8bc3934e21 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts @@ -33,6 +33,8 @@ import { fillMaxSignals, fillNote, fillReferenceUrls, + // fillRelatedIntegrations, + // fillRequiredFields, fillRiskScore, fillRuleName, fillRuleTags, @@ -67,9 +69,13 @@ describe('Common rule creation flows', { tags: ['@ess', '@serverless'] }, () => it('Creates and enables a rule', function () { cy.log('Filling define section'); importSavedQuery(this.timelineId); - // The following step is flaky due to a recent EUI upgrade. - // Underlying EUI issue: https://github.com/elastic/eui/issues/7761 - // Issue to uncomment this once EUI fix is in place: https://github.com/elastic/kibana/issues/183485 + /* + The following steps are flaky due to a recent EUI upgrade. + + Underlying EUI issue: https://github.com/elastic/eui/issues/7761 + Issue to uncomment these once the EUI fix is in place: https://github.com/elastic/kibana/issues/183485 + */ + // fillRequiredFields(); // fillRelatedIntegrations(); cy.get(DEFINE_CONTINUE_BUTTON).click(); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/prebuilt_rules_preview.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/prebuilt_rules_preview.cy.ts index 84198ccd702dd..15229445e54f0 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/prebuilt_rules_preview.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/prebuilt_rules_preview.cy.ts @@ -137,8 +137,8 @@ describe('Detection rules, Prebuilt Rules Installation and Update workflow', () { package: 'windows', version: '^1.5.0' }, ], required_fields: [ - { ecs: true, name: 'event.type', type: 'keyword' }, - { ecs: true, name: 'file.extension', type: 'keyword' }, + { name: 'event.type', type: 'keyword' }, + { name: 'file.extension', type: 'keyword' }, ], timeline_id: '3e827bab-838a-469f-bd1e-5e19a2bff2fd', timeline_title: 'Alerts Involving a Single User Timeline', diff --git a/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts b/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts index d8feace6af1d0..b0af7275ae808 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts @@ -130,6 +130,9 @@ export const IMPORT_QUERY_FROM_SAVED_TIMELINE_LINK = export const RELATED_INTEGRATION_COMBO_BOX_INPUT = '[data-test-subj="relatedIntegrationComboBox"] [data-test-subj="comboBoxSearchInput"]'; +export const REQUIRED_FIELD_COMBO_BOX_INPUT = + '[data-test-subj^="requiredFieldNameSelect"] [data-test-subj="comboBoxSearchInput"]'; + export const INDICATOR_MATCH_TYPE = '[data-test-subj="threatMatchRuleType"]'; export const INPUT = '[data-test-subj="input"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts index 7ee1811760480..a71d990ec31a9 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts @@ -82,6 +82,7 @@ import { MITRE_TACTIC, QUERY_BAR, REFERENCE_URLS_INPUT, + REQUIRED_FIELD_COMBO_BOX_INPUT, RISK_MAPPING_OVERRIDE_OPTION, RISK_OVERRIDE, RULE_DESCRIPTION_INPUT, @@ -480,6 +481,18 @@ export const fillScheduleRuleAndContinue = (rule: RuleCreateProps) => { cy.get(SCHEDULE_CONTINUE_BUTTON).click({ force: true }); }; +export const fillRequiredFields = (): void => { + addRequiredField(); + addRequiredField(); +}; + +const addRequiredField = (): void => { + cy.contains('button', 'Add required field').should('be.enabled').click(); + + cy.get(REQUIRED_FIELD_COMBO_BOX_INPUT).last().should('be.enabled').click(); + cy.get(COMBO_BOX_OPTION).first().click(); +}; + /** * use default schedule options */