From da17d7f7b42f5718baf0111cc4128e259d7868bb Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 5 Mar 2024 16:36:03 +0100 Subject: [PATCH 01/66] Add support for "required_fields" to create and edit rule endpoints --- .../api/detection_engine/model/rule_schema/rule_schemas.gen.ts | 1 + .../detection_engine/rule_management/logic/crud/update_rules.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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..634aac4a91d3d 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 @@ -116,6 +116,7 @@ export const BaseOptionalFields = z.object({ meta: RuleMetadata.optional(), investigation_fields: InvestigationFields.optional(), throttle: RuleActionThrottle.optional(), + required_fields: RequiredFieldArray.optional(), }); export type BaseDefaultableFields = z.infer; 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..db808796cc3f3 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 @@ -58,7 +58,7 @@ export const updateRules = async ({ meta: ruleUpdate.meta, maxSignals: ruleUpdate.max_signals ?? DEFAULT_MAX_SIGNALS, relatedIntegrations: ruleUpdate.related_integrations ?? [], - requiredFields: existingRule.params.requiredFields, + requiredFields: ruleUpdate.required_fields ?? [], riskScore: ruleUpdate.risk_score, riskScoreMapping: ruleUpdate.risk_score_mapping ?? [], ruleNameOverride: ruleUpdate.rule_name_override, From ce45a3ad770bb42f77183a642d33c379c5804d00 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Fri, 12 Apr 2024 13:01:09 +0200 Subject: [PATCH 02/66] WIP --- .../rule_schema/common_attributes.gen.ts | 6 + .../rule_schema/common_attributes.schema.yaml | 11 + .../model/rule_schema/rule_schemas.gen.ts | 3 +- .../rule_schema/rule_schemas.schema.yaml | 5 + .../components/step_define_rule/index.tsx | 316 +++++++++++++++++- .../pages/rule_creation/helpers.ts | 5 + .../logic/crud/update_rules.ts | 17 +- .../normalization/rule_converters.ts | 16 +- .../factories/utils/strip_non_ecs_fields.ts | 4 +- 9 files changed, 375 insertions(+), 8 deletions(-) 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..22a4f9db1a0a9 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 @@ -310,6 +310,12 @@ export const RequiredField = z.object({ ecs: z.boolean(), }); +export type RequiredFieldInput = z.infer; +export const RequiredFieldInput = z.object({ + name: NonEmptyString, + 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..b952e55ec171b 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 @@ -327,6 +327,17 @@ components: - type - ecs + RequiredFieldInput: + type: object + properties: + name: + $ref: '../../../model/primitives.schema.yaml#/components/schemas/NonEmptyString' + type: + $ref: '../../../model/primitives.schema.yaml#/components/schemas/NonEmptyString' + 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 634aac4a91d3d..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, @@ -116,7 +117,6 @@ export const BaseOptionalFields = z.object({ meta: RuleMetadata.optional(), investigation_fields: InvestigationFields.optional(), throttle: RuleActionThrottle.optional(), - required_fields: RequiredFieldArray.optional(), }); export type BaseDefaultableFields = z.infer; @@ -138,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..9d1064b0bf127 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 @@ -133,6 +133,11 @@ components: related_integrations: $ref: './common_attributes.schema.yaml#/components/schemas/RelatedIntegrationArray' + 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/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..94cd0b1e071e3 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 @@ -5,23 +5,34 @@ * 2.0. */ -import type { EuiButtonGroupOptionProps } from '@elastic/eui'; +import type { + EuiButtonGroupOptionProps, + EuiComboBoxOptionOption, + EuiSelectOption, +} from '@elastic/eui'; import { EuiButtonEmpty, + EuiButtonIcon, + EuiCallOut, + EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiIcon, EuiLoadingSpinner, + EuiSelect, EuiSpacer, EuiButtonGroup, EuiText, EuiRadioGroup, + EuiTextColor, EuiToolTip, } from '@elastic/eui'; import type { FC } from 'react'; import React, { memo, useCallback, useState, useEffect, useMemo, useRef } from 'react'; import styled from 'styled-components'; +import { euiThemeVars } from '@kbn/ui-theme'; import { i18n as i18nCore } from '@kbn/i18n'; import { isEqual, isEmpty } from 'lodash'; import type { FieldSpec } from '@kbn/data-views-plugin/common'; @@ -68,7 +79,7 @@ import { useFormData, UseMultiFields, } from '../../../../shared_imports'; -import type { FormHook } from '../../../../shared_imports'; +import type { FieldHook, FormHook } from '../../../../shared_imports'; import { schema } from './schema'; import { getTermsAggregationFields } from './utils'; import { useExperimentalFeatureFieldsTransform } from './use_experimental_feature_fields_transform'; @@ -95,12 +106,15 @@ import { defaultCustomQuery } from '../../../../detections/pages/detection_engin import { MultiSelectFieldsAutocomplete } from '../multi_select_fields'; import { useLicense } from '../../../../common/hooks/use_license'; import { AlertSuppressionMissingFieldsStrategyEnum } from '../../../../../common/api/detection_engine/model/rule_schema'; +import type { RequiredField } from '../../../../../common/api/detection_engine/model/rule_schema'; import { DurationInput } from '../duration_input'; import { MINIMUM_LICENSE_FOR_SUPPRESSION } from '../../../../../common/detection_engine/constants'; import { useUpsellingMessage } from '../../../../common/hooks/use_upselling'; import { useAlertSuppression } from '../../../rule_management/logic/use_alert_suppression'; import { RelatedIntegrations } from '../../../rule_creation/components/related_integrations'; +// import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs'; + const CommonUseField = getUseField({ component: Field }); const StyledVisibleContainer = styled.div<{ isVisible: boolean }>` @@ -1116,6 +1130,20 @@ const StepDefineRuleComponent: FC = ({ + + + + + + (browserFields: T[]): T[] { return browserFields.filter((field) => field.aggregatable === true); } + +function applyChangeToFieldValue( + selectedOptions: Array>, + index: number, + field: FieldHook, + typesByFieldName: Record +): void { + const newlySelectedOption = selectedOptions[0]; + + const fieldValue = field.value as Array & { ecs?: boolean }>; + + const newFieldValue: RequiredField[] = fieldValue.map((requiredField, requiredFieldIndex) => { + if (index === requiredFieldIndex) { + const availableFieldTypes = typesByFieldName[newlySelectedOption.label]; + + return { + name: newlySelectedOption.label, + // Preserve previous type if possible + type: availableFieldTypes.includes(requiredField.type) + ? requiredField.type + : availableFieldTypes[0], + }; + } + + return requiredField; + }); + + field.setValue(newFieldValue); +} + +function updateRequiredFieldType(newType: string, index: number, field: FieldHook): void { + const fieldValue = field.value as RequiredField[]; + + const newFieldValue: RequiredField[] = fieldValue.map((requiredField, requiredFieldIndex) => { + if (index === requiredFieldIndex) { + return { + ...requiredField, + type: newType, + }; + } + + return requiredField; + }); + + field.setValue(newFieldValue); +} + +function deleteRequiredField(index: number, field: FieldHook): void { + const fieldValue = field.value as RequiredField[]; + const newFieldValue = fieldValue.filter((_, requiredFieldIndex) => index !== requiredFieldIndex); + field.setValue(newFieldValue); +} + +function addRequiredField( + nameOptions: Array>, + typesByFieldName: Record, + field: FieldHook +): void { + // const newOptionFieldName = nameOptions[0].label; + // const newOptionType = typesByFieldName[newOptionFieldName][0]; + // const newOption = { ecs: true, name: newOptionFieldName, type: newOptionType }; + + const newOption = { name: '', type: '' }; + + const fieldValue = field.value as RequiredField[]; + const newFieldValue = [...fieldValue, newOption]; + field.setValue(newFieldValue); +} + +interface RequiredFieldRowProps { + requiredField: RequiredField; + requiredFieldIndex: number; + typesByFieldName: Record; + field: FieldHook; + allFieldNames: string[]; + availableFieldNames: string[]; + nameWarning?: string; + typeWarning?: string; +} + +const RequiredFieldRow = ({ + requiredField, + requiredFieldIndex, + availableFieldNames, + typesByFieldName, + field, + allFieldNames, + nameWarning, + typeWarning, +}: RequiredFieldRowProps) => { + const isFieldNameFoundInIndexPatterns = allFieldNames.includes(requiredField.name); + + // /* Show a warning if selected field name is not found in the index pattern */ + // let nameWarning = ''; + // if (requiredField.name && !isFieldNameFoundInIndexPatterns) { + // nameWarning = `Field "${requiredField.name}" is not found within specified index patterns`; + // } + + // Do not not add empty option to the list of selectable field names + const selectableNameOptions = (requiredField.name ? [requiredField.name] : []) + .concat(availableFieldNames) + .map((name) => ({ + label: name, + })); + + const selectedNameOption = selectableNameOptions.find( + (option) => option.label === requiredField.name + ) || { label: '' }; + + // let typeWarning = ''; + + const typesAvailableForSelectedName = typesByFieldName[requiredField.name]; + + let selectableTypeOptions: EuiSelectOption[] = []; + if (typesAvailableForSelectedName) { + const isSelectedTypeAvailable = typesAvailableForSelectedName.includes(requiredField.type); + + selectableTypeOptions = typesAvailableForSelectedName.map((type) => ({ + text: type, + })); + + if (!isSelectedTypeAvailable) { + // case: field name exists, but such type is not among the list of field types + selectableTypeOptions.push({ text: requiredField.type }); + // typeWarning = `Field "${selectedNameOption.label}" with type "${requiredField.type}" is not found within specified index patterns`; + } + } else { + // case: no such field name in index patterns + selectableTypeOptions = [ + { + text: requiredField.type, + }, + ]; + } + + const warningText = nameWarning || typeWarning; + + return ( + {warningText} : ''} + color="warning" + > + + + + applyChangeToFieldValue(selectedOptions, requiredFieldIndex, field, typesByFieldName) + } + isClearable={false} + prepend={ + nameWarning ? ( + + ) : undefined + } + /> + + + { + updateRequiredFieldType(event.target.value, requiredFieldIndex, field); + }} + prepend={ + typeWarning ? ( + + ) : undefined + } + /> + + + deleteRequiredField(requiredFieldIndex, field)} + /> + + + + ); +}; + +const RequiredFields = ({ + field, + indexPatternFields, +}: { + field: FieldHook; + indexPatternFields: BrowserField[]; +}) => { + // const { loading, jobs } = useSecurityJobs(); + // console.log('[dbg] jobs', jobs); + + const fieldValue = field.value as RequiredField[]; + + const selectedFieldNames = fieldValue.map(({ name }) => name); + + const fieldsWithTypes = indexPatternFields.filter( + (indexPatternField) => indexPatternField.esTypes && indexPatternField.esTypes.length > 0 + ); + + const allFieldNames = fieldsWithTypes.map(({ name }) => name); + const availableFieldNames = allFieldNames.filter((name) => !selectedFieldNames.includes(name)); + + const availableNameOptions: Array> = availableFieldNames.map( + (availableFieldName) => ({ + label: availableFieldName, + }) + ); + + const typesByFieldName: Record = fieldsWithTypes.reduce( + (accumulator, browserField) => { + if (browserField.esTypes) { + accumulator[browserField.name] = browserField.esTypes; + } + return accumulator; + }, + {} as Record + ); + + const isEmptyRowDisplayed = !!fieldValue.find(({ name }) => name === ''); + + const nameWarnings = fieldValue.reduce>((warnings, { name }) => { + if (name !== '' && !allFieldNames.includes(name)) { + warnings[name] = `Field "${name}" is not found within specified index patterns`; + } + return warnings; + }, {}); + + const typeWarnings = fieldValue.reduce>((warnings, { name, type }) => { + if (name !== '' && !typesByFieldName[name]?.includes(type)) { + warnings[ + name + ] = `Field "${name}" with type "${type}" is not found within specified index patterns`; + } + return warnings; + }, {}); + + const hasWarnings = Object.keys(nameWarnings).length > 0 || Object.keys(typeWarnings).length > 0; + + return ( + + <> + {hasWarnings && ( + +

{`This doesn't break rule execution, but it might indicate that required fields were set incorrectly. Please check that indices specified in index patterns exist and have expected fields and types in mappings.`}

+
+ )} + + {fieldValue.map((requiredField, requiredFieldIndex) => ( + + ))} + { + addRequiredField(availableNameOptions, typesByFieldName, field); + }} + isDisabled={availableNameOptions.length === 0 || isEmptyRowDisplayed} + > + {'Add required field'} + + +
+ ); +}; 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..b380063cef8ab 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, + RequiredField, } 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,9 @@ export const getStepDataDataSource = ( return copiedStepData; }; +const removeEmptyRequiredFieldsValues = (requiredFields: RequiredField[]) => + requiredFields.filter((requiredField) => requiredField.name !== ''); + export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { const stepData = getStepDataDataSource(defineStepData); @@ -404,6 +408,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep const baseFields = { type: ruleType, related_integrations: defineStepData.relatedIntegrations?.filter((ri) => !isEmpty(ri.package)), + required_fields: removeEmptyRequiredFieldsValues(defineStepData.requiredFields), ...(timeline.id != null && timeline.title != null && { timeline_id: timeline.id, 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 db808796cc3f3..26efb944283c7 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 @@ -6,6 +6,7 @@ */ /* eslint-disable complexity */ +import { ecsFieldMap } from '@kbn/alerts-as-data-utils'; import type { PartialRule, RulesClient } from '@kbn/alerting-plugin/server'; import { DEFAULT_MAX_SIGNALS } from '../../../../../../common/constants'; import type { RuleUpdateProps } from '../../../../../../common/api/detection_engine/model/rule_schema'; @@ -32,12 +33,26 @@ export const updateRules = async ({ return null; } + const requiredFieldsWithEcs = (ruleUpdate.required_fields ?? []).map( + (requiredFieldWithoutEcs) => { + const isEcsField = Boolean( + ecsFieldMap[requiredFieldWithoutEcs.name]?.type === requiredFieldWithoutEcs.type + ); + + return { + ...requiredFieldWithoutEcs, + ecs: isEcsField, + }; + } + ); + const alertActions = ruleUpdate.actions?.map((action) => transformRuleToAlertAction(action)) ?? []; const actions = transformToActionFrequency(alertActions, ruleUpdate.throttle); const typeSpecificParams = typeSpecificSnakeToCamel(ruleUpdate); const enabled = ruleUpdate.enabled ?? true; + const newInternalRule: InternalRuleUpdate = { name: ruleUpdate.name, tags: ruleUpdate.tags ?? [], @@ -58,7 +73,7 @@ export const updateRules = async ({ meta: ruleUpdate.meta, maxSignals: ruleUpdate.max_signals ?? DEFAULT_MAX_SIGNALS, relatedIntegrations: ruleUpdate.related_integrations ?? [], - requiredFields: ruleUpdate.required_fields ?? [], + requiredFields: requiredFieldsWithEcs, 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..c730df4bdebc5 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 @@ -11,6 +11,7 @@ import { stringifyZodError } from '@kbn/zod-helpers'; import { BadRequestError } from '@kbn/securitysolution-es-utils'; import { ruleTypeMappings } from '@kbn/securitysolution-rules'; import type { ResolvedSanitizedRule, SanitizedRule } from '@kbn/alerting-plugin/common'; +import { ecsFieldMap } from '@kbn/alerts-as-data-utils'; import type { RequiredOptional } from '@kbn/zod-helpers'; import { @@ -491,7 +492,7 @@ export const convertPatchAPIToInternalSchema = ( export const convertCreateAPIToInternalSchema = ( input: RuleCreateProps & { related_integrations?: RelatedIntegrationArray; - required_fields?: RequiredFieldArray; + // required_fields?: RequiredFieldArray; }, immutable = false, defaultEnabled = true @@ -502,6 +503,17 @@ export const convertCreateAPIToInternalSchema = ( const alertActions = input.actions?.map((action) => transformRuleToAlertAction(action)) ?? []; const actions = transformToActionFrequency(alertActions, input.throttle); + const requiredFieldsWithEcs = (input.required_fields ?? []).map((requiredFieldWithoutEcs) => { + const isEcsField = Boolean( + ecsFieldMap[requiredFieldWithoutEcs.name]?.type === requiredFieldWithoutEcs.type + ); + + return { + ...requiredFieldWithoutEcs, + ecs: isEcsField, + }; + }); + return { name: input.name, tags: input.tags ?? [], @@ -537,7 +549,7 @@ export const convertCreateAPIToInternalSchema = ( version: input.version ?? 1, exceptionsList: input.exceptions_list ?? [], relatedIntegrations: input.related_integrations ?? [], - requiredFields: input.required_fields ?? [], + requiredFields: requiredFieldsWithEcs, setup: input.setup ?? '', ...typeSpecificParams, }, 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; }; From ae926df05f115e33e5ed8e788b4f43b6589228c7 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Thu, 18 Apr 2024 13:50:02 +0200 Subject: [PATCH 03/66] WIP --- .../components/required_fields/index.ts | 8 + .../required_fields/required_fields.test.tsx | 138 ++++++++ .../required_fields/required_fields.tsx | 147 ++++++++ .../required_fields/required_fields_row.tsx | 231 +++++++++++++ .../required_fields/translations.ts | 50 +++ .../components/required_fields/types.ts | 11 + .../components/step_define_rule/index.tsx | 323 +----------------- .../components/step_define_rule/schema.tsx | 15 +- .../pages/rule_editing/index.tsx | 63 ++-- 9 files changed, 628 insertions(+), 358 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/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_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/types.ts diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/index.ts new file mode 100644 index 0000000000000..58fcf22f3fd3d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { RequiredFields } from './required_fields'; 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..01e59103d0fa5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields.test.tsx @@ -0,0 +1,138 @@ +/* + * 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 { screen, render, act, fireEvent, waitFor } from '@testing-library/react'; +import { FIELD_TYPES, Form, useForm } from '../../../../shared_imports'; + +import type { DataViewFieldBase } from '@kbn/es-query'; +import { RequiredFields } from './required_fields'; +import type { RequiredFieldWithOptionalEcs } from './types'; + +const COMBO_BOX_TOGGLE_BUTTON_TEST_ID = 'comboBoxToggleListButton'; + +describe('RequiredFields form part', () => { + it('displays the required fields label', () => { + render(); + + expect(screen.getByText('Required fields')); + }); + + it('displays previosuly saved required fields', () => { + const initialState: RequiredFieldWithOptionalEcs[] = [ + { 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 and submit', async () => { + const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field2', esTypes: ['text'] }), + ]; + + const handleSubmit = jest.fn(); + + render( + + ); + + await addRequiredFieldRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + }); + }); +}); + +function createIndexPatternField(overrides: Partial): DataViewFieldBase { + return { + name: 'one', + type: 'string', + esTypes: [], + ...overrides, + }; +} + +function addRequiredFieldRow(): Promise { + return act(async () => { + fireEvent.click(screen.getByText('Add required field')); + }); +} + +function showEuiComboBoxOptions(comboBoxToggleButton: HTMLElement): Promise { + fireEvent.click(comboBoxToggleButton); + + return waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); +} + +function selectEuiComboBoxOption({ + comboBoxToggleButton, + optionIndex, +}: { + comboBoxToggleButton: HTMLElement; + optionIndex: number; +}): Promise { + return act(async () => { + await showEuiComboBoxOptions(comboBoxToggleButton); + + fireEvent.click(screen.getAllByRole('option')[optionIndex]); + }); +} + +function selectFirstEuiComboBoxOption({ + comboBoxToggleButton, +}: { + comboBoxToggleButton: HTMLElement; +}): Promise { + return selectEuiComboBoxOption({ comboBoxToggleButton, optionIndex: 0 }); +} + +interface TestFormProps { + initialState?: RequiredFieldWithOptionalEcs[]; + onSubmit?: (args: { data: RequiredFieldWithOptionalEcs[]; isValid: boolean }) => void; + indexPatternFields?: BrowserField[]; +} + +function TestForm({ indexPatternFields, initialState, onSubmit }: TestFormProps): JSX.Element { + const { form } = useForm({ + options: { stripEmptyFields: false }, + schema: { + requiredFieldsField: { + type: FIELD_TYPES.JSON, + }, + }, + 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..07037d0a35f99 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields.tsx @@ -0,0 +1,147 @@ +/* + * 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 { EuiButtonEmpty, EuiCallOut, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import type { DataViewFieldBase } from '@kbn/es-query'; +import { UseArray, useFormData } from '../../../../shared_imports'; +import { RequiredFieldRow } from './required_fields_row'; +import * as ruleDetailsI18n from '../../../rule_management/components/rule_details/translations'; +import * as i18n from './translations'; + +import type { RequiredFieldWithOptionalEcs } from './types'; + +interface RequiredFieldsProps { + path: string; + indexPatternFields?: DataViewFieldBase[]; +} + +export const RequiredFields = ({ path, indexPatternFields = [] }: RequiredFieldsProps) => { + const useFormDataResult = useFormData(); + const [_formData] = useFormDataResult; + + return ( + + {({ items, addItem, removeItem, form }) => { + const formData = form.getFormData(); + const fieldValue: RequiredFieldWithOptionalEcs[] = formData[path] ?? []; + + const selectedFieldNames = fieldValue.map(({ name }) => name); + + const fieldsWithTypes = indexPatternFields.filter( + (indexPatternField) => indexPatternField.esTypes && indexPatternField.esTypes.length > 0 + ); + + const allFieldNames = fieldsWithTypes.map(({ name }) => name); + const availableFieldNames = allFieldNames.filter( + (name) => !selectedFieldNames.includes(name) + ); + + const availableNameOptions: Array> = + availableFieldNames.map((availableFieldName) => ({ + label: availableFieldName, + })); + + const typesByFieldName: Record = fieldsWithTypes.reduce( + (accumulator, browserField) => { + if (browserField.esTypes) { + accumulator[browserField.name] = browserField.esTypes; + } + return accumulator; + }, + {} as Record + ); + + const isEmptyRowDisplayed = !!fieldValue.find(({ name }) => name === ''); + + const areIndexPatternFieldsAvailable = indexPatternFields.length > 0; + + const nameWarnings = fieldValue.reduce>((warnings, { name }) => { + if (areIndexPatternFieldsAvailable && name !== '' && !allFieldNames.includes(name)) { + warnings[name] = `Field "${name}" is not found within specified index patterns`; + } + return warnings; + }, {}); + + const typeWarnings = fieldValue.reduce>( + (warnings, { name, type }) => { + if ( + areIndexPatternFieldsAvailable && + name !== '' && + !typesByFieldName[name]?.includes(type) + ) { + warnings[ + name + ] = `Field "${name}" with type "${type}" is not found within specified index patterns`; + } + return warnings; + }, + {} + ); + + const getWarnings = (name: string) => ({ + nameWarning: nameWarnings[name] || '', + typeWarning: typeWarnings[name] || '', + }); + + const hasWarnings = + Object.keys(nameWarnings).length > 0 || Object.keys(typeWarnings).length > 0; + + return ( + <> + {hasWarnings && ( + +

{i18n.REQUIRED_FIELDS_GENERAL_WARNING_DESCRIPTION}

+
+ )} + + + {i18n.OPTIONAL} + + } + helpText={i18n.REQUIRED_FIELDS_HELP_TEXT} + hasChildLabel={false} + labelType="legend" + > + <> + {items.map((item) => ( + + ))} + + + + {i18n.ADD_REQUIRED_FIELD} + + + + + ); + }} +
+ ); +}; 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..8245c5c0d1c6c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_row.tsx @@ -0,0 +1,231 @@ +/* + * 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 } from 'react'; +import { + EuiButtonIcon, + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiSelect, + EuiTextColor, +} from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; +import type { EuiComboBoxOptionOption, EuiSelectOption } from '@elastic/eui'; +import { FIELD_TYPES, UseField } from '../../../../shared_imports'; +import * as i18n from './translations'; + +import type { FieldHook, ArrayItem, FieldConfig } from '../../../../shared_imports'; +import type { RequiredFieldWithOptionalEcs } from './types'; + +interface RequiredFieldRowProps { + item: ArrayItem; + removeItem: (id: number) => void; + typesByFieldName: Record; + availableFieldNames: string[]; + getWarnings: (name: string) => { nameWarning: string; typeWarning: string }; +} + +export const RequiredFieldRow = ({ + item, + removeItem, + typesByFieldName, + availableFieldNames, + getWarnings, +}: RequiredFieldRowProps) => { + const handleRemove = useCallback(() => removeItem(item.id), [removeItem, item.id]); + + return ( + + ); +}; + +interface RequiredFieldRowInnerProps { + field: FieldHook; + onRemove: () => void; + typesByFieldName: Record; + availableFieldNames: string[]; + getWarnings: (name: string) => { nameWarning: string; typeWarning: string }; +} + +const RequiredFieldRowInner = ({ + field, + typesByFieldName, + onRemove, + availableFieldNames, + getWarnings, +}: RequiredFieldRowInnerProps) => { + // Do not not add empty option to the list of selectable field names + const selectableNameOptions: Array> = ( + field.value.name ? [field.value.name] : [] + ) + .concat(availableFieldNames) + .map((name) => ({ + label: name, + value: name, + })); + + const selectedNameOption = selectableNameOptions.find( + (option) => option.label === field.value.name + ) || { label: '' }; + + const typesAvailableForSelectedName = typesByFieldName[field.value.name]; + + let selectableTypeOptions: EuiSelectOption[] = []; + if (typesAvailableForSelectedName) { + const isSelectedTypeAvailable = typesAvailableForSelectedName.includes(field.value.type); + + selectableTypeOptions = typesAvailableForSelectedName.map((type) => ({ + text: type, + })); + + if (!isSelectedTypeAvailable) { + // case: field name exists, but such type is not among the list of field types + selectableTypeOptions.push({ text: field.value.type }); + } + } else { + // case: no such field name in index patterns + selectableTypeOptions = [ + { + text: field.value.type, + }, + ]; + } + + const { nameWarning, typeWarning } = getWarnings(field.value.name); + + const warningText = nameWarning || typeWarning; + + const handleNameChange = useCallback( + ([newlySelectedOption]: Array>) => { + const updatedName = newlySelectedOption.value; + if (!updatedName) { + // TODO: Check if it's a legit case + return; + } + + const isCurrentTypeAvailableForNewName = typesByFieldName[updatedName]?.includes( + field.value.type + ); + + const updatedType = isCurrentTypeAvailableForNewName + ? field.value.type + : typesByFieldName[updatedName][0]; + + const updatedFieldValue: RequiredFieldWithOptionalEcs = { + name: updatedName, + type: updatedType, + }; + + field.setValue(updatedFieldValue); + }, + [field, typesByFieldName] + ); + + const handleTypeChange = useCallback( + (event: React.ChangeEvent) => { + const updatedType = event.target.value; + + const updatedFieldValue: RequiredFieldWithOptionalEcs = { + name: field.value.name, + type: updatedType, + }; + + field.setValue(updatedFieldValue); + }, + [field] + ); + + return ( + + {warningText} + + ) : ( + '' + ) + } + color="warning" + > + + + + ) : undefined + } + /> + + + + ) : undefined + } + /> + + + + + + + ); +}; + +const REQUIRED_FIELDS_FIELD_CONFIG: FieldConfig< + RequiredFieldWithOptionalEcs, + RequiredFieldWithOptionalEcs +> = { + type: FIELD_TYPES.JSON, + // validations: [{ validator: validateRelatedIntegration }], + defaultValue: { name: '', type: '' }, +}; 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..139e98be87709 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/translations.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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_HELP_TEXT = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.fieldRequiredFieldsHelpText', + { + defaultMessage: 'Fields required for this Rule to function.', + } +); + +export const REQUIRED_FIELDS_GENERAL_WARNING_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.generalWarningTitle', + { + defaultMessage: 'Some fields are not found within specified index patterns.', + } +); + +export const REQUIRED_FIELDS_GENERAL_WARNING_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.generalWarningDescription', + { + defaultMessage: `This doesn't break rule execution, but it might indicate that required fields were set incorrectly. Please check that indices specified in index patterns exist and have expected fields and types in mappings.`, + } +); + +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', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/types.ts new file mode 100644 index 0000000000000..dd5988c3ad1af --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/types.ts @@ -0,0 +1,11 @@ +/* + * 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 type { RequiredField } from '../../../../../common/api/detection_engine/model/rule_schema'; +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; + +export type RequiredFieldWithOptionalEcs = RequiredField | RequiredFieldInput; 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 94cd0b1e071e3..736729acc7b48 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 @@ -5,34 +5,23 @@ * 2.0. */ -import type { - EuiButtonGroupOptionProps, - EuiComboBoxOptionOption, - EuiSelectOption, -} from '@elastic/eui'; +import type { EuiButtonGroupOptionProps } from '@elastic/eui'; import { EuiButtonEmpty, - EuiButtonIcon, - EuiCallOut, - EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiIcon, EuiLoadingSpinner, - EuiSelect, EuiSpacer, EuiButtonGroup, EuiText, EuiRadioGroup, - EuiTextColor, EuiToolTip, } from '@elastic/eui'; import type { FC } from 'react'; import React, { memo, useCallback, useState, useEffect, useMemo, useRef } from 'react'; import styled from 'styled-components'; -import { euiThemeVars } from '@kbn/ui-theme'; import { i18n as i18nCore } from '@kbn/i18n'; import { isEqual, isEmpty } from 'lodash'; import type { FieldSpec } from '@kbn/data-views-plugin/common'; @@ -79,7 +68,7 @@ import { useFormData, UseMultiFields, } from '../../../../shared_imports'; -import type { FieldHook, FormHook } from '../../../../shared_imports'; +import type { FormHook } from '../../../../shared_imports'; import { schema } from './schema'; import { getTermsAggregationFields } from './utils'; import { useExperimentalFeatureFieldsTransform } from './use_experimental_feature_fields_transform'; @@ -101,20 +90,18 @@ 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'; import { useLicense } from '../../../../common/hooks/use_license'; import { AlertSuppressionMissingFieldsStrategyEnum } from '../../../../../common/api/detection_engine/model/rule_schema'; -import type { RequiredField } from '../../../../../common/api/detection_engine/model/rule_schema'; import { DurationInput } from '../duration_input'; import { MINIMUM_LICENSE_FOR_SUPPRESSION } from '../../../../../common/detection_engine/constants'; import { useUpsellingMessage } from '../../../../common/hooks/use_upselling'; import { useAlertSuppression } from '../../../rule_management/logic/use_alert_suppression'; import { RelatedIntegrations } from '../../../rule_creation/components/related_integrations'; -// import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs'; - const CommonUseField = getUseField({ component: Field }); const StyledVisibleContainer = styled.div<{ isVisible: boolean }>` @@ -929,7 +916,6 @@ const StepDefineRuleComponent: FC = ({ )} - {isQueryRule(ruleType) && ( <> @@ -954,7 +940,6 @@ const StepDefineRuleComponent: FC = ({ )} - <> = ({ /> - <> @@ -1132,17 +1116,12 @@ const StepDefineRuleComponent: FC = ({ - - - + {!isMlRule(ruleType) && ( + <> + + + + )} @@ -1187,287 +1166,3 @@ export const StepDefineRuleReadOnly = memo(StepDefineRuleReadOnlyComponent); export function aggregatableFields(browserFields: T[]): T[] { return browserFields.filter((field) => field.aggregatable === true); } - -function applyChangeToFieldValue( - selectedOptions: Array>, - index: number, - field: FieldHook, - typesByFieldName: Record -): void { - const newlySelectedOption = selectedOptions[0]; - - const fieldValue = field.value as Array & { ecs?: boolean }>; - - const newFieldValue: RequiredField[] = fieldValue.map((requiredField, requiredFieldIndex) => { - if (index === requiredFieldIndex) { - const availableFieldTypes = typesByFieldName[newlySelectedOption.label]; - - return { - name: newlySelectedOption.label, - // Preserve previous type if possible - type: availableFieldTypes.includes(requiredField.type) - ? requiredField.type - : availableFieldTypes[0], - }; - } - - return requiredField; - }); - - field.setValue(newFieldValue); -} - -function updateRequiredFieldType(newType: string, index: number, field: FieldHook): void { - const fieldValue = field.value as RequiredField[]; - - const newFieldValue: RequiredField[] = fieldValue.map((requiredField, requiredFieldIndex) => { - if (index === requiredFieldIndex) { - return { - ...requiredField, - type: newType, - }; - } - - return requiredField; - }); - - field.setValue(newFieldValue); -} - -function deleteRequiredField(index: number, field: FieldHook): void { - const fieldValue = field.value as RequiredField[]; - const newFieldValue = fieldValue.filter((_, requiredFieldIndex) => index !== requiredFieldIndex); - field.setValue(newFieldValue); -} - -function addRequiredField( - nameOptions: Array>, - typesByFieldName: Record, - field: FieldHook -): void { - // const newOptionFieldName = nameOptions[0].label; - // const newOptionType = typesByFieldName[newOptionFieldName][0]; - // const newOption = { ecs: true, name: newOptionFieldName, type: newOptionType }; - - const newOption = { name: '', type: '' }; - - const fieldValue = field.value as RequiredField[]; - const newFieldValue = [...fieldValue, newOption]; - field.setValue(newFieldValue); -} - -interface RequiredFieldRowProps { - requiredField: RequiredField; - requiredFieldIndex: number; - typesByFieldName: Record; - field: FieldHook; - allFieldNames: string[]; - availableFieldNames: string[]; - nameWarning?: string; - typeWarning?: string; -} - -const RequiredFieldRow = ({ - requiredField, - requiredFieldIndex, - availableFieldNames, - typesByFieldName, - field, - allFieldNames, - nameWarning, - typeWarning, -}: RequiredFieldRowProps) => { - const isFieldNameFoundInIndexPatterns = allFieldNames.includes(requiredField.name); - - // /* Show a warning if selected field name is not found in the index pattern */ - // let nameWarning = ''; - // if (requiredField.name && !isFieldNameFoundInIndexPatterns) { - // nameWarning = `Field "${requiredField.name}" is not found within specified index patterns`; - // } - - // Do not not add empty option to the list of selectable field names - const selectableNameOptions = (requiredField.name ? [requiredField.name] : []) - .concat(availableFieldNames) - .map((name) => ({ - label: name, - })); - - const selectedNameOption = selectableNameOptions.find( - (option) => option.label === requiredField.name - ) || { label: '' }; - - // let typeWarning = ''; - - const typesAvailableForSelectedName = typesByFieldName[requiredField.name]; - - let selectableTypeOptions: EuiSelectOption[] = []; - if (typesAvailableForSelectedName) { - const isSelectedTypeAvailable = typesAvailableForSelectedName.includes(requiredField.type); - - selectableTypeOptions = typesAvailableForSelectedName.map((type) => ({ - text: type, - })); - - if (!isSelectedTypeAvailable) { - // case: field name exists, but such type is not among the list of field types - selectableTypeOptions.push({ text: requiredField.type }); - // typeWarning = `Field "${selectedNameOption.label}" with type "${requiredField.type}" is not found within specified index patterns`; - } - } else { - // case: no such field name in index patterns - selectableTypeOptions = [ - { - text: requiredField.type, - }, - ]; - } - - const warningText = nameWarning || typeWarning; - - return ( - {warningText} : ''} - color="warning" - > - - - - applyChangeToFieldValue(selectedOptions, requiredFieldIndex, field, typesByFieldName) - } - isClearable={false} - prepend={ - nameWarning ? ( - - ) : undefined - } - /> - - - { - updateRequiredFieldType(event.target.value, requiredFieldIndex, field); - }} - prepend={ - typeWarning ? ( - - ) : undefined - } - /> - - - deleteRequiredField(requiredFieldIndex, field)} - /> - - - - ); -}; - -const RequiredFields = ({ - field, - indexPatternFields, -}: { - field: FieldHook; - indexPatternFields: BrowserField[]; -}) => { - // const { loading, jobs } = useSecurityJobs(); - // console.log('[dbg] jobs', jobs); - - const fieldValue = field.value as RequiredField[]; - - const selectedFieldNames = fieldValue.map(({ name }) => name); - - const fieldsWithTypes = indexPatternFields.filter( - (indexPatternField) => indexPatternField.esTypes && indexPatternField.esTypes.length > 0 - ); - - const allFieldNames = fieldsWithTypes.map(({ name }) => name); - const availableFieldNames = allFieldNames.filter((name) => !selectedFieldNames.includes(name)); - - const availableNameOptions: Array> = availableFieldNames.map( - (availableFieldName) => ({ - label: availableFieldName, - }) - ); - - const typesByFieldName: Record = fieldsWithTypes.reduce( - (accumulator, browserField) => { - if (browserField.esTypes) { - accumulator[browserField.name] = browserField.esTypes; - } - return accumulator; - }, - {} as Record - ); - - const isEmptyRowDisplayed = !!fieldValue.find(({ name }) => name === ''); - - const nameWarnings = fieldValue.reduce>((warnings, { name }) => { - if (name !== '' && !allFieldNames.includes(name)) { - warnings[name] = `Field "${name}" is not found within specified index patterns`; - } - return warnings; - }, {}); - - const typeWarnings = fieldValue.reduce>((warnings, { name, type }) => { - if (name !== '' && !typesByFieldName[name]?.includes(type)) { - warnings[ - name - ] = `Field "${name}" with type "${type}" is not found within specified index patterns`; - } - return warnings; - }, {}); - - const hasWarnings = Object.keys(nameWarnings).length > 0 || Object.keys(typeWarnings).length > 0; - - return ( - - <> - {hasWarnings && ( - -

{`This doesn't break rule execution, but it might indicate that required fields were set incorrectly. Please check that indices specified in index patterns exist and have expected fields and types in mappings.`}

-
- )} - - {fieldValue.map((requiredField, requiredFieldIndex) => ( - - ))} - { - addRequiredField(availableNameOptions, typesByFieldName, field); - }} - isDisabled={availableNameOptions.length === 0 || isEmptyRowDisplayed} - > - {'Add required field'} - - -
- ); -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx index 99502fbb97666..2dbe7955d257b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx @@ -48,7 +48,7 @@ import { getQueryRequiredMessage } from './utils'; export const schema: FormSchema = { index: { defaultValue: [], - fieldsToValidateOnChange: ['index', 'queryBar'], + fieldsToValidateOnChange: ['index', 'queryBar', 'requiredFields'], type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fiedIndexPatternsLabel', @@ -248,18 +248,7 @@ export const schema: FormSchema = { type: FIELD_TYPES.JSON, }, requiredFields: { - label: i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel', - { - defaultMessage: 'Required fields', - } - ), - helpText: i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText', - { - defaultMessage: 'Fields required for this Rule to function.', - } - ), + type: FIELD_TYPES.JSON, }, timeline: { label: i18n.translate( 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..00c125cc7cf5f 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 @@ -149,13 +149,44 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { actionsStepDefault: ruleActionsData, }); + // Since in the edit step we start with an existing rule, we assume that + // the steps are valid if isValid is undefined. Once the user triggers validation by + // trying to submit the edits, the isValid statuses will be tracked and the callout appears + // if some steps are invalid + const stepIsValid = useCallback( + (step: RuleStep): boolean => { + switch (step) { + case RuleStep.defineRule: + return defineStepForm.isValid ?? true; + case RuleStep.aboutRule: + return aboutStepForm.isValid ?? true; + case RuleStep.scheduleRule: + return scheduleStepForm.isValid ?? true; + case RuleStep.ruleActions: + return actionsStepForm.isValid ?? true; + default: + return true; + } + }, + [ + aboutStepForm.isValid, + actionsStepForm.isValid, + defineStepForm.isValid, + scheduleStepForm.isValid, + ] + ); + + const invalidSteps = ruleStepsOrder.filter((step) => { + return !stepIsValid(step); + }); + 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 + stepIsValid(RuleStep.defineRule) || activeStep !== RuleStep.defineRule ); const memoizedIndex = useMemo( () => (isEsqlRule(defineStepData.ruleType) ? esqlIndex : defineStepData.index), @@ -182,36 +213,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { ruleType: rule?.type, }); - // Since in the edit step we start with an existing rule, we assume that - // the steps are valid if isValid is undefined. Once the user triggers validation by - // trying to submit the edits, the isValid statuses will be tracked and the callout appears - // if some steps are invalid - const stepIsValid = useCallback( - (step: RuleStep): boolean => { - switch (step) { - case RuleStep.defineRule: - return defineStepForm.isValid ?? true; - case RuleStep.aboutRule: - return aboutStepForm.isValid ?? true; - case RuleStep.scheduleRule: - return scheduleStepForm.isValid ?? true; - case RuleStep.ruleActions: - return actionsStepForm.isValid ?? true; - default: - return true; - } - }, - [ - aboutStepForm.isValid, - actionsStepForm.isValid, - defineStepForm.isValid, - scheduleStepForm.isValid, - ] - ); - - const invalidSteps = ruleStepsOrder.filter((step) => { - return !stepIsValid(step); - }); const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule?.type]); const { indexPattern, isIndexPatternLoading, browserFields } = useRuleIndexPattern({ From d7829db54c5cabd7dd1b709ca5855d7a4438295a Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Mon, 22 Apr 2024 09:42:55 +0200 Subject: [PATCH 04/66] Fix placeholders and validation --- .../required_fields/required_fields_row.tsx | 65 ++++++++++++------- .../pages/rule_creation/index.tsx | 4 +- 2 files changed, 43 insertions(+), 26 deletions(-) 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 index 8245c5c0d1c6c..dab1781fa6434 100644 --- 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 @@ -13,11 +13,10 @@ import { EuiFlexItem, EuiFormRow, EuiIcon, - EuiSelect, EuiTextColor, } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; -import type { EuiComboBoxOptionOption, EuiSelectOption } from '@elastic/eui'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { FIELD_TYPES, UseField } from '../../../../shared_imports'; import * as i18n from './translations'; @@ -85,42 +84,50 @@ const RequiredFieldRowInner = ({ const selectedNameOption = selectableNameOptions.find( (option) => option.label === field.value.name - ) || { label: '' }; + ); + + const selectedNameOptions = selectedNameOption ? [selectedNameOption] : []; const typesAvailableForSelectedName = typesByFieldName[field.value.name]; - let selectableTypeOptions: EuiSelectOption[] = []; + let selectableTypeOptions: Array> = []; if (typesAvailableForSelectedName) { const isSelectedTypeAvailable = typesAvailableForSelectedName.includes(field.value.type); selectableTypeOptions = typesAvailableForSelectedName.map((type) => ({ - text: type, + label: type, + value: type, })); if (!isSelectedTypeAvailable) { // case: field name exists, but such type is not among the list of field types - selectableTypeOptions.push({ text: field.value.type }); + selectableTypeOptions.push({ label: field.value.type, value: field.value.type }); } } else { - // case: no such field name in index patterns - selectableTypeOptions = [ - { - text: field.value.type, - }, - ]; + if (field.value.type) { + // case: no such field name in index patterns + selectableTypeOptions = [ + { + label: field.value.type, + value: field.value.type, + }, + ]; + } } + const selectedTypeOption = selectableTypeOptions.find( + (option) => option.value === field.value.type + ); + + const selectedTypeOptions = selectedTypeOption ? [selectedTypeOption] : []; + const { nameWarning, typeWarning } = getWarnings(field.value.name); const warningText = nameWarning || typeWarning; const handleNameChange = useCallback( ([newlySelectedOption]: Array>) => { - const updatedName = newlySelectedOption.value; - if (!updatedName) { - // TODO: Check if it's a legit case - return; - } + const updatedName = newlySelectedOption.value || ''; const isCurrentTypeAvailableForNewName = typesByFieldName[updatedName]?.includes( field.value.type @@ -141,8 +148,8 @@ const RequiredFieldRowInner = ({ ); const handleTypeChange = useCallback( - (event: React.ChangeEvent) => { - const updatedType = event.target.value; + ([newlySelectedOption]: Array>) => { + const updatedType = newlySelectedOption.value || ''; const updatedFieldValue: RequiredFieldWithOptionalEcs = { name: field.value.name, @@ -171,11 +178,11 @@ const RequiredFieldRowInner = ({ - { const esqlIndex = useEsqlIndex( defineStepData.queryBar.query.query, ruleType, - defineStepForm.isValid + // defineStepForm.isValid + defineStepForm.getErrors().length === 0 ); + const memoizedIndex = useMemo( () => (isEsqlRuleValue ? esqlIndex : defineStepData.index), [defineStepData.index, esqlIndex, isEsqlRuleValue] From 39fec2614dde2a5b8e7f257968c0ca34489faf98 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Mon, 22 Apr 2024 23:49:01 +0200 Subject: [PATCH 05/66] Add more tests --- .../required_fields/required_fields.test.tsx | 524 +++++++++++++++++- .../required_fields/required_fields.tsx | 55 +- .../required_fields/required_fields_row.tsx | 3 + .../required_fields/translations.ts | 18 + .../components/step_define_rule/index.tsx | 6 +- .../pages/rule_creation/helpers.ts | 4 - 6 files changed, 558 insertions(+), 52 deletions(-) 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 index 01e59103d0fa5..7f6d10965c8d3 100644 --- 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 @@ -13,8 +13,6 @@ import type { DataViewFieldBase } from '@kbn/es-query'; import { RequiredFields } from './required_fields'; import type { RequiredFieldWithOptionalEcs } from './types'; -const COMBO_BOX_TOGGLE_BUTTON_TEST_ID = 'comboBoxToggleListButton'; - describe('RequiredFields form part', () => { it('displays the required fields label', () => { render(); @@ -37,26 +35,448 @@ describe('RequiredFields form part', () => { expect(screen.getByDisplayValue('number')).toBeVisible(); }); - it('user can add a new required field and submit', async () => { + 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 previosly saved form', async () => { const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; const indexPatternFields: DataViewFieldBase[] = [ - createIndexPatternField({ name: 'field2', esTypes: ['text'] }), + createIndexPatternField({ name: 'field2', esTypes: ['keyword'] }), ]; - const handleSubmit = jest.fn(); + 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('field type dropdown allows to choose from options if multiple types are available', async () => { + const initialState: RequiredFieldWithOptionalEcs[] = [{ 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('field type dropdown is disabled if only a single type option is available', async () => { + const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + expect(screen.getByDisplayValue('string')).toBeVisible(); + expect(screen.getByDisplayValue('string')).toBeDisabled(); + }); + + it('user can remove a required field', async () => { + const initialState: RequiredFieldWithOptionalEcs[] = [{ 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: RequiredFieldWithOptionalEcs[] = [{ 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('addRequiredFieldButton')).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('addRequiredFieldButton')).toBeEnabled(); + + await addRequiredFieldRow(); + + expect(screen.getByTestId('addRequiredFieldButton')).toBeDisabled(); + }); + + it('adding a new required field is disabled when there are no available field names left', async () => { + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + createIndexPatternField({ name: 'field2', esTypes: ['keyword'] }), + ]; + + render(); + await addRequiredFieldRow(); await selectFirstEuiComboBoxOption({ - comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + }); + + expect(screen.getByTestId('addRequiredFieldButton')).toBeEnabled(); + + await addRequiredFieldRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: getSelectToggleButtonForName('empty'), + }); + + expect(screen.getByTestId('addRequiredFieldButton')).toBeDisabled(); + }); + + describe('warnings', () => { + it('displays a warning when a selected field name is not found within index patterns', async () => { + const initialState: RequiredFieldWithOptionalEcs[] = [ + { name: 'field-that-does-not-exist', type: 'keyword' }, + ]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + expect( + screen.getByText('Some fields are not found within specified index patterns.') + ).toBeVisible(); + + expect( + screen.getByText( + 'Field "field-that-does-not-exist" is not found within specified index patterns' + ) + ).toBeVisible(); + }); + + it('displays a warning when a selected field type is not found within index patterns', async () => { + const initialState: RequiredFieldWithOptionalEcs[] = [ + { name: 'field1', type: 'type-that-does-not-exist' }, + ]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + expect( + screen.getByText('Some fields are not found within specified index patterns.') + ).toBeVisible(); + + expect( + screen.getByText( + 'Field "field1" with type "type-that-does-not-exist" is not found within specified index patterns' + ) + ).toBeVisible(); + }); + + it(`doesn't display a warning for a an empty row`, async () => { + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + await addRequiredFieldRow(); + + expect( + screen.queryByText('Some fields are not found within specified index patterns.') + ).toBeNull(); + }); + + it(`doesn't display a warning when all selected fields are found within index patterns`, async () => { + const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + + const indexPatternFields: DataViewFieldBase[] = [ + createIndexPatternField({ name: 'field1', esTypes: ['string'] }), + ]; + + render(); + + expect( + screen.queryByText('Some fields are not found within specified index patterns.') + ).toBeNull(); + }); + }); + + describe('form submission', () => { + it('submits undefined when no required fields are selected', async () => { + const handleSubmit = jest.fn(); + + render(); + + await submitForm(); + + /* + useForm's "submit" implementation calls setTimeout internally in cases when form is untouched. + We need to tell Jest to wait for the next tick of the event loop to allow the form to be submitted. + */ + await waitFor(() => { + new Promise((resolve) => { + setImmediate(resolve); + }); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: undefined, + isValid: true, + }); + }); + + it('submits undefined when all selected fields were removed', async () => { + const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + + const handleSubmit = jest.fn(); + + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('removeRequiredFieldButton-field1')); + }); + + await submitForm(); + + /* + useForm's "submit" implementation calls setTimeout internally in cases when form is untouched. + We need to tell Jest to wait for the next tick of the event loop to allow the form to be submitted. + */ + await waitFor(() => { + new Promise((resolve) => { + setImmediate(resolve); + }); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: undefined, + isValid: true, + }); + }); + + it('submits without empty rows', async () => { + const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + + const handleSubmit = jest.fn(); + + render(); + + await addRequiredFieldRow(); + await submitForm(); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ name: 'field1', type: 'string' }], + 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: RequiredFieldWithOptionalEcs[] = [{ 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: RequiredFieldWithOptionalEcs[] = [{ 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: RequiredFieldWithOptionalEcs[] = [ + { 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.queryByText('Some fields are not found within specified index patterns.') + ).toBeVisible(); + + await submitForm(); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ name: 'name-that-does-not-exist', type: 'type-that-does-not-exist' }], + isValid: true, + }); }); }); }); @@ -70,6 +490,16 @@ function createIndexPatternField(overrides: Partial): DataVie }; } +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; +} + function addRequiredFieldRow(): Promise { return act(async () => { fireEvent.click(screen.getByText('Add required field')); @@ -84,17 +514,43 @@ function showEuiComboBoxOptions(comboBoxToggleButton: HTMLElement): Promise { + optionText, +}: SelectEuiComboBoxOptionParameters): Promise { return act(async () => { await showEuiComboBoxOptions(comboBoxToggleButton); - fireEvent.click(screen.getAllByRole('option')[optionIndex]); + const options = screen.getAllByRole('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]); + } }); } @@ -106,15 +562,39 @@ function selectFirstEuiComboBoxOption({ return selectEuiComboBoxOption({ comboBoxToggleButton, optionIndex: 0 }); } +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?: RequiredFieldWithOptionalEcs[]; onSubmit?: (args: { data: RequiredFieldWithOptionalEcs[]; isValid: boolean }) => void; - indexPatternFields?: BrowserField[]; + indexPatternFields?: DataViewFieldBase[]; + isIndexPatternLoading?: boolean; } -function TestForm({ indexPatternFields, initialState, onSubmit }: TestFormProps): JSX.Element { +function TestForm({ + indexPatternFields, + initialState, + isIndexPatternLoading, + onSubmit, +}: TestFormProps): JSX.Element { const { form } = useForm({ - options: { stripEmptyFields: false }, + options: { stripEmptyFields: true }, schema: { requiredFieldsField: { type: FIELD_TYPES.JSON, @@ -129,7 +609,11 @@ function TestForm({ indexPatternFields, initialState, onSubmit }: TestFormProps) 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 index 07037d0a35f99..500b2d6b7e4d0 100644 --- 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 @@ -19,16 +19,20 @@ import type { RequiredFieldWithOptionalEcs } from './types'; interface RequiredFieldsProps { path: string; indexPatternFields?: DataViewFieldBase[]; + isIndexPatternLoading?: boolean; } -export const RequiredFields = ({ path, indexPatternFields = [] }: RequiredFieldsProps) => { +export const RequiredFields = ({ + path, + indexPatternFields = [], + isIndexPatternLoading = false, +}: RequiredFieldsProps) => { const useFormDataResult = useFormData(); - const [_formData] = useFormDataResult; return ( - {({ items, addItem, removeItem, form }) => { - const formData = form.getFormData(); + {({ items, addItem, removeItem }) => { + const [formData] = useFormDataResult; const fieldValue: RequiredFieldWithOptionalEcs[] = formData[path] ?? []; const selectedFieldNames = fieldValue.map(({ name }) => name); @@ -59,30 +63,26 @@ export const RequiredFields = ({ path, indexPatternFields = [] }: RequiredFields const isEmptyRowDisplayed = !!fieldValue.find(({ name }) => name === ''); - const areIndexPatternFieldsAvailable = indexPatternFields.length > 0; - - const nameWarnings = fieldValue.reduce>((warnings, { name }) => { - if (areIndexPatternFieldsAvailable && name !== '' && !allFieldNames.includes(name)) { - warnings[name] = `Field "${name}" is not found within specified index patterns`; - } - return warnings; - }, {}); - - const typeWarnings = fieldValue.reduce>( - (warnings, { name, type }) => { - if ( - areIndexPatternFieldsAvailable && - name !== '' && - !typesByFieldName[name]?.includes(type) - ) { - warnings[ - name - ] = `Field "${name}" with type "${type}" is not found within specified index patterns`; + const isAddNewFieldButtonDisabled = + isIndexPatternLoading || isEmptyRowDisplayed || availableNameOptions.length === 0; + + const nameWarnings = fieldValue + .filter(({ name }) => name !== '') + .reduce>((warnings, { name }) => { + if (!isIndexPatternLoading && !allFieldNames.includes(name)) { + warnings[name] = i18n.FIELD_NAME_NOT_FOUND_WARNING(name); } return warnings; - }, - {} - ); + }, {}); + + const typeWarnings = fieldValue + .filter(({ name }) => name !== '') + .reduce>((warnings, { name, type }) => { + if (!isIndexPatternLoading && !typesByFieldName[name]?.includes(type)) { + warnings[name] = i18n.FIELD_TYPE_NOT_FOUND_WARNING(name, type); + } + return warnings; + }, {}); const getWarnings = (name: string) => ({ nameWarning: nameWarnings[name] || '', @@ -133,7 +133,8 @@ export const RequiredFields = ({ path, indexPatternFields = [] }: RequiredFields size="xs" iconType="plusInCircle" onClick={addItem} - isDisabled={availableNameOptions.length === 0 || isEmptyRowDisplayed} + isDisabled={isAddNewFieldButtonDisabled} + data-test-subj="addRequiredFieldButton" > {i18n.ADD_REQUIRED_FIELD} 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 index dab1781fa6434..f46223809604d 100644 --- 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 @@ -178,6 +178,7 @@ const RequiredFieldRowInner = ({ 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 index 139e98be87709..b493ca2c31471 100644 --- 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 @@ -48,3 +48,21 @@ export const ADD_REQUIRED_FIELD = i18n.translate( 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 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 specified index patterns`, + } + ); 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 736729acc7b48..4f2b974437763 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 @@ -1118,7 +1118,11 @@ const StepDefineRuleComponent: FC = ({ {!isMlRule(ruleType) && ( <> - + )} 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 b380063cef8ab..ada328ac48fb9 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,7 +48,6 @@ import { import type { RuleCreateProps, AlertSuppression, - RequiredField, } 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'; @@ -396,9 +395,6 @@ export const getStepDataDataSource = ( return copiedStepData; }; -const removeEmptyRequiredFieldsValues = (requiredFields: RequiredField[]) => - requiredFields.filter((requiredField) => requiredField.name !== ''); - export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { const stepData = getStepDataDataSource(defineStepData); From d9006c4542392a83bb7752a8918e4547f68108e0 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 23 Apr 2024 08:11:33 +0200 Subject: [PATCH 06/66] Update i18n --- x-pack/plugins/translations/translations/fr-FR.json | 2 ++ x-pack/plugins/translations/translations/ja-JP.json | 2 ++ x-pack/plugins/translations/translations/zh-CN.json | 2 ++ 3 files changed, 6 insertions(+) diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 845fe18530a5a..3852a923f8b08 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -34242,6 +34242,8 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel": "URL de référence", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText": "Champs requis pour le fonctionnement de cette règle.", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel": "Champ requis", + "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText": "Intégration liée à cette règle.", + "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel": "Intégrations liées", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideHelpText": "Choisissez un champ de l'événement source pour remplir le nom de règle dans la liste d'alertes.", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideLabel": "Remplacement du nom de règle", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldTagsHelpText": "Saisissez une ou plusieurs balises d'identification personnalisées pour cette règle. Appuyez sur Entrée après chaque balise pour en ajouter une nouvelle.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 351327102a5f1..24f2600b2a51e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -34211,6 +34211,8 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel": "参照URL", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText": "このルールの機能に必要なフィールド。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel": "必須フィールド", + "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText": "統合はこのルールに関連しています。", + "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel": "関連する統合", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideHelpText": "ソースイベントからフィールドを選択し、アラートリストのルール名を入力します。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideLabel": "ルール名無効化", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldTagsHelpText": "このルールの1つ以上のカスタム識別タグを入力します。新しいタグを開始するには、各タグの後でEnterを押します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 69f740ad5ac0e..3a791e1197733 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -34254,6 +34254,8 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel": "引用 URL", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText": "此规则正常运行所需的字段。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel": "必填字段", + "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText": "与此规则相关的集成。", + "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel": "相关集成", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideHelpText": "从源事件中选择字段来填充告警列表中的规则名称。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideLabel": "规则名称覆盖", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldTagsHelpText": "为此规则键入一个或多个定制识别标签。在每个标签后按 Enter 键可开始新的标签。", From 194c5e6273b73aa3a11a01175a8761ea2f137523 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 23 Apr 2024 08:21:28 +0200 Subject: [PATCH 07/66] Remove commented out code --- .../components/required_fields/required_fields_row.tsx | 1 - .../rule_creation_ui/pages/rule_creation/index.tsx | 1 - .../rule_management/normalization/rule_converters.ts | 1 - 3 files changed, 3 deletions(-) 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 index f46223809604d..335e7093cf6f5 100644 --- 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 @@ -244,6 +244,5 @@ const REQUIRED_FIELDS_FIELD_CONFIG: FieldConfig< RequiredFieldWithOptionalEcs > = { type: FIELD_TYPES.JSON, - // validations: [{ validator: validateRelatedIntegration }], defaultValue: { name: '', type: '' }, }; 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 c90e963622db7..d972967015b4f 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 @@ -213,7 +213,6 @@ const CreateRulePageComponent: React.FC = () => { const esqlIndex = useEsqlIndex( defineStepData.queryBar.query.query, ruleType, - // defineStepForm.isValid defineStepForm.getErrors().length === 0 ); 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 c730df4bdebc5..21b4da51f2284 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 @@ -492,7 +492,6 @@ export const convertPatchAPIToInternalSchema = ( export const convertCreateAPIToInternalSchema = ( input: RuleCreateProps & { related_integrations?: RelatedIntegrationArray; - // required_fields?: RequiredFieldArray; }, immutable = false, defaultEnabled = true From 316e4042ee8c3604568e5e15c99a505db601a9e3 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 23 Apr 2024 10:13:39 +0200 Subject: [PATCH 08/66] Update Cypress E2E tests --- .../rule_creation/common_flows.cy.ts | 2 ++ .../cypress/screens/create_new_rule.ts | 3 +++ .../cypress/tasks/create_new_rule.ts | 13 +++++++++++++ 3 files changed, 18 insertions(+) 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 bdcbbcf987eb6..dc88682b478e1 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 @@ -34,6 +34,7 @@ import { fillNote, fillReferenceUrls, fillRelatedIntegrations, + fillRequiredFields, fillRiskScore, fillRuleName, fillRuleTags, @@ -67,6 +68,7 @@ describe('Common rule creation flows', { tags: ['@ess', '@serverless'] }, () => it('Creates and enables a rule', function () { cy.log('Filling define section'); importSavedQuery(this.timelineId); + fillRequiredFields(); fillRelatedIntegrations(); cy.get(DEFINE_CONTINUE_BUTTON).click(); 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 88b8c2192d1bf..4797b4aed824a 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 @@ -128,6 +128,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 */ From 954034e14152afd0f44b655894d424a8af806b23 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 23 Apr 2024 14:23:18 +0200 Subject: [PATCH 09/66] Update API integration tests --- .../logic/crud/update_rules.ts | 15 ++------ .../normalization/rule_converters.ts | 20 +++-------- .../rule_management/utils/utils.ts | 15 ++++++++ .../perform_bulk_action.ts | 11 ++++++ .../create_rules.ts | 36 ++++++++++++++++++- .../create_rules_bulk.ts | 32 +++++++++++++++++ .../export_rules.ts | 26 ++++++++++++++ .../import_rules.ts | 5 +++ .../patch_rules.ts | 29 +++++++++++++++ .../patch_rules_bulk.ts | 31 ++++++++++++++++ .../update_rules.ts | 29 +++++++++++++++ .../update_rules_bulk.ts | 29 +++++++++++++++ 12 files changed, 249 insertions(+), 29 deletions(-) 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 26efb944283c7..3317f120486b6 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 @@ -6,7 +6,6 @@ */ /* eslint-disable complexity */ -import { ecsFieldMap } from '@kbn/alerts-as-data-utils'; import type { PartialRule, RulesClient } from '@kbn/alerting-plugin/server'; import { DEFAULT_MAX_SIGNALS } from '../../../../../../common/constants'; import type { RuleUpdateProps } from '../../../../../../common/api/detection_engine/model/rule_schema'; @@ -15,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; @@ -33,18 +33,7 @@ export const updateRules = async ({ return null; } - const requiredFieldsWithEcs = (ruleUpdate.required_fields ?? []).map( - (requiredFieldWithoutEcs) => { - const isEcsField = Boolean( - ecsFieldMap[requiredFieldWithoutEcs.name]?.type === requiredFieldWithoutEcs.type - ); - - return { - ...requiredFieldWithoutEcs, - ecs: isEcsField, - }; - } - ); + const requiredFieldsWithEcs = addEcsToRequiredFields(ruleUpdate.required_fields); const alertActions = ruleUpdate.actions?.map((action) => transformRuleToAlertAction(action)) ?? []; 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 21b4da51f2284..21f1d88a3f3f9 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 @@ -11,7 +11,6 @@ import { stringifyZodError } from '@kbn/zod-helpers'; import { BadRequestError } from '@kbn/securitysolution-es-utils'; import { ruleTypeMappings } from '@kbn/securitysolution-rules'; import type { ResolvedSanitizedRule, SanitizedRule } from '@kbn/alerting-plugin/common'; -import { ecsFieldMap } from '@kbn/alerts-as-data-utils'; import type { RequiredOptional } from '@kbn/zod-helpers'; import { @@ -23,7 +22,6 @@ import { import type { PatchRuleRequestBody } from '../../../../../common/api/detection_engine/rule_management'; import type { RelatedIntegrationArray, - RequiredFieldArray, RuleCreateProps, TypeSpecificCreateProps, TypeSpecificResponse, @@ -79,6 +77,7 @@ import type { } from '../../rule_schema'; import { transformFromAlertThrottle, transformToActionFrequency } from './rule_actions'; import { + addEcsToRequiredFields, convertAlertSuppressionToCamel, convertAlertSuppressionToSnake, migrateLegacyInvestigationFields, @@ -432,13 +431,14 @@ export const patchTypeSpecificSnakeToCamel = ( export const convertPatchAPIToInternalSchema = ( nextParams: PatchRuleRequestBody & { related_integrations?: RelatedIntegrationArray; - required_fields?: RequiredFieldArray; }, existingRule: SanitizedRule ): InternalRuleUpdate => { const typeSpecificParams = patchTypeSpecificSnakeToCamel(nextParams, existingRule.params); const existingParams = existingRule.params; + const requiredFieldsWithEcs = addEcsToRequiredFields(nextParams.required_fields); + const alertActions = nextParams.actions?.map((action) => transformRuleToAlertAction(action)) ?? existingRule.actions; const throttle = nextParams.throttle ?? transformFromAlertThrottle(existingRule); @@ -463,7 +463,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: requiredFieldsWithEcs, riskScore: nextParams.risk_score ?? existingParams.riskScore, riskScoreMapping: nextParams.risk_score_mapping ?? existingParams.riskScoreMapping, ruleNameOverride: nextParams.rule_name_override ?? existingParams.ruleNameOverride, @@ -488,7 +488,6 @@ export const convertPatchAPIToInternalSchema = ( }; }; -// eslint-disable-next-line complexity export const convertCreateAPIToInternalSchema = ( input: RuleCreateProps & { related_integrations?: RelatedIntegrationArray; @@ -502,16 +501,7 @@ export const convertCreateAPIToInternalSchema = ( const alertActions = input.actions?.map((action) => transformRuleToAlertAction(action)) ?? []; const actions = transformToActionFrequency(alertActions, input.throttle); - const requiredFieldsWithEcs = (input.required_fields ?? []).map((requiredFieldWithoutEcs) => { - const isEcsField = Boolean( - ecsFieldMap[requiredFieldWithoutEcs.name]?.type === requiredFieldWithoutEcs.type - ); - - return { - ...requiredFieldWithoutEcs, - ecs: isEcsField, - }; - }); + const requiredFieldsWithEcs = addEcsToRequiredFields(input.required_fields); return { name: input.name, 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..34fbca6e33c5e 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,7 @@ import type { AlertSuppression, AlertSuppressionCamel, InvestigationFields, + RequiredFieldInput, RuleResponse, } from '../../../../../common/api/detection_engine/model/rule_schema'; import type { @@ -388,3 +391,15 @@ export const migrateLegacyInvestigationFields = ( return investigationFields; }; + +export const addEcsToRequiredFields = (requiredFields?: RequiredFieldInput[]) => + (requiredFields ?? []).map((requiredFieldWithoutEcs) => { + const isEcsField = Boolean( + ecsFieldMap[requiredFieldWithoutEcs.name]?.type === requiredFieldWithoutEcs.type + ); + + return { + ...requiredFieldWithoutEcs, + ecs: isEcsField, + }; + }); 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..aed7591d81f69 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); @@ -162,6 +166,13 @@ export default ({ getService }: FtrProviderContext): void => { const [ruleJson] = body.toString().split(/\n/); expect(JSON.parse(ruleJson)).toMatchObject(defaultableFields); + + const parsedRule = JSON.parse(ruleJson); + + expect(parsedRule.required_fields).to.eql([ + { name: '@timestamp', type: 'date', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ]); }); it('should export rules with actions connectors', async () => { 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..3f092e8f8d76e 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 @@ -6,12 +6,16 @@ */ import expect from 'expect'; -import { RuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { + RuleCreateProps, + BaseDefaultableFields, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; import { getSimpleRule, getCustomQueryRuleParams, getSimpleRuleOutputWithoutRuleId, + getCustomQueryRuleParams, getSimpleRuleWithoutRuleId, removeServerGeneratedProperties, removeServerGeneratedPropertiesIncludingRuleId, @@ -95,6 +99,36 @@ export default ({ getService }: FtrProviderContext) => { expect(createdRule).toMatchObject(expectedRule); }); + it('should create a rule with defaultable fields', async () => { + const defaultableFields: BaseDefaultableFields = { + required_fields: [ + { name: '@timestamp', type: 'date' }, + { name: 'my-non-ecs-field', type: 'keyword' }, + ], + }; + const mockRule = getCustomQueryRuleParams({ rule_id: 'rule-1', ...defaultableFields }); + + const { body: createdRuleResponse } = await securitySolutionApi + .createRule({ body: mockRule }) + .expect(200); + + expect(createdRuleResponse.required_fields).to.eql([ + { name: '@timestamp', type: 'date', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ]); + + const { body: createdRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(createdRule.required_fields).to.eql([ + { name: '@timestamp', type: 'date', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ]); + }); + it('should create a single rule without an input index', async () => { const rule: RuleCreateProps = { name: 'Simple Rule Query', 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..97951fe77ddf8 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 @@ -6,6 +6,7 @@ */ import expect from 'expect'; +import { BaseDefaultableFields } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { EsArchivePathBuilder } from '../../../../../es_archive_path_builder'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; @@ -13,6 +14,7 @@ import { getCustomQueryRuleParams, getSimpleRule, getSimpleRuleOutput, + getCustomQueryRuleParams, getSimpleRuleOutputWithoutRuleId, getSimpleRuleWithoutRuleId, removeServerGeneratedProperties, @@ -94,6 +96,36 @@ export default ({ getService }: FtrProviderContext): void => { expect(createdRule).toMatchObject(expectedRule); }); + it('should create a rule with defaultable fields', async () => { + const defaultableFields: BaseDefaultableFields = { + required_fields: [ + { name: '@timestamp', type: 'date' }, + { name: 'my-non-ecs-field', type: 'keyword' }, + ], + }; + const mockRule = getCustomQueryRuleParams({ rule_id: 'rule-1', ...defaultableFields }); + + const { body: createdRulesBulkResponse } = await securitySolutionApi + .bulkCreateRules({ body: [mockRule] }) + .expect(200); + + expect(createdRulesBulkResponse[0].required_fields).to.eql([ + { name: '@timestamp', type: 'date', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ]); + + const { body: createdRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(createdRule.required_fields).to.eql([ + { name: '@timestamp', type: 'date', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ]); + }); + it('should create a single rule without a rule_id', async () => { const { body } = await securitySolutionApi .bulkCreateRules({ body: [getSimpleRuleWithoutRuleId()] }) 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..9dfc503ea6e20 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 @@ -7,6 +7,7 @@ import expect from 'expect'; +import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; import { BaseDefaultableFields } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { binaryToString, getCustomQueryRuleParams } from '../../../utils'; @@ -57,7 +58,12 @@ 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); await securitySolutionApi.createRule({ body: ruleToExport }); @@ -69,6 +75,26 @@ export default ({ getService }: FtrProviderContext): void => { const exportedRule = JSON.parse(body.toString().split(/\n/)[0]); + expect(exportedRule).toMatchObject({ + required_fields: [ + { name: '@timestamp', type: 'date', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ], + }); + }); + + it('should have export summary reflecting a number of rules', async () => { + await createRule(supertest, log, getCustomQueryRuleParams()); + + await securitySolutionApi.createRule({ body: ruleToExport }); + + const { body } = await securitySolutionApi + .exportRules({ query: {}, body: null }) + .expect(200) + .parse(binaryToString); + + const exportedRule = JSON.parse(body.toString().split(/\n/)[0]); + expect(exportedRule).toMatchObject(defaultableFields); }); 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..2008ca53145ff 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,7 +125,12 @@ 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', ecs: true }, + { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + ], }; + const ruleToImport = getCustomQueryRuleParams({ ...defaultableFields, rule_id: 'rule-1', 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..64e0781a6779f 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 @@ -97,6 +97,35 @@ export default ({ getService }: FtrProviderContext) => { expect(patchedRule).toMatchObject(expectedRule); }); + it('should patch defaultable fields', async () => { + const expectedRule = { + required_fields: [{ name: '@timestamp', type: 'date', ecs: true }], + }; + + await securitySolutionApi.createRule({ + body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), + }); + + const { body: patchedRuleResponse } = await securitySolutionApi + .patchRule({ + body: { + rule_id: 'rule-1', + required_fields: [{ name: '@timestamp', type: 'date' }], + }, + }) + .expect(200); + + expect(patchedRuleResponse.required_fields).to.eql(expectedRule.required_fields); + + const { body: patchedRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(patchedRule.required_fields).to.eql(expectedRule.required_fields); + }); + it('@skipInServerless should return a "403 forbidden" using a rule_id of type "machine learning"', async () => { await createRule(supertest, log, getSimpleRule('rule-1')); 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..a63729a2c4aa5 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 @@ -98,6 +98,37 @@ export default ({ getService }: FtrProviderContext) => { expect(patchedRule).toMatchObject(expectedRule); }); + it('should patch defaultable fields', async () => { + const expectedRule = { + required_fields: [{ name: '@timestamp', type: 'date', ecs: true }], + }; + + await securitySolutionApi.createRule({ + body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), + }); + + const { body: patchedRulesBulkResponse } = await securitySolutionApi + .bulkPatchRules({ + body: [ + { + rule_id: 'rule-1', + required_fields: [{ name: '@timestamp', type: 'date' }], + }, + ], + }) + .expect(200); + + expect(patchedRulesBulkResponse[0].required_fields).to.eql(expectedRule.required_fields); + + const { body: patchedRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(patchedRule.required_fields).to.eql(expectedRule.required_fields); + }); + it('should patch two rule properties of name using the two rules rule_id', async () => { await createRule(supertest, log, getSimpleRule('rule-1')); await createRule(supertest, log, getSimpleRule('rule-2')); 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..85f7ad31c1f28 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 @@ -97,6 +97,35 @@ export default ({ getService }: FtrProviderContext) => { expect(updatedRule).toMatchObject(expectedRule); }); + it('should update a rule with defaultable fields', async () => { + const expectedRule = { + ...getCustomQueryRuleParams({ + rule_id: 'rule-1', + }), + 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, + }) + .expect(200); + + expect(updatedRuleResponse.required_fields).to.eql(expectedRule.required_fields); + + const { body: updatedRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(updatedRule.required_fields).to.eql(expectedRule.required_fields); + }); + it('@skipInServerless should return a 403 forbidden if it is a machine learning job', async () => { await createRule(supertest, log, getSimpleRule('rule-1')); 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..bb273df1df34f 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 @@ -96,6 +96,35 @@ export default ({ getService }: FtrProviderContext) => { expect(updatedRule).toMatchObject(expectedRule); }); + it('should update a rule with defaultable fields', async () => { + const expectedRule = { + ...getCustomQueryRuleParams({ + rule_id: 'rule-1', + }), + 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], + }) + .expect(200); + + expect(updatedRulesBulkResponse[0].required_fields).to.eql(expectedRule.required_fields); + + const { body: updatedRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(updatedRule.required_fields).to.eql(expectedRule.required_fields); + }); + it('should update two rule properties of name using the two rules rule_id', async () => { await createRule(supertest, log, getSimpleRule('rule-1')); From cc6f6cd8628e6c7869771a26692011f46bb16e9f Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Fri, 26 Apr 2024 12:51:03 +0200 Subject: [PATCH 10/66] Remove `related_integrations` from `PrebuiltRuleAsset` type --- .../prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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..2b3cee2c57a91 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 @@ -8,6 +8,8 @@ import * as z from 'zod'; import { RequiredFieldArray, + RelatedIntegrationArray, + SetupGuide, RuleSignatureId, RuleVersion, BaseCreateProps, @@ -33,6 +35,5 @@ export const PrebuiltRuleAsset = BaseCreateProps.and(TypeSpecificCreateProps).an z.object({ rule_id: RuleSignatureId, version: RuleVersion, - required_fields: RequiredFieldArray.optional(), }) ); From 3c7eaa5cdc465a305cc24b1e0680c6d5244d3975 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Fri, 3 May 2024 22:08:28 +0200 Subject: [PATCH 11/66] Fix UI issues, update tests --- .../required_fields/required_fields.test.tsx | 278 +++++++++++---- .../required_fields/required_fields.tsx | 29 +- .../required_fields/required_fields_row.tsx | 336 +++++++++++++----- .../required_fields/translations.ts | 37 ++ .../step_define_rule/index.test.tsx | 187 +++++++++- .../components/step_define_rule/schema.tsx | 15 +- .../pages/rule_creation/helpers.test.ts | 16 + .../pages/rule_creation/helpers.ts | 17 +- .../components/rules_table/__mocks__/mock.ts | 2 +- .../pages/detection_engine/rules/types.ts | 5 +- 10 files changed, 732 insertions(+), 190 deletions(-) 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 index 7f6d10965c8d3..d8f358d41a405 100644 --- 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 @@ -7,11 +7,12 @@ import React from 'react'; import { screen, render, act, fireEvent, waitFor } from '@testing-library/react'; -import { FIELD_TYPES, Form, useForm } from '../../../../shared_imports'; +import { Form, useForm } from '../../../../shared_imports'; import type { DataViewFieldBase } from '@kbn/es-query'; import { RequiredFields } from './required_fields'; import type { RequiredFieldWithOptionalEcs } from './types'; +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine'; describe('RequiredFields form part', () => { it('displays the required fields label', () => { @@ -99,6 +100,26 @@ describe('RequiredFields form part', () => { 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(); + }); + it('field type dropdown allows to choose from options if multiple types are available', async () => { const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; @@ -116,19 +137,6 @@ describe('RequiredFields form part', () => { expect(screen.getByDisplayValue('keyword')).toBeVisible(); }); - it('field type dropdown is disabled if only a single type option is available', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; - - const indexPatternFields: DataViewFieldBase[] = [ - createIndexPatternField({ name: 'field1', esTypes: ['string'] }), - ]; - - render(); - - expect(screen.getByDisplayValue('string')).toBeVisible(); - expect(screen.getByDisplayValue('string')).toBeDisabled(); - }); - it('user can remove a required field', async () => { const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; @@ -192,29 +200,6 @@ describe('RequiredFields form part', () => { expect(screen.getByTestId('addRequiredFieldButton')).toBeDisabled(); }); - it('adding a new required field is disabled when there are no available field names left', async () => { - const indexPatternFields: DataViewFieldBase[] = [ - createIndexPatternField({ name: 'field1', esTypes: ['string'] }), - createIndexPatternField({ name: 'field2', esTypes: ['keyword'] }), - ]; - - render(); - - await addRequiredFieldRow(); - await selectFirstEuiComboBoxOption({ - comboBoxToggleButton: getSelectToggleButtonForName('empty'), - }); - - expect(screen.getByTestId('addRequiredFieldButton')).toBeEnabled(); - - await addRequiredFieldRow(); - await selectFirstEuiComboBoxOption({ - comboBoxToggleButton: getSelectToggleButtonForName('empty'), - }); - - expect(screen.getByTestId('addRequiredFieldButton')).toBeDisabled(); - }); - describe('warnings', () => { it('displays a warning when a selected field name is not found within index patterns', async () => { const initialState: RequiredFieldWithOptionalEcs[] = [ @@ -236,6 +221,15 @@ describe('RequiredFields form part', () => { 'Field "field-that-does-not-exist" is not found within 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-euiicon-type="warning"]')).toHaveLength(1); }); it('displays a warning when a selected field type is not found within index patterns', async () => { @@ -258,20 +252,46 @@ describe('RequiredFields form part', () => { 'Field "field1" with type "type-that-does-not-exist" is not found within 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-euiicon-type="warning"]')).toHaveLength(1); }); - it(`doesn't display a warning for a an empty row`, async () => { + it('displays a warning only for field name when both field name and type are not found within index patterns', async () => { + const initialState: RequiredFieldWithOptionalEcs[] = [ + { name: 'field-that-does-not-exist', type: 'type-that-does-not-exist' }, + ]; + const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), ]; - render(); + render(); - await addRequiredFieldRow(); + expect( + screen.getByText('Some fields are not found within specified index patterns.') + ).toBeVisible(); expect( - screen.queryByText('Some fields are not found within specified index patterns.') - ).toBeNull(); + screen.getByText( + 'Field "field-that-does-not-exist" is not found within 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-euiicon-type="warning"]')).toHaveLength(1); }); it(`doesn't display a warning when all selected fields are found within index patterns`, async () => { @@ -287,6 +307,119 @@ describe('RequiredFields form part', () => { screen.queryByText('Some fields are not found within specified index patterns.') ).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.queryByText('Some fields are not found within specified index patterns.') + ).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: RequiredFieldWithOptionalEcs[] = [{ 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', () => { @@ -342,22 +475,6 @@ describe('RequiredFields form part', () => { }); }); - it('submits without empty rows', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; - - const handleSubmit = jest.fn(); - - render(); - - await addRequiredFieldRow(); - await submitForm(); - - expect(handleSubmit).toHaveBeenCalledWith({ - data: [{ name: 'field1', type: 'string' }], - isValid: true, - }); - }); - it('submits newly added required fields', async () => { const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), @@ -481,7 +598,7 @@ describe('RequiredFields form part', () => { }); }); -function createIndexPatternField(overrides: Partial): DataViewFieldBase { +export function createIndexPatternField(overrides: Partial): DataViewFieldBase { return { name: 'one', type: 'string', @@ -500,7 +617,7 @@ async function getDropdownOptions(dropdownToggleButton: HTMLElement): Promise { +export function addRequiredFieldRow(): Promise { return act(async () => { fireEvent.click(screen.getByText('Add required field')); }); @@ -510,7 +627,10 @@ function showEuiComboBoxOptions(comboBoxToggleButton: HTMLElement): Promise { - expect(screen.getByRole('listbox')).toBeInTheDocument(); + const listWithOptionsElement = document.querySelector('[role="listbox"]'); + const emptyListElement = document.querySelector('.euiComboBoxOptionsList__empty'); + + expect(listWithOptionsElement || emptyListElement).toBeInTheDocument(); }); } @@ -534,7 +654,9 @@ function selectEuiComboBoxOption({ return act(async () => { await showEuiComboBoxOptions(comboBoxToggleButton); - const options = screen.getAllByRole('option'); + const options = Array.from( + document.querySelectorAll('[data-test-subj*="comboBoxOptionsList"] [role="option"]') + ); if (typeof optionText === 'string') { const optionToSelect = options.find((option) => option.textContent === optionText); @@ -562,7 +684,29 @@ function selectFirstEuiComboBoxOption({ return selectEuiComboBoxOption({ comboBoxToggleButton, optionIndex: 0 }); } -function getSelectToggleButtonForName(value: string): HTMLElement { +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; @@ -582,7 +726,7 @@ function submitForm(): Promise { interface TestFormProps { initialState?: RequiredFieldWithOptionalEcs[]; - onSubmit?: (args: { data: RequiredFieldWithOptionalEcs[]; isValid: boolean }) => void; + onSubmit?: (args: { data: RequiredFieldInput[]; isValid: boolean }) => void; indexPatternFields?: DataViewFieldBase[]; isIndexPatternLoading?: boolean; } @@ -594,12 +738,6 @@ function TestForm({ onSubmit, }: TestFormProps): JSX.Element { const { form } = useForm({ - options: { stripEmptyFields: true }, - schema: { - requiredFieldsField: { - type: FIELD_TYPES.JSON, - }, - }, defaultValue: { requiredFieldsField: initialState, }, 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 index 500b2d6b7e4d0..7eea5583e9653 100644 --- 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 @@ -7,15 +7,13 @@ import React from 'react'; import { EuiButtonEmpty, EuiCallOut, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; import type { DataViewFieldBase } from '@kbn/es-query'; +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine'; import { UseArray, useFormData } from '../../../../shared_imports'; import { RequiredFieldRow } from './required_fields_row'; import * as ruleDetailsI18n from '../../../rule_management/components/rule_details/translations'; import * as i18n from './translations'; -import type { RequiredFieldWithOptionalEcs } from './types'; - interface RequiredFieldsProps { path: string; indexPatternFields?: DataViewFieldBase[]; @@ -31,9 +29,9 @@ export const RequiredFields = ({ return ( - {({ items, addItem, removeItem }) => { + {({ items, addItem, removeItem, form, error }) => { const [formData] = useFormDataResult; - const fieldValue: RequiredFieldWithOptionalEcs[] = formData[path] ?? []; + const fieldValue: RequiredFieldInput[] = formData[path] ?? []; const selectedFieldNames = fieldValue.map(({ name }) => name); @@ -46,11 +44,6 @@ export const RequiredFields = ({ (name) => !selectedFieldNames.includes(name) ); - const availableNameOptions: Array> = - availableFieldNames.map((availableFieldName) => ({ - label: availableFieldName, - })); - const typesByFieldName: Record = fieldsWithTypes.reduce( (accumulator, browserField) => { if (browserField.esTypes) { @@ -63,8 +56,7 @@ export const RequiredFields = ({ const isEmptyRowDisplayed = !!fieldValue.find(({ name }) => name === ''); - const isAddNewFieldButtonDisabled = - isIndexPatternLoading || isEmptyRowDisplayed || availableNameOptions.length === 0; + const isAddNewFieldButtonDisabled = isIndexPatternLoading || isEmptyRowDisplayed; const nameWarnings = fieldValue .filter(({ name }) => name !== '') @@ -78,15 +70,19 @@ export const RequiredFields = ({ const typeWarnings = fieldValue .filter(({ name }) => name !== '') .reduce>((warnings, { name, type }) => { - if (!isIndexPatternLoading && !typesByFieldName[name]?.includes(type)) { - warnings[name] = i18n.FIELD_TYPE_NOT_FOUND_WARNING(name, type); + if ( + !isIndexPatternLoading && + typesByFieldName[name] && + !typesByFieldName[name].includes(type) + ) { + warnings[`${name}-${type}`] = i18n.FIELD_TYPE_NOT_FOUND_WARNING(name, type); } return warnings; }, {}); - const getWarnings = (name: string) => ({ + const getWarnings = ({ name, type }: { name: string; type: string }) => ({ nameWarning: nameWarnings[name] || '', - typeWarning: typeWarnings[name] || '', + typeWarning: typeWarnings[`${name}-${type}`] || '', }); const hasWarnings = @@ -125,6 +121,7 @@ export const RequiredFields = ({ getWarnings={getWarnings} typesByFieldName={typesByFieldName} availableFieldNames={availableFieldNames} + parentFieldPath={path} /> ))} 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 index 335e7093cf6f5..31b721fd29803 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { EuiButtonIcon, EuiComboBox, @@ -20,15 +20,29 @@ import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { FIELD_TYPES, UseField } from '../../../../shared_imports'; import * as i18n from './translations'; -import type { FieldHook, ArrayItem, FieldConfig } from '../../../../shared_imports'; +import type { + ArrayItem, + ERROR_CODE, + FieldConfig, + FieldHook, + FormData, + ValidationFunc, +} from '../../../../shared_imports'; +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; import type { RequiredFieldWithOptionalEcs } from './types'; +const SINGLE_SELECTION_AS_PLAIN_TEXT = { asPlainText: true }; + interface RequiredFieldRowProps { item: ArrayItem; removeItem: (id: number) => void; - typesByFieldName: Record; + typesByFieldName: Record; availableFieldNames: string[]; - getWarnings: (name: string) => { nameWarning: string; typeWarning: string }; + getWarnings: ({ name, type }: { name: string; type: string }) => { + nameWarning: string; + typeWarning: string; + }; + parentFieldPath: string; } export const RequiredFieldRow = ({ @@ -37,17 +51,40 @@ export const RequiredFieldRow = ({ typesByFieldName, availableFieldNames, getWarnings, + parentFieldPath, }: RequiredFieldRowProps) => { const handleRemove = useCallback(() => removeItem(item.id), [removeItem, item.id]); + const rowFieldConfig: FieldConfig< + RequiredFieldWithOptionalEcs, + RequiredFieldInput, + RequiredFieldInput + > = useMemo( + () => ({ + type: FIELD_TYPES.JSON, + deserializer: (value) => { + const rowValueWithoutEcs: RequiredFieldInput = { + name: value.name, + type: value.type, + }; + + return rowValueWithoutEcs; + }, + validations: [{ validator: makeValidateRequiredField(parentFieldPath) }], + defaultValue: { name: '', type: '' }, + }), + [parentFieldPath] + ); + return ( ; + field: FieldHook; onRemove: () => void; - typesByFieldName: Record; + typesByFieldName: Record; availableFieldNames: string[]; - getWarnings: (name: string) => { nameWarning: string; typeWarning: string }; + getWarnings: ({ name, type }: { name: string; type: string }) => { + nameWarning: string; + typeWarning: string; + }; + itemId: string; } const RequiredFieldRowInner = ({ @@ -71,73 +112,111 @@ const RequiredFieldRowInner = ({ onRemove, availableFieldNames, getWarnings, + itemId, }: RequiredFieldRowInnerProps) => { // Do not not add empty option to the list of selectable field names - const selectableNameOptions: Array> = ( - field.value.name ? [field.value.name] : [] - ) - .concat(availableFieldNames) - .map((name) => ({ - label: name, - value: name, - })); - - const selectedNameOption = selectableNameOptions.find( - (option) => option.label === field.value.name + const selectableNameOptions: Array> = useMemo( + () => + (field.value.name ? [field.value.name] : []).concat(availableFieldNames).map((name) => ({ + label: name, + value: name, + })), + [availableFieldNames, field.value.name] ); - const selectedNameOptions = selectedNameOption ? [selectedNameOption] : []; + const [selectedNameOptions, setSelectedNameOptions] = useState< + Array> + >(() => { + const selectedNameOption = selectableNameOptions.find( + (option) => option.label === field.value.name + ); - const typesAvailableForSelectedName = typesByFieldName[field.value.name]; + return selectedNameOption ? [selectedNameOption] : []; + }); - let selectableTypeOptions: Array> = []; - if (typesAvailableForSelectedName) { - const isSelectedTypeAvailable = typesAvailableForSelectedName.includes(field.value.type); + useEffect(() => { + const selectedNameOption = selectableNameOptions.find( + (option) => option.label === field.value.name + ); - selectableTypeOptions = typesAvailableForSelectedName.map((type) => ({ - label: type, - value: type, - })); + setSelectedNameOptions(selectedNameOption ? [selectedNameOption] : []); + }, [field.value.name, selectableNameOptions]); - if (!isSelectedTypeAvailable) { - // case: field name exists, but such type is not among the list of field types - selectableTypeOptions.push({ label: field.value.type, value: field.value.type }); - } - } else { - if (field.value.type) { - // case: no such field name in index patterns - selectableTypeOptions = [ - { - label: field.value.type, - value: field.value.type, - }, - ]; + const selectableTypeOptions: Array> = useMemo(() => { + const typesAvailableForSelectedName = typesByFieldName[field.value.name]; + + let _selectableTypeOptions: Array> = []; + if (typesAvailableForSelectedName) { + const isSelectedTypeAvailable = typesAvailableForSelectedName.includes(field.value.type); + + _selectableTypeOptions = typesAvailableForSelectedName.map((type) => ({ + label: type, + value: type, + })); + + if (!isSelectedTypeAvailable) { + // case: field name exists, but such type is not among the list of field types + _selectableTypeOptions.push({ label: field.value.type, value: field.value.type }); + } + } else { + if (field.value.type) { + // case: no such field name in index patterns + _selectableTypeOptions = [ + { + label: field.value.type, + value: field.value.type, + }, + ]; + } } - } - const selectedTypeOption = selectableTypeOptions.find( - (option) => option.value === field.value.type - ); + return _selectableTypeOptions; + }, [field.value.name, field.value.type, typesByFieldName]); + + const [selectedTypeOptions, setSelectedTypeOptions] = useState< + Array> + >(() => { + const selectedTypeOption = selectableTypeOptions.find( + (option) => option.value === field.value.type + ); + + return selectedTypeOption ? [selectedTypeOption] : []; + }); - const selectedTypeOptions = selectedTypeOption ? [selectedTypeOption] : []; + useEffect(() => { + const selectedTypeOption = selectableTypeOptions.find( + (option) => option.value === field.value.type + ); - const { nameWarning, typeWarning } = getWarnings(field.value.name); + setSelectedTypeOptions(selectedTypeOption ? [selectedTypeOption] : []); + }, [field.value.type, selectableTypeOptions]); - const warningText = nameWarning || typeWarning; + 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; const handleNameChange = useCallback( - ([newlySelectedOption]: Array>) => { - const updatedName = newlySelectedOption.value || ''; + (selectedOptions: Array>) => { + const newlySelectedOption: EuiComboBoxOptionOption | undefined = selectedOptions[0]; + + if (!newlySelectedOption) { + setSelectedNameOptions([]); + return; + } - const isCurrentTypeAvailableForNewName = typesByFieldName[updatedName]?.includes( - field.value.type - ); + const updatedName = newlySelectedOption?.value || ''; - const updatedType = isCurrentTypeAvailableForNewName - ? field.value.type - : typesByFieldName[updatedName][0]; + const updatedType = pickTypeForName(updatedName, field.value.type, typesByFieldName); - const updatedFieldValue: RequiredFieldWithOptionalEcs = { + const updatedFieldValue: RequiredFieldInput = { name: updatedName, type: updatedType, }; @@ -148,10 +227,17 @@ const RequiredFieldRowInner = ({ ); const handleTypeChange = useCallback( - ([newlySelectedOption]: Array>) => { - const updatedType = newlySelectedOption.value || ''; + (selectedOptions: Array>) => { + const newlySelectedOption: EuiComboBoxOptionOption | undefined = selectedOptions[0]; - const updatedFieldValue: RequiredFieldWithOptionalEcs = { + if (!newlySelectedOption) { + setSelectedTypeOptions([]); + return; + } + + const updatedType = newlySelectedOption?.value || ''; + + const updatedFieldValue: RequiredFieldInput = { name: field.value.name, type: updatedType, }; @@ -161,13 +247,43 @@ const RequiredFieldRowInner = ({ [field] ); + const handleAddCustomName = useCallback( + (newName: string) => { + const updatedFieldValue: RequiredFieldInput = { + name: newName, + type: pickTypeForName(newName, field.value.type, typesByFieldName), + }; + + field.setValue(updatedFieldValue); + }, + [field, typesByFieldName] + ); + + const handleAddCustomType = useCallback( + (newType: string) => { + const updatedFieldValue: RequiredFieldInput = { + name: field.value.name, + type: newType, + }; + + field.setValue(updatedFieldValue); + }, + [field] + ); + return ( - {warningText} + warningMessage && !hasError ? ( + + {warningMessage} ) : ( '' @@ -179,20 +295,23 @@ const RequiredFieldRowInner = ({ ) : undefined } @@ -201,25 +320,23 @@ const RequiredFieldRowInner = ({ ) : undefined } @@ -239,10 +356,63 @@ const RequiredFieldRowInner = ({ ); }; -const REQUIRED_FIELDS_FIELD_CONFIG: FieldConfig< - RequiredFieldWithOptionalEcs, - RequiredFieldWithOptionalEcs -> = { - type: FIELD_TYPES.JSON, - defaultValue: { name: '', type: '' }, -}; +function makeValidateRequiredField(parentFieldPath: string) { + return function validateRequiredField( + ...args: Parameters> + ): 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, + }; + } + }; +} + +function pickTypeForName( + currentName: string, + currentType: string, + typesByFieldName: Record +) { + const typesAvailableForNewName = typesByFieldName[currentName] || []; + const isCurrentTypeAvailableForNewName = typesAvailableForNewName.includes(currentType); + + let updatedType = currentType; + if (isCurrentTypeAvailableForNewName) { + updatedType = currentType; + } else if (typesAvailableForNewName.length > 0) { + updatedType = typesAvailableForNewName[0]; + } + + return updatedType; +} 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 index b493ca2c31471..a6972be35a9ce 100644 --- 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 @@ -7,6 +7,20 @@ import { i18n } from '@kbn/i18n'; +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 REQUIRED_FIELDS_HELP_TEXT = i18n.translate( 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.fieldRequiredFieldsHelpText', { @@ -66,3 +80,26 @@ export const FIELD_TYPE_NOT_FOUND_WARNING = (name: string, type: string) => defaultMessage: `Field "{name}" with type "{type}" is not found within 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_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/schema.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx index 2dbe7955d257b..99502fbb97666 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx @@ -48,7 +48,7 @@ import { getQueryRequiredMessage } from './utils'; export const schema: FormSchema = { index: { defaultValue: [], - fieldsToValidateOnChange: ['index', 'queryBar', 'requiredFields'], + fieldsToValidateOnChange: ['index', 'queryBar'], type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fiedIndexPatternsLabel', @@ -248,7 +248,18 @@ export const schema: FormSchema = { type: FIELD_TYPES.JSON, }, requiredFields: { - type: FIELD_TYPES.JSON, + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel', + { + defaultMessage: 'Required fields', + } + ), + helpText: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText', + { + defaultMessage: 'Fields required for this Rule to function.', + } + ), }, timeline: { label: i18n.translate( 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 ada328ac48fb9..77d5644ec4c4b 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); @@ -404,7 +411,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep const baseFields = { type: ruleType, related_integrations: defineStepData.relatedIntegrations?.filter((ri) => !isEmpty(ri.package)), - required_fields: removeEmptyRequiredFieldsValues(defineStepData.requiredFields), + required_fields: removeEmptyRequiredFields(defineStepData.requiredFields), ...(timeline.id != null && timeline.title != null && { timeline_id: timeline.id, @@ -427,6 +434,8 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep } : {}; + const requiredFields = removeEmptyRequiredFields(defineStepData.requiredFields ?? []); + const typeFields = isMlFields(ruleFields) ? { anomaly_threshold: ruleFields.anomalyThreshold, @@ -439,6 +448,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 ?? [], @@ -466,6 +476,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, @@ -480,6 +491,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, @@ -491,6 +503,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, @@ -499,6 +512,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep ? { language: ruleFields.queryBar?.query?.language, query: ruleFields.queryBar?.query?.query as string, + required_fields: requiredFields, } : { ...alertSuppressionFields, @@ -507,6 +521,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_management_ui/components/rules_table/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts index 7552ac9b711f3..0de6e5d1e0844 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts @@ -216,7 +216,7 @@ export const mockDefineStepRule = (): DefineStepRule => ({ 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 { From 44f48d094cd039cc5c3cce05d0ac262504ab80eb Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Sat, 4 May 2024 17:18:40 +0200 Subject: [PATCH 12/66] Remove required_fields from baseFields --- .../rule_creation_ui/pages/rule_creation/helpers.ts | 1 - 1 file changed, 1 deletion(-) 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 77d5644ec4c4b..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 @@ -411,7 +411,6 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep const baseFields = { type: ruleType, related_integrations: defineStepData.relatedIntegrations?.filter((ri) => !isEmpty(ri.package)), - required_fields: removeEmptyRequiredFields(defineStepData.requiredFields), ...(timeline.id != null && timeline.title != null && { timeline_id: timeline.id, From a4294c42aadbff0bc518cc7eba0af9f370064f33 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Sat, 4 May 2024 18:31:58 +0200 Subject: [PATCH 13/66] Update i18n JSONs --- x-pack/plugins/translations/translations/fr-FR.json | 2 -- x-pack/plugins/translations/translations/ja-JP.json | 2 -- x-pack/plugins/translations/translations/zh-CN.json | 2 -- 3 files changed, 6 deletions(-) diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 3852a923f8b08..845fe18530a5a 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -34242,8 +34242,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel": "URL de référence", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText": "Champs requis pour le fonctionnement de cette règle.", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel": "Champ requis", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText": "Intégration liée à cette règle.", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel": "Intégrations liées", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideHelpText": "Choisissez un champ de l'événement source pour remplir le nom de règle dans la liste d'alertes.", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideLabel": "Remplacement du nom de règle", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldTagsHelpText": "Saisissez une ou plusieurs balises d'identification personnalisées pour cette règle. Appuyez sur Entrée après chaque balise pour en ajouter une nouvelle.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 24f2600b2a51e..351327102a5f1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -34211,8 +34211,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel": "参照URL", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText": "このルールの機能に必要なフィールド。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel": "必須フィールド", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText": "統合はこのルールに関連しています。", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel": "関連する統合", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideHelpText": "ソースイベントからフィールドを選択し、アラートリストのルール名を入力します。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideLabel": "ルール名無効化", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldTagsHelpText": "このルールの1つ以上のカスタム識別タグを入力します。新しいタグを開始するには、各タグの後でEnterを押します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3a791e1197733..69f740ad5ac0e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -34254,8 +34254,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel": "引用 URL", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText": "此规则正常运行所需的字段。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel": "必填字段", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText": "与此规则相关的集成。", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel": "相关集成", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideHelpText": "从源事件中选择字段来填充告警列表中的规则名称。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideLabel": "规则名称覆盖", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldTagsHelpText": "为此规则键入一个或多个定制识别标签。在每个标签后按 Enter 键可开始新的标签。", From f25362a8f134e7a35946a6674712bc75a7ab04a0 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Sat, 4 May 2024 19:03:03 +0200 Subject: [PATCH 14/66] Merge API integration tests with Related Integerations --- .../model/rule_assets/prebuilt_rule_asset.ts | 3 -- .../create_rules.ts | 50 ++++++------------- .../create_rules_bulk.ts | 44 +++++----------- .../export_rules.ts | 21 ++------ .../import_rules.ts | 15 ++++-- .../patch_rules.ts | 13 +++-- .../patch_rules_bulk.ts | 40 +++------------ .../update_rules.ts | 35 +++---------- .../update_rules_bulk.ts | 35 +++---------- 9 files changed, 68 insertions(+), 188 deletions(-) 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 2b3cee2c57a91..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,9 +7,6 @@ import * as z from 'zod'; import { - RequiredFieldArray, - RelatedIntegrationArray, - SetupGuide, RuleSignatureId, RuleVersion, BaseCreateProps, 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 3f092e8f8d76e..4ee712eafa209 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 @@ -6,16 +6,12 @@ */ import expect from 'expect'; -import { - RuleCreateProps, - BaseDefaultableFields, -} from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { RuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { - getSimpleRule, getCustomQueryRuleParams, + getSimpleRule, getSimpleRuleOutputWithoutRuleId, - getCustomQueryRuleParams, getSimpleRuleWithoutRuleId, removeServerGeneratedProperties, removeServerGeneratedPropertiesIncludingRuleId, @@ -74,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', @@ -82,40 +78,25 @@ export default ({ getService }: FtrProviderContext) => { { package: 'package-a', version: '^1.2.3' }, { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], - }); - - const { body: createdRuleResponse } = await securitySolutionApi - .createRule({ body: expectedRule }) - .expect(200); - - expect(createdRuleResponse).toMatchObject(expectedRule); - - const { body: createdRule } = await securitySolutionApi - .readRule({ - query: { rule_id: 'rule-1' }, - }) - .expect(200); - - expect(createdRule).toMatchObject(expectedRule); - }); - - it('should create a rule with defaultable fields', async () => { - const defaultableFields: BaseDefaultableFields = { 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 mockRule = getCustomQueryRuleParams({ rule_id: 'rule-1', ...defaultableFields }); const { body: createdRuleResponse } = await securitySolutionApi - .createRule({ body: mockRule }) + .createRule({ body: ruleCreateProperties }) .expect(200); - expect(createdRuleResponse.required_fields).to.eql([ - { name: '@timestamp', type: 'date', ecs: true }, - { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, - ]); + expect(createdRuleResponse).toMatchObject(expectedRule); const { body: createdRule } = await securitySolutionApi .readRule({ @@ -123,10 +104,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - expect(createdRule.required_fields).to.eql([ - { name: '@timestamp', type: 'date', ecs: true }, - { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, - ]); + expect(createdRule).toMatchObject(expectedRule); }); it('should create a single rule without an input index', async () => { 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 97951fe77ddf8..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 @@ -6,7 +6,6 @@ */ import expect from 'expect'; -import { BaseDefaultableFields } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { EsArchivePathBuilder } from '../../../../../es_archive_path_builder'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; @@ -14,7 +13,6 @@ import { getCustomQueryRuleParams, getSimpleRule, getSimpleRuleOutput, - getCustomQueryRuleParams, getSimpleRuleOutputWithoutRuleId, getSimpleRuleWithoutRuleId, removeServerGeneratedProperties, @@ -71,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', @@ -79,40 +77,25 @@ export default ({ getService }: FtrProviderContext): void => { { package: 'package-a', version: '^1.2.3' }, { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], - }); - - const { body: createdRulesBulkResponse } = await securitySolutionApi - .bulkCreateRules({ body: [expectedRule] }) - .expect(200); - - expect(createdRulesBulkResponse[0]).toMatchObject(expectedRule); - - const { body: createdRule } = await securitySolutionApi - .readRule({ - query: { rule_id: 'rule-1' }, - }) - .expect(200); - - expect(createdRule).toMatchObject(expectedRule); - }); - - it('should create a rule with defaultable fields', async () => { - const defaultableFields: BaseDefaultableFields = { 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 mockRule = getCustomQueryRuleParams({ rule_id: 'rule-1', ...defaultableFields }); const { body: createdRulesBulkResponse } = await securitySolutionApi - .bulkCreateRules({ body: [mockRule] }) + .bulkCreateRules({ body: [ruleCreateProperties] }) .expect(200); - expect(createdRulesBulkResponse[0].required_fields).to.eql([ - { name: '@timestamp', type: 'date', ecs: true }, - { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, - ]); + expect(createdRulesBulkResponse[0]).toMatchObject(expectedRule); const { body: createdRule } = await securitySolutionApi .readRule({ @@ -120,10 +103,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(createdRule.required_fields).to.eql([ - { name: '@timestamp', type: 'date', ecs: true }, - { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, - ]); + expect(createdRule).toMatchObject(expectedRule); }); it('should create a single rule without a rule_id', async () => { 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 9dfc503ea6e20..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 @@ -7,7 +7,6 @@ import expect from 'expect'; -import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; import { BaseDefaultableFields } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { binaryToString, getCustomQueryRuleParams } from '../../../utils'; @@ -66,25 +65,13 @@ export default ({ getService }: FtrProviderContext): void => { const ruleToExport = getCustomQueryRuleParams(defaultableFields); - await securitySolutionApi.createRule({ body: ruleToExport }); - - const { body } = await securitySolutionApi - .exportRules({ query: {}, body: null }) - .expect(200) - .parse(binaryToString); - - const exportedRule = JSON.parse(body.toString().split(/\n/)[0]); - - expect(exportedRule).toMatchObject({ + const expectedRule = { + ...ruleToExport, required_fields: [ { name: '@timestamp', type: 'date', ecs: true }, { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, ], - }); - }); - - it('should have export summary reflecting a number of rules', async () => { - await createRule(supertest, log, getCustomQueryRuleParams()); + }; await securitySolutionApi.createRule({ body: ruleToExport }); @@ -95,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 2008ca53145ff..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 @@ -126,8 +126,8 @@ export default ({ getService }: FtrProviderContext): void => { { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, ], required_fields: [ - { name: '@timestamp', type: 'date', ecs: true }, - { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, + { name: '@timestamp', type: 'date' }, + { name: 'my-non-ecs-field', type: 'keyword' }, ], }; @@ -135,6 +135,15 @@ export default ({ getService }: FtrProviderContext): void => { ...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 @@ -148,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 64e0781a6779f..d18446a878115 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 a63729a2c4aa5..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,38 +68,11 @@ 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' }], }); - await securitySolutionApi.createRule({ - body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), - }); - - const { body: patchedRulesBulkResponse } = await securitySolutionApi - .bulkPatchRules({ - body: [ - { - rule_id: 'rule-1', - max_signals: expectedRule.max_signals, - setup: expectedRule.setup, - related_integrations: expectedRule.related_integrations, - }, - ], - }) - .expect(200); - - expect(patchedRulesBulkResponse[0]).toMatchObject(expectedRule); - - const { body: patchedRule } = await securitySolutionApi - .readRule({ - query: { rule_id: 'rule-1' }, - }) - .expect(200); - - expect(patchedRule).toMatchObject(expectedRule); - }); - - it('should patch defaultable fields', async () => { const expectedRule = { + ...rulePatchProperties, required_fields: [{ name: '@timestamp', type: 'date', ecs: true }], }; @@ -111,14 +84,13 @@ export default ({ getService }: FtrProviderContext) => { .bulkPatchRules({ body: [ { - rule_id: 'rule-1', - required_fields: [{ name: '@timestamp', type: 'date' }], + ...rulePatchProperties, }, ], }) .expect(200); - expect(patchedRulesBulkResponse[0].required_fields).to.eql(expectedRule.required_fields); + expect(patchedRulesBulkResponse[0]).toMatchObject(expectedRule); const { body: patchedRule } = await securitySolutionApi .readRule({ @@ -126,7 +98,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - expect(patchedRule.required_fields).to.eql(expectedRule.required_fields); + expect(patchedRule).toMatchObject(expectedRule); }); it('should patch two rule properties of name using the two rules rule_id', async () => { 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 85f7ad31c1f28..5e15e8e9d14c4 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,34 +74,11 @@ 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' }], }); - await securitySolutionApi.createRule({ - body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), - }); - - const { body: updatedRuleResponse } = await securitySolutionApi - .updateRule({ - body: expectedRule, - }) - .expect(200); - - expect(updatedRuleResponse).toMatchObject(expectedRule); - - const { body: updatedRule } = await securitySolutionApi - .readRule({ - query: { rule_id: 'rule-1' }, - }) - .expect(200); - - expect(updatedRule).toMatchObject(expectedRule); - }); - - it('should update a rule with defaultable fields', async () => { const expectedRule = { - ...getCustomQueryRuleParams({ - rule_id: 'rule-1', - }), + ...ruleUpdateProperties, required_fields: [{ name: '@timestamp', type: 'date', ecs: true }], }; @@ -111,11 +88,11 @@ export default ({ getService }: FtrProviderContext) => { const { body: updatedRuleResponse } = await securitySolutionApi .updateRule({ - body: expectedRule, + body: ruleUpdateProperties, }) .expect(200); - expect(updatedRuleResponse.required_fields).to.eql(expectedRule.required_fields); + expect(updatedRuleResponse).toMatchObject(expectedRule); const { body: updatedRule } = await securitySolutionApi .readRule({ @@ -123,7 +100,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - expect(updatedRule.required_fields).to.eql(expectedRule.required_fields); + expect(updatedRule).toMatchObject(expectedRule); }); it('@skipInServerless should return a 403 forbidden if it is a machine learning job', async () => { 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 bb273df1df34f..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,34 +73,11 @@ 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' }], }); - await securitySolutionApi.createRule({ - body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), - }); - - const { body: updatedRulesBulkResponse } = await securitySolutionApi - .bulkUpdateRules({ - body: [expectedRule], - }) - .expect(200); - - expect(updatedRulesBulkResponse[0]).toMatchObject(expectedRule); - - const { body: updatedRule } = await securitySolutionApi - .readRule({ - query: { rule_id: 'rule-1' }, - }) - .expect(200); - - expect(updatedRule).toMatchObject(expectedRule); - }); - - it('should update a rule with defaultable fields', async () => { const expectedRule = { - ...getCustomQueryRuleParams({ - rule_id: 'rule-1', - }), + ...ruleUpdateProperties, required_fields: [{ name: '@timestamp', type: 'date', ecs: true }], }; @@ -110,11 +87,11 @@ export default ({ getService }: FtrProviderContext) => { const { body: updatedRulesBulkResponse } = await securitySolutionApi .bulkUpdateRules({ - body: [expectedRule], + body: [ruleUpdateProperties], }) .expect(200); - expect(updatedRulesBulkResponse[0].required_fields).to.eql(expectedRule.required_fields); + expect(updatedRulesBulkResponse[0]).toMatchObject(expectedRule); const { body: updatedRule } = await securitySolutionApi .readRule({ @@ -122,7 +99,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - expect(updatedRule.required_fields).to.eql(expectedRule.required_fields); + expect(updatedRule).toMatchObject(expectedRule); }); it('should update two rule properties of name using the two rules rule_id', async () => { From 6c72992d094d544850172c20973ab8d13d045fea Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Sat, 4 May 2024 19:23:26 +0200 Subject: [PATCH 15/66] Add a couple API integration tests to check defaulting req. fields value --- .../create_rules.ts | 20 ++++++++++++ .../update_rules.ts | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+) 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 4ee712eafa209..a7341fd6c4ad4 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 @@ -219,6 +219,26 @@ export default ({ getService }: FtrProviderContext) => { ); }); }); + + describe('required_fields', () => { + afterEach(async () => { + await deleteAllRules(supertest, log); + }); + + it('creates a rule with required_fields defaulted to an empty array when not present', async () => { + const customQueryRuleParams = getCustomQueryRuleParams(); + + expect(customQueryRuleParams.required_fields).toBeUndefined(); + + const { body } = await securitySolutionApi + .createRule({ + body: customQueryRuleParams, + }) + .expect(200); + + expect(body.required_fields).toEqual([]); + }); + }); }); }); }; 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 5e15e8e9d14c4..12e6d2dd6bd21 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 @@ -279,6 +279,37 @@ export default ({ getService }: FtrProviderContext) => { ); }); }); + + describe('required_fields', () => { + afterEach(async () => { + await deleteAllRules(supertest, log); + }); + + it('should reset required fields field to default value on update when not present', async () => { + const expectedRule = getCustomQueryRuleParams({ + rule_id: 'rule-1', + required_fields: [], + }); + + await securitySolutionApi.createRule({ + body: getCustomQueryRuleParams({ + rule_id: 'rule-1', + required_fields: [{ name: 'host.name', type: 'keyword' }], + }), + }); + + const { body: updatedRuleResponse } = await securitySolutionApi + .updateRule({ + body: getCustomQueryRuleParams({ + rule_id: 'rule-1', + max_signals: undefined, + }), + }) + .expect(200); + + expect(updatedRuleResponse).toMatchObject(expectedRule); + }); + }); }); }); }; From 1998ca3f1a944c93f8bc70aca38f28b1fa1a5ed6 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Sat, 4 May 2024 19:35:07 +0200 Subject: [PATCH 16/66] Fix typos --- .../components/required_fields/required_fields.test.tsx | 4 ++-- .../rule_management/normalization/rule_converters.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) 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 index d8f358d41a405..285150a0af826 100644 --- 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 @@ -21,7 +21,7 @@ describe('RequiredFields form part', () => { expect(screen.getByText('Required fields')); }); - it('displays previosuly saved required fields', () => { + it('displays previously saved required fields', () => { const initialState: RequiredFieldWithOptionalEcs[] = [ { name: 'field1', type: 'string' }, { name: 'field2', type: 'number' }, @@ -52,7 +52,7 @@ describe('RequiredFields form part', () => { expect(screen.getByDisplayValue('string')).toBeVisible(); }); - it('user can add a new required field to a previosly saved form', async () => { + it('user can add a new required field to a previously saved form', async () => { const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; const indexPatternFields: DataViewFieldBase[] = [ 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 21f1d88a3f3f9..ab009792b9f6c 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 @@ -437,8 +437,6 @@ export const convertPatchAPIToInternalSchema = ( const typeSpecificParams = patchTypeSpecificSnakeToCamel(nextParams, existingRule.params); const existingParams = existingRule.params; - const requiredFieldsWithEcs = addEcsToRequiredFields(nextParams.required_fields); - const alertActions = nextParams.actions?.map((action) => transformRuleToAlertAction(action)) ?? existingRule.actions; const throttle = nextParams.throttle ?? transformFromAlertThrottle(existingRule); @@ -463,7 +461,7 @@ export const convertPatchAPIToInternalSchema = ( meta: nextParams.meta ?? existingParams.meta, maxSignals: nextParams.max_signals ?? existingParams.maxSignals, relatedIntegrations: nextParams.related_integrations ?? existingParams.relatedIntegrations, - requiredFields: requiredFieldsWithEcs, + 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, From 8c1b79e0329e6253b2d8af376bde31fd07905ea6 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Sun, 5 May 2024 18:48:41 +0200 Subject: [PATCH 17/66] Fix a couple integration tests --- .../import_rules/rule_to_import.ts | 2 ++ .../model/rule_assets/prebuilt_rule_asset.ts | 15 +++++++--- .../perform_bulk_action.ts | 7 ----- .../patch_rules.ts | 29 ------------------- 4 files changed, 13 insertions(+), 40 deletions(-) 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..4ccabdc9fdf6e 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,6 @@ export const RuleToImport = BaseCreateProps.and(TypeSpecificCreateProps).and( ResponseFields.partial().extend({ rule_id: RuleSignatureId, immutable: z.literal(false).default(false), + required_fields: z.array(RequiredFieldInput).default([]), }) ); 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 38ead608a3b75..dfe932ca84e32 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 @@ -10,6 +10,7 @@ import { RuleSignatureId, RuleVersion, BaseCreateProps, + RequiredFieldArray, TypeSpecificCreateProps, } from '../../../../../../common/api/detection_engine/model/rule_schema'; @@ -28,9 +29,15 @@ import { * - version is a required field that must exist */ export type PrebuiltRuleAsset = z.infer; -export const PrebuiltRuleAsset = BaseCreateProps.and(TypeSpecificCreateProps).and( +export const PrebuiltRuleAsset = BaseCreateProps.merge( z.object({ - rule_id: RuleSignatureId, - version: RuleVersion, + required_fields: RequiredFieldArray.optional(), }) -); +) + .and(TypeSpecificCreateProps) + .and( + z.object({ + rule_id: RuleSignatureId, + version: RuleVersion, + }) + ); 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 aed7591d81f69..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 @@ -166,13 +166,6 @@ export default ({ getService }: FtrProviderContext): void => { const [ruleJson] = body.toString().split(/\n/); expect(JSON.parse(ruleJson)).toMatchObject(defaultableFields); - - const parsedRule = JSON.parse(ruleJson); - - expect(parsedRule.required_fields).to.eql([ - { name: '@timestamp', type: 'date', ecs: true }, - { name: 'my-non-ecs-field', type: 'keyword', ecs: false }, - ]); }); it('should export rules with actions connectors', 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 d18446a878115..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 @@ -100,35 +100,6 @@ export default ({ getService }: FtrProviderContext) => { expect(patchedRule).toMatchObject(expectedRule); }); - it('should patch defaultable fields', async () => { - const expectedRule = { - required_fields: [{ name: '@timestamp', type: 'date', ecs: true }], - }; - - await securitySolutionApi.createRule({ - body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), - }); - - const { body: patchedRuleResponse } = await securitySolutionApi - .patchRule({ - body: { - rule_id: 'rule-1', - required_fields: [{ name: '@timestamp', type: 'date' }], - }, - }) - .expect(200); - - expect(patchedRuleResponse.required_fields).to.eql(expectedRule.required_fields); - - const { body: patchedRule } = await securitySolutionApi - .readRule({ - query: { rule_id: 'rule-1' }, - }) - .expect(200); - - expect(patchedRule.required_fields).to.eql(expectedRule.required_fields); - }); - it('@skipInServerless should return a "403 forbidden" using a rule_id of type "machine learning"', async () => { await createRule(supertest, log, getSimpleRule('rule-1')); From b5f1c92b3770c2b1a04cdc99f28546ab87648ffd Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Sun, 5 May 2024 21:33:25 +0200 Subject: [PATCH 18/66] Update types --- .../rule_management/import_rules/rule_to_import.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4ccabdc9fdf6e..f92f93b152f08 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 @@ -30,6 +30,6 @@ export const RuleToImport = BaseCreateProps.and(TypeSpecificCreateProps).and( ResponseFields.partial().extend({ rule_id: RuleSignatureId, immutable: z.literal(false).default(false), - required_fields: z.array(RequiredFieldInput).default([]), + required_fields: z.array(RequiredFieldInput).optional(), }) ); From 6a92698a6bbe89ce5612de9425ccb3045dc3b095 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Mon, 6 May 2024 19:07:40 +0200 Subject: [PATCH 19/66] Attempt to fix a bug with extracting indices from ESQL queries --- .../rule_creation_ui/hooks/index.tsx | 1 + .../hooks/use_is_valid_esql_query.ts | 35 ++++++++ .../pages/rule_creation/index.tsx | 11 ++- .../pages/rule_editing/index.tsx | 84 +++++++++++-------- 4 files changed, 94 insertions(+), 37 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/index.tsx index d56c317b93fb1..9134728f8fd09 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/index.tsx @@ -7,3 +7,4 @@ export { useEsqlIndex } from './use_esql_index'; export { useEsqlQueryForAboutStep } from './use_esql_query_for_about_step'; +export { useIsValidEsqlQuery } from './use_is_valid_esql_query'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts new file mode 100644 index 0000000000000..2694d06cea00b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts @@ -0,0 +1,35 @@ +/* + * 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 { useEffect, useRef, useState } from 'react'; +import type { Query } from '@kbn/es-query'; +import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; + +export const useIsValidEsqlQuery = ( + query: Query['query'], + validateFields: FormHook['validateFields'] +) => { + const [isValid, setIsValid] = useState<{ isValid: boolean }>({ isValid: false }); + const isValidationComplete = useRef(false); + + const previousQueryRef = useRef(query); + if (previousQueryRef.current !== query) { + previousQueryRef.current = query; + isValidationComplete.current = false; + } + + useEffect(() => { + isValidationComplete.current = false; + + validateFields(['queryBar']).then((validationResult) => { + isValidationComplete.current = true; + setIsValid({ isValid: validationResult.areFieldsValid ?? false }); + }); + }, [query, validateFields]); + + return isValidationComplete.current ? isValid.isValid : false; +}; 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 d972967015b4f..659377047f911 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 @@ -59,7 +59,7 @@ import { import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types'; import { RuleStep } from '../../../../detections/pages/detection_engine/rules/types'; import { formatRule } from './helpers'; -import { useEsqlIndex, useEsqlQueryForAboutStep } from '../../hooks'; +import { useEsqlIndex, useEsqlQueryForAboutStep, useIsValidEsqlQuery } from '../../hooks'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../app/types'; import { @@ -210,10 +210,15 @@ const CreateRulePageComponent: React.FC = () => { const [isThreatQueryBarValid, setIsThreatQueryBarValid] = useState(false); const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep }); + const isValidEsqlQuery = useIsValidEsqlQuery( + defineStepData.queryBar.query.query, + defineStepForm.validateFields + ); + const esqlIndex = useEsqlIndex( defineStepData.queryBar.query.query, - ruleType, - defineStepForm.getErrors().length === 0 + defineStepData.ruleType, + isValidEsqlQuery ); const memoizedIndex = useMemo( 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 00c125cc7cf5f..697b9cd2bdd09 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 @@ -68,7 +68,7 @@ import { useStartTransaction } from '../../../../common/lib/apm/use_start_transa import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions'; import { useGetSavedQuery } from '../../../../detections/pages/detection_engine/rules/use_get_saved_query'; import { useRuleForms, useRuleIndexPattern } from '../form'; -import { useEsqlIndex, useEsqlQueryForAboutStep } from '../../hooks'; +import { useEsqlIndex, useEsqlQueryForAboutStep, useIsValidEsqlQuery } from '../../hooks'; import { CustomHeaderPageMemo } from '..'; const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { @@ -149,45 +149,31 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { actionsStepDefault: ruleActionsData, }); - // Since in the edit step we start with an existing rule, we assume that - // the steps are valid if isValid is undefined. Once the user triggers validation by - // trying to submit the edits, the isValid statuses will be tracked and the callout appears - // if some steps are invalid - const stepIsValid = useCallback( - (step: RuleStep): boolean => { - switch (step) { - case RuleStep.defineRule: - return defineStepForm.isValid ?? true; - case RuleStep.aboutRule: - return aboutStepForm.isValid ?? true; - case RuleStep.scheduleRule: - return scheduleStepForm.isValid ?? true; - case RuleStep.ruleActions: - return actionsStepForm.isValid ?? true; - default: - return true; - } - }, - [ - aboutStepForm.isValid, - actionsStepForm.isValid, - defineStepForm.isValid, - scheduleStepForm.isValid, - ] - ); + const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep }); - const invalidSteps = ruleStepsOrder.filter((step) => { - return !stepIsValid(step); - }); + // 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 isValidEsqlQuery = useIsValidEsqlQuery( + defineStepData.queryBar.query.query, + defineStepForm.validateFields + ); - 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 - stepIsValid(RuleStep.defineRule) || activeStep !== RuleStep.defineRule + isValidEsqlQuery ); + + // --- + const memoizedIndex = useMemo( () => (isEsqlRule(defineStepData.ruleType) ? esqlIndex : defineStepData.index), [defineStepData.index, esqlIndex, defineStepData.ruleType] @@ -213,6 +199,36 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { ruleType: rule?.type, }); + // Since in the edit step we start with an existing rule, we assume that + // the steps are valid if isValid is undefined. Once the user triggers validation by + // trying to submit the edits, the isValid statuses will be tracked and the callout appears + // if some steps are invalid + const stepIsValid = useCallback( + (step: RuleStep): boolean => { + switch (step) { + case RuleStep.defineRule: + return defineStepForm.isValid ?? true; + case RuleStep.aboutRule: + return aboutStepForm.isValid ?? true; + case RuleStep.scheduleRule: + return scheduleStepForm.isValid ?? true; + case RuleStep.ruleActions: + return actionsStepForm.isValid ?? true; + default: + return true; + } + }, + [ + aboutStepForm.isValid, + actionsStepForm.isValid, + defineStepForm.isValid, + scheduleStepForm.isValid, + ] + ); + + const invalidSteps = ruleStepsOrder.filter((step) => { + return !stepIsValid(step); + }); const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule?.type]); const { indexPattern, isIndexPatternLoading, browserFields } = useRuleIndexPattern({ From 835ca7e2b00433819b5196b1af0c8c78c829ddb4 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Mon, 6 May 2024 23:03:16 +0200 Subject: [PATCH 20/66] Update comments --- .../hooks/use_is_valid_esql_query.ts | 49 ++++++++++++++----- .../pages/rule_creation/index.tsx | 2 + .../pages/rule_editing/index.tsx | 13 +---- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts index 2694d06cea00b..470946bedc075 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts @@ -8,28 +8,55 @@ import { useEffect, useRef, useState } from 'react'; import type { Query } from '@kbn/es-query'; import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; +import { isEsqlRule } from '../../../../common/detection_engine/utils'; +/** + * Runs field validation on the queryBar form field for ES|QL queries + * @param query - ES|QL query to retrieve index 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 boolean - true if field is valid, false if field is invalid or if validation is in progress + */ export const useIsValidEsqlQuery = ( query: Query['query'], + ruleType: Type, validateFields: FormHook['validateFields'] ) => { - const [isValid, setIsValid] = useState<{ isValid: boolean }>({ isValid: false }); - const isValidationComplete = useRef(false); + /* + Using an object to store isValid instead of a boolean to ensure + React component re-renders if a valid query changes to another valid query. + If boolean was used, React would not re-render the component. + */ + const [validity, setValidity] = useState<{ isValid: boolean }>({ isValid: false }); + const isValidating = useRef(true); const previousQueryRef = useRef(query); - if (previousQueryRef.current !== query) { + + const hasQueryChanged = previousQueryRef.current !== query; + if (hasQueryChanged) { previousQueryRef.current = query; - isValidationComplete.current = false; + /* + Setting isValidating to true to make the hook return false + since the new query is not validated yet. + */ + isValidating.current = true; } useEffect(() => { - isValidationComplete.current = false; + isValidating.current = true; + + const esqlQuery = typeof query === 'string' && isEsqlRule(ruleType) ? query : undefined; - validateFields(['queryBar']).then((validationResult) => { - isValidationComplete.current = true; - setIsValid({ isValid: validationResult.areFieldsValid ?? false }); - }); - }, [query, validateFields]); + if (esqlQuery) { + validateFields(['queryBar']).then((validationResult) => { + isValidating.current = false; + setValidity({ isValid: validationResult.areFieldsValid ?? false }); + }); + } else { + isValidating.current = false; + } + }, [query, validateFields, ruleType]); - return isValidationComplete.current ? isValid.isValid : false; + return isValidating.current ? false : validity.isValid; }; 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 659377047f911..c82e3bec4f3d7 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,8 +210,10 @@ const CreateRulePageComponent: React.FC = () => { const [isThreatQueryBarValid, setIsThreatQueryBarValid] = useState(false); const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep }); + const isValidEsqlQuery = useIsValidEsqlQuery( defineStepData.queryBar.query.query, + defineStepData.ruleType, defineStepForm.validateFields ); 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 697b9cd2bdd09..9c532dbeefa04 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 @@ -151,18 +151,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 isValidEsqlQuery = useIsValidEsqlQuery( defineStepData.queryBar.query.query, + defineStepData.ruleType, defineStepForm.validateFields ); @@ -172,8 +163,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { isValidEsqlQuery ); - // --- - const memoizedIndex = useMemo( () => (isEsqlRule(defineStepData.ruleType) ? esqlIndex : defineStepData.index), [defineStepData.index, esqlIndex, defineStepData.ruleType] From 85dc485313412bda387cae344745ea542fb1993c Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Mon, 6 May 2024 23:07:25 +0200 Subject: [PATCH 21/66] Update paddings --- .../rule_creation_ui/components/step_define_rule/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 4f2b974437763..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 @@ -1114,7 +1114,7 @@ const StepDefineRuleComponent: FC = ({ - + {!isMlRule(ruleType) && ( <> @@ -1123,7 +1123,7 @@ const StepDefineRuleComponent: FC = ({ indexPatternFields={indexPattern.fields} isIndexPatternLoading={isIndexPatternLoading} /> - + )} From e4f12708b3baec90eaabd99221abee115450512d Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 7 May 2024 12:00:49 +0200 Subject: [PATCH 22/66] Update comments in schemas --- .../rule_schema/common_attributes.gen.ts | 21 +++++++++++++++++++ .../rule_schema/common_attributes.schema.yaml | 7 +++++++ .../rule_schema/rule_schemas.schema.yaml | 5 +++++ 3 files changed, 33 insertions(+) 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 22a4f9db1a0a9..953c2e6c37d4c 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,16 +303,37 @@ 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 the Elasticsearch field + */ type: NonEmptyString, }); 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 b952e55ec171b..c37bf69d5c0e7 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,13 +315,17 @@ 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 @@ -329,11 +333,14 @@ components: 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 the Elasticsearch field required: - name - type 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 9d1064b0bf127..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,11 @@ 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: From eb7e38250d6ebda316011e2515fb3b5d00204f3c Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 7 May 2024 12:16:24 +0200 Subject: [PATCH 23/66] Fix missing "Related Integrations" label in rule creation preview --- .../rule_creation_ui/components/step_define_rule/schema.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx index 99502fbb97666..bdf14f4b6fd4a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/schema.tsx @@ -246,6 +246,12 @@ export const schema: FormSchema = { }, relatedIntegrations: { type: FIELD_TYPES.JSON, + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel', + { + defaultMessage: 'Related integrations', + } + ), }, requiredFields: { label: i18n.translate( From 817bfe7f076558ec911b58bbc187b26e4e4b26b7 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 7 May 2024 16:10:52 +0200 Subject: [PATCH 24/66] Extract render function into a component, move field logic into hooks, add comments to explain what's going on --- .../required_fields/required_fields.tsx | 252 ++++++++++-------- .../required_fields/required_fields_row.tsx | 174 +----------- .../required_fields/use_name_field.ts | 126 +++++++++ .../required_fields/use_type_field.ts | 135 ++++++++++ 4 files changed, 408 insertions(+), 279 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_type_field.ts 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 index 7eea5583e9653..22150841889d7 100644 --- 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 @@ -10,6 +10,7 @@ import { EuiButtonEmpty, EuiCallOut, EuiFormRow, EuiSpacer, EuiText } from '@ela import type { DataViewFieldBase } from '@kbn/es-query'; import type { RequiredFieldInput } from '../../../../../common/api/detection_engine'; import { UseArray, useFormData } from '../../../../shared_imports'; +import type { ArrayItem } from '../../../../shared_imports'; import { RequiredFieldRow } from './required_fields_row'; import * as ruleDetailsI18n from '../../../rule_management/components/rule_details/translations'; import * as i18n from './translations'; @@ -24,122 +25,145 @@ export const RequiredFields = ({ path, indexPatternFields = [], isIndexPatternLoading = false, -}: RequiredFieldsProps) => { +}: RequiredFieldsProps) => ( + + {({ items, addItem, removeItem }) => ( + + )} + +); + +interface RequiredFieldsListProps { + items: ArrayItem[]; + addItem: () => void; + removeItem: (id: number) => void; + indexPatternFields: DataViewFieldBase[]; + isIndexPatternLoading: boolean; + path: string; +} + +const RequiredFieldsList = ({ + items, + addItem, + removeItem, + indexPatternFields, + isIndexPatternLoading, + path, +}: RequiredFieldsListProps) => { const useFormDataResult = useFormData(); + const [formData] = useFormDataResult; + const fieldValue: RequiredFieldInput[] = formData[path] ?? []; + + const selectedFieldNames = fieldValue.map(({ name }) => name); + + const fieldsWithTypes = indexPatternFields.filter( + (indexPatternField) => indexPatternField.esTypes && indexPatternField.esTypes.length > 0 + ); + + const allFieldNames = fieldsWithTypes.map(({ name }) => name); + const availableFieldNames = allFieldNames.filter((name) => !selectedFieldNames.includes(name)); + + const typesByFieldName: Record = fieldsWithTypes.reduce( + (accumulator, browserField) => { + if (browserField.esTypes) { + accumulator[browserField.name] = browserField.esTypes; + } + return accumulator; + }, + {} as Record + ); + + const isEmptyRowDisplayed = !!fieldValue.find(({ name }) => name === ''); + + const isAddNewFieldButtonDisabled = isIndexPatternLoading || isEmptyRowDisplayed; + + const nameWarnings = fieldValue + /* Not creating warning for empty "name" value */ + .filter(({ name }) => name !== '') + .reduce>((warnings, { name }) => { + if (!isIndexPatternLoading && !allFieldNames.includes(name)) { + warnings[name] = i18n.FIELD_NAME_NOT_FOUND_WARNING(name); + } + return warnings; + }, {}); + + const typeWarnings = fieldValue + /* Not creating a warning for "type" if there's no "name" value */ + .filter(({ name }) => name !== '') + .reduce>((warnings, { name, type }) => { + if ( + !isIndexPatternLoading && + 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 hasWarnings = Object.keys(nameWarnings).length > 0 || Object.keys(typeWarnings).length > 0; return ( - - {({ items, addItem, removeItem, form, error }) => { - const [formData] = useFormDataResult; - const fieldValue: RequiredFieldInput[] = formData[path] ?? []; - - const selectedFieldNames = fieldValue.map(({ name }) => name); - - const fieldsWithTypes = indexPatternFields.filter( - (indexPatternField) => indexPatternField.esTypes && indexPatternField.esTypes.length > 0 - ); - - const allFieldNames = fieldsWithTypes.map(({ name }) => name); - const availableFieldNames = allFieldNames.filter( - (name) => !selectedFieldNames.includes(name) - ); - - const typesByFieldName: Record = fieldsWithTypes.reduce( - (accumulator, browserField) => { - if (browserField.esTypes) { - accumulator[browserField.name] = browserField.esTypes; - } - return accumulator; - }, - {} as Record - ); - - const isEmptyRowDisplayed = !!fieldValue.find(({ name }) => name === ''); - - const isAddNewFieldButtonDisabled = isIndexPatternLoading || isEmptyRowDisplayed; - - const nameWarnings = fieldValue - .filter(({ name }) => name !== '') - .reduce>((warnings, { name }) => { - if (!isIndexPatternLoading && !allFieldNames.includes(name)) { - warnings[name] = i18n.FIELD_NAME_NOT_FOUND_WARNING(name); - } - return warnings; - }, {}); - - const typeWarnings = fieldValue - .filter(({ name }) => name !== '') - .reduce>((warnings, { name, type }) => { - if ( - !isIndexPatternLoading && - 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 hasWarnings = - Object.keys(nameWarnings).length > 0 || Object.keys(typeWarnings).length > 0; - - return ( - <> - {hasWarnings && ( - -

{i18n.REQUIRED_FIELDS_GENERAL_WARNING_DESCRIPTION}

-
- )} - - - {i18n.OPTIONAL} - - } - helpText={i18n.REQUIRED_FIELDS_HELP_TEXT} - hasChildLabel={false} - labelType="legend" - > - <> - {items.map((item) => ( - - ))} - - - - {i18n.ADD_REQUIRED_FIELD} - - - - - ); - }} -
+ <> + {hasWarnings && ( + +

{i18n.REQUIRED_FIELDS_GENERAL_WARNING_DESCRIPTION}

+
+ )} + + + {i18n.OPTIONAL} + + } + helpText={i18n.REQUIRED_FIELDS_HELP_TEXT} + hasChildLabel={false} + labelType="legend" + > + <> + {items.map((item) => ( + + ))} + + + + {i18n.ADD_REQUIRED_FIELD} + + + + ); }; 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 index 31b721fd29803..1bfdbd92b0243 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiButtonIcon, EuiComboBox, @@ -16,8 +16,9 @@ import { EuiTextColor, } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { FIELD_TYPES, UseField } from '../../../../shared_imports'; +import { useNameField } from './use_name_field'; +import { useTypeField } from './use_type_field'; import * as i18n from './translations'; import type { @@ -81,7 +82,7 @@ export const RequiredFieldRow = ({ key={item.id} path={item.path} config={rowFieldConfig} - component={RequiredFieldRowInner} + component={RequiredFieldRowContent} readDefaultValueOnForm={!item.isNew} componentProps={{ itemId: item.id, @@ -106,7 +107,7 @@ interface RequiredFieldRowInnerProps { itemId: string; } -const RequiredFieldRowInner = ({ +const RequiredFieldRowContent = ({ field, typesByFieldName, onRemove, @@ -114,82 +115,11 @@ const RequiredFieldRowInner = ({ getWarnings, itemId, }: RequiredFieldRowInnerProps) => { - // Do not not add empty option to the list of selectable field names - const selectableNameOptions: Array> = useMemo( - () => - (field.value.name ? [field.value.name] : []).concat(availableFieldNames).map((name) => ({ - label: name, - value: name, - })), - [availableFieldNames, field.value.name] - ); - - const [selectedNameOptions, setSelectedNameOptions] = useState< - Array> - >(() => { - const selectedNameOption = selectableNameOptions.find( - (option) => option.label === field.value.name - ); - - return selectedNameOption ? [selectedNameOption] : []; - }); - - useEffect(() => { - const selectedNameOption = selectableNameOptions.find( - (option) => option.label === field.value.name - ); - - setSelectedNameOptions(selectedNameOption ? [selectedNameOption] : []); - }, [field.value.name, selectableNameOptions]); - - const selectableTypeOptions: Array> = useMemo(() => { - const typesAvailableForSelectedName = typesByFieldName[field.value.name]; - - let _selectableTypeOptions: Array> = []; - if (typesAvailableForSelectedName) { - const isSelectedTypeAvailable = typesAvailableForSelectedName.includes(field.value.type); - - _selectableTypeOptions = typesAvailableForSelectedName.map((type) => ({ - label: type, - value: type, - })); - - if (!isSelectedTypeAvailable) { - // case: field name exists, but such type is not among the list of field types - _selectableTypeOptions.push({ label: field.value.type, value: field.value.type }); - } - } else { - if (field.value.type) { - // case: no such field name in index patterns - _selectableTypeOptions = [ - { - label: field.value.type, - value: field.value.type, - }, - ]; - } - } + const { selectableNameOptions, selectedNameOptions, handleNameChange, handleAddCustomName } = + useNameField(field, availableFieldNames, typesByFieldName); - return _selectableTypeOptions; - }, [field.value.name, field.value.type, typesByFieldName]); - - const [selectedTypeOptions, setSelectedTypeOptions] = useState< - Array> - >(() => { - const selectedTypeOption = selectableTypeOptions.find( - (option) => option.value === field.value.type - ); - - return selectedTypeOption ? [selectedTypeOption] : []; - }); - - useEffect(() => { - const selectedTypeOption = selectableTypeOptions.find( - (option) => option.value === field.value.type - ); - - setSelectedTypeOptions(selectedTypeOption ? [selectedTypeOption] : []); - }, [field.value.type, selectableTypeOptions]); + const { selectableTypeOptions, selectedTypeOptions, handleTypeChange, handleAddCustomType } = + useTypeField(field, typesByFieldName); const { nameWarning, typeWarning } = getWarnings(field.value); const warningMessage = nameWarning || typeWarning; @@ -203,74 +133,6 @@ const RequiredFieldRowInner = ({ const hasError = Boolean(nameError) || Boolean(typeError); const errorMessage = nameError?.message || typeError?.message; - const handleNameChange = useCallback( - (selectedOptions: Array>) => { - const newlySelectedOption: EuiComboBoxOptionOption | undefined = selectedOptions[0]; - - if (!newlySelectedOption) { - setSelectedNameOptions([]); - return; - } - - const updatedName = newlySelectedOption?.value || ''; - - const updatedType = pickTypeForName(updatedName, field.value.type, typesByFieldName); - - const updatedFieldValue: RequiredFieldInput = { - name: updatedName, - type: updatedType, - }; - - field.setValue(updatedFieldValue); - }, - [field, typesByFieldName] - ); - - const handleTypeChange = useCallback( - (selectedOptions: Array>) => { - const newlySelectedOption: EuiComboBoxOptionOption | undefined = selectedOptions[0]; - - if (!newlySelectedOption) { - setSelectedTypeOptions([]); - return; - } - - const updatedType = newlySelectedOption?.value || ''; - - const updatedFieldValue: RequiredFieldInput = { - name: field.value.name, - type: updatedType, - }; - - field.setValue(updatedFieldValue); - }, - [field] - ); - - const handleAddCustomName = useCallback( - (newName: string) => { - const updatedFieldValue: RequiredFieldInput = { - name: newName, - type: pickTypeForName(newName, field.value.type, typesByFieldName), - }; - - field.setValue(updatedFieldValue); - }, - [field, typesByFieldName] - ); - - const handleAddCustomType = useCallback( - (newType: string) => { - const updatedFieldValue: RequiredFieldInput = { - name: field.value.name, - type: newType, - }; - - field.setValue(updatedFieldValue); - }, - [field] - ); - return ( -) { - const typesAvailableForNewName = typesByFieldName[currentName] || []; - const isCurrentTypeAvailableForNewName = typesAvailableForNewName.includes(currentType); - - let updatedType = currentType; - if (isCurrentTypeAvailableForNewName) { - updatedType = currentType; - } else if (typesAvailableForNewName.length > 0) { - updatedType = typesAvailableForNewName[0]; - } - - return updatedType; -} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts new file mode 100644 index 0000000000000..662affca0ce2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts @@ -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 { useCallback, useMemo, useState, useEffect } from 'react'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import type { FieldHook } from '../../../../shared_imports'; +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; + +interface UseNameFieldReturn { + selectableNameOptions: Array>; + selectedNameOptions: Array>; + handleNameChange: (selectedOptions: Array>) => void; + handleAddCustomName: (newName: string) => void; +} + +export const useNameField = ( + field: FieldHook, + availableFieldNames: string[], + typesByFieldName: Record +): UseNameFieldReturn => { + const selectableNameOptions: Array> = useMemo( + () => + /* Not adding an empty string to the list of selectable field names */ + (field.value.name ? [field.value.name] : []).concat(availableFieldNames).map((name) => ({ + label: name, + value: name, + })), + [availableFieldNames, field.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 [selectedNameOptions, setSelectedNameOptions] = useState< + Array> + >(() => { + const selectedNameOption = selectableNameOptions.find( + (option) => option.label === field.value.name + ); + + return selectedNameOption ? [selectedNameOption] : []; + }); + + useEffect(() => { + /* Re-computing the new selected name option when the field value changes */ + const selectedNameOption = selectableNameOptions.find( + (option) => option.label === field.value.name + ); + + setSelectedNameOptions(selectedNameOption ? [selectedNameOption] : []); + }, [field.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 */ + setSelectedNameOptions([]); + return; + } + + const updatedName = newlySelectedOption?.value || ''; + + const updatedType = pickTypeForName(updatedName, field.value.type, typesByFieldName); + + const updatedFieldValue: RequiredFieldInput = { + name: updatedName, + type: updatedType, + }; + + field.setValue(updatedFieldValue); + }, + [field, typesByFieldName] + ); + + const handleAddCustomName = useCallback( + (newName: string) => { + const updatedFieldValue: RequiredFieldInput = { + name: newName, + type: pickTypeForName(newName, field.value.type, typesByFieldName), + }; + + field.setValue(updatedFieldValue); + }, + [field, typesByFieldName] + ); + + return { + selectableNameOptions, + selectedNameOptions, + handleNameChange, + handleAddCustomName, + }; +}; + +function pickTypeForName( + currentName: string, + currentType: string, + typesByFieldName: Record +) { + const typesAvailableForNewName = typesByFieldName[currentName] || []; + const isCurrentTypeAvailableForNewName = typesAvailableForNewName.includes(currentType); + + let updatedType = currentType; + + /* First try to keep the current type if it's available for the new name */ + if (isCurrentTypeAvailableForNewName) { + return updatedType; + } + + /* If it's not available, pick the first available type */ + if (typesAvailableForNewName.length > 0) { + updatedType = typesAvailableForNewName[0]; + } + + /* Otherwise use currently selected type */ + return updatedType; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_type_field.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_type_field.ts new file mode 100644 index 0000000000000..f568c26fe62c2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_type_field.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useMemo, useState, useEffect } from 'react'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import type { FieldHook } from '../../../../shared_imports'; +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; + +interface UseTypeFieldReturn { + selectableTypeOptions: Array>; + selectedTypeOptions: Array>; + handleTypeChange: (selectedOptions: Array>) => void; + handleAddCustomType: (newType: string) => void; +} + +export const useTypeField = ( + field: FieldHook, + typesByFieldName: Record +): UseTypeFieldReturn => { + const selectableTypeOptions: Array> = useMemo(() => { + const typesAvailableForSelectedName = typesByFieldName[field.value.name]; + const isSelectedTypeAvailable = (typesAvailableForSelectedName || []).includes( + field.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: field.value.type, value: field.value.type }); + } else if (field.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: field.value.type, + value: field.value.type, + }, + ]; + } + + return []; + }, [field.value.name, field.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 [selectedTypeOptions, setSelectedTypeOptions] = useState< + Array> + >(() => { + const selectedTypeOption = selectableTypeOptions.find( + (option) => option.value === field.value.type + ); + + return selectedTypeOption ? [selectedTypeOption] : []; + }); + + useEffect(() => { + /* Re-computing the new selected type option when the field value changes */ + const selectedTypeOption = selectableTypeOptions.find( + (option) => option.value === field.value.type + ); + + setSelectedTypeOptions(selectedTypeOption ? [selectedTypeOption] : []); + }, [field.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 */ + setSelectedTypeOptions([]); + return; + } + + const updatedType = newlySelectedOption?.value || ''; + + const updatedFieldValue: RequiredFieldInput = { + name: field.value.name, + type: updatedType, + }; + + field.setValue(updatedFieldValue); + }, + [field] + ); + + const handleAddCustomType = useCallback( + (newType: string) => { + const updatedFieldValue: RequiredFieldInput = { + name: field.value.name, + type: newType, + }; + + field.setValue(updatedFieldValue); + }, + [field] + ); + + return { + selectableTypeOptions, + selectedTypeOptions, + handleTypeChange, + handleAddCustomType, + }; +}; From b20f629ef86302887e5ce4d95b81f37a04be5352 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 7 May 2024 16:22:39 +0200 Subject: [PATCH 25/66] Use `ruleType` instead of `defineStepData.ruleType` (they are same value) --- .../rule_creation_ui/pages/rule_creation/index.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 c82e3bec4f3d7..79f8fb98cb7ed 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 @@ -213,15 +213,11 @@ const CreateRulePageComponent: React.FC = () => { const isValidEsqlQuery = useIsValidEsqlQuery( defineStepData.queryBar.query.query, - defineStepData.ruleType, + ruleType, defineStepForm.validateFields ); - const esqlIndex = useEsqlIndex( - defineStepData.queryBar.query.query, - defineStepData.ruleType, - isValidEsqlQuery - ); + const esqlIndex = useEsqlIndex(defineStepData.queryBar.query.query, ruleType, isValidEsqlQuery); const memoizedIndex = useMemo( () => (isEsqlRuleValue ? esqlIndex : defineStepData.index), From 20fc01e562c179feaba51c3cc248a81b287a4842 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 7 May 2024 16:25:44 +0200 Subject: [PATCH 26/66] Fix a typo in comment --- .../components/required_fields/required_fields.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 22150841889d7..1bdd5a397728c 100644 --- 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 @@ -85,7 +85,7 @@ const RequiredFieldsList = ({ const isAddNewFieldButtonDisabled = isIndexPatternLoading || isEmptyRowDisplayed; const nameWarnings = fieldValue - /* Not creating warning for empty "name" value */ + /* Not creating a warning for empty "name" value */ .filter(({ name }) => name !== '') .reduce>((warnings, { name }) => { if (!isIndexPatternLoading && !allFieldNames.includes(name)) { From 9c8ad086b5f281c2bc0c92316f9ec3c9c2a0e97c Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 8 May 2024 10:45:27 +0200 Subject: [PATCH 27/66] Get rid of the hacky `isValidEsqlQuery` hook --- .../rule_creation_ui/hooks/index.tsx | 1 - .../rule_creation_ui/hooks/use_esql_index.ts | 43 +++++++++---- .../hooks/use_is_valid_esql_query.ts | 62 ------------------- .../pages/rule_creation/index.tsx | 10 +-- .../pages/rule_editing/index.tsx | 14 +---- 5 files changed, 34 insertions(+), 96 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/index.tsx index 9134728f8fd09..d56c317b93fb1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/index.tsx @@ -7,4 +7,3 @@ export { useEsqlIndex } from './use_esql_index'; export { useEsqlQueryForAboutStep } from './use_esql_query_for_about_step'; -export { useIsValidEsqlQuery } from './use_is_valid_esql_query'; 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..2aef8c81d6bd0 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 */ -export const useEsqlIndex = ( - query: Query['query'], - ruleType: Type, - isQueryReadEnabled: boolean | undefined -) => { +export const useEsqlIndex = (query: Query['query'], ruleType: Type): string[] => { + const [debouncedQuery, setDebouncedQuery] = useState(''); + + 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/hooks/use_is_valid_esql_query.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts deleted file mode 100644 index 470946bedc075..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/hooks/use_is_valid_esql_query.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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 { useEffect, useRef, useState } from 'react'; -import type { Query } from '@kbn/es-query'; -import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; -import { isEsqlRule } from '../../../../common/detection_engine/utils'; - -/** - * Runs field validation on the queryBar form field for ES|QL queries - * @param query - ES|QL query to retrieve index 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 boolean - true if field is valid, false if field is invalid or if validation is in progress - */ -export const useIsValidEsqlQuery = ( - query: Query['query'], - ruleType: Type, - validateFields: FormHook['validateFields'] -) => { - /* - Using an object to store isValid instead of a boolean to ensure - React component re-renders if a valid query changes to another valid query. - If boolean was used, React would not re-render the component. - */ - const [validity, setValidity] = useState<{ isValid: boolean }>({ isValid: false }); - const isValidating = useRef(true); - - const previousQueryRef = useRef(query); - - const hasQueryChanged = previousQueryRef.current !== query; - if (hasQueryChanged) { - previousQueryRef.current = query; - /* - Setting isValidating to true to make the hook return false - since the new query is not validated yet. - */ - isValidating.current = true; - } - - useEffect(() => { - isValidating.current = true; - - const esqlQuery = typeof query === 'string' && isEsqlRule(ruleType) ? query : undefined; - - if (esqlQuery) { - validateFields(['queryBar']).then((validationResult) => { - isValidating.current = false; - setValidity({ isValid: validationResult.areFieldsValid ?? false }); - }); - } else { - isValidating.current = false; - } - }, [query, validateFields, ruleType]); - - return isValidating.current ? false : validity.isValid; -}; 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 79f8fb98cb7ed..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 @@ -59,7 +59,7 @@ import { import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types'; import { RuleStep } from '../../../../detections/pages/detection_engine/rules/types'; import { formatRule } from './helpers'; -import { useEsqlIndex, useEsqlQueryForAboutStep, useIsValidEsqlQuery } from '../../hooks'; +import { useEsqlIndex, useEsqlQueryForAboutStep } from '../../hooks'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../app/types'; import { @@ -211,13 +211,7 @@ const CreateRulePageComponent: React.FC = () => { const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep }); - const isValidEsqlQuery = useIsValidEsqlQuery( - defineStepData.queryBar.query.query, - ruleType, - defineStepForm.validateFields - ); - - const esqlIndex = useEsqlIndex(defineStepData.queryBar.query.query, ruleType, isValidEsqlQuery); + const esqlIndex = useEsqlIndex(defineStepData.queryBar.query.query, ruleType); const memoizedIndex = useMemo( () => (isEsqlRuleValue ? esqlIndex : defineStepData.index), 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 9c532dbeefa04..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 @@ -68,7 +68,7 @@ import { useStartTransaction } from '../../../../common/lib/apm/use_start_transa import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions'; import { useGetSavedQuery } from '../../../../detections/pages/detection_engine/rules/use_get_saved_query'; import { useRuleForms, useRuleIndexPattern } from '../form'; -import { useEsqlIndex, useEsqlQueryForAboutStep, useIsValidEsqlQuery } from '../../hooks'; +import { useEsqlIndex, useEsqlQueryForAboutStep } from '../../hooks'; import { CustomHeaderPageMemo } from '..'; const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { @@ -151,17 +151,7 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep }); - const isValidEsqlQuery = useIsValidEsqlQuery( - defineStepData.queryBar.query.query, - defineStepData.ruleType, - defineStepForm.validateFields - ); - - const esqlIndex = useEsqlIndex( - defineStepData.queryBar.query.query, - defineStepData.ruleType, - isValidEsqlQuery - ); + const esqlIndex = useEsqlIndex(defineStepData.queryBar.query.query, defineStepData.ruleType); const memoizedIndex = useMemo( () => (isEsqlRule(defineStepData.ruleType) ? esqlIndex : defineStepData.index), From 0ee95a4d8b8f065c3262f763f87cd488d2d638f0 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 8 May 2024 15:38:43 +0200 Subject: [PATCH 28/66] Update `useEsqlIndex` tests --- .../hooks/use_esql_index.test.ts | 27 +++++++++---------- .../rule_creation_ui/hooks/use_esql_index.ts | 4 +-- 2 files changed, 15 insertions(+), 16 deletions(-) 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 2aef8c81d6bd0..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 @@ -18,10 +18,10 @@ import { isEsqlRule } from '../../../../common/detection_engine/utils'; * parses ES|QL query and returns memoized array of indices * @param query - ES|QL query to retrieve indices from * @param ruleType - rule type value - * @returns string[] - array of indices + * @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): string[] => { - const [debouncedQuery, setDebouncedQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(query); useDebounce( () => { From d56fe2c2899f0075aa73839e381d5c46342a6e0e Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 8 May 2024 15:39:11 +0200 Subject: [PATCH 29/66] Add an explainer comment for `RuleToImport` type --- .../rule_management/import_rules/rule_to_import.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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 f92f93b152f08..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 @@ -30,6 +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(), }) ); From c6796272898e60a6f572a5832579c2228912cc65 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 8 May 2024 17:09:53 +0200 Subject: [PATCH 30/66] Refactor `use_name_field` --- .../components/required_fields/use_name_field.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts index 662affca0ce2e..9bdeb0f1c3d34 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts @@ -109,18 +109,16 @@ function pickTypeForName( const typesAvailableForNewName = typesByFieldName[currentName] || []; const isCurrentTypeAvailableForNewName = typesAvailableForNewName.includes(currentType); - let updatedType = currentType; - /* First try to keep the current type if it's available for the new name */ if (isCurrentTypeAvailableForNewName) { - return updatedType; + return currentType; } /* If it's not available, pick the first available type */ if (typesAvailableForNewName.length > 0) { - updatedType = typesAvailableForNewName[0]; + return typesAvailableForNewName[0]; } /* Otherwise use currently selected type */ - return updatedType; + return currentType; } From 09db2e6df941fc0f66e482f52ec0c3cd7093d2ff Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 8 May 2024 17:21:17 +0200 Subject: [PATCH 31/66] Apply suggested refactoring to `useNameField` --- .../components/required_fields/use_name_field.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts index 9bdeb0f1c3d34..8951dcf4ff780 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts @@ -114,11 +114,9 @@ function pickTypeForName( return currentType; } - /* If it's not available, pick the first available type */ - if (typesAvailableForNewName.length > 0) { - return typesAvailableForNewName[0]; - } - - /* Otherwise use currently selected type */ - return currentType; + /* + If current type is not available, pick the first available type. + If no type is available, use the currently selected type. + */ + return typesAvailableForNewName?.[0] ?? currentType; } From f9470b20c6bc91a2c898921274877298744dcb84 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 8 May 2024 17:24:14 +0200 Subject: [PATCH 32/66] Update `fieldsWithTypes` assignment in `RequiredFieldsList` --- .../components/required_fields/required_fields.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 1bdd5a397728c..436227dd0ee72 100644 --- 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 @@ -63,8 +63,8 @@ const RequiredFieldsList = ({ const selectedFieldNames = fieldValue.map(({ name }) => name); - const fieldsWithTypes = indexPatternFields.filter( - (indexPatternField) => indexPatternField.esTypes && indexPatternField.esTypes.length > 0 + const fieldsWithTypes = indexPatternFields.filter((indexPatternField) => + Boolean(indexPatternField.esTypes?.length) ); const allFieldNames = fieldsWithTypes.map(({ name }) => name); From c2566d19545e1d9df04c5d6383ac378be19dd0a6 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 8 May 2024 17:28:33 +0200 Subject: [PATCH 33/66] Fix a typo in schema definition comment --- .../detection_engine/model/rule_schema/common_attributes.gen.ts | 2 +- .../model/rule_schema/common_attributes.schema.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 953c2e6c37d4c..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 @@ -332,7 +332,7 @@ export const RequiredFieldInput = z.object({ */ name: NonEmptyString, /** - * Type of the Elasticsearch field + * Type of an Elasticsearch field */ type: NonEmptyString, }); 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 c37bf69d5c0e7..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 @@ -340,7 +340,7 @@ components: description: Name of an Elasticsearch field type: $ref: '../../../model/primitives.schema.yaml#/components/schemas/NonEmptyString' - description: Type of the Elasticsearch field + description: Type of an Elasticsearch field required: - name - type From c5067d8adcdfd763a771c78f735c51d8aed16b09 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 8 May 2024 17:48:50 +0200 Subject: [PATCH 34/66] Omit the use of empty `initialState` in `RequiredFields` tests --- .../required_fields/required_fields.test.tsx | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) 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 index 285150a0af826..aca8768ba68d2 100644 --- 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 @@ -16,7 +16,7 @@ import type { RequiredFieldInput } from '../../../../../common/api/detection_eng describe('RequiredFields form part', () => { it('displays the required fields label', () => { - render(); + render(); expect(screen.getByText('Required fields')); }); @@ -41,7 +41,7 @@ describe('RequiredFields form part', () => { createIndexPatternField({ name: 'field1', esTypes: ['string'] }), ]; - render(); + render(); await addRequiredFieldRow(); await selectFirstEuiComboBoxOption({ @@ -77,7 +77,7 @@ describe('RequiredFields form part', () => { createIndexPatternField({ name: 'field2', esTypes: ['keyword'] }), ]; - render(); + render(); await addRequiredFieldRow(); @@ -101,7 +101,7 @@ describe('RequiredFields form part', () => { }); it('user can add his own custom field name and type', async () => { - render(); + render(); await addRequiredFieldRow(); @@ -179,9 +179,7 @@ describe('RequiredFields form part', () => { }); it('adding a new required field is disabled when index patterns are loading', async () => { - render( - - ); + render(); expect(screen.getByTestId('addRequiredFieldButton')).toBeDisabled(); }); @@ -191,7 +189,7 @@ describe('RequiredFields form part', () => { createIndexPatternField({ name: 'field1', esTypes: ['string'] }), ]; - render(); + render(); expect(screen.getByTestId('addRequiredFieldButton')).toBeEnabled(); @@ -313,7 +311,7 @@ describe('RequiredFields form part', () => { createIndexPatternField({ name: 'field1', esTypes: ['string'] }), ]; - render(); + render(); await addRequiredFieldRow(); @@ -323,7 +321,7 @@ describe('RequiredFields form part', () => { }); it(`doesn't display a warning when field is invalid`, async () => { - render(); + render(); await addRequiredFieldRow(); @@ -340,7 +338,7 @@ describe('RequiredFields form part', () => { describe('validation', () => { it('form is invalid when only field name is empty', async () => { - render(); + render(); await addRequiredFieldRow(); @@ -360,7 +358,7 @@ describe('RequiredFields form part', () => { }); it('form is invalid when only field type is empty', async () => { - render(); + render(); await addRequiredFieldRow(); @@ -409,7 +407,7 @@ describe('RequiredFields form part', () => { it('form is valid when both field name and type are empty', async () => { const handleSubmit = jest.fn(); - render(); + render(); await addRequiredFieldRow(); @@ -426,7 +424,7 @@ describe('RequiredFields form part', () => { it('submits undefined when no required fields are selected', async () => { const handleSubmit = jest.fn(); - render(); + render(); await submitForm(); @@ -482,13 +480,7 @@ describe('RequiredFields form part', () => { const handleSubmit = jest.fn(); - render( - - ); + render(); await addRequiredFieldRow(); @@ -733,7 +725,7 @@ interface TestFormProps { function TestForm({ indexPatternFields, - initialState, + initialState = [], isIndexPatternLoading, onSubmit, }: TestFormProps): JSX.Element { From aceed3cc9c52c2bff5d7dec16eb366b8e5acec27 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 8 May 2024 18:11:16 +0200 Subject: [PATCH 35/66] Use testIds in form tests --- .../required_fields/required_fields.test.tsx | 33 +++++++------------ .../required_fields/required_fields.tsx | 1 + 2 files changed, 13 insertions(+), 21 deletions(-) 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 index aca8768ba68d2..10b2eb934e3a4 100644 --- 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 @@ -14,6 +14,9 @@ import { RequiredFields } from './required_fields'; import type { RequiredFieldWithOptionalEcs } from './types'; 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(); @@ -181,7 +184,7 @@ describe('RequiredFields form part', () => { it('adding a new required field is disabled when index patterns are loading', async () => { render(); - expect(screen.getByTestId('addRequiredFieldButton')).toBeDisabled(); + 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 () => { @@ -191,11 +194,11 @@ describe('RequiredFields form part', () => { render(); - expect(screen.getByTestId('addRequiredFieldButton')).toBeEnabled(); + expect(screen.getByTestId(ADD_REQUIRED_FIELD_BUTTON_TEST_ID)).toBeEnabled(); await addRequiredFieldRow(); - expect(screen.getByTestId('addRequiredFieldButton')).toBeDisabled(); + expect(screen.getByTestId(ADD_REQUIRED_FIELD_BUTTON_TEST_ID)).toBeDisabled(); }); describe('warnings', () => { @@ -210,9 +213,7 @@ describe('RequiredFields form part', () => { render(); - expect( - screen.getByText('Some fields are not found within specified index patterns.') - ).toBeVisible(); + expect(screen.getByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeVisible(); expect( screen.getByText( @@ -241,9 +242,7 @@ describe('RequiredFields form part', () => { render(); - expect( - screen.getByText('Some fields are not found within specified index patterns.') - ).toBeVisible(); + expect(screen.getByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeVisible(); expect( screen.getByText( @@ -272,9 +271,7 @@ describe('RequiredFields form part', () => { render(); - expect( - screen.getByText('Some fields are not found within specified index patterns.') - ).toBeVisible(); + expect(screen.getByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeVisible(); expect( screen.getByText( @@ -301,9 +298,7 @@ describe('RequiredFields form part', () => { render(); - expect( - screen.queryByText('Some fields are not found within specified index patterns.') - ).toBeNull(); + expect(screen.queryByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeNull(); }); it(`doesn't display a warning for an empty row`, async () => { @@ -315,9 +310,7 @@ describe('RequiredFields form part', () => { await addRequiredFieldRow(); - expect( - screen.queryByText('Some fields are not found within specified index patterns.') - ).toBeNull(); + expect(screen.queryByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeNull(); }); it(`doesn't display a warning when field is invalid`, async () => { @@ -576,9 +569,7 @@ describe('RequiredFields form part', () => { /> ); - expect( - screen.queryByText('Some fields are not found within specified index patterns.') - ).toBeVisible(); + expect(screen.queryByTestId(REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID)).toBeVisible(); await submitForm(); 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 index 436227dd0ee72..7fbdf2659d847 100644 --- 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 @@ -122,6 +122,7 @@ const RequiredFieldsList = ({ title={i18n.REQUIRED_FIELDS_GENERAL_WARNING_TITLE} color="warning" iconType="help" + data-test-subj="requiredFieldsGeneralWarning" >

{i18n.REQUIRED_FIELDS_GENERAL_WARNING_DESCRIPTION}

From 6de4f44f626b6b056531fab095d53541ae7edc6d Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 8 May 2024 18:17:09 +0200 Subject: [PATCH 36/66] Remove the unnecessary `type` from form field config --- .../components/required_fields/required_fields_row.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 1bfdbd92b0243..469758f0ebfc9 100644 --- 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 @@ -16,7 +16,7 @@ import { EuiTextColor, } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; -import { FIELD_TYPES, UseField } from '../../../../shared_imports'; +import { UseField } from '../../../../shared_imports'; import { useNameField } from './use_name_field'; import { useTypeField } from './use_type_field'; import * as i18n from './translations'; @@ -62,7 +62,6 @@ export const RequiredFieldRow = ({ RequiredFieldInput > = useMemo( () => ({ - type: FIELD_TYPES.JSON, deserializer: (value) => { const rowValueWithoutEcs: RequiredFieldInput = { name: value.name, From eb55a4ebf2ef0c7e1c3cae6ae490dd41a1c2e485 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Thu, 9 May 2024 18:37:06 +0200 Subject: [PATCH 37/66] Update `PrebuiltRuleAsset` type --- .../normalization/convert_rule_to_diffable.ts | 3 ++- .../model/rule_assets/prebuilt_rule_asset.ts | 15 ++++----------- .../rule_management/logic/crud/update_rules.ts | 4 +--- .../normalization/rule_converters.ts | 4 +--- .../rule_management/utils/utils.ts | 7 ++++++- .../prebuilt_rules/prebuilt_rules_preview.cy.ts | 4 ++-- 6 files changed, 16 insertions(+), 21 deletions(-) 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 dfe932ca84e32..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 @@ -10,7 +10,6 @@ import { RuleSignatureId, RuleVersion, BaseCreateProps, - RequiredFieldArray, TypeSpecificCreateProps, } from '../../../../../../common/api/detection_engine/model/rule_schema'; @@ -29,15 +28,9 @@ import { * - version is a required field that must exist */ export type PrebuiltRuleAsset = z.infer; -export const PrebuiltRuleAsset = BaseCreateProps.merge( +export const PrebuiltRuleAsset = BaseCreateProps.and(TypeSpecificCreateProps).and( z.object({ - required_fields: RequiredFieldArray.optional(), + rule_id: RuleSignatureId, + version: RuleVersion, }) -) - .and(TypeSpecificCreateProps) - .and( - z.object({ - rule_id: RuleSignatureId, - version: RuleVersion, - }) - ); +); 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 3317f120486b6..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 @@ -33,8 +33,6 @@ export const updateRules = async ({ return null; } - const requiredFieldsWithEcs = addEcsToRequiredFields(ruleUpdate.required_fields); - const alertActions = ruleUpdate.actions?.map((action) => transformRuleToAlertAction(action)) ?? []; const actions = transformToActionFrequency(alertActions, ruleUpdate.throttle); @@ -62,7 +60,7 @@ export const updateRules = async ({ meta: ruleUpdate.meta, maxSignals: ruleUpdate.max_signals ?? DEFAULT_MAX_SIGNALS, relatedIntegrations: ruleUpdate.related_integrations ?? [], - requiredFields: requiredFieldsWithEcs, + 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 ab009792b9f6c..804ddf6a4687b 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 @@ -499,8 +499,6 @@ export const convertCreateAPIToInternalSchema = ( const alertActions = input.actions?.map((action) => transformRuleToAlertAction(action)) ?? []; const actions = transformToActionFrequency(alertActions, input.throttle); - const requiredFieldsWithEcs = addEcsToRequiredFields(input.required_fields); - return { name: input.name, tags: input.tags ?? [], @@ -536,7 +534,7 @@ export const convertCreateAPIToInternalSchema = ( version: input.version ?? 1, exceptionsList: input.exceptions_list ?? [], relatedIntegrations: input.related_integrations ?? [], - requiredFields: requiredFieldsWithEcs, + requiredFields: addEcsToRequiredFields(input.required_fields), setup: input.setup ?? '', ...typeSpecificParams, }, 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 34fbca6e33c5e..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 @@ -20,6 +20,7 @@ import type { AlertSuppression, AlertSuppressionCamel, InvestigationFields, + RequiredField, RequiredFieldInput, RuleResponse, } from '../../../../../common/api/detection_engine/model/rule_schema'; @@ -392,7 +393,11 @@ export const migrateLegacyInvestigationFields = ( return investigationFields; }; -export const addEcsToRequiredFields = (requiredFields?: RequiredFieldInput[]) => +/* + 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 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', From a779cd8a5bdf5cbfcdc24a2bd7e38b711887fbc5 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Thu, 9 May 2024 19:13:38 +0200 Subject: [PATCH 38/66] Remove unnecessary `RequiredFieldWithOptionalEcs` type --- .../required_fields/required_fields.test.tsx | 37 +++++++----------- .../required_fields/required_fields_row.tsx | 39 +++++++++---------- .../components/required_fields/types.ts | 11 ------ 3 files changed, 34 insertions(+), 53 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/types.ts 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 index 10b2eb934e3a4..8a41e2d8c4ca8 100644 --- 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 @@ -11,7 +11,6 @@ import { Form, useForm } from '../../../../shared_imports'; import type { DataViewFieldBase } from '@kbn/es-query'; import { RequiredFields } from './required_fields'; -import type { RequiredFieldWithOptionalEcs } from './types'; import type { RequiredFieldInput } from '../../../../../common/api/detection_engine'; const ADD_REQUIRED_FIELD_BUTTON_TEST_ID = 'addRequiredFieldButton'; @@ -25,7 +24,7 @@ describe('RequiredFields form part', () => { }); it('displays previously saved required fields', () => { - const initialState: RequiredFieldWithOptionalEcs[] = [ + const initialState = [ { name: 'field1', type: 'string' }, { name: 'field2', type: 'number' }, ]; @@ -56,7 +55,7 @@ describe('RequiredFields form part', () => { }); it('user can add a new required field to a previously saved form', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + const initialState = [{ name: 'field1', type: 'string' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field2', esTypes: ['keyword'] }), @@ -124,7 +123,7 @@ describe('RequiredFields form part', () => { }); it('field type dropdown allows to choose from options if multiple types are available', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + const initialState = [{ name: 'field1', type: 'string' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string', 'keyword'] }), @@ -141,7 +140,7 @@ describe('RequiredFields form part', () => { }); it('user can remove a required field', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + const initialState = [{ name: 'field1', type: 'string' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), @@ -157,7 +156,7 @@ describe('RequiredFields form part', () => { }); it('user can not select the same field twice', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + const initialState = [{ name: 'field1', type: 'string' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), @@ -203,9 +202,7 @@ describe('RequiredFields form part', () => { describe('warnings', () => { it('displays a warning when a selected field name is not found within index patterns', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [ - { name: 'field-that-does-not-exist', type: 'keyword' }, - ]; + const initialState = [{ name: 'field-that-does-not-exist', type: 'keyword' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), @@ -232,9 +229,7 @@ describe('RequiredFields form part', () => { }); it('displays a warning when a selected field type is not found within index patterns', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [ - { name: 'field1', type: 'type-that-does-not-exist' }, - ]; + const initialState = [{ name: 'field1', type: 'type-that-does-not-exist' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), @@ -261,7 +256,7 @@ describe('RequiredFields form part', () => { }); it('displays a warning only for field name when both field name and type are not found within index patterns', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [ + const initialState = [ { name: 'field-that-does-not-exist', type: 'type-that-does-not-exist' }, ]; @@ -290,7 +285,7 @@ describe('RequiredFields form part', () => { }); it(`doesn't display a warning when all selected fields are found within index patterns`, async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + const initialState = [{ name: 'field1', type: 'string' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), @@ -371,7 +366,7 @@ describe('RequiredFields form part', () => { }); it('form is invalid when same field name is selected more than once', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + const initialState = [{ name: 'field1', type: 'string' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), @@ -438,7 +433,7 @@ describe('RequiredFields form part', () => { }); it('submits undefined when all selected fields were removed', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + const initialState = [{ name: 'field1', type: 'string' }]; const handleSubmit = jest.fn(); @@ -490,7 +485,7 @@ describe('RequiredFields form part', () => { }); it('submits previously saved required fields', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + const initialState = [{ name: 'field1', type: 'string' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), @@ -515,7 +510,7 @@ describe('RequiredFields form part', () => { }); it('submits updated required fields', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [{ name: 'field1', type: 'string' }]; + const initialState = [{ name: 'field1', type: 'string' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), @@ -551,9 +546,7 @@ describe('RequiredFields form part', () => { }); it('submits a form with warnings', async () => { - const initialState: RequiredFieldWithOptionalEcs[] = [ - { name: 'name-that-does-not-exist', type: 'type-that-does-not-exist' }, - ]; + const initialState = [{ name: 'name-that-does-not-exist', type: 'type-that-does-not-exist' }]; const indexPatternFields: DataViewFieldBase[] = [ createIndexPatternField({ name: 'field1', esTypes: ['string'] }), @@ -708,7 +701,7 @@ function submitForm(): Promise { } interface TestFormProps { - initialState?: RequiredFieldWithOptionalEcs[]; + initialState?: RequiredFieldInput[]; onSubmit?: (args: { data: RequiredFieldInput[]; isValid: boolean }) => void; indexPatternFields?: DataViewFieldBase[]; isIndexPatternLoading?: boolean; 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 index 469758f0ebfc9..653fc22a6cef2 100644 --- 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 @@ -29,8 +29,10 @@ import type { FormData, ValidationFunc, } from '../../../../shared_imports'; -import type { RequiredFieldInput } from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; -import type { RequiredFieldWithOptionalEcs } from './types'; +import type { + RequiredField, + RequiredFieldInput, +} from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; const SINGLE_SELECTION_AS_PLAIN_TEXT = { asPlainText: true }; @@ -56,25 +58,22 @@ export const RequiredFieldRow = ({ }: RequiredFieldRowProps) => { const handleRemove = useCallback(() => removeItem(item.id), [removeItem, item.id]); - const rowFieldConfig: FieldConfig< - RequiredFieldWithOptionalEcs, - RequiredFieldInput, - RequiredFieldInput - > = useMemo( - () => ({ - deserializer: (value) => { - const rowValueWithoutEcs: RequiredFieldInput = { - name: value.name, - type: value.type, - }; + 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 rowValueWithoutEcs; + }, + validations: [{ validator: makeValidateRequiredField(parentFieldPath) }], + defaultValue: { name: '', type: '' }, + }), + [parentFieldPath] + ); return ( Date: Sun, 12 May 2024 13:35:07 +0200 Subject: [PATCH 39/66] Update doc link for Related Integrations --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + .../related_integrations/related_integrations_help_info.tsx | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) 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/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: ( - + Date: Mon, 13 May 2024 00:37:25 +0200 Subject: [PATCH 40/66] Optimize UI perf by skipping re-renders when irrelevant fields change --- .../{use_name_field.ts => name_combobox.tsx} | 114 +++++++++--------- .../required_fields/required_fields.tsx | 112 +++++++++++------ .../required_fields/required_fields_row.tsx | 88 +++----------- .../{use_type_field.ts => type_combobox.tsx} | 104 +++++++++------- .../components/required_fields/utils.ts | 26 ++++ 5 files changed, 243 insertions(+), 201 deletions(-) rename x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/{use_name_field.ts => name_combobox.tsx} (50%) rename x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/{use_type_field.ts => type_combobox.tsx} (61%) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.ts diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/name_combobox.tsx similarity index 50% rename from x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts rename to x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/name_combobox.tsx index 8951dcf4ff780..90b655183d5aa 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_name_field.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/name_combobox.tsx @@ -5,31 +5,42 @@ * 2.0. */ -import { useCallback, useMemo, useState, useEffect } from 'react'; +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'; - -interface UseNameFieldReturn { - selectableNameOptions: Array>; - selectedNameOptions: Array>; - handleNameChange: (selectedOptions: Array>) => void; - handleAddCustomName: (newName: string) => void; +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 const useNameField = ( - field: FieldHook, - availableFieldNames: string[], - typesByFieldName: Record -): UseNameFieldReturn => { +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 */ - (field.value.name ? [field.value.name] : []).concat(availableFieldNames).map((name) => ({ + (value.name ? [value.name] : []).concat(availableFieldNames).map((name) => ({ label: name, value: name, })), - [availableFieldNames, field.value.name] + [availableFieldNames, value.name] ); /* @@ -37,25 +48,21 @@ export const useNameField = ( 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 [selectedNameOptions, setSelectedNameOptions] = useState< Array> >(() => { - const selectedNameOption = selectableNameOptions.find( - (option) => option.label === field.value.name - ); + const selectedNameOption = selectableNameOptions.find((option) => option.label === value.name); return selectedNameOption ? [selectedNameOption] : []; }); useEffect(() => { /* Re-computing the new selected name option when the field value changes */ - const selectedNameOption = selectableNameOptions.find( - (option) => option.label === field.value.name - ); + const selectedNameOption = selectableNameOptions.find((option) => option.label === value.name); setSelectedNameOptions(selectedNameOption ? [selectedNameOption] : []); - }, [field.value.name, selectableNameOptions]); + }, [value.name, selectableNameOptions]); const handleNameChange = useCallback( (selectedOptions: Array>) => { @@ -69,54 +76,53 @@ export const useNameField = ( const updatedName = newlySelectedOption?.value || ''; - const updatedType = pickTypeForName(updatedName, field.value.type, typesByFieldName); + const updatedType = pickTypeForName(updatedName, value.type, typesByFieldName); const updatedFieldValue: RequiredFieldInput = { name: updatedName, type: updatedType, }; - field.setValue(updatedFieldValue); + setValue(updatedFieldValue); }, - [field, typesByFieldName] + [setValue, value.type, typesByFieldName] ); const handleAddCustomName = useCallback( (newName: string) => { const updatedFieldValue: RequiredFieldInput = { name: newName, - type: pickTypeForName(newName, field.value.type, typesByFieldName), + type: pickTypeForName(newName, value.type, typesByFieldName), }; - field.setValue(updatedFieldValue); + setValue(updatedFieldValue); }, - [field, typesByFieldName] + [setValue, value.type, typesByFieldName] ); - return { - selectableNameOptions, - selectedNameOptions, - handleNameChange, - handleAddCustomName, - }; -}; - -function pickTypeForName( - currentName: string, - currentType: string, - typesByFieldName: Record -) { - const typesAvailableForNewName = typesByFieldName[currentName] || []; - const isCurrentTypeAvailableForNewName = typesAvailableForNewName.includes(currentType); - - /* First try to keep the current type if it's available for the new name */ - if (isCurrentTypeAvailableForNewName) { - return currentType; - } - - /* - If current type is not available, pick the first available type. - If no type is available, use the currently selected type. - */ - return typesAvailableForNewName?.[0] ?? currentType; + return ( + + ) : undefined + } + /> + ); } 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 index 7fbdf2659d847..1efa2e2686009 100644 --- 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 @@ -5,40 +5,43 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiButtonEmpty, EuiCallOut, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; import type { DataViewFieldBase } from '@kbn/es-query'; import type { RequiredFieldInput } from '../../../../../common/api/detection_engine'; import { UseArray, useFormData } from '../../../../shared_imports'; -import type { ArrayItem } from '../../../../shared_imports'; -import { RequiredFieldRow } from './required_fields_row'; +import type { FormHook, ArrayItem } from '../../../../shared_imports'; import * as ruleDetailsI18n from '../../../rule_management/components/rule_details/translations'; +import { RequiredFieldRow } from './required_fields_row'; import * as i18n from './translations'; -interface RequiredFieldsProps { +interface RequiredFieldsComponentProps { path: string; indexPatternFields?: DataViewFieldBase[]; isIndexPatternLoading?: boolean; } -export const RequiredFields = ({ +const RequiredFieldsComponent = ({ path, indexPatternFields = [], isIndexPatternLoading = false, -}: RequiredFieldsProps) => ( - - {({ items, addItem, removeItem }) => ( - - )} - -); +}: RequiredFieldsComponentProps) => { + return ( + + {({ items, addItem, removeItem, form }) => ( + + )} + + ); +}; interface RequiredFieldsListProps { items: ArrayItem[]; @@ -47,6 +50,7 @@ interface RequiredFieldsListProps { indexPatternFields: DataViewFieldBase[]; isIndexPatternLoading: boolean; path: string; + form: FormHook; } const RequiredFieldsList = ({ @@ -56,33 +60,61 @@ const RequiredFieldsList = ({ indexPatternFields, isIndexPatternLoading, path, + form, }: RequiredFieldsListProps) => { - const useFormDataResult = useFormData(); - const [formData] = useFormDataResult; - const fieldValue: RequiredFieldInput[] = formData[path] ?? []; + /* + This component should only re-render when either the "index" form field (index patterns) or the required fields change. - const selectedFieldNames = fieldValue.map(({ name }) => name); + 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. + + Hovewer, 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". - const fieldsWithTypes = indexPatternFields.filter((indexPatternField) => - Boolean(indexPatternField.esTypes?.length) + 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. + */ + + /* `form.getFields` returns an object with "flattened" keys like "requiredFields[0]", "requiredFields[1]"... */ + const flattenedFieldNames = Object.keys(form.getFields()); + const flattenedRequiredFieldsFieldNames = flattenedFieldNames.filter((key) => + key.startsWith(path) ); - const allFieldNames = fieldsWithTypes.map(({ name }) => name); - const availableFieldNames = allFieldNames.filter((name) => !selectedFieldNames.includes(name)); + /* + 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 = flattenedRequiredFieldsFieldNames.length > 0; + const fieldsToWatch = hasRenderedInitially ? ['index', ...flattenedRequiredFieldsFieldNames] : []; - const typesByFieldName: Record = fieldsWithTypes.reduce( - (accumulator, browserField) => { - if (browserField.esTypes) { - accumulator[browserField.name] = browserField.esTypes; - } - return accumulator; - }, - {} as Record + const [formData] = useFormData({ watch: fieldsToWatch }); + + const fieldValue: RequiredFieldInput[] = formData[path] ?? []; + + const fieldsWithTypes = useMemo( + () => + indexPatternFields.filter((indexPatternField) => Boolean(indexPatternField.esTypes?.length)), + [indexPatternFields] ); - const isEmptyRowDisplayed = !!fieldValue.find(({ name }) => name === ''); + const allFieldNames = useMemo(() => fieldsWithTypes.map(({ name }) => name), [fieldsWithTypes]); - const isAddNewFieldButtonDisabled = isIndexPatternLoading || isEmptyRowDisplayed; + const selectedFieldNames = fieldValue.map(({ name }) => name); + + const availableFieldNames = allFieldNames.filter((name) => !selectedFieldNames.includes(name)); + + const typesByFieldName: Record = useMemo( + () => + fieldsWithTypes.reduce((accumulator, browserField) => { + if (browserField.esTypes) { + accumulator[browserField.name] = browserField.esTypes; + } + return accumulator; + }, {} as Record), + [fieldsWithTypes] + ); const nameWarnings = fieldValue /* Not creating a warning for empty "name" value */ @@ -113,6 +145,8 @@ const RequiredFieldsList = ({ typeWarning: typeWarnings[`${name}-${type}`] || '', }); + const hasEmptyFieldName = !!fieldValue.find(({ name }) => name === ''); + const hasWarnings = Object.keys(nameWarnings).length > 0 || Object.keys(typeWarnings).length > 0; return ( @@ -158,7 +192,7 @@ const RequiredFieldsList = ({ size="xs" iconType="plusInCircle" onClick={addItem} - isDisabled={isAddNewFieldButtonDisabled} + isDisabled={isIndexPatternLoading || hasEmptyFieldName} data-test-subj="addRequiredFieldButton" > {i18n.ADD_REQUIRED_FIELD} @@ -168,3 +202,5 @@ const RequiredFieldsList = ({ ); }; + +export const RequiredFields = React.memo(RequiredFieldsComponent); 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 index 653fc22a6cef2..789eca401b25e 100644 --- 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 @@ -6,19 +6,10 @@ */ import React, { useCallback, useMemo } from 'react'; -import { - EuiButtonIcon, - EuiComboBox, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiIcon, - EuiTextColor, -} from '@elastic/eui'; -import { euiThemeVars } from '@kbn/ui-theme'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiTextColor } from '@elastic/eui'; import { UseField } from '../../../../shared_imports'; -import { useNameField } from './use_name_field'; -import { useTypeField } from './use_type_field'; +import { NameComboBox } from './name_combobox'; +import { TypeComboBox } from './type_combobox'; import * as i18n from './translations'; import type { @@ -34,8 +25,6 @@ import type { RequiredFieldInput, } from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; -const SINGLE_SELECTION_AS_PLAIN_TEXT = { asPlainText: true }; - interface RequiredFieldRowProps { item: ArrayItem; removeItem: (id: number) => void; @@ -80,7 +69,7 @@ export const RequiredFieldRow = ({ key={item.id} path={item.path} config={rowFieldConfig} - component={RequiredFieldRowContent} + component={RequiredFieldField} readDefaultValueOnForm={!item.isNew} componentProps={{ itemId: item.id, @@ -93,7 +82,7 @@ export const RequiredFieldRow = ({ ); }; -interface RequiredFieldRowInnerProps { +interface RequiredFieldFieldProps { field: FieldHook; onRemove: () => void; typesByFieldName: Record; @@ -105,20 +94,14 @@ interface RequiredFieldRowInnerProps { itemId: string; } -const RequiredFieldRowContent = ({ +const RequiredFieldField = ({ field, typesByFieldName, onRemove, availableFieldNames, getWarnings, itemId, -}: RequiredFieldRowInnerProps) => { - const { selectableNameOptions, selectedNameOptions, handleNameChange, handleAddCustomName } = - useNameField(field, availableFieldNames, typesByFieldName); - - const { selectableTypeOptions, selectedTypeOptions, handleTypeChange, handleAddCustomType } = - useTypeField(field, typesByFieldName); - +}: RequiredFieldFieldProps) => { const { nameWarning, typeWarning } = getWarnings(field.value); const warningMessage = nameWarning || typeWarning; @@ -153,53 +136,22 @@ const RequiredFieldRowContent = ({ > - - ) : undefined - } + - - ) : undefined - } + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_type_field.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/type_combobox.tsx similarity index 61% rename from x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_type_field.ts rename to x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/type_combobox.tsx index f568c26fe62c2..691ed409a890c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/use_type_field.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/type_combobox.tsx @@ -5,27 +5,34 @@ * 2.0. */ -import { useCallback, useMemo, useState, useEffect } from 'react'; +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'; - -interface UseTypeFieldReturn { - selectableTypeOptions: Array>; - selectedTypeOptions: Array>; - handleTypeChange: (selectedOptions: Array>) => void; - handleAddCustomType: (newType: string) => void; +import * as i18n from './translations'; + +interface TypeComboBoxProps { + field: FieldHook; + itemId: string; + typesByFieldName: Record; + typeWarning: string; + typeError: { message: string } | undefined; } -export const useTypeField = ( - field: FieldHook, - typesByFieldName: Record -): UseTypeFieldReturn => { +export function TypeComboBox({ + field, + itemId, + typesByFieldName, + typeWarning, + typeError, +}: TypeComboBoxProps) { + const { value, setValue } = field; + const selectableTypeOptions: Array> = useMemo(() => { - const typesAvailableForSelectedName = typesByFieldName[field.value.name]; - const isSelectedTypeAvailable = (typesAvailableForSelectedName || []).includes( - field.value.type - ); + const typesAvailableForSelectedName = typesByFieldName[value.name]; + const isSelectedTypeAvailable = (typesAvailableForSelectedName || []).includes(value.type); if (typesAvailableForSelectedName && isSelectedTypeAvailable) { /* @@ -49,8 +56,8 @@ export const useTypeField = ( label: type, value: type, })) - .concat({ label: field.value.type, value: field.value.type }); - } else if (field.value.name) { + .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. @@ -58,39 +65,35 @@ export const useTypeField = ( */ return [ { - label: field.value.type, - value: field.value.type, + label: value.type, + value: value.type, }, ]; } return []; - }, [field.value.name, field.value.type, typesByFieldName]); + }, [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 [selectedTypeOptions, setSelectedTypeOptions] = useState< Array> >(() => { - const selectedTypeOption = selectableTypeOptions.find( - (option) => option.value === field.value.type - ); + const selectedTypeOption = selectableTypeOptions.find((option) => option.value === value.type); return selectedTypeOption ? [selectedTypeOption] : []; }); useEffect(() => { /* Re-computing the new selected type option when the field value changes */ - const selectedTypeOption = selectableTypeOptions.find( - (option) => option.value === field.value.type - ); + const selectedTypeOption = selectableTypeOptions.find((option) => option.value === value.type); setSelectedTypeOptions(selectedTypeOption ? [selectedTypeOption] : []); - }, [field.value.type, selectableTypeOptions]); + }, [value.type, selectableTypeOptions]); const handleTypeChange = useCallback( (selectedOptions: Array>) => { @@ -105,31 +108,50 @@ export const useTypeField = ( const updatedType = newlySelectedOption?.value || ''; const updatedFieldValue: RequiredFieldInput = { - name: field.value.name, + name: value.name, type: updatedType, }; - field.setValue(updatedFieldValue); + setValue(updatedFieldValue); }, - [field] + [value.name, setValue] ); const handleAddCustomType = useCallback( (newType: string) => { const updatedFieldValue: RequiredFieldInput = { - name: field.value.name, + name: value.name, type: newType, }; - field.setValue(updatedFieldValue); + setValue(updatedFieldValue); }, - [field] + [value.name, setValue] ); - return { - selectableTypeOptions, - selectedTypeOptions, - handleTypeChange, - handleAddCustomType, - }; -}; + return ( + + ) : undefined + } + /> + ); +} 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..664337d929dfa --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function pickTypeForName( + currentName: string, + currentType: string, + typesByFieldName: Record +) { + const typesAvailableForNewName = typesByFieldName[currentName] || []; + const isCurrentTypeAvailableForNewName = typesAvailableForNewName.includes(currentType); + + /* First try to keep the current type if it's available for the new name */ + if (isCurrentTypeAvailableForNewName) { + return currentType; + } + + /* + If current type is not available, pick the first available type. + If no type is available, use the currently selected type. + */ + return typesAvailableForNewName?.[0] ?? currentType; +} From 9fbb3d82c87c3ee5f5288948071ef35cacaedd8d Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Mon, 13 May 2024 01:06:37 +0200 Subject: [PATCH 41/66] Fix failing API integration test --- .../rule_management/normalization/rule_converters.ts | 1 + 1 file changed, 1 insertion(+) 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 804ddf6a4687b..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 @@ -773,6 +773,7 @@ export const convertPrebuiltRuleAssetToRuleResponse = ( return RuleResponse.parse({ ...prebuiltRuleAssetDefaults, ...prebuiltRuleAsset, + required_fields: addEcsToRequiredFields(prebuiltRuleAsset.required_fields), ...ruleResponseSpecificFields, }); }; From 23c78f27f8fa8bed922d8b29d45828fb19684fa7 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Mon, 13 May 2024 15:35:35 +0200 Subject: [PATCH 42/66] Use a help popover instead of `helpText` --- .../required_fields/required_fields.tsx | 10 +++- .../required_fields_help_info.tsx | 55 +++++++++++++++++++ .../required_fields/translations.ts | 14 ++--- 3 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_help_info.tsx 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 index 1efa2e2686009..f37b8ebd6a840 100644 --- 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 @@ -11,7 +11,7 @@ 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 * as ruleDetailsI18n from '../../../rule_management/components/rule_details/translations'; +import { RequiredFieldsHelpInfo } from './required_fields_help_info'; import { RequiredFieldRow } from './required_fields_row'; import * as i18n from './translations'; @@ -164,13 +164,17 @@ const RequiredFieldsList = ({ + {i18n.REQUIRED_FIELDS_LABEL} + + + } labelAppend={ {i18n.OPTIONAL} } - helpText={i18n.REQUIRED_FIELDS_HELP_TEXT} hasChildLabel={false} labelType="legend" > 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..5fc7117e8bfce --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_help_info.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EuiLink, EuiPopover, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '../../../../common/lib/kibana'; + +/** + * 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 { docLinks } = useKibana().services; + + const button = ( + + ); + + return ( + + + + + + ), + }} + /> + + + ); +} 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 index a6972be35a9ce..da966f29fa08f 100644 --- 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 @@ -7,6 +7,13 @@ 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', { @@ -21,13 +28,6 @@ export const FIELD_TYPE = i18n.translate( } ); -export const REQUIRED_FIELDS_HELP_TEXT = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.fieldRequiredFieldsHelpText', - { - defaultMessage: 'Fields required for this Rule to function.', - } -); - export const REQUIRED_FIELDS_GENERAL_WARNING_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.generalWarningTitle', { From c01b461ac2e72c2321a469ee88c2247445e91690 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 09:39:33 +0200 Subject: [PATCH 43/66] Mock `useKibana` in tests to fix them --- .../required_fields/required_fields.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 index 8a41e2d8c4ca8..4c57c46ee2c50 100644 --- 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 @@ -16,6 +16,20 @@ import type { RequiredFieldInput } from '../../../../../common/api/detection_eng const ADD_REQUIRED_FIELD_BUTTON_TEST_ID = 'addRequiredFieldButton'; const REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID = 'requiredFieldsGeneralWarning'; +jest.mock('../../../../common/lib/kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + docLinks: { + links: { + securitySolution: { + ruleUiAdvancedParams: 'http://link-to-docs', + }, + }, + }, + }, + }), +})); + describe('RequiredFields form part', () => { it('displays the required fields label', () => { render(); From 1ee24f18251966d55e950cf64aa55cd4902f0446 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 09:48:29 +0200 Subject: [PATCH 44/66] Jest tests: Simplify waiting for `handleSubmit` to be called --- .../required_fields/required_fields.test.tsx | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) 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 index 4c57c46ee2c50..a8cc9ec99b9db 100644 --- 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 @@ -430,20 +430,12 @@ describe('RequiredFields form part', () => { await submitForm(); - /* - useForm's "submit" implementation calls setTimeout internally in cases when form is untouched. - We need to tell Jest to wait for the next tick of the event loop to allow the form to be submitted. - */ await waitFor(() => { - new Promise((resolve) => { - setImmediate(resolve); + expect(handleSubmit).toHaveBeenCalledWith({ + data: undefined, + isValid: true, }); }); - - expect(handleSubmit).toHaveBeenCalledWith({ - data: undefined, - isValid: true, - }); }); it('submits undefined when all selected fields were removed', async () => { @@ -459,20 +451,12 @@ describe('RequiredFields form part', () => { await submitForm(); - /* - useForm's "submit" implementation calls setTimeout internally in cases when form is untouched. - We need to tell Jest to wait for the next tick of the event loop to allow the form to be submitted. - */ await waitFor(() => { - new Promise((resolve) => { - setImmediate(resolve); + expect(handleSubmit).toHaveBeenCalledWith({ + data: undefined, + isValid: true, }); }); - - expect(handleSubmit).toHaveBeenCalledWith({ - data: undefined, - isValid: true, - }); }); it('submits newly added required fields', async () => { From 3e7368d7741ede17c23ba5b274e43e04bb739e69 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 09:52:37 +0200 Subject: [PATCH 45/66] Jest tests: Refactor `selectEuiComboBoxOption` --- .../components/required_fields/required_fields.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index a8cc9ec99b9db..75d9b05e193a7 100644 --- 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 @@ -635,15 +635,15 @@ function selectEuiComboBoxOption({ if (typeof optionText === 'string') { const optionToSelect = options.find((option) => option.textContent === optionText); - if (optionToSelect) { - fireEvent.click(optionToSelect); - } else { + 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]); } From 4edc2d2356ace05ab7722d8e6facc489589097c2 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 09:55:15 +0200 Subject: [PATCH 46/66] Jest tests: Use a different testId in warning tests --- .../components/required_fields/required_fields.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 75d9b05e193a7..c7db4d74e846a 100644 --- 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 @@ -239,7 +239,7 @@ describe('RequiredFields form part', () => { expect(nameWarningIcon).toBeVisible(); /* Make sure only one warning icon is displayed - the one for name */ - expect(document.querySelectorAll('[data-euiicon-type="warning"]')).toHaveLength(1); + 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 () => { @@ -266,7 +266,7 @@ describe('RequiredFields form part', () => { expect(typeWarningIcon).toBeVisible(); /* Make sure only one warning icon is displayed - the one for type */ - expect(document.querySelectorAll('[data-euiicon-type="warning"]')).toHaveLength(1); + 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 () => { @@ -295,7 +295,7 @@ describe('RequiredFields form part', () => { expect(nameWarningIcon).toBeVisible(); /* Make sure only one warning icon is displayed - the one for name */ - expect(document.querySelectorAll('[data-euiicon-type="warning"]')).toHaveLength(1); + 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 () => { From 4cc35ba100e66ccad5445ac0e635b065849cd08c Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 09:58:05 +0200 Subject: [PATCH 47/66] Refactor: Use `.some` instead of `.find` to compute `hasEmptyFieldName` --- .../components/required_fields/required_fields.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f37b8ebd6a840..b7a664a565e03 100644 --- 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 @@ -145,7 +145,7 @@ const RequiredFieldsList = ({ typeWarning: typeWarnings[`${name}-${type}`] || '', }); - const hasEmptyFieldName = !!fieldValue.find(({ name }) => name === ''); + const hasEmptyFieldName = fieldValue.some(({ name }) => name === ''); const hasWarnings = Object.keys(nameWarnings).length > 0 || Object.keys(typeWarnings).length > 0; From 61a6475a4444b55e0abf7f0625d5040119a1e7de Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 10:02:42 +0200 Subject: [PATCH 48/66] Refactor: Move `makeValidateRequiredField` into a separate file --- .../make_validate_required_field.ts | 53 +++++++++++++++++++ .../required_fields/required_fields_row.tsx | 53 +------------------ 2 files changed, 55 insertions(+), 51 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/make_validate_required_field.ts diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/make_validate_required_field.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/make_validate_required_field.ts new file mode 100644 index 0000000000000..7402f55a47e54 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/make_validate_required_field.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RequiredFieldInput } from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen'; +import type { ERROR_CODE, FormData, ValidationFunc } from '../../../../shared_imports'; +import * as i18n from './translations'; + +export function makeValidateRequiredField(parentFieldPath: string) { + return function validateRequiredField( + ...args: Parameters> + ): 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/required_fields_row.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_row.tsx index 789eca401b25e..755f1de413760 100644 --- 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 @@ -10,16 +10,10 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiTextColor } fr 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, - ERROR_CODE, - FieldConfig, - FieldHook, - FormData, - ValidationFunc, -} from '../../../../shared_imports'; +import type { ArrayItem, FieldConfig, FieldHook } from '../../../../shared_imports'; import type { RequiredField, RequiredFieldInput, @@ -167,46 +161,3 @@ const RequiredFieldField = ({
); }; - -function makeValidateRequiredField(parentFieldPath: string) { - return function validateRequiredField( - ...args: Parameters> - ): 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, - }; - } - }; -} From 2a571e0ae320da1f967ac4df27a3bb92c79be2d6 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 10:38:09 +0200 Subject: [PATCH 49/66] Refactor `pickTypeForName` and cover it with tests --- .../required_fields/name_combobox.tsx | 8 ++- .../components/required_fields/utils.test.tsx | 58 +++++++++++++++++++ .../components/required_fields/utils.ts | 24 ++++---- 3 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/utils.test.tsx 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 index 90b655183d5aa..722fdcd323e2e 100644 --- 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 @@ -76,7 +76,11 @@ export function NameComboBox({ const updatedName = newlySelectedOption?.value || ''; - const updatedType = pickTypeForName(updatedName, value.type, typesByFieldName); + const updatedType = pickTypeForName({ + name: updatedName, + type: value.type, + typesByFieldName, + }); const updatedFieldValue: RequiredFieldInput = { name: updatedName, @@ -92,7 +96,7 @@ export function NameComboBox({ (newName: string) => { const updatedFieldValue: RequiredFieldInput = { name: newName, - type: pickTypeForName(newName, value.type, typesByFieldName), + type: pickTypeForName({ name: newName, type: value.type, typesByFieldName }), }; setValue(updatedFieldValue); 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 index 664337d929dfa..a265013a5b11b 100644 --- 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 @@ -5,22 +5,24 @@ * 2.0. */ -export function pickTypeForName( - currentName: string, - currentType: string, - typesByFieldName: Record -) { - const typesAvailableForNewName = typesByFieldName[currentName] || []; - const isCurrentTypeAvailableForNewName = typesAvailableForNewName.includes(currentType); +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 current type if it's available for the new name */ + /* First try to keep the type if it's available for the name */ if (isCurrentTypeAvailableForNewName) { - return currentType; + return type; } /* If current type is not available, pick the first available type. - If no type is available, use the currently selected type. + If no type is available, use the current type. */ - return typesAvailableForNewName?.[0] ?? currentType; + return typesAvailableForName?.[0] ?? type; } From 5a36af88133876584c6bc9d920d0e31f5ae1b7b2 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 11:55:52 +0200 Subject: [PATCH 50/66] Refactor: Warning creation --- .../required_fields/required_fields.tsx | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) 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 index b7a664a565e03..a07803f87ecd4 100644 --- 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 @@ -116,29 +116,30 @@ const RequiredFieldsList = ({ [fieldsWithTypes] ); - const nameWarnings = fieldValue - /* Not creating a warning for empty "name" value */ - .filter(({ name }) => name !== '') - .reduce>((warnings, { name }) => { - if (!isIndexPatternLoading && !allFieldNames.includes(name)) { - warnings[name] = i18n.FIELD_NAME_NOT_FOUND_WARNING(name); - } - return warnings; - }, {}); - - const typeWarnings = fieldValue - /* Not creating a warning for "type" if there's no "name" value */ - .filter(({ name }) => name !== '') - .reduce>((warnings, { name, type }) => { - if ( - !isIndexPatternLoading && - typesByFieldName[name] && - !typesByFieldName[name].includes(type) - ) { - warnings[`${name}-${type}`] = i18n.FIELD_TYPE_NOT_FOUND_WARNING(name, type); - } - return warnings; - }, {}); + 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] || '', From a8ae5c8e855b57ac13687cf31be0bdc891164a75 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 12:26:28 +0200 Subject: [PATCH 51/66] Refactor API integration test: "creates a rule with required_fields defaulted to an empty array when not present" --- .../create_rules.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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 a7341fd6c4ad4..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 @@ -221,12 +221,10 @@ export default ({ getService }: FtrProviderContext) => { }); describe('required_fields', () => { - afterEach(async () => { - await deleteAllRules(supertest, log); - }); - it('creates a rule with required_fields defaulted to an empty array when not present', async () => { - const customQueryRuleParams = getCustomQueryRuleParams(); + const customQueryRuleParams = getCustomQueryRuleParams({ + rule_id: 'rule-without-required-fields', + }); expect(customQueryRuleParams.required_fields).toBeUndefined(); @@ -237,6 +235,14 @@ export default ({ getService }: FtrProviderContext) => { .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([]); }); }); }); From 941609e8f03c3cb896c048f23a3407d98d13580e Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 12:35:48 +0200 Subject: [PATCH 52/66] Refactor API integration test: "should reset required fields field to default value on update when not present" --- .../basic_license_essentials_tier/update_rules.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) 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 12e6d2dd6bd21..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 @@ -281,19 +281,15 @@ export default ({ getService }: FtrProviderContext) => { }); describe('required_fields', () => { - afterEach(async () => { - await deleteAllRules(supertest, log); - }); - it('should reset required fields field to default value on update when not present', async () => { const expectedRule = getCustomQueryRuleParams({ - rule_id: 'rule-1', + rule_id: 'required-fields-default-value-test', required_fields: [], }); await securitySolutionApi.createRule({ body: getCustomQueryRuleParams({ - rule_id: 'rule-1', + rule_id: 'required-fields-default-value-test', required_fields: [{ name: 'host.name', type: 'keyword' }], }), }); @@ -301,8 +297,8 @@ export default ({ getService }: FtrProviderContext) => { const { body: updatedRuleResponse } = await securitySolutionApi .updateRule({ body: getCustomQueryRuleParams({ - rule_id: 'rule-1', - max_signals: undefined, + rule_id: 'required-fields-default-value-test', + required_fields: undefined, }), }) .expect(200); From 6f875f2f1a832b225afd31f8d83874625d0e3f32 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 14:08:36 +0200 Subject: [PATCH 53/66] Fix existing bug: display field type icons in Rule Creation and Rule Definition the same way as in Discovery --- .../components/description_step/helpers.tsx | 9 +++++---- .../components/rule_details/rule_definition_section.tsx | 9 ++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) 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..064c614b62008 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,10 @@ 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 { FieldIcon, getFieldIconType } from '@kbn/field-utils'; import type { ThreatMapping, Type, Threats } from '@kbn/securitysolution-io-ts-alerting-types'; import { FilterBadgeGroup } from '@kbn/unified-search-plugin/public'; @@ -568,8 +566,11 @@ export const buildRequiredFieldsDescription = ( 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..0e54221f384bb 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,7 @@ 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 { FieldIcon, getFieldIconType } from '@kbn/field-utils'; import { FilterItems } from '@kbn/unified-search-plugin/public'; import type { AlertSuppressionMissingFieldsStrategy, @@ -254,6 +253,7 @@ interface RequiredFieldsProps { const RequiredFields = ({ requiredFields }: RequiredFieldsProps) => { const styles = useRequiredFieldsStyles(); + return ( {requiredFields.map((rF, index) => ( @@ -262,8 +262,11 @@ const RequiredFields = ({ requiredFields }: RequiredFieldsProps) => { From 0838d576f91d98d71a46b8ea511d80059adc73d5 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 14:55:43 +0200 Subject: [PATCH 54/66] Refactor: wrap selected combobox items into an array just before passing them to `EuiComboBox` --- .../required_fields/name_combobox.tsx | 18 ++++++------------ .../required_fields/type_combobox.tsx | 18 ++++++------------ 2 files changed, 12 insertions(+), 24 deletions(-) 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 index 722fdcd323e2e..848e3c9a3c558 100644 --- 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 @@ -49,19 +49,13 @@ export function NameComboBox({ and trigger a validation error. By using a separate state, we can clear the selected option without clearing the field value. */ - const [selectedNameOptions, setSelectedNameOptions] = useState< - Array> - >(() => { - const selectedNameOption = selectableNameOptions.find((option) => option.label === value.name); - - return selectedNameOption ? [selectedNameOption] : []; - }); + 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 */ - const selectedNameOption = selectableNameOptions.find((option) => option.label === value.name); - - setSelectedNameOptions(selectedNameOption ? [selectedNameOption] : []); + setSelectedNameOption(selectableNameOptions.find((option) => option.label === value.name)); }, [value.name, selectableNameOptions]); const handleNameChange = useCallback( @@ -70,7 +64,7 @@ export function NameComboBox({ if (!newlySelectedOption) { /* This occurs when the user hits backspace in combobox */ - setSelectedNameOptions([]); + setSelectedNameOption(undefined); return; } @@ -111,7 +105,7 @@ export function NameComboBox({ placeholder={i18n.FIELD_NAME} singleSelection={{ asPlainText: true }} options={selectableNameOptions} - selectedOptions={selectedNameOptions} + selectedOptions={selectedNameOption ? [selectedNameOption] : []} onChange={handleNameChange} isClearable={false} onCreateOption={handleAddCustomName} 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 index 691ed409a890c..48d5a009c4abe 100644 --- 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 @@ -80,19 +80,13 @@ export function TypeComboBox({ and trigger a validation error. By using a separate state, we can clear the selected option without clearing the field value. */ - const [selectedTypeOptions, setSelectedTypeOptions] = useState< - Array> - >(() => { - const selectedTypeOption = selectableTypeOptions.find((option) => option.value === value.type); - - return selectedTypeOption ? [selectedTypeOption] : []; - }); + 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 */ - const selectedTypeOption = selectableTypeOptions.find((option) => option.value === value.type); - - setSelectedTypeOptions(selectedTypeOption ? [selectedTypeOption] : []); + setSelectedTypeOption(selectableTypeOptions.find((option) => option.value === value.type)); }, [value.type, selectableTypeOptions]); const handleTypeChange = useCallback( @@ -101,7 +95,7 @@ export function TypeComboBox({ if (!newlySelectedOption) { /* This occurs when the user hits backspace in combobox */ - setSelectedTypeOptions([]); + setSelectedTypeOption(undefined); return; } @@ -136,7 +130,7 @@ export function TypeComboBox({ placeholder={i18n.FIELD_TYPE} singleSelection={{ asPlainText: true }} options={selectableTypeOptions} - selectedOptions={selectedTypeOptions} + selectedOptions={selectedTypeOption ? [selectedTypeOption] : []} onChange={handleTypeChange} isClearable={false} onCreateOption={handleAddCustomType} From f920a585be10ed258e0df2859b911340541560b5 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 14:57:35 +0200 Subject: [PATCH 55/66] Commit an automatic tsconfig.json fix --- x-pack/plugins/security_solution/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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" ] } From 6fefac8e731108305e18ca53c1f9e900a952bd11 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Tue, 14 May 2024 16:49:26 +0200 Subject: [PATCH 56/66] Jest test: Fix incorrectly mocked URL --- .../related_integrations/related_integrations.test.tsx | 2 +- .../components/required_fields/required_fields.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx index 21fa15c358719..b15705c7becc9 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx @@ -28,7 +28,7 @@ jest.mock('../../../../common/lib/kibana', () => ({ docLinks: { links: { securitySolution: { - ruleUiAdvancedParams: 'http://link-to-docs', + createDetectionRules: 'http://link-to-docs', }, }, }, 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 index c7db4d74e846a..cfcab676a4571 100644 --- 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 @@ -22,7 +22,7 @@ jest.mock('../../../../common/lib/kibana', () => ({ docLinks: { links: { securitySolution: { - ruleUiAdvancedParams: 'http://link-to-docs', + createDetectionRules: 'http://link-to-docs', }, }, }, From ec9e0494576831893ecde2e3e162690654b72af4 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 15 May 2024 13:43:01 +0200 Subject: [PATCH 57/66] Fix: Add missing icons --- .../components/description_step/helpers.tsx | 12 +--- .../rule_details/required_field_icon.tsx | 67 +++++++++++++++++++ .../rule_details/rule_definition_section.tsx | 11 +-- 3 files changed, 72 insertions(+), 18 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/required_field_icon.tsx 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 064c614b62008..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 @@ -21,7 +21,6 @@ import { ALERT_RISK_SCORE } from '@kbn/rule-data-utils'; import { isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; -import { FieldIcon, getFieldIconType } from '@kbn/field-utils'; import type { ThreatMapping, Type, Threats } from '@kbn/securitysolution-io-ts-alerting-types'; import { FilterBadgeGroup } from '@kbn/unified-search-plugin/public'; @@ -48,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; @@ -564,14 +565,7 @@ export const buildRequiredFieldsDescription = ( - + 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 0e54221f384bb..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,7 +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, getFieldIconType } from '@kbn/field-utils'; import { FilterItems } from '@kbn/unified-search-plugin/public'; import type { AlertSuppressionMissingFieldsStrategy, @@ -52,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, @@ -260,14 +260,7 @@ const RequiredFields = ({ requiredFields }: RequiredFieldsProps) => { - + Date: Wed, 15 May 2024 13:53:49 +0200 Subject: [PATCH 58/66] Refactor: remove unnecessary optional chaining in `pickTypeForName` --- .../rule_creation/components/required_fields/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a265013a5b11b..55beca264e120 100644 --- 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 @@ -24,5 +24,5 @@ export function pickTypeForName({ name, type, typesByFieldName = {} }: PickTypeF If current type is not available, pick the first available type. If no type is available, use the current type. */ - return typesAvailableForName?.[0] ?? type; + return typesAvailableForName[0] ?? type; } From 92c798077763812bbd5a169bfbb034902b727adf Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 15 May 2024 14:07:19 +0200 Subject: [PATCH 59/66] Fix a typo --- .../components/required_fields/required_fields.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a07803f87ecd4..c75f93ff0ae19 100644 --- 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 @@ -68,7 +68,7 @@ const RequiredFieldsList = ({ 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. - Hovewer, it doesn't work with fields created using the `UseArray` component. + 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". From dfae1a34ceaa0fee4f7d4b22d9ca6253e7bd0856 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 15 May 2024 14:45:23 +0200 Subject: [PATCH 60/66] Refactor: getting `flattenedFieldNames` in `RequiredFieldsList` --- .../components/required_fields/required_fields.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) 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 index c75f93ff0ae19..803a3c46ec344 100644 --- 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 @@ -76,18 +76,16 @@ const RequiredFieldsList = ({ This is a temporary solution and ideally, `useFormData` should be updated to handle this scenario. */ - /* `form.getFields` returns an object with "flattened" keys like "requiredFields[0]", "requiredFields[1]"... */ - const flattenedFieldNames = Object.keys(form.getFields()); - const flattenedRequiredFieldsFieldNames = flattenedFieldNames.filter((key) => - key.startsWith(path) - ); + 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 = flattenedRequiredFieldsFieldNames.length > 0; - const fieldsToWatch = hasRenderedInitially ? ['index', ...flattenedRequiredFieldsFieldNames] : []; + const hasRenderedInitially = flattenedFieldNames.length > 0; + const fieldsToWatch = hasRenderedInitially ? ['index', ...flattenedFieldNames] : []; const [formData] = useFormData({ watch: fieldsToWatch }); From af57ac5b4a9bcffa9d5506d4ab4f22af3d977c05 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Wed, 15 May 2024 18:08:01 +0200 Subject: [PATCH 61/66] Update UI copy --- .../required_fields/required_fields.test.tsx | 20 +++---------------- .../required_fields/required_fields.tsx | 12 ++++++++++- .../required_fields_help_info.tsx | 19 ++++++------------ .../required_fields/translations.ts | 16 +++++++-------- .../translations/translations/fr-FR.json | 2 +- .../translations/translations/ja-JP.json | 2 +- .../translations/translations/zh-CN.json | 2 +- 7 files changed, 31 insertions(+), 42 deletions(-) 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 index cfcab676a4571..0ba8847c4148d 100644 --- 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 @@ -16,20 +16,6 @@ import type { RequiredFieldInput } from '../../../../../common/api/detection_eng const ADD_REQUIRED_FIELD_BUTTON_TEST_ID = 'addRequiredFieldButton'; const REQUIRED_FIELDS_GENERAL_WARNING_TEST_ID = 'requiredFieldsGeneralWarning'; -jest.mock('../../../../common/lib/kibana', () => ({ - useKibana: jest.fn().mockReturnValue({ - services: { - docLinks: { - links: { - securitySolution: { - createDetectionRules: 'http://link-to-docs', - }, - }, - }, - }, - }), -})); - describe('RequiredFields form part', () => { it('displays the required fields label', () => { render(); @@ -228,7 +214,7 @@ describe('RequiredFields form part', () => { expect( screen.getByText( - 'Field "field-that-does-not-exist" is not found within specified index patterns' + `Field "field-that-does-not-exist" is not found within the rule's specified index patterns` ) ).toBeVisible(); @@ -255,7 +241,7 @@ describe('RequiredFields form part', () => { expect( screen.getByText( - 'Field "field1" with type "type-that-does-not-exist" is not found within specified index patterns' + `Field "field1" with type "type-that-does-not-exist" is not found within the rule's specified index patterns` ) ).toBeVisible(); @@ -284,7 +270,7 @@ describe('RequiredFields form part', () => { expect( screen.getByText( - 'Field "field-that-does-not-exist" is not found within specified index patterns' + `Field "field-that-does-not-exist" is not found within the rule's specified index patterns` ) ).toBeVisible(); 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 index 803a3c46ec344..3f975ce098998 100644 --- 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 @@ -7,12 +7,14 @@ 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 { @@ -157,7 +159,15 @@ const RequiredFieldsList = ({ iconType="help" data-test-subj="requiredFieldsGeneralWarning" > -

{i18n.REQUIRED_FIELDS_GENERAL_WARNING_DESCRIPTION}

+

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

)} 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 index 5fc7117e8bfce..2ae61bfc0152e 100644 --- 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 @@ -7,9 +7,10 @@ import React from 'react'; import { useToggle } from 'react-use'; -import { EuiLink, EuiPopover, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { EuiPopover, EuiText, EuiButtonIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useKibana } from '../../../../common/lib/kibana'; +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 @@ -22,13 +23,12 @@ const POPOVER_WIDTH = 320; export function RequiredFieldsHelpInfo(): JSX.Element { const [isPopoverOpen, togglePopover] = useToggle(false); - const { docLinks } = useKibana().services; const button = ( ); @@ -37,16 +37,9 @@ export function RequiredFieldsHelpInfo(): JSX.Element { - - - ), + source: {defineRuleI18n.SOURCE}, }} /> 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 index da966f29fa08f..bed9a4ea0024c 100644 --- 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 @@ -28,17 +28,17 @@ export const FIELD_TYPE = i18n.translate( } ); -export const REQUIRED_FIELDS_GENERAL_WARNING_TITLE = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.generalWarningTitle', +export const OPEN_HELP_POPOVER_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.openHelpPopoverAriaLabel', { - defaultMessage: 'Some fields are not found within specified index patterns.', + defaultMessage: 'Open help popover', } ); -export const REQUIRED_FIELDS_GENERAL_WARNING_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.generalWarningDescription', +export const REQUIRED_FIELDS_GENERAL_WARNING_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.generalWarningTitle', { - defaultMessage: `This doesn't break rule execution, but it might indicate that required fields were set incorrectly. Please check that indices specified in index patterns exist and have expected fields and types in mappings.`, + defaultMessage: `Some fields aren't found within the rule's specified index patterns.`, } ); @@ -68,7 +68,7 @@ export const FIELD_NAME_NOT_FOUND_WARNING = (name: string) => 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.fieldNameNotFoundWarning', { values: { name }, - defaultMessage: `Field "{name}" is not found within specified index patterns`, + defaultMessage: `Field "{name}" is not found within the rule's specified index patterns`, } ); @@ -77,7 +77,7 @@ export const FIELD_TYPE_NOT_FOUND_WARNING = (name: string, type: string) => 'xpack.securitySolution.detectionEngine.ruleDescription.requiredFields.fieldTypeNotFoundWarning', { values: { name, type }, - defaultMessage: `Field "{name}" with type "{type}" is not found within specified index patterns`, + defaultMessage: `Field "{name}" with type "{type}" is not found within the rule's specified index patterns`, } ); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 00db92de8fb88..083ded718df5e 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -45103,4 +45103,4 @@ "xpack.serverlessObservability.nav.projectSettings": "Paramètres de projet", "xpack.serverlessObservability.nav.synthetics": "Synthetics" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 44c9d24e178e7..4cc917e9d5408 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -45073,4 +45073,4 @@ "xpack.serverlessObservability.nav.projectSettings": "プロジェクト設定", "xpack.serverlessObservability.nav.synthetics": "Synthetics" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 26974aebbaf97..b12268d5c4673 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -45121,4 +45121,4 @@ "xpack.serverlessObservability.nav.projectSettings": "项目设置", "xpack.serverlessObservability.nav.synthetics": "Synthetics" } -} \ No newline at end of file +} From 48d68801a7b8586d7370c890274f3dbd3c1d5d13 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Fri, 17 May 2024 10:56:50 +0200 Subject: [PATCH 62/66] Update tooltip text --- .../components/required_fields/required_fields_help_info.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2ae61bfc0152e..187f05880d205 100644 --- 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 @@ -37,7 +37,7 @@ export function RequiredFieldsHelpInfo(): JSX.Element { {defineRuleI18n.SOURCE}, }} From a824c2ff57bae17a2cc51f70761fe99916fb5572 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Fri, 17 May 2024 11:04:45 +0200 Subject: [PATCH 63/66] Refactor: remove unnecessary `fieldsWithTypes` computation --- .../required_fields/required_fields.tsx | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) 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 index 3f975ce098998..f53c41ce98d00 100644 --- 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 @@ -93,29 +93,23 @@ const RequiredFieldsList = ({ const fieldValue: RequiredFieldInput[] = formData[path] ?? []; - const fieldsWithTypes = useMemo( + const typesByFieldName: Record = useMemo( () => - indexPatternFields.filter((indexPatternField) => Boolean(indexPatternField.esTypes?.length)), + indexPatternFields.reduce((accumulator, field) => { + if (field.esTypes?.length) { + accumulator[field.name] = field.esTypes; + } + return accumulator; + }, {} as Record), [indexPatternFields] ); - const allFieldNames = useMemo(() => fieldsWithTypes.map(({ name }) => name), [fieldsWithTypes]); + const allFieldNames = useMemo(() => Object.keys(typesByFieldName), [typesByFieldName]); const selectedFieldNames = fieldValue.map(({ name }) => name); const availableFieldNames = allFieldNames.filter((name) => !selectedFieldNames.includes(name)); - const typesByFieldName: Record = useMemo( - () => - fieldsWithTypes.reduce((accumulator, browserField) => { - if (browserField.esTypes) { - accumulator[browserField.name] = browserField.esTypes; - } - return accumulator; - }, {} as Record), - [fieldsWithTypes] - ); - const nameWarnings = fieldValue.reduce>((warnings, { name }) => { if ( !isIndexPatternLoading && From 050be5fe765de8663791a76fb4eedd28a254d066 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Fri, 17 May 2024 11:07:29 +0200 Subject: [PATCH 64/66] Jest tests: Wrap into `I18nProvider` to remove warnings --- .../required_fields/required_fields.test.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) 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 index 0ba8847c4148d..dcbbe298d843e 100644 --- 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 @@ -6,6 +6,7 @@ */ 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'; @@ -706,15 +707,17 @@ function TestForm({ }); return ( - - - - + +
+ + + +
); } From 936fa94461cf580f9234843c7f48a2cb8745b721 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Fri, 17 May 2024 11:25:48 +0200 Subject: [PATCH 65/66] Jest tests: Add a warning assertion --- .../components/required_fields/required_fields.test.tsx | 1 + 1 file changed, 1 insertion(+) 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 index dcbbe298d843e..2812c147d9c2d 100644 --- 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 @@ -121,6 +121,7 @@ describe('RequiredFields form part', () => { }); 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 () => { From 3838882fc481ccc5a13a1bdf9074f06ca2ddc298 Mon Sep 17 00:00:00 2001 From: Nikita Indik Date: Fri, 17 May 2024 11:47:55 +0200 Subject: [PATCH 66/66] Related Integrations: Allow to remove the last row to match Required Fields behavior --- .../related_integration_field.tsx | 15 +--- .../related_integrations.test.tsx | 81 ------------------- 2 files changed, 2 insertions(+), 94 deletions(-) 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 ( { }); }); }); - - 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')); }