From 6eeffd3cfba1dc129ad344f53c5997149870eaea Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Fri, 17 May 2024 13:37:05 +0200 Subject: [PATCH] [Security Solution] Allow users to edit required_fields field for custom rules (#180682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Resolves: https://github.com/elastic/kibana/issues/173594** **Flaky test runner:** https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5915 ## Summary This PR adds an ability to add and edit custom rule's required fields. "Required fields" is an optional field that shows the user which Elasticsearch fields are needed for the rule to run properly. The values in "required fields" don't affect rule execution in any way. It's purely documentational, similar to "setup guide" and "investigation guide". This functionality is added to both rule creation and rule editing screens. It's available for all rule types except ML. Scherm­afbeelding 2024-05-07 om 12 28 50 ## Details The basic flow goes like this: first you specify your index patterns (or a data view), then you can set required fields for these index patterns. Once a user adds a required field and chooses its name, he can then choose its type from the dropdown. The first available type for the field name selected automatically. User can also add their own custom names and types. ### Warnings If a field that is not present in the selected index pattern, you will see a warning message. This can happen in the following cases: - You have specified an index pattern, selected a required field from this index pattern, and then removed this index pattern. - The index doesn't yet exist. For example, you have installed a prebuilt rule but the data for it hasn't been ingested yet, so there's no index yet. - The index was removed. - The mappings for the index were changed and the field is no longer present. In any of these cases, you'll see a general warning message above the form section. And then also a more specific warning message next to the field that is causing the issue. ### ESQL and ML rules Here's how available dropdown options are determined for different rule types: For all rule types except ESQL and ML, we take the index patterns specified by the user and fetch their mappings. Then we use these fields and types to populate the dropdowns. For ESQL rules we parse index patterns out of the query since there's no explicit index pattern form field. We then fetch the mappings for these index patterns and use them to populate the dropdowns. For ML rules, we don't show "required fields" at all. ML rules are a special case. 1. The concept of "required fields" is sort of handled during creation of the ML job itself, where the user specifies the fields that are required for the job to run. 2. In the ML rule creation/editing interface, we don't display the index patterns a rule operates on. So, even if we allowed specifying required fields, the user would need to check the ML job details to see the index patterns the job uses. 3. The ML job dropdown includes both existing and not-yet-created jobs. We can't get index patterns for jobs that don't exist yet, so we can't fill the dropdowns with fields and types. ## Screenshots screen1_ screen2_ --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + .../rule_schema/common_attributes.gen.ts | 27 + .../rule_schema/common_attributes.schema.yaml | 18 + .../model/rule_schema/rule_schemas.gen.ts | 2 + .../rule_schema/rule_schemas.schema.yaml | 10 + .../import_rules/rule_to_import.ts | 9 + .../related_integration_field.tsx | 15 +- .../related_integrations.test.tsx | 83 +- .../related_integrations_help_info.tsx | 2 +- .../components/required_fields/index.ts | 8 + .../make_validate_required_field.ts | 53 ++ .../required_fields/name_combobox.tsx | 126 +++ .../required_fields/required_fields.test.tsx | 724 ++++++++++++++++++ .../required_fields/required_fields.tsx | 213 ++++++ .../required_fields_help_info.tsx | 48 ++ .../required_fields/required_fields_row.tsx | 163 ++++ .../required_fields/translations.ts | 105 +++ .../required_fields/type_combobox.tsx | 151 ++++ .../components/required_fields/utils.test.tsx | 58 ++ .../components/required_fields/utils.ts | 28 + .../components/description_step/helpers.tsx | 11 +- .../step_define_rule/index.test.tsx | 187 ++++- .../components/step_define_rule/index.tsx | 17 +- .../components/step_define_rule/schema.tsx | 6 + .../hooks/use_esql_index.test.ts | 27 +- .../rule_creation_ui/hooks/use_esql_index.ts | 43 +- .../pages/rule_creation/helpers.test.ts | 16 + .../pages/rule_creation/helpers.ts | 15 + .../pages/rule_creation/index.tsx | 8 +- .../pages/rule_editing/index.tsx | 10 +- .../rule_details/required_field_icon.tsx | 67 ++ .../rule_details/rule_definition_section.tsx | 10 +- .../components/rules_table/__mocks__/mock.ts | 2 +- .../pages/detection_engine/rules/types.ts | 5 +- .../normalization/convert_rule_to_diffable.ts | 3 +- .../model/rule_assets/prebuilt_rule_asset.ts | 2 - .../logic/crud/update_rules.ts | 4 +- .../normalization/rule_converters.ts | 10 +- .../rule_management/utils/utils.ts | 20 + .../factories/utils/strip_non_ecs_fields.ts | 4 +- .../plugins/security_solution/tsconfig.json | 3 +- .../perform_bulk_action.ts | 4 + .../create_rules.ts | 44 +- .../create_rules_bulk.ts | 16 +- .../export_rules.ts | 15 +- .../import_rules.ts | 16 +- .../patch_rules.ts | 13 +- .../patch_rules_bulk.ts | 13 +- .../update_rules.ts | 37 +- .../update_rules_bulk.ts | 10 +- .../rule_creation/common_flows.cy.ts | 12 +- .../prebuilt_rules_preview.cy.ts | 4 +- .../cypress/screens/create_new_rule.ts | 3 + .../cypress/tasks/create_new_rule.ts | 13 + 55 files changed, 2305 insertions(+), 210 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/index.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/make_validate_required_field.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/name_combobox.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_help_info.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_row.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/type_combobox.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/required_field_icon.tsx 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 */