diff --git a/packages/kbn-securitysolution-utils/index.ts b/packages/kbn-securitysolution-utils/index.ts index d29b356f31783..8769a281e2201 100644 --- a/packages/kbn-securitysolution-utils/index.ts +++ b/packages/kbn-securitysolution-utils/index.ts @@ -12,3 +12,4 @@ export * from './src/axios'; export * from './src/transform_data_to_ndjson'; export * from './src/path_validations'; export * from './src/esql'; +export * from './src/debounce_async/debounce_async'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/validators.test.ts b/packages/kbn-securitysolution-utils/src/debounce_async/debounce_async.test.ts similarity index 64% rename from x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/validators.test.ts rename to packages/kbn-securitysolution-utils/src/debounce_async/debounce_async.test.ts index 6c5cadf41a7a6..d7e1201e44e8d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/validators.test.ts +++ b/packages/kbn-securitysolution-utils/src/debounce_async/debounce_async.test.ts @@ -1,22 +1,20 @@ /* * 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. + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". */ -import { debounceAsync } from './validators'; +import { debounceAsync } from './debounce_async'; jest.useFakeTimers({ legacyFakeTimers: true }); describe('debounceAsync', () => { - let fn: jest.Mock; - - beforeEach(() => { - fn = jest.fn().mockResolvedValueOnce('first'); - }); - it('resolves with the underlying invocation result', async () => { + const fn = jest.fn().mockResolvedValueOnce('first'); + const debounced = debounceAsync(fn, 0); const promise = debounced(); jest.runOnlyPendingTimers(); @@ -25,6 +23,8 @@ describe('debounceAsync', () => { }); it('resolves intermediate calls when the next invocation resolves', async () => { + const fn = jest.fn().mockResolvedValueOnce('first'); + const debounced = debounceAsync(fn, 200); fn.mockResolvedValueOnce('second'); @@ -39,6 +39,8 @@ describe('debounceAsync', () => { }); it('debounces the function', async () => { + const fn = jest.fn().mockResolvedValueOnce('first'); + const debounced = debounceAsync(fn, 200); debounced(); diff --git a/packages/kbn-securitysolution-utils/src/debounce_async/debounce_async.ts b/packages/kbn-securitysolution-utils/src/debounce_async/debounce_async.ts new file mode 100644 index 0000000000000..99fe653b0e21e --- /dev/null +++ b/packages/kbn-securitysolution-utils/src/debounce_async/debounce_async.ts @@ -0,0 +1,43 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Unlike lodash's debounce, which resolves intermediate calls with the most + * recent value, this implementation waits to resolve intermediate calls until + * the next invocation resolves. + * + * @param fn an async function + * + * @returns A debounced async function that resolves on the next invocation + */ +export function debounceAsync( + fn: (...args: Args) => Result, + intervalMs: number +): (...args: Args) => Promise> { + let timeoutId: ReturnType | undefined; + let resolve: (value: Awaited) => void; + let promise = new Promise>((_resolve) => { + resolve = _resolve; + }); + + return (...args) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(async () => { + resolve(await fn(...args)); + promise = new Promise((_resolve) => { + resolve = _resolve; + }); + }, intervalMs); + + return promise; + }; +} diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_field_types.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_field_types.ts index 5138e94b78db2..6262fd0e579e7 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_field_types.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_field_types.ts @@ -9,14 +9,17 @@ import { z } from '@kbn/zod'; import { BuildingBlockType, DataViewId, + EventCategoryOverride, IndexPatternArray, KqlQueryLanguage, RuleFilterArray, RuleNameOverride, RuleQuery, SavedQueryId, + TiebreakerField, TimelineTemplateId, TimelineTemplateTitle, + TimestampField, TimestampOverride, TimestampOverrideFallbackDisabled, } from '../../../../model/rule_schema'; @@ -78,6 +81,9 @@ export const RuleEqlQuery = z.object({ query: RuleQuery, language: z.literal('eql'), filters: RuleFilterArray, + event_category_override: EventCategoryOverride.optional(), + timestamp_field: TimestampField.optional(), + tiebreaker_field: TiebreakerField.optional(), }); export type RuleEsqlQuery = z.infer; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts index 374c6ff492e8d..38331d3a01c62 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts @@ -9,7 +9,6 @@ import { z } from '@kbn/zod'; import { AlertSuppression, AnomalyThreshold, - EventCategoryOverride, HistoryWindowStart, InvestigationFields, InvestigationGuide, @@ -37,8 +36,6 @@ import { ThreatMapping, Threshold, ThresholdAlertSuppression, - TiebreakerField, - TimestampField, } from '../../../../model/rule_schema'; import { @@ -113,9 +110,6 @@ export const DiffableEqlFields = z.object({ type: z.literal('eql'), eql_query: RuleEqlQuery, // NOTE: new field data_source: RuleDataSource.optional(), // NOTE: new field - event_category_override: EventCategoryOverride.optional(), - timestamp_field: TimestampField.optional(), - tiebreaker_field: TiebreakerField.optional(), alert_suppression: AlertSuppression.optional(), }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts index 882dcae3e36aa..95ceb5c718825 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts @@ -175,11 +175,15 @@ const extractDiffableEqlFieldsFromRuleObject = ( ): RequiredOptional => { return { type: rule.type, - eql_query: extractRuleEqlQuery(rule.query, rule.language, rule.filters), + eql_query: extractRuleEqlQuery({ + query: rule.query, + language: rule.language, + filters: rule.filters, + eventCategoryOverride: rule.event_category_override, + timestampField: rule.timestamp_field, + tiebreakerField: rule.tiebreaker_field, + }), data_source: extractRuleDataSource(rule.index, rule.data_view_id), - event_category_override: rule.event_category_override, - timestamp_field: rule.timestamp_field, - tiebreaker_field: rule.tiebreaker_field, alert_suppression: rule.alert_suppression, }; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_data_query.ts b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_data_query.ts index 4f4164c6a0086..99bb27b99357f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_data_query.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_data_query.ts @@ -8,9 +8,12 @@ import type { EqlQueryLanguage, EsqlQueryLanguage, + EventCategoryOverride, KqlQueryLanguage, RuleFilterArray, RuleQuery, + TiebreakerField, + TimestampField, } from '../../../api/detection_engine/model/rule_schema'; import type { InlineKqlQuery, @@ -49,15 +52,23 @@ export const extractInlineKqlQuery = ( }; }; -export const extractRuleEqlQuery = ( - query: RuleQuery, - language: EqlQueryLanguage, - filters: RuleFilterArray | undefined -): RuleEqlQuery => { +interface ExtractRuleEqlQueryParams { + query: RuleQuery; + language: EqlQueryLanguage; + filters: RuleFilterArray | undefined; + eventCategoryOverride: EventCategoryOverride | undefined; + timestampField: TimestampField | undefined; + tiebreakerField: TiebreakerField | undefined; +} + +export const extractRuleEqlQuery = (params: ExtractRuleEqlQueryParams): RuleEqlQuery => { return { - query, - language, - filters: filters ?? [], + query: params.query, + language: params.language, + filters: params.filters ?? [], + event_category_override: params.eventCategoryOverride, + timestamp_field: params.timestampField, + tiebreaker_field: params.tiebreakerField, }; }; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts index 10f993b468189..ce7805f89db1a 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts @@ -7,7 +7,7 @@ export type { TimelineEqlResponse, - EqlOptionsData, - EqlOptionsSelected, + EqlFieldsComboBoxOptions, + EqlOptions, FieldsEqlOptions, } from '@kbn/timelines-plugin/common'; diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/api.test.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/api.test.ts index ee7c9a1515f9e..a0e47595c5da7 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/api.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/api.test.ts @@ -34,7 +34,7 @@ const triggerValidateEql = () => { query: 'any where true', signal, runtimeMappings: undefined, - options: undefined, + eqlOptions: undefined, }); }; diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/api.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/api.ts index 569344297f319..b586e0593ab6f 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/api.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/api.ts @@ -11,7 +11,7 @@ import type { EqlSearchStrategyRequest, EqlSearchStrategyResponse } from '@kbn/d import { EQL_SEARCH_STRATEGY } from '@kbn/data-plugin/common'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { EqlOptionsSelected } from '../../../../common/search_strategy'; +import type { EqlOptions } from '../../../../common/search_strategy'; import { getValidationErrors, isErrorResponse, @@ -31,9 +31,9 @@ interface Params { dataViewTitle: string; query: string; data: DataPublicPluginStart; - signal: AbortSignal; runtimeMappings: estypes.MappingRuntimeFields | undefined; - options: Omit | undefined; + eqlOptions: Omit | undefined; + signal?: AbortSignal; } export interface EqlResponseError { @@ -51,9 +51,9 @@ export const validateEql = async ({ data, dataViewTitle, query, - signal, runtimeMappings, - options, + eqlOptions, + signal, }: Params): Promise => { try { const { rawResponse: response } = await firstValueFrom( @@ -62,9 +62,12 @@ export const validateEql = async ({ params: { index: dataViewTitle, body: { query, runtime_mappings: runtimeMappings, size: 0 }, - timestamp_field: options?.timestampField, - tiebreaker_field: options?.tiebreakerField || undefined, - event_category_field: options?.eventCategoryField, + // Prevent passing empty string values + timestamp_field: eqlOptions?.timestampField ? eqlOptions.timestampField : undefined, + tiebreaker_field: eqlOptions?.tiebreakerField ? eqlOptions.tiebreakerField : undefined, + event_category_field: eqlOptions?.eventCategoryField + ? eqlOptions.eventCategoryField + : undefined, }, options: { ignore: [400] }, }, @@ -79,19 +82,23 @@ export const validateEql = async ({ valid: false, error: { code: EQL_ERROR_CODES.INVALID_SYNTAX, messages: getValidationErrors(response) }, }; - } else if (isVerificationErrorResponse(response) || isMappingErrorResponse(response)) { + } + + if (isVerificationErrorResponse(response) || isMappingErrorResponse(response)) { return { valid: false, error: { code: EQL_ERROR_CODES.INVALID_EQL, messages: getValidationErrors(response) }, }; - } else if (isErrorResponse(response)) { + } + + if (isErrorResponse(response)) { return { valid: false, error: { code: EQL_ERROR_CODES.FAILED_REQUEST, error: new Error(JSON.stringify(response)) }, }; - } else { - return { valid: true }; } + + return { valid: true }; } catch (error) { if (error instanceof Error && error.message.startsWith('index_not_found_exception')) { return { @@ -99,6 +106,7 @@ export const validateEql = async ({ error: { code: EQL_ERROR_CODES.MISSING_DATA_SOURCE, messages: [error.message] }, }; } + return { valid: false, error: { diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 6ae63769b114d..79bd0eb558683 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -349,7 +349,6 @@ export const mockGlobalState: State = { description: '', eqlOptions: { eventCategoryField: 'event.category', - tiebreakerField: '', timestampField: '@timestamp', }, eventIdToNoteIds: { '1': ['1'] }, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index 7af93ac62803c..5feee5adafcdc 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -2042,7 +2042,6 @@ export const defaultTimelineProps: CreateTimelineProps = { eventCategoryField: 'event.category', query: '', size: 100, - tiebreakerField: '', timestampField: '@timestamp', }, eventIdToNoteIds: {}, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/eql_overview_link.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_overview_link.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/eql_overview_link.tsx rename to x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_overview_link.tsx diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/eql_query_bar.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_bar.test.tsx similarity index 64% rename from x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/eql_query_bar.test.tsx rename to x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_bar.test.tsx index 67e0e516e22e3..0496f08dd264c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/eql_query_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_bar.test.tsx @@ -8,9 +8,10 @@ import React from 'react'; import { shallow } from 'enzyme'; import { render, screen, fireEvent, within } from '@testing-library/react'; - +import type { SecuritySolutionDataViewBase } from '../../../../common/types'; import { mockIndexPattern, TestProviders, useFormFieldMock } from '../../../../common/mock'; import { mockQueryBar } from '../../../rule_management_ui/components/rules_table/__mocks__/mock'; +import { selectEuiComboBoxOption } from '../../../../common/test/eui/combobox'; import type { EqlQueryBarProps } from './eql_query_bar'; import { EqlQueryBar } from './eql_query_bar'; import { getEqlValidationError } from './validators.mock'; @@ -115,36 +116,82 @@ describe('EqlQueryBar', () => { }); describe('EQL options interaction', () => { - const mockOptionsData = { - keywordFields: [], - dateFields: [{ label: 'timestamp', value: 'timestamp' }], - nonDateFields: [], + const mockIndexPatternWithEqlOptionsFields: SecuritySolutionDataViewBase = { + fields: [ + { + name: 'category', + searchable: true, + type: 'keyword', + esTypes: ['keyword'], + aggregatable: true, + }, + { + name: 'timestamp', + searchable: true, + type: 'date', + aggregatable: true, + }, + { + name: 'tiebreaker', + searchable: true, + type: 'string', + aggregatable: true, + }, + ], + title: 'test-*', }; - it('invokes onOptionsChange when the EQL options change', () => { - const onOptionsChangeMock = jest.fn(); + it('updates EQL options', async () => { + let eqlOptions = {}; + + const mockEqlOptionsField = useFormFieldMock({ + value: {}, + setValue: (updater) => { + if (typeof updater === 'function') { + eqlOptions = updater(eqlOptions); + } + }, + }); - const { getByTestId, getByText } = render( + const { getByTestId } = render( ); // open options popover fireEvent.click(getByTestId('eql-settings-trigger')); - // display combobox options - within(getByTestId(`eql-timestamp-field`)).getByRole('combobox').focus(); - // select timestamp - getByText('timestamp').click(); - expect(onOptionsChangeMock).toHaveBeenCalledWith('timestampField', 'timestamp'); + await selectEuiComboBoxOption({ + comboBoxToggleButton: within(getByTestId('eql-event-category-field')).getByRole('combobox'), + optionText: 'category', + }); + + expect(eqlOptions).toEqual({ eventCategoryField: 'category' }); + + await selectEuiComboBoxOption({ + comboBoxToggleButton: within(getByTestId('eql-tiebreaker-field')).getByRole('combobox'), + optionText: 'tiebreaker', + }); + + expect(eqlOptions).toEqual({ eventCategoryField: 'category', tiebreakerField: 'tiebreaker' }); + + await selectEuiComboBoxOption({ + comboBoxToggleButton: within(getByTestId('eql-timestamp-field')).getByRole('combobox'), + optionText: 'timestamp', + }); + + expect(eqlOptions).toEqual({ + eventCategoryField: 'category', + tiebreakerField: 'tiebreaker', + timestampField: 'timestamp', + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/eql_query_bar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_bar.tsx similarity index 83% rename from x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/eql_query_bar.tsx rename to x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_bar.tsx index 607b7a3ef2bb2..079735aab3c48 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/eql_query_bar.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_bar.tsx @@ -17,16 +17,13 @@ import { FilterManager } from '@kbn/data-plugin/public'; import type { FieldHook } from '../../../../shared_imports'; import { FilterBar } from '../../../../common/components/filter_bar'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types'; -import * as i18n from './translations'; +import type { EqlOptions } from '../../../../../common/search_strategy'; +import { useKibana } from '../../../../common/lib/kibana'; +import type { FieldValueQueryBar } from '../../../rule_creation_ui/components/query_bar'; +import type { EqlQueryBarFooterProps } from './footer'; import { EqlQueryBarFooter } from './footer'; import { getValidationResults } from './validators'; -import type { - EqlOptionsData, - EqlOptionsSelected, - FieldsEqlOptions, -} from '../../../../../common/search_strategy'; -import { useKibana } from '../../../../common/lib/kibana'; +import * as i18n from './translations'; const TextArea = styled(EuiTextArea)` display: block; @@ -60,32 +57,28 @@ const StyledFormRow = styled(EuiFormRow)` export interface EqlQueryBarProps { dataTestSubj: string; - field: FieldHook; - isLoading: boolean; + field: FieldHook; + eqlOptionsField?: FieldHook; + isLoading?: boolean; indexPattern: DataViewBase; showFilterBar?: boolean; idAria?: string; - optionsData?: EqlOptionsData; - optionsSelected?: EqlOptionsSelected; isSizeOptionDisabled?: boolean; - onOptionsChange?: (field: FieldsEqlOptions, newValue: string | undefined) => void; onValidityChange?: (arg: boolean) => void; - onValiditingChange?: (arg: boolean) => void; + onValidatingChange?: (arg: boolean) => void; } export const EqlQueryBar: FC = ({ dataTestSubj, field, + eqlOptionsField, isLoading = false, indexPattern, showFilterBar, idAria, - optionsData, - optionsSelected, isSizeOptionDisabled, - onOptionsChange, onValidityChange, - onValiditingChange, + onValidatingChange, }) => { const { addError } = useAppToasts(); const [errorMessages, setErrorMessages] = useState([]); @@ -115,10 +108,10 @@ export const EqlQueryBar: FC = ({ }, [error, addError]); useEffect(() => { - if (onValiditingChange) { - onValiditingChange(isValidating); + if (onValidatingChange) { + onValidatingChange(isValidating); } - }, [isValidating, onValiditingChange]); + }, [isValidating, onValidatingChange]); useEffect(() => { let isSubscribed = true; @@ -156,8 +149,8 @@ export const EqlQueryBar: FC = ({ const handleChange = useCallback( (e: ChangeEvent) => { const newQuery = e.target.value; - if (onValiditingChange) { - onValiditingChange(true); + if (onValidatingChange) { + onValidatingChange(true); } setErrorMessages([]); setFieldValue({ @@ -169,7 +162,19 @@ export const EqlQueryBar: FC = ({ saved_id: null, }); }, - [fieldValue, setFieldValue, onValiditingChange] + [fieldValue, setFieldValue, onValidatingChange] + ); + + const handleEqlOptionsChange = useCallback< + NonNullable + >( + (eqlOptionsFieldName, value) => { + eqlOptionsField?.setValue((prevEqlOptions) => ({ + ...prevEqlOptions, + [eqlOptionsFieldName]: value, + })); + }, + [eqlOptionsField] ); return ( @@ -195,9 +200,9 @@ export const EqlQueryBar: FC = ({ errors={errorMessages} isLoading={isValidating} isSizeOptionDisabled={isSizeOptionDisabled} - optionsData={optionsData} - optionsSelected={optionsSelected} - onOptionsChange={onOptionsChange} + dataView={indexPattern} + eqlOptions={eqlOptionsField?.value} + onEqlOptionsChange={handleEqlOptionsChange} /> {showFilterBar && ( <> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_edit.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_edit.tsx new file mode 100644 index 0000000000000..5b519cb43c841 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_edit.tsx @@ -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 React, { useMemo } from 'react'; +import type { DataViewBase } from '@kbn/es-query'; +import { debounceAsync } from '@kbn/securitysolution-utils'; +import type { FormData, FieldConfig, ValidationFuncArg } from '../../../../shared_imports'; +import { UseMultiFields } from '../../../../shared_imports'; +import type { EqlFieldsComboBoxOptions, EqlOptions } from '../../../../../common/search_strategy'; +import { queryRequiredValidatorFactory } from '../../../rule_creation_ui/validators/query_required_validator_factory'; +import { eqlQueryValidatorFactory } from './eql_query_validator_factory'; +import { EqlQueryBar } from './eql_query_bar'; +import * as i18n from './translations'; +import type { FieldValueQueryBar } from '../../../rule_creation_ui/components/query_bar'; + +interface EqlQueryEditProps { + path: string; + eqlOptionsPath: string; + fieldsToValidateOnChange?: string | string[]; + eqlFieldsComboBoxOptions?: EqlFieldsComboBoxOptions; + showEqlSizeOption?: boolean; + showFilterBar?: boolean; + dataView: DataViewBase; + required?: boolean; + loading?: boolean; + disabled?: boolean; + // This is a temporal solution for Prebuilt Customization workflow + skipEqlValidation?: boolean; + onValidityChange?: (arg: boolean) => void; +} + +export function EqlQueryEdit({ + path, + eqlOptionsPath, + fieldsToValidateOnChange, + showEqlSizeOption = false, + showFilterBar = false, + dataView, + required, + loading, + disabled, + skipEqlValidation, + onValidityChange, +}: EqlQueryEditProps): JSX.Element { + const componentProps = useMemo( + () => ({ + isSizeOptionDisabled: !showEqlSizeOption, + isDisabled: disabled, + isLoading: loading, + indexPattern: dataView, + showFilterBar, + idAria: 'ruleEqlQueryBar', + dataTestSubj: 'ruleEqlQueryBar', + onValidityChange, + }), + [showEqlSizeOption, showFilterBar, onValidityChange, dataView, loading, disabled] + ); + const fieldConfig: FieldConfig = useMemo( + () => ({ + label: i18n.EQL_QUERY_BAR_LABEL, + fieldsToValidateOnChange: fieldsToValidateOnChange + ? [path, fieldsToValidateOnChange].flat() + : undefined, + validations: [ + ...(required + ? [ + { + validator: queryRequiredValidatorFactory('eql'), + }, + ] + : []), + ...(!skipEqlValidation + ? [ + { + validator: debounceAsync( + (data: ValidationFuncArg) => { + const { formData } = data; + const eqlOptions = + eqlOptionsPath && formData[eqlOptionsPath] ? formData[eqlOptionsPath] : {}; + + return eqlQueryValidatorFactory( + dataView.id + ? { + dataViewId: dataView.id, + eqlOptions, + } + : { + indexPatterns: dataView.title.split(','), + eqlOptions, + } + )(data); + }, + 300 + ), + }, + ] + : []), + ], + }), + [ + skipEqlValidation, + eqlOptionsPath, + required, + dataView.id, + dataView.title, + path, + fieldsToValidateOnChange, + ] + ); + + return ( + + fields={{ + eqlQuery: { + path, + config: fieldConfig, + }, + eqlOptions: { + path: eqlOptionsPath, + }, + }} + > + {({ eqlQuery, eqlOptions }) => ( + + )} + + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_validator_factory.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_validator_factory.ts new file mode 100644 index 0000000000000..54a0b3e3b6a65 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/eql_query_validator_factory.ts @@ -0,0 +1,91 @@ +/* + * 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 { isEmpty } from 'lodash'; +import type { FormData, ValidationError, ValidationFunc } from '../../../../shared_imports'; +import { KibanaServices } from '../../../../common/lib/kibana'; +import type { EqlOptions } from '../../../../../common/search_strategy'; +import type { FieldValueQueryBar } from '../../../rule_creation_ui/components/query_bar'; +import type { EqlResponseError } from '../../../../common/hooks/eql/api'; +import { EQL_ERROR_CODES, validateEql } from '../../../../common/hooks/eql/api'; +import { EQL_VALIDATION_REQUEST_ERROR } from './translations'; + +type EqlQueryValidatorFactoryParams = + | { + indexPatterns: string[]; + dataViewId?: never; + eqlOptions: EqlOptions; + } + | { + indexPatterns?: never; + dataViewId: string; + eqlOptions: EqlOptions; + }; + +export function eqlQueryValidatorFactory({ + indexPatterns, + dataViewId, + eqlOptions, +}: EqlQueryValidatorFactoryParams): ValidationFunc { + return async (...args) => { + const [{ value }] = args; + + if (isEmpty(value.query.query)) { + return; + } + + try { + const { data } = KibanaServices.get(); + const dataView = isDataViewIdValid(dataViewId) + ? await data.dataViews.get(dataViewId) + : undefined; + + const dataViewTitle = dataView?.getIndexPattern() ?? indexPatterns?.join(',') ?? ''; + const runtimeMappings = dataView?.getRuntimeMappings() ?? {}; + + const response = await validateEql({ + data, + query: value.query.query as string, + dataViewTitle, + runtimeMappings, + eqlOptions, + }); + + if (response?.valid === false && response.error) { + return transformEqlResponseErrorToValidationError(response.error); + } + } catch (error) { + return { + code: EQL_ERROR_CODES.FAILED_REQUEST, + message: EQL_VALIDATION_REQUEST_ERROR, + error, + }; + } + }; +} + +function transformEqlResponseErrorToValidationError( + responseError: EqlResponseError +): ValidationError { + if (responseError.error) { + return { + code: EQL_ERROR_CODES.FAILED_REQUEST, + message: EQL_VALIDATION_REQUEST_ERROR, + error: responseError.error, + }; + } + + return { + code: responseError.code, + message: '', + messages: responseError.messages, + }; +} + +function isDataViewIdValid(dataViewId: unknown): dataViewId is string { + return typeof dataViewId === 'string' && dataViewId !== ''; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/errors_popover.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/errors_popover.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/errors_popover.test.tsx rename to x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/errors_popover.test.tsx diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/errors_popover.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/errors_popover.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/errors_popover.tsx rename to x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/errors_popover.tsx diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/footer.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/footer.test.tsx similarity index 83% rename from x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/footer.test.tsx rename to x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/footer.test.tsx index cb15e5002ca4c..ded190cc86de5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/footer.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/footer.test.tsx @@ -32,7 +32,11 @@ describe('EQL footer', () => { it('EQL settings button is enable when popover is NOT open', () => { const wrapper = mount( - + ); @@ -44,7 +48,11 @@ describe('EQL footer', () => { it('disable EQL settings button when popover is open', () => { const wrapper = mount( - + ); wrapper.find(`[data-test-subj="eql-settings-trigger"]`).first().simulate('click'); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/footer.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/footer.tsx similarity index 73% rename from x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/footer.tsx rename to x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/footer.tsx index 45ab4a1c969c5..fd9431c8d20f5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/footer.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/eql_query_edit/footer.tsx @@ -20,28 +20,27 @@ import { import type { FC } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; - +import type { DataViewBase } from '@kbn/es-query'; import type { DebouncedFunc } from 'lodash'; -import { debounce } from 'lodash'; -import type { - EqlOptionsData, - EqlOptionsSelected, - FieldsEqlOptions, -} from '../../../../../common/search_strategy'; +import { debounce, isEmpty } from 'lodash'; +import type { EqlOptions } from '../../../../../common/search_strategy'; import * as i18n from './translations'; import { ErrorsPopover } from './errors_popover'; import { EqlOverviewLink } from './eql_overview_link'; -export interface Props { +export interface EqlQueryBarFooterProps { errors: string[]; isLoading?: boolean; isSizeOptionDisabled?: boolean; - optionsData?: EqlOptionsData; - optionsSelected?: EqlOptionsSelected; - onOptionsChange?: (field: FieldsEqlOptions, newValue: string | undefined) => void; + dataView: DataViewBase; + eqlOptions?: EqlOptions; + onEqlOptionsChange?: ( + field: Field, + newValue: EqlOptions[Field] + ) => void; } -type SizeVoidFunc = (newSize: string) => void; +type SizeVoidFunc = (newSize: number) => void; const Container = styled(EuiFlexGroup)` border-radius: 0; @@ -69,18 +68,40 @@ const Spinner = styled(EuiLoadingSpinner)` const singleSelection = { asPlainText: true }; -export const EqlQueryBarFooter: FC = ({ +export const EqlQueryBarFooter: FC = ({ errors, isLoading, isSizeOptionDisabled, - optionsData, - optionsSelected, - onOptionsChange, + dataView, + eqlOptions, + onEqlOptionsChange, }) => { const [openEqlSettings, setIsOpenEqlSettings] = useState(false); - const [localSize, setLocalSize] = useState(optionsSelected?.size ?? 100); + const [localSize, setLocalSize] = useState(eqlOptions?.size ?? 100); const debounceSize = useRef>(); + const { keywordFields, nonDateFields, dateFields } = useMemo( + () => + isEmpty(dataView?.fields) + ? { + keywordFields: [], + dateFields: [], + nonDateFields: [], + } + : { + keywordFields: dataView.fields + .filter((f) => f.esTypes?.includes('keyword')) + .map((f) => ({ label: f.name })), + dateFields: dataView.fields + .filter((f) => f.type === 'date') + .map((f) => ({ label: f.name })), + nonDateFields: dataView.fields + .filter((f) => f.type !== 'date') + .map((f) => ({ label: f.name })), + }, + [dataView] + ); + const openEqlSettingsHandler = useCallback(() => { setIsOpenEqlSettings(true); }, []); @@ -90,74 +111,70 @@ export const EqlQueryBarFooter: FC = ({ const handleEventCategoryField = useCallback( (opt: EuiComboBoxOptionOption[]) => { - if (onOptionsChange) { + if (onEqlOptionsChange) { if (opt.length > 0) { - onOptionsChange('eventCategoryField', opt[0].label); + onEqlOptionsChange('eventCategoryField', opt[0].label); } else { - onOptionsChange('eventCategoryField', undefined); + onEqlOptionsChange('eventCategoryField', undefined); } } }, - [onOptionsChange] + [onEqlOptionsChange] ); const handleTiebreakerField = useCallback( (opt: EuiComboBoxOptionOption[]) => { - if (onOptionsChange) { + if (onEqlOptionsChange) { if (opt.length > 0) { - onOptionsChange('tiebreakerField', opt[0].label); + onEqlOptionsChange('tiebreakerField', opt[0].label); } else { - onOptionsChange('tiebreakerField', undefined); + onEqlOptionsChange('tiebreakerField', undefined); } } }, - [onOptionsChange] + [onEqlOptionsChange] ); const handleTimestampField = useCallback( (opt: EuiComboBoxOptionOption[]) => { - if (onOptionsChange) { + if (onEqlOptionsChange) { if (opt.length > 0) { - onOptionsChange('timestampField', opt[0].label); + onEqlOptionsChange('timestampField', opt[0].label); } else { - onOptionsChange('timestampField', undefined); + onEqlOptionsChange('timestampField', undefined); } } }, - [onOptionsChange] + [onEqlOptionsChange] ); const handleSizeField = useCallback>( (evt) => { - if (onOptionsChange) { + if (onEqlOptionsChange) { setLocalSize(evt?.target?.valueAsNumber); if (debounceSize.current?.cancel) { debounceSize.current?.cancel(); } - debounceSize.current = debounce((newSize) => onOptionsChange('size', newSize), 800); - debounceSize.current(evt?.target?.value); + debounceSize.current = debounce((newSize) => onEqlOptionsChange('size', newSize), 800); + debounceSize.current(evt?.target?.valueAsNumber); } }, - [onOptionsChange] + [onEqlOptionsChange] ); const eventCategoryField = useMemo( () => - optionsSelected?.eventCategoryField != null - ? [{ label: optionsSelected?.eventCategoryField }] + eqlOptions?.eventCategoryField != null + ? [{ label: eqlOptions?.eventCategoryField }] : undefined, - [optionsSelected?.eventCategoryField] + [eqlOptions?.eventCategoryField] ); const tiebreakerField = useMemo( () => - optionsSelected?.tiebreakerField != null - ? [{ label: optionsSelected?.tiebreakerField }] - : undefined, - [optionsSelected?.tiebreakerField] + eqlOptions?.tiebreakerField != null ? [{ label: eqlOptions?.tiebreakerField }] : undefined, + [eqlOptions?.tiebreakerField] ); const timestampField = useMemo( () => - optionsSelected?.timestampField != null - ? [{ label: optionsSelected?.timestampField }] - : undefined, - [optionsSelected?.timestampField] + eqlOptions?.timestampField != null ? [{ label: eqlOptions?.timestampField }] : undefined, + [eqlOptions?.timestampField] ); return ( @@ -183,13 +200,13 @@ export const EqlQueryBarFooter: FC = ({ - {!onOptionsChange && ( + {!onEqlOptionsChange && ( )} - {onOptionsChange && ( + {onEqlOptionsChange && ( <> @@ -232,7 +249,7 @@ export const EqlQueryBarFooter: FC = ({ helpText={i18n.EQL_OPTIONS_EVENT_CATEGORY_FIELD_HELPER} > = ({ helpText={i18n.EQL_OPTIONS_EVENT_TIEBREAKER_FIELD_HELPER} > = ({ helpText={i18n.EQL_OPTIONS_EVENT_TIMESTAMP_FIELD_HELPER} > ( + field: FieldHook +): { isValid: boolean; message: string; messages?: string[]; error?: Error } => { + const hasErrors = field.errors.length > 0; + const isValid = !field.isChangingValue && !hasErrors; + + if (hasErrors) { + const [error] = field.errors; + const message = error.message; + + if (error.code === EQL_ERROR_CODES.FAILED_REQUEST) { + return { isValid, message, error: error.error }; + } else { + return { isValid, message, messages: error.messages }; + } + } else { + return { isValid, message: '' }; + } +}; 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 6740e9fc8d014..57292d91953d8 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 @@ -32,7 +32,7 @@ import type { } from '../../../../../common/api/detection_engine/model/rule_schema'; import { AlertSuppressionMissingFieldsStrategyEnum } from '../../../../../common/api/detection_engine/model/rule_schema'; import { MATCHES, AND, OR } from '../../../../common/components/threat_match/translations'; -import type { EqlOptionsSelected } from '../../../../../common/search_strategy'; +import type { EqlOptions } from '../../../../../common/search_strategy'; import { assertUnreachable } from '../../../../../common/utility_types'; import * as i18nSeverity from '../severity_mapping/translations'; import * as i18nRiskScore from '../risk_score_mapping/translations'; @@ -147,7 +147,7 @@ export const buildQueryBarDescription = ({ return items; }; -export const buildEqlOptionsDescription = (eqlOptions: EqlOptionsSelected): ListItems[] => { +export const buildEqlOptionsDescription = (eqlOptions: EqlOptions): ListItems[] => { let items: ListItems[] = []; if (!isEmpty(eqlOptions.eventCategoryField)) { items = [ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/index.tsx index 657f592fe47c4..24ad5f4135a14 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/description_step/index.tsx @@ -19,7 +19,7 @@ import type { } from '../../../../../common/api/detection_engine/model/rule_schema'; import { buildRelatedIntegrationsDescription } from '../../../../detections/components/rules/related_integrations/integrations_description'; import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; -import type { EqlOptionsSelected } from '../../../../../common/search_strategy'; +import type { EqlOptions } from '../../../../../common/search_strategy'; import { useKibana } from '../../../../common/lib/kibana'; import type { AboutStepRiskScore, @@ -275,7 +275,7 @@ export const getDescriptionItem = ( return []; } } else if (field === 'eqlOptions') { - const eqlOptions: EqlOptionsSelected = get(field, data); + const eqlOptions: EqlOptions = get(field, data); return buildEqlOptionsDescription(eqlOptions); } else if (field === 'threat') { const threats: Threats = get(field, data); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/validators.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/validators.ts deleted file mode 100644 index 8cd9a4d60745e..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/eql_query_bar/validators.ts +++ /dev/null @@ -1,137 +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 { isEmpty } from 'lodash'; - -import type { FieldHook, ValidationError, ValidationFunc } from '../../../../shared_imports'; -import { isEqlRule } from '../../../../../common/detection_engine/utils'; -import { KibanaServices } from '../../../../common/lib/kibana'; -import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types'; -import { DataSourceType } from '../../../../detections/pages/detection_engine/rules/types'; -import type { EqlResponseError } from '../../../../common/hooks/eql/api'; -import { validateEql, EQL_ERROR_CODES } from '../../../../common/hooks/eql/api'; -import type { FieldValueQueryBar } from '../query_bar'; -import * as i18n from './translations'; - -/** - * Unlike lodash's debounce, which resolves intermediate calls with the most - * recent value, this implementation waits to resolve intermediate calls until - * the next invocation resolves. - * - * @param fn an async function - * - * @returns A debounced async function that resolves on the next invocation - */ -export const debounceAsync = >( - fn: (...args: Args) => Result, - interval: number -): ((...args: Args) => Result) => { - let handle: ReturnType | undefined; - let resolves: Array<(value?: Result) => void> = []; - - return (...args: Args): Result => { - if (handle) { - clearTimeout(handle); - } - - handle = setTimeout(() => { - const result = fn(...args); - resolves.forEach((resolve) => resolve(result)); - resolves = []; - }, interval); - - return new Promise((resolve) => resolves.push(resolve)) as Result; - }; -}; - -export const transformEqlResponseErrorToValidationError = ( - responseError: EqlResponseError -): ValidationError => { - if (responseError.error) { - return { - code: EQL_ERROR_CODES.FAILED_REQUEST, - message: i18n.EQL_VALIDATION_REQUEST_ERROR, - error: responseError.error, - }; - } - return { - code: responseError.code, - message: '', - messages: responseError.messages, - }; -}; - -export const eqlValidator = async ( - ...args: Parameters -): Promise | void | undefined> => { - const [{ value, formData }] = args; - const { query: queryValue } = value as FieldValueQueryBar; - const query = queryValue.query as string; - const { dataViewId, index, ruleType, eqlOptions } = formData as DefineStepRule; - - const needsValidation = - (ruleType === undefined && !isEmpty(query)) || (isEqlRule(ruleType) && !isEmpty(query)); - if (!needsValidation) { - return; - } - - try { - const { data } = KibanaServices.get(); - let dataViewTitle = index?.join(); - let runtimeMappings = {}; - if ( - dataViewId != null && - dataViewId !== '' && - formData.dataSourceType === DataSourceType.DataView - ) { - const dataView = await data.dataViews.get(dataViewId); - - dataViewTitle = dataView.title; - runtimeMappings = dataView.getRuntimeMappings(); - } - - const signal = new AbortController().signal; - const response = await validateEql({ - data, - query, - signal, - dataViewTitle, - runtimeMappings, - options: eqlOptions, - }); - - if (response?.valid === false && response.error) { - return transformEqlResponseErrorToValidationError(response.error); - } - } catch (error) { - return { - code: EQL_ERROR_CODES.FAILED_REQUEST, - message: i18n.EQL_VALIDATION_REQUEST_ERROR, - error, - }; - } -}; - -export const getValidationResults = ( - field: FieldHook -): { isValid: boolean; message: string; messages?: string[]; error?: Error } => { - const hasErrors = field.errors.length > 0; - const isValid = !field.isChangingValue && !hasErrors; - - if (hasErrors) { - const [error] = field.errors; - const message = error.message; - - if (error.code === EQL_ERROR_CODES.FAILED_REQUEST) { - return { isValid, message, error: error.error }; - } else { - return { isValid, message, messages: error.messages }; - } - } else { - return { isValid, message: '' }; - } -}; 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 50264fffabfb8..364f1b7705732 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } 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'; @@ -638,7 +638,6 @@ function TestForm({ onSubmit, formProps, }: TestFormProps): JSX.Element { - const [selectedEqlOptions, setSelectedEqlOptions] = useState(stepDefineDefaultValue.eqlOptions); const { form } = useForm({ options: { stripEmptyFields: false }, schema: defineRuleSchema, @@ -653,8 +652,6 @@ function TestForm({ form={form} indicesConfig={[]} threatIndicesConfig={[]} - optionsSelected={selectedEqlOptions} - setOptionsSelected={setSelectedEqlOptions} indexPattern={indexPattern} isIndexPatternLoading={false} isQueryBarValid={true} 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 7085371eea276..053544e6fdbb5 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 @@ -20,7 +20,7 @@ import React, { memo, useCallback, useState, useEffect, useMemo, useRef } from ' import styled from 'styled-components'; import { i18n as i18nCore } from '@kbn/i18n'; -import { isEqual, isEmpty } from 'lodash'; +import { isEqual } from 'lodash'; import type { FieldSpec } from '@kbn/data-plugin/common'; import usePrevious from 'react-use/lib/usePrevious'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; @@ -33,7 +33,6 @@ import { useSetFieldValueWithCallback } from '../../../../common/utils/use_set_f import type { SetRuleQuery } from '../../../../detections/containers/detection_engine/rules/use_rule_from_timeline'; import { useRuleFromTimeline } from '../../../../detections/containers/detection_engine/rules/use_rule_from_timeline'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; -import type { EqlOptionsSelected, FieldsEqlOptions } from '../../../../../common/search_strategy'; import { filterRuleFieldsForType, getStepDataDataSource } from '../../pages/rule_creation/helpers'; import type { DefineStepRule, @@ -74,7 +73,7 @@ import { isEqlSequenceQuery, isSuppressionRuleInGA, } from '../../../../../common/detection_engine/utils'; -import { EqlQueryBar } from '../eql_query_bar'; +import { EqlQueryEdit } from '../../../rule_creation/components/eql_query_edit'; import { DataViewSelectorField } from '../data_view_selector_field'; import { ThreatMatchInput } from '../threatmatch_input'; import { useFetchIndex } from '../../../../common/containers/source'; @@ -91,7 +90,10 @@ import { useAlertSuppression } from '../../../rule_management/logic/use_alert_su import { AiAssistant } from '../ai_assistant'; import { RelatedIntegrations } from '../../../rule_creation/components/related_integrations'; import { useMLRuleConfig } from '../../../../common/components/ml/hooks/use_ml_rule_config'; -import { AlertSuppressionEdit } from '../../../rule_creation/components/alert_suppression_edit'; +import { + ALERT_SUPPRESSION_FIELDS_FIELD_NAME, + AlertSuppressionEdit, +} from '../../../rule_creation/components/alert_suppression_edit'; import { ThresholdAlertSuppressionEdit } from '../../../rule_creation/components/threshold_alert_suppression_edit'; import { usePersistentAlertSuppressionState } from './use_persistent_alert_suppression_state'; @@ -105,8 +107,6 @@ export interface StepDefineRuleProps extends RuleStepProps { threatIndicesConfig: string[]; defaultSavedQuery?: SavedQuery; form: FormHook; - optionsSelected: EqlOptionsSelected; - setOptionsSelected: React.Dispatch>; indexPattern: DataViewBase; isIndexPatternLoading: boolean; isQueryBarValid: boolean; @@ -163,13 +163,11 @@ const StepDefineRuleComponent: FC = ({ isLoading, isQueryBarValid, isUpdateView = false, - optionsSelected, queryBarSavedId, queryBarTitle, ruleType, setIsQueryBarValid, setIsThreatQueryBarValid, - setOptionsSelected, shouldLoadQueryDynamically, threatIndex, threatIndicesConfig, @@ -220,15 +218,12 @@ const StepDefineRuleComponent: FC = ({ }; if (timelineQueryBar.query.language === 'eql') { setRuleTypeCallback('eql', setQuery); - setOptionsSelected((prevOptions) => ({ - ...prevOptions, - ...(eqlOptions != null ? eqlOptions : {}), - })); + setFieldValue('eqlOptions', eqlOptions ?? {}); } else { setQuery(); } }, - [setFieldValue, setRuleTypeCallback, setOptionsSelected] + [setFieldValue, setRuleTypeCallback] ); const { onOpenTimeline, loading: timelineQueryLoading } = @@ -719,43 +714,6 @@ const StepDefineRuleComponent: FC = ({ ] ); - const onOptionsChange = useCallback( - (field: FieldsEqlOptions, value: string | undefined) => { - setOptionsSelected((prevOptions) => { - const newOptions = { - ...prevOptions, - [field]: value, - }; - - setFieldValue('eqlOptions', newOptions); - return newOptions; - }); - }, - [setFieldValue, setOptionsSelected] - ); - - const optionsData = useMemo( - () => - isEmpty(indexPattern.fields) - ? { - keywordFields: [], - dateFields: [], - nonDateFields: [], - } - : { - keywordFields: (indexPattern.fields as FieldSpec[]) - .filter((f) => f.esTypes?.includes('keyword')) - .map((f) => ({ label: f.name })), - dateFields: indexPattern.fields - .filter((f) => f.type === 'date') - .map((f) => ({ label: f.name })), - nonDateFields: indexPattern.fields - .filter((f) => f.type !== 'date') - .map((f) => ({ label: f.name })), - }, - [indexPattern] - ); - const selectRuleTypeProps = useMemo( () => ({ describedByIds: ['detectionEngineStepDefineRuleType'], @@ -794,29 +752,18 @@ const StepDefineRuleComponent: FC = ({ {isEqlRule(ruleType) ? ( <> - - ) : isEsqlRule(ruleType) ? ( EsqlQueryBarMemo 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 6563c0d3b175f..835cab7a4cfc2 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 @@ -9,8 +9,7 @@ import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; import { EuiText } from '@elastic/eui'; import React from 'react'; - -import { fromKueryExpression } from '@kbn/es-query'; +import { debounceAsync } from '@kbn/securitysolution-utils'; import { singleEntryThreat, containsInvalidItems, @@ -27,32 +26,29 @@ import { } from '../../../../../common/detection_engine/utils'; import { MAX_NUMBER_OF_NEW_TERMS_FIELDS } from '../../../../../common/constants'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; -import type { FieldValueQueryBar } from '../query_bar'; import type { ERROR_CODE, FormSchema, ValidationFunc } from '../../../../shared_imports'; import { FIELD_TYPES, fieldValidators } from '../../../../shared_imports'; import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types'; import { DataSourceType } from '../../../../detections/pages/detection_engine/rules/types'; -import { debounceAsync, eqlValidator } from '../eql_query_bar/validators'; import { esqlValidator } from '../../../rule_creation/logic/esql_validator'; import { dataViewIdValidatorFactory } from '../../validators/data_view_id_validator_factory'; import { indexPatternValidatorFactory } from '../../validators/index_pattern_validator_factory'; import { alertSuppressionFieldsValidatorFactory } from '../../validators/alert_suppression_fields_validator_factory'; import { - CUSTOM_QUERY_REQUIRED, - INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT, THREAT_MATCH_INDEX_HELPER_TEXT, THREAT_MATCH_REQUIRED, THREAT_MATCH_EMPTIES, EQL_SEQUENCE_SUPPRESSION_GROUPBY_VALIDATION_TEXT, } from './translations'; -import { getQueryRequiredMessage } from './utils'; import { ALERT_SUPPRESSION_DURATION_FIELD_NAME, ALERT_SUPPRESSION_FIELDS_FIELD_NAME, ALERT_SUPPRESSION_MISSING_FIELDS_FIELD_NAME, } from '../../../rule_creation/components/alert_suppression_edit'; import * as alertSuppressionEditI81n from '../../../rule_creation/components/alert_suppression_edit/components/translations'; +import { queryRequiredValidatorFactory } from '../../validators/query_required_validator_factory'; +import { kueryValidatorFactory } from '../../validators/kuery_validator_factory'; export const schema: FormSchema = { index: { @@ -68,7 +64,7 @@ export const schema: FormSchema = { helpText: {INDEX_HELPER_TEXT}, validations: [ { - validator: (...args: Parameters) => { + validator: (...args) => { const [{ formData }] = args; if ( @@ -94,7 +90,7 @@ export const schema: FormSchema = { fieldsToValidateOnChange: ['dataViewId'], validations: [ { - validator: (...args: Parameters) => { + validator: (...args) => { const [{ formData }] = args; if (isMlRule(formData.ruleType) || formData.dataSourceType !== DataSourceType.DataView) { @@ -122,55 +118,21 @@ export const schema: FormSchema = { fieldsToValidateOnChange: ['queryBar', ALERT_SUPPRESSION_FIELDS_FIELD_NAME], validations: [ { - validator: ( - ...args: Parameters - ): ReturnType> | undefined => { - const [{ value, path, formData }] = args; - const { query, filters, saved_id: savedId } = value as FieldValueQueryBar; - const needsValidation = !isMlRule(formData.ruleType); - if (!needsValidation) { - return undefined; - } - const isFieldEmpty = isEmpty(query.query as string) && isEmpty(filters); - if (!isFieldEmpty) { - return undefined; - } - if (savedId) { + validator: (...args) => { + const [{ value, formData }] = args; + + if (isMlRule(formData.ruleType) || value.saved_id) { // Ignore field validation error in this case. // Instead, we show the error toast when saved query object does not exist. // https://github.com/elastic/kibana/issues/159060 - return undefined; - } - const message = getQueryRequiredMessage(formData.ruleType); - return { code: 'ERR_FIELD_MISSING', path, message }; - }, - }, - { - validator: ( - ...args: Parameters - ): ReturnType> | undefined => { - const [{ value, path, formData }] = args; - const { query } = value as FieldValueQueryBar; - const needsValidation = !isMlRule(formData.ruleType); - if (!needsValidation) { return; } - if (!isEmpty(query.query as string) && query.language === 'kuery') { - try { - fromKueryExpression(query.query); - } catch (err) { - return { - code: 'ERR_FIELD_FORMAT', - path, - message: INVALID_CUSTOM_QUERY, - }; - } - } + return queryRequiredValidatorFactory(formData.ruleType)(...args); }, }, { - validator: debounceAsync(eqlValidator, 300), + validator: kueryValidatorFactory(), }, { validator: debounceAsync(esqlValidator, 300), @@ -509,49 +471,17 @@ export const schema: FormSchema = { ), validations: [ { - validator: ( - ...args: Parameters - ): ReturnType> | undefined => { - const [{ value, path, formData }] = args; - const needsValidation = isThreatMatchRule(formData.ruleType); - if (!needsValidation) { + validator: (...args) => { + const [{ formData }] = args; + if (!isThreatMatchRule(formData.ruleType)) { return; } - const { query, filters } = value as FieldValueQueryBar; - - return isEmpty(query.query as string) && isEmpty(filters) - ? { - code: 'ERR_FIELD_MISSING', - path, - message: CUSTOM_QUERY_REQUIRED, - } - : undefined; + return queryRequiredValidatorFactory(formData.ruleType)(...args); }, }, { - validator: ( - ...args: Parameters - ): ReturnType> | undefined => { - const [{ value, path, formData }] = args; - const needsValidation = isThreatMatchRule(formData.ruleType); - if (!needsValidation) { - return; - } - const { query } = value as FieldValueQueryBar; - - if (!isEmpty(query.query as string) && query.language === 'kuery') { - try { - fromKueryExpression(query.query); - } catch (err) { - return { - code: 'ERR_FIELD_FORMAT', - path, - message: INVALID_CUSTOM_QUERY, - }; - } - } - }, + validator: kueryValidatorFactory(), }, ], }, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/translations.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/translations.tsx index d8b24f978afd0..7b8063b23e306 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/translations.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/translations.tsx @@ -9,34 +9,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -export const CUSTOM_QUERY_REQUIRED = i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customQueryFieldRequiredError', - { - defaultMessage: 'A custom query is required.', - } -); - -export const EQL_QUERY_REQUIRED = i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.eqlQueryFieldRequiredError', - { - defaultMessage: 'An EQL query is required.', - } -); - -export const ESQL_QUERY_REQUIRED = i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryFieldRequiredError', - { - defaultMessage: 'An ES|QL query is required.', - } -); - -export const INVALID_CUSTOM_QUERY = i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customQueryFieldInvalidError', - { - defaultMessage: 'The KQL is invalid', - } -); - export const INDEX_HELPER_TEXT = i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.indicesHelperDescription', { @@ -66,13 +38,6 @@ export const QUERY_BAR_LABEL = i18n.translate( } ); -export const EQL_QUERY_BAR_LABEL = i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.EqlQueryBarLabel', - { - defaultMessage: 'EQL query', - } -); - export const SAVED_QUERY_FORM_ROW_LABEL = i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.SavedQueryFormRowLabel', { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/utils.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/utils.ts index 88592da7ecd8d..baedda1ae1620 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/utils.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/utils.ts @@ -5,13 +5,8 @@ * 2.0. */ -import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; import type { FieldSpec } from '@kbn/data-plugin/common'; -import { CUSTOM_QUERY_REQUIRED, EQL_QUERY_REQUIRED, ESQL_QUERY_REQUIRED } from './translations'; - -import { isEqlRule, isEsqlRule } from '../../../../../common/detection_engine/utils'; - /** * Filters out fields, that are not supported in terms aggregation. * Terms aggregation supports limited number of types: @@ -25,18 +20,3 @@ export const getTermsAggregationFields = (fields: FieldSpec[]): FieldSpec[] => // binary types is excluded, as binary field has property aggregatable === false const ALLOWED_AGGREGATABLE_FIELD_TYPES_SET = new Set(['string', 'number', 'ip', 'boolean']); - -/** - * return query is required message depends on a rule type - */ -export const getQueryRequiredMessage = (ruleType: Type) => { - if (isEsqlRule(ruleType)) { - return ESQL_QUERY_REQUIRED; - } - - if (isEqlRule(ruleType)) { - return EQL_QUERY_REQUIRED; - } - - return CUSTOM_QUERY_REQUIRED; -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.test.ts index 84d10bae1c5d5..d2bbe9edb1160 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.test.ts @@ -17,7 +17,6 @@ import type { } from '../../../detections/pages/detection_engine/rules/types'; import { useRuleFormsErrors } from './form'; -import { transformEqlResponseErrorToValidationError } from '../components/eql_query_bar/validators'; import { ALERT_SUPPRESSION_FIELDS_FIELD_NAME } from '../../rule_creation/components/alert_suppression_edit'; const getFormWithErrorsMock = (fields: { @@ -33,13 +32,15 @@ describe('useRuleFormsErrors', () => { it('should return blocking error in case of syntax validation error', async () => { const { result } = renderHook(() => useRuleFormsErrors()); - const validationError = transformEqlResponseErrorToValidationError({ - code: EQL_ERROR_CODES.INVALID_SYNTAX, - messages: ["line 1:5: missing 'where' at 'demo'"], - }); const defineStepForm = getFormWithErrorsMock({ queryBar: { - errors: [validationError], + errors: [ + { + code: EQL_ERROR_CODES.INVALID_SYNTAX, + message: '', + messages: ["line 1:5: missing 'where' at 'demo'"], + }, + ], }, }); @@ -53,13 +54,17 @@ describe('useRuleFormsErrors', () => { it('should return non-blocking error in case of missing data source validation error', async () => { const { result } = renderHook(() => useRuleFormsErrors()); - const validationError = transformEqlResponseErrorToValidationError({ - code: EQL_ERROR_CODES.MISSING_DATA_SOURCE, - messages: ['index_not_found_exception Found 1 problem line -1:-1: Unknown index [*,-*]'], - }); const defineStepForm = getFormWithErrorsMock({ queryBar: { - errors: [validationError], + errors: [ + { + code: EQL_ERROR_CODES.MISSING_DATA_SOURCE, + message: '', + messages: [ + 'index_not_found_exception Found 1 problem line -1:-1: Unknown index [*,-*]', + ], + }, + ], }, }); @@ -75,15 +80,17 @@ describe('useRuleFormsErrors', () => { it('should return non-blocking error in case of missing data field validation error', async () => { const { result } = renderHook(() => useRuleFormsErrors()); - const validationError = transformEqlResponseErrorToValidationError({ - code: EQL_ERROR_CODES.INVALID_EQL, - messages: [ - 'Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]', - ], - }); const defineStepForm = getFormWithErrorsMock({ queryBar: { - errors: [validationError], + errors: [ + { + code: EQL_ERROR_CODES.INVALID_EQL, + message: '', + messages: [ + 'Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]', + ], + }, + ], }, }); @@ -99,13 +106,15 @@ describe('useRuleFormsErrors', () => { it('should return non-blocking error in case of failed request error', async () => { const { result } = renderHook(() => useRuleFormsErrors()); - const validationError = transformEqlResponseErrorToValidationError({ - code: EQL_ERROR_CODES.FAILED_REQUEST, - error: new Error('Some internal error'), - }); const defineStepForm = getFormWithErrorsMock({ queryBar: { - errors: [validationError], + errors: [ + { + code: EQL_ERROR_CODES.FAILED_REQUEST, + message: 'An error occurred while validating your EQL query', + error: new Error('Some internal error'), + }, + ], }, }); @@ -121,13 +130,15 @@ describe('useRuleFormsErrors', () => { it('should return blocking and non-blocking errors', async () => { const { result } = renderHook(() => useRuleFormsErrors()); - const validationError = transformEqlResponseErrorToValidationError({ - code: EQL_ERROR_CODES.MISSING_DATA_SOURCE, - messages: ['Missing data source'], - }); const defineStepForm = getFormWithErrorsMock({ queryBar: { - errors: [validationError], + errors: [ + { + code: EQL_ERROR_CODES.MISSING_DATA_SOURCE, + message: '', + messages: ['Missing data source'], + }, + ], }, }); const aboutStepForm = getFormWithErrorsMock({ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.tsx index 9e232e4bff2be..64230fd3a8a23 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.tsx @@ -19,7 +19,6 @@ import { useKibana } from '../../../common/lib/kibana'; import type { FormHook, ValidationError } from '../../../shared_imports'; import { useForm, useFormData } from '../../../shared_imports'; import { schema as defineRuleSchema } from '../components/step_define_rule/schema'; -import type { EqlOptionsSelected } from '../../../../common/search_strategy'; import { schema as aboutRuleSchema, threatMatchAboutSchema, @@ -53,20 +52,14 @@ export const useRuleForms = ({ options: { stripEmptyFields: false }, schema: defineRuleSchema, }); - const [eqlOptionsSelected, setEqlOptionsSelected] = useState( - defineStepDefault.eqlOptions - ); const [defineStepFormData] = useFormData({ form: defineStepForm, }); // FormData doesn't populate on the first render, so we use the defaultValue if the formData // doesn't have what we wanted const defineStepData = useMemo( - () => - 'index' in defineStepFormData - ? { ...defineStepFormData, eqlOptions: eqlOptionsSelected } - : defineStepDefault, - [defineStepDefault, defineStepFormData, eqlOptionsSelected] + () => ('index' in defineStepFormData ? defineStepFormData : defineStepDefault), + [defineStepDefault, defineStepFormData] ); // ABOUT STEP FORM @@ -118,8 +111,6 @@ export const useRuleForms = ({ scheduleStepData, actionsStepForm, actionsStepData, - eqlOptionsSelected, - setEqlOptionsSelected, }; }; 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 d19c9c7c89d0c..01435a2f7c654 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 @@ -171,8 +171,6 @@ const CreateRulePageComponent: React.FC = () => { scheduleStepData, actionsStepForm, actionsStepData, - eqlOptionsSelected, - setEqlOptionsSelected, } = useRuleForms({ defineStepDefault, aboutStepDefault: stepAboutDefaultValue, @@ -392,10 +390,9 @@ const CreateRulePageComponent: React.FC = () => { const createRuleFromFormData = useCallback( async (enabled: boolean) => { - const localDefineStepData: DefineStepRule = defineFieldsTransform({ - ...defineStepForm.getFormData(), - eqlOptions: eqlOptionsSelected, - }); + const localDefineStepData: DefineStepRule = defineFieldsTransform( + defineStepForm.getFormData() + ); const localAboutStepData = aboutStepForm.getFormData(); const localScheduleStepData = scheduleStepForm.getFormData(); const localActionsStepData = actionsStepForm.getFormData(); @@ -435,7 +432,6 @@ const CreateRulePageComponent: React.FC = () => { createRule, defineFieldsTransform, defineStepForm, - eqlOptionsSelected, navigateToApp, ruleType, scheduleStepForm, @@ -556,8 +552,6 @@ const CreateRulePageComponent: React.FC = () => { indicesConfig={indicesConfig} threatIndicesConfig={threatIndicesConfig} form={defineStepForm} - optionsSelected={eqlOptionsSelected} - setOptionsSelected={setEqlOptionsSelected} indexPattern={indexPattern} isIndexPatternLoading={isIndexPatternLoading} isQueryBarValid={isQueryBarValid} @@ -588,7 +582,6 @@ const CreateRulePageComponent: React.FC = () => { defineStepData, memoizedIndex, defineStepForm, - eqlOptionsSelected, indexPattern, indicesConfig, isCreateRuleLoading, @@ -596,7 +589,6 @@ const CreateRulePageComponent: React.FC = () => { isQueryBarValid, loading, memoDefineStepReadOnly, - setEqlOptionsSelected, threatIndicesConfig, ] ); 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 0a1733bd831fd..b09ee5f4e3f43 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 @@ -131,8 +131,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { scheduleStepData, actionsStepForm, actionsStepData, - eqlOptionsSelected, - setEqlOptionsSelected, } = useRuleForms({ defineStepDefault: defineRuleData, aboutStepDefault: aboutRuleData, @@ -232,8 +230,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { threatIndicesConfig={threatIndicesConfig} defaultSavedQuery={savedQuery} form={defineStepForm} - optionsSelected={eqlOptionsSelected} - setOptionsSelected={setEqlOptionsSelected} key="defineStep" indexPattern={indexPattern} isIndexPatternLoading={isIndexPatternLoading} @@ -355,8 +351,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { threatIndicesConfig, savedQuery, defineStepForm, - eqlOptionsSelected, - setEqlOptionsSelected, indexPattern, isIndexPatternLoading, isQueryBarValid, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/validators/kuery_validator_factory.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/validators/kuery_validator_factory.ts new file mode 100644 index 0000000000000..f362acffd3bcf --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/validators/kuery_validator_factory.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import { fromKueryExpression } from '@kbn/es-query'; +import type { FormData, ValidationFunc } from '../../../shared_imports'; +import type { FieldValueQueryBar } from '../components/query_bar'; + +export function kueryValidatorFactory(): ValidationFunc { + return (...args) => { + const [{ path, value }] = args; + + if (isEmpty(value.query.query) || value.query.language !== 'kuery') { + return; + } + + try { + fromKueryExpression(value.query.query); + } catch (err) { + return { + code: 'ERR_FIELD_FORMAT', + path, + message: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customQueryFieldInvalidError', + { + defaultMessage: 'The KQL is invalid', + } + ), + }; + } + }; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/validators/query_required_validator_factory.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/validators/query_required_validator_factory.ts new file mode 100644 index 0000000000000..f06aaa6b312f8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/validators/query_required_validator_factory.ts @@ -0,0 +1,56 @@ +/* + * 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'; +import { isEmpty } from 'lodash'; +import type { RuleType } from '@kbn/securitysolution-rules'; +import type { FormData, ValidationFunc } from '../../../shared_imports'; +import { isEqlRule, isEsqlRule } from '../../../../common/detection_engine/utils'; +import type { FieldValueQueryBar } from '../components/query_bar'; + +export function queryRequiredValidatorFactory( + ruleType: RuleType +): ValidationFunc { + return (...args) => { + const [{ path, value }] = args; + + if (isEmpty(value.query.query as string) && isEmpty(value.filters)) { + return { + code: 'ERR_FIELD_MISSING', + path, + message: getErrorMessage(ruleType), + }; + } + }; +} + +function getErrorMessage(ruleType: RuleType): string { + if (isEsqlRule(ruleType)) { + return i18n.translate( + 'xpack.securitySolution.ruleManagement.ruleCreation.validation.query.esqlQueryFieldRequiredError', + { + defaultMessage: 'An ES|QL query is required.', + } + ); + } + + if (isEqlRule(ruleType)) { + return i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.eqlQueryFieldRequiredError', + { + defaultMessage: 'An EQL query is required.', + } + ); + } + + return i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customQueryFieldRequiredError', + { + defaultMessage: 'A custom query is required.', + } + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts index 04660191c9cbf..8e159c94aca0e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts @@ -34,9 +34,6 @@ export const DEFINITION_UPGRADE_FIELD_ORDER: Array = [ 'type', 'kql_query', 'eql_query', - 'event_category_override', - 'timestamp_field', - 'tiebreaker_field', 'esql_query', 'anomaly_threshold', 'machine_learning_job_id', diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.test.tsx index 5e417b3d862ed..701ab18e9a0ab 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.test.tsx @@ -92,16 +92,16 @@ describe('PerFieldRuleDiffTab', () => { }); describe('Undefined values are displayed with empty diffs', () => { - test('Displays only an updated field value when changed from undefined', () => { + test('Displays only an updated field value when changed from an empty value', () => { const mockData: PartialRuleDiff = { ...ruleFieldsDiffMock, fields: { - timestamp_field: { + name: { ...ruleFieldsDiffBaseFieldsMock, base_version: undefined, - current_version: undefined, - merged_version: 'new timestamp field', - target_version: 'new timestamp field', + current_version: '', + merged_version: 'new name', + target_version: 'new name', }, }, }; @@ -109,19 +109,19 @@ describe('PerFieldRuleDiffTab', () => { const diffContent = wrapper.getByTestId('ruleUpgradePerFieldDiffContent').textContent; // Only the new timestamp field should be displayed - expect(diffContent).toEqual('+new timestamp field'); + expect(diffContent).toEqual('+new name'); }); - test('Displays only an outdated field value when incoming update is undefined', () => { + test('Displays only an outdated field value when incoming update is an empty value', () => { const mockData: PartialRuleDiff = { ...ruleFieldsDiffMock, fields: { - timestamp_field: { + name: { ...ruleFieldsDiffBaseFieldsMock, - base_version: 'old timestamp field', - current_version: 'old timestamp field', - merged_version: undefined, - target_version: undefined, + base_version: 'old name', + current_version: 'old name', + merged_version: '', + target_version: '', }, }, }; @@ -129,7 +129,7 @@ describe('PerFieldRuleDiffTab', () => { const diffContent = wrapper.getByTestId('ruleUpgradePerFieldDiffContent').textContent; // Only the old timestamp_field should be displayed - expect(diffContent).toEqual('-old timestamp field'); + expect(diffContent).toEqual('-old name'); }); }); @@ -144,13 +144,6 @@ describe('PerFieldRuleDiffTab', () => { merged_version: 'new setup', target_version: 'new setup', }, - timestamp_field: { - ...ruleFieldsDiffBaseFieldsMock, - base_version: undefined, - current_version: undefined, - merged_version: 'new timestamp', - target_version: 'new timestamp', - }, name: { ...ruleFieldsDiffBaseFieldsMock, base_version: 'old name', @@ -166,11 +159,11 @@ describe('PerFieldRuleDiffTab', () => { const sectionLabels = matchedSectionElements.map((element) => element.textContent); // Schedule doesn't have any fields in the diff and shouldn't be displayed - expect(sectionLabels).toEqual(['About', 'Definition', 'Setup guide']); + expect(sectionLabels).toEqual(['About', 'Setup guide']); const matchedFieldElements = wrapper.queryAllByTestId('ruleUpgradePerFieldDiffLabel'); const fieldLabels = matchedFieldElements.map((element) => element.textContent); - expect(fieldLabels).toEqual(['Name', 'Timestamp Field', 'Setup']); + expect(fieldLabels).toEqual(['Name', 'Setup']); }); }); 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 623ae20fa484f..295323017f06a 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 @@ -27,6 +27,7 @@ import type { DataView } from '@kbn/data-views-plugin/public'; import { FilterItems } from '@kbn/unified-search-plugin/public'; import type { AlertSuppressionMissingFieldsStrategy, + EqlOptionalFields, RequiredFieldArray, RuleResponse, Threshold as ThresholdType, @@ -59,6 +60,11 @@ import { } from './rule_definition_section.styles'; import { getQueryLanguageLabel } from './helpers'; import { useDefaultIndexPattern } from '../../hooks/use_default_index_pattern'; +import { + EQL_OPTIONS_EVENT_CATEGORY_FIELD_LABEL, + EQL_OPTIONS_EVENT_TIEBREAKER_FIELD_LABEL, + EQL_OPTIONS_EVENT_TIMESTAMP_FIELD_LABEL, +} from '../../../rule_creation/components/eql_query_edit/translations'; interface SavedQueryNameProps { savedQueryName: string; @@ -562,6 +568,51 @@ const prepareDefinitionSectionListItems = ( } } + if ((rule as EqlOptionalFields).event_category_override) { + definitionSectionListItems.push({ + title: ( + + {EQL_OPTIONS_EVENT_CATEGORY_FIELD_LABEL} + + ), + description: ( + + {(rule as EqlOptionalFields).event_category_override} + + ), + }); + } + + if ((rule as EqlOptionalFields).tiebreaker_field) { + definitionSectionListItems.push({ + title: ( + + {EQL_OPTIONS_EVENT_TIEBREAKER_FIELD_LABEL} + + ), + description: ( + + {(rule as EqlOptionalFields).tiebreaker_field} + + ), + }); + } + + if ((rule as EqlOptionalFields).timestamp_field) { + definitionSectionListItems.push({ + title: ( + + {EQL_OPTIONS_EVENT_TIMESTAMP_FIELD_LABEL} + + ), + description: ( + + {(rule as EqlOptionalFields).timestamp_field} + + ), + }); + } + if (rule.type) { definitionSectionListItems.push({ title: i18n.RULE_TYPE_FIELD_LABEL, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/get_subfield_changes/eql_query.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/get_subfield_changes/eql_query.ts index 25a4dff97dd21..b68eb44f7f86f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/get_subfield_changes/eql_query.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/get_subfield_changes/eql_query.ts @@ -15,26 +15,25 @@ export const getSubfieldChangesForEqlQuery = ( ): SubfieldChange[] => { const changes: SubfieldChange[] = []; - const oldQuery = stringifyToSortedJson(oldFieldValue?.query); - const newQuery = stringifyToSortedJson(newFieldValue?.query); + const subFieldNames: Array = [ + 'query', + 'filters', + 'event_category_override', + 'tiebreaker_field', + 'timestamp_field', + ]; - if (oldQuery !== newQuery) { - changes.push({ - subfieldName: 'query', - oldSubfieldValue: oldQuery, - newSubfieldValue: newQuery, - }); - } - - const oldFilters = stringifyToSortedJson(oldFieldValue?.filters); - const newFilters = stringifyToSortedJson(newFieldValue?.filters); + for (const subFieldName of subFieldNames) { + const oldValue = stringifyToSortedJson(oldFieldValue?.[subFieldName]); + const newValue = stringifyToSortedJson(newFieldValue?.[subFieldName]); - if (oldFilters !== newFilters) { - changes.push({ - subfieldName: 'filters', - oldSubfieldValue: oldFilters, - newSubfieldValue: newFilters, - }); + if (newValue !== oldValue) { + changes.push({ + subfieldName: subFieldName, + oldSubfieldValue: oldValue, + newSubfieldValue: newValue, + }); + } } return changes; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/custom_query_rule_field_edit.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/custom_query_rule_field_edit.tsx index 69e11c85e4d51..843f128dae8a6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/custom_query_rule_field_edit.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/custom_query_rule_field_edit.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { assertUnreachable } from '../../../../../../../common/utility_types'; import type { UpgradeableCustomQueryFields } from '../../../../model/prebuilt_rule_upgrade/fields'; import { KqlQueryEditForm } from './fields/kql_query'; import { DataSourceEditForm } from './fields/data_source'; @@ -24,6 +25,6 @@ export function CustomQueryRuleFieldEdit({ fieldName }: CustomQueryRuleFieldEdit case 'alert_suppression': return ; default: - return null; // Will be replaced with `assertUnreachable(fieldName)` once all fields are implemented + return assertUnreachable(fieldName); } } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/eql_rule_field_edit.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/eql_rule_field_edit.tsx index dba33e57d56a7..29fcfdf7d522e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/eql_rule_field_edit.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/eql_rule_field_edit.tsx @@ -6,9 +6,11 @@ */ import React from 'react'; +import { assertUnreachable } from '../../../../../../../common/utility_types'; import type { UpgradeableEqlFields } from '../../../../model/prebuilt_rule_upgrade/fields'; import { DataSourceEditForm } from './fields/data_source'; import { AlertSuppressionEditForm } from './fields/alert_suppression'; +import { EqlQueryEditForm } from './fields/eql_query'; interface EqlRuleFieldEditProps { fieldName: UpgradeableEqlFields; @@ -16,11 +18,13 @@ interface EqlRuleFieldEditProps { export function EqlRuleFieldEdit({ fieldName }: EqlRuleFieldEditProps) { switch (fieldName) { + case 'eql_query': + return ; case 'data_source': return ; case 'alert_suppression': return ; default: - return null; // Will be replaced with `assertUnreachable(fieldName)` once all fields are implemented + return assertUnreachable(fieldName); } } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/eql_query/eql_query_edit_adapter.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/eql_query/eql_query_edit_adapter.tsx new file mode 100644 index 0000000000000..787891452f1d7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/eql_query/eql_query_edit_adapter.tsx @@ -0,0 +1,40 @@ +/* + * 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 type { DataViewBase } from '@kbn/es-query'; +import { EqlQueryEdit } from '../../../../../../../rule_creation/components/eql_query_edit'; +import type { RuleFieldEditComponentProps } from '../rule_field_edit_component_props'; +import { useDiffableRuleDataView } from '../hooks/use_diffable_rule_data_view'; + +export function EqlQueryEditAdapter({ + finalDiffableRule, +}: RuleFieldEditComponentProps): JSX.Element | null { + const { dataView, isLoading } = useDiffableRuleDataView(finalDiffableRule); + + // Wait for dataView to be defined to trigger validation with the correct index patterns + if (!dataView) { + return null; + } + + return ( + + ); +} + +const DEFAULT_DATA_VIEW_BASE: DataViewBase = { + title: '', + fields: [], +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/eql_query/eql_query_edit_form.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/eql_query/eql_query_edit_form.tsx new file mode 100644 index 0000000000000..3a8312897671e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/eql_query/eql_query_edit_form.tsx @@ -0,0 +1,86 @@ +/* + * 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 type { Filter } from '@kbn/es-query'; +import type { EqlOptions } from '@kbn/timelines-plugin/common'; +import type { FormData, FormSchema } from '../../../../../../../../shared_imports'; +import { RuleFieldEditFormWrapper } from '../rule_field_edit_form_wrapper'; +import type { FieldValueQueryBar } from '../../../../../../../rule_creation_ui/components/query_bar'; +import { + type DiffableRule, + RuleEqlQuery, + QueryLanguageEnum, +} from '../../../../../../../../../common/api/detection_engine'; +import { EqlQueryEditAdapter } from './eql_query_edit_adapter'; + +export function EqlQueryEditForm(): JSX.Element { + return ( + + ); +} + +const kqlQuerySchema = {} as FormSchema<{ + eqlQuery: RuleEqlQuery; +}>; + +function deserializer( + fieldValue: FormData, + finalDiffableRule: DiffableRule +): { + eqlQuery: FieldValueQueryBar; + eqlOptions: EqlOptions; +} { + const parsedEqlQuery = + 'eql_query' in finalDiffableRule + ? RuleEqlQuery.parse(fieldValue.eql_query) + : { + query: '', + language: QueryLanguageEnum.eql, + filters: [], + }; + + return { + eqlQuery: { + query: { + query: parsedEqlQuery.query, + language: parsedEqlQuery.language, + }, + // cast to Filter since RuleEqlQuery checks it's an array + // potentially it might be incompatible type + filters: parsedEqlQuery.filters as Filter[], + saved_id: null, + }, + eqlOptions: { + eventCategoryField: parsedEqlQuery.event_category_override, + timestampField: parsedEqlQuery.timestamp_field, + tiebreakerField: parsedEqlQuery.tiebreaker_field, + }, + }; +} + +function serializer(formData: FormData): { + eql_query: RuleEqlQuery; +} { + const formValue = formData as { eqlQuery: FieldValueQueryBar; eqlOptions: EqlOptions }; + + return { + eql_query: { + query: formValue.eqlQuery.query.query as string, + language: QueryLanguageEnum.eql, + filters: formValue.eqlQuery.filters, + event_category_override: formValue.eqlOptions.eventCategoryField, + timestamp_field: formValue.eqlOptions.timestampField, + tiebreaker_field: formValue.eqlOptions.tiebreakerField, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/eql_query/index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/eql_query/index.ts new file mode 100644 index 0000000000000..c11bd722a717d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/eql_query/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 * from './eql_query_edit_form'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/saved_query_rule_field_edit.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/saved_query_rule_field_edit.tsx index b18b3203fdfb5..0c1ba5b1b6f61 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/saved_query_rule_field_edit.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/saved_query_rule_field_edit.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { assertUnreachable } from '../../../../../../../common/utility_types'; import type { UpgradeableSavedQueryFields } from '../../../../model/prebuilt_rule_upgrade/fields'; import { KqlQueryEditForm } from './fields/kql_query'; import { DataSourceEditForm } from './fields/data_source'; @@ -24,6 +25,6 @@ export function SavedQueryRuleFieldEdit({ fieldName }: SavedQueryRuleFieldEditPr case 'alert_suppression': return ; default: - return null; // Will be replaced with `assertUnreachable(fieldName)` once all fields are implemented + return assertUnreachable(fieldName); } } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/eql_rule_field_readonly.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/eql_rule_field_readonly.tsx index b72fce91f198c..5fbd9516e78ba 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/eql_rule_field_readonly.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/eql_rule_field_readonly.tsx @@ -12,9 +12,6 @@ import { EqlQueryReadOnly } from './fields/eql_query/eql_query'; import { TypeReadOnly } from './fields/type/type'; import { AlertSuppressionReadOnly } from './fields/alert_suppression/alert_suppression'; import { assertUnreachable } from '../../../../../../../common/utility_types'; -import { EventCategoryOverrideReadOnly } from './fields/event_category_override/event_category_override'; -import { TimestampFieldReadOnly } from './fields/timestamp_field/timestamp_field'; -import { TiebreakerFieldReadOnly } from './fields/tiebreaker_field/tiebreaker_field'; interface EqlRuleFieldReadOnlyProps { fieldName: keyof DiffableEqlFields; @@ -39,16 +36,6 @@ export function EqlRuleFieldReadOnly({ fieldName, finalDiffableRule }: EqlRuleFi dataSource={finalDiffableRule.data_source} /> ); - case 'event_category_override': - return ( - - ); - case 'tiebreaker_field': - return ; - case 'timestamp_field': - return ; case 'type': return ; default: diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.tsx index f94f0bbfbe6c8..f2c49507b8ad5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/eql_query/eql_query.tsx @@ -6,11 +6,11 @@ */ import React from 'react'; -import { EuiDescriptionList } from '@elastic/eui'; +import { EuiDescriptionList, EuiText } from '@elastic/eui'; import type { EuiDescriptionListProps } from '@elastic/eui'; -import type { - RuleDataSource, - RuleEqlQuery, +import { + type RuleDataSource, + type RuleEqlQuery, } from '../../../../../../../../../common/api/detection_engine'; import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations'; import { Query, Filters } from '../../../../rule_definition_section'; @@ -38,5 +38,26 @@ export function EqlQueryReadOnly({ eqlQuery, dataSource }: EqlQueryReadOnlyProps }); } + if (eqlQuery.event_category_override) { + listItems.push({ + title: descriptionStepI18n.EQL_EVENT_CATEGORY_FIELD_LABEL, + description: {eqlQuery.event_category_override}, + }); + } + + if (eqlQuery.tiebreaker_field) { + listItems.push({ + title: descriptionStepI18n.EQL_TIEBREAKER_FIELD_LABEL, + description: {eqlQuery.tiebreaker_field}, + }); + } + + if (eqlQuery.timestamp_field) { + listItems.push({ + title: descriptionStepI18n.EQL_TIMESTAMP_FIELD_LABEL, + description: {eqlQuery.timestamp_field}, + }); + } + return ; } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/event_category_override/event_category_override.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/event_category_override/event_category_override.stories.tsx deleted file mode 100644 index 1f5987287f665..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/event_category_override/event_category_override.stories.tsx +++ /dev/null @@ -1,40 +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 React from 'react'; -import type { Story } from '@storybook/react'; -import { EventCategoryOverrideReadOnly } from './event_category_override'; -import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; -import { mockEqlRule } from '../../storybook/mocks'; -import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers'; - -export default { - component: EventCategoryOverrideReadOnly, - title: - 'Rule Management/Prebuilt Rules/Upgrade Flyout/ThreeWayDiff/FieldReadOnly/event_category_override', -}; - -interface TemplateProps { - finalDiffableRule: DiffableRule; -} - -const Template: Story = (args) => { - return ( - - - - ); -}; - -export const Default = Template.bind({}); - -Default.args = { - finalDiffableRule: mockEqlRule({ - event_category_override: 'event.action', - }), -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/event_category_override/event_category_override.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/event_category_override/event_category_override.tsx deleted file mode 100644 index 910e639049f96..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/event_category_override/event_category_override.tsx +++ /dev/null @@ -1,42 +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 React from 'react'; -import { EuiDescriptionList, EuiText } from '@elastic/eui'; -import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations'; -import type { EventCategoryOverride as EventCategoryOverrideType } from '../../../../../../../../../common/api/detection_engine'; - -interface EventCategoryOverrideReadOnlyProps { - eventCategoryOverride?: EventCategoryOverrideType; -} - -export function EventCategoryOverrideReadOnly({ - eventCategoryOverride, -}: EventCategoryOverrideReadOnlyProps) { - if (!eventCategoryOverride) { - return null; - } - - return ( - , - }, - ]} - /> - ); -} - -interface EventCategoryOverrideProps { - eventCategoryOverride: EventCategoryOverrideType; -} - -function EventCategoryOverride({ eventCategoryOverride }: EventCategoryOverrideProps) { - return {eventCategoryOverride}; -} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tiebreaker_field/tiebreaker_field.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tiebreaker_field/tiebreaker_field.stories.tsx deleted file mode 100644 index 3e73afda8f4eb..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tiebreaker_field/tiebreaker_field.stories.tsx +++ /dev/null @@ -1,40 +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 React from 'react'; -import type { Story } from '@storybook/react'; -import { TiebreakerFieldReadOnly } from './tiebreaker_field'; -import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; -import { mockEqlRule } from '../../storybook/mocks'; -import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers'; - -export default { - component: TiebreakerFieldReadOnly, - title: - 'Rule Management/Prebuilt Rules/Upgrade Flyout/ThreeWayDiff/FieldReadOnly/tiebreaker_field', -}; - -interface TemplateProps { - finalDiffableRule: DiffableRule; -} - -const Template: Story = (args) => { - return ( - - - - ); -}; - -export const Default = Template.bind({}); - -Default.args = { - finalDiffableRule: mockEqlRule({ - tiebreaker_field: 'process.name', - }), -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tiebreaker_field/tiebreaker_field.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tiebreaker_field/tiebreaker_field.tsx deleted file mode 100644 index 10e52240748c7..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/tiebreaker_field/tiebreaker_field.tsx +++ /dev/null @@ -1,40 +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 React from 'react'; -import { EuiDescriptionList, EuiText } from '@elastic/eui'; -import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations'; -import type { TiebreakerField as TiebreakerFieldType } from '../../../../../../../../../common/api/detection_engine'; - -interface TiebreakerFieldReadOnlyProps { - tiebreakerField?: TiebreakerFieldType; -} - -export function TiebreakerFieldReadOnly({ tiebreakerField }: TiebreakerFieldReadOnlyProps) { - if (!tiebreakerField) { - return null; - } - - return ( - , - }, - ]} - /> - ); -} - -interface TiebreakerFieldProps { - tiebreakerField: TiebreakerFieldType; -} - -function TiebreakerField({ tiebreakerField }: TiebreakerFieldProps) { - return {tiebreakerField}; -} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/timestamp_field/timestamp_field.stories.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/timestamp_field/timestamp_field.stories.tsx deleted file mode 100644 index 9b3977c3deeb2..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/timestamp_field/timestamp_field.stories.tsx +++ /dev/null @@ -1,39 +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 React from 'react'; -import type { Story } from '@storybook/react'; -import { TimestampFieldReadOnly } from './timestamp_field'; -import { FieldReadOnly } from '../../field_readonly'; -import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine'; -import { mockEqlRule } from '../../storybook/mocks'; -import { ThreeWayDiffStorybookProviders } from '../../storybook/three_way_diff_storybook_providers'; - -export default { - component: TimestampFieldReadOnly, - title: 'Rule Management/Prebuilt Rules/Upgrade Flyout/ThreeWayDiff/FieldReadOnly/timestamp_field', -}; - -interface TemplateProps { - finalDiffableRule: DiffableRule; -} - -const Template: Story = (args) => { - return ( - - - - ); -}; - -export const Default = Template.bind({}); - -Default.args = { - finalDiffableRule: mockEqlRule({ - timestamp_field: 'event.created', - }), -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/timestamp_field/timestamp_field.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/timestamp_field/timestamp_field.tsx deleted file mode 100644 index cd27bfde3db60..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/timestamp_field/timestamp_field.tsx +++ /dev/null @@ -1,40 +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 React from 'react'; -import { EuiDescriptionList, EuiText } from '@elastic/eui'; -import * as descriptionStepI18n from '../../../../../../../rule_creation_ui/components/description_step/translations'; -import type { TimestampField as TimestampFieldType } from '../../../../../../../../../common/api/detection_engine'; - -interface TimestampFieldReadOnlyProps { - timestampField?: TimestampFieldType; -} - -export function TimestampFieldReadOnly({ timestampField }: TimestampFieldReadOnlyProps) { - if (!timestampField) { - return null; - } - - return ( - , - }, - ]} - /> - ); -} - -interface TimestampFieldProps { - timestampField: TimestampFieldType; -} - -function TimestampField({ timestampField }: TimestampFieldProps) { - return {timestampField}; -} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 68c6465aabcbc..e44f3e8785bed 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -354,7 +354,6 @@ describe('alert actions', () => { eventCategoryField: 'event.category', query: '', size: 100, - tiebreakerField: '', timestampField: '@timestamp', }, eventIdToNoteIds: {}, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.tsx index 27c2699cdf195..b72c043ffa408 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.tsx @@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { i18n } from '@kbn/i18n'; -import type { EqlOptionsSelected } from '@kbn/timelines-plugin/common'; +import type { EqlOptions } from '@kbn/timelines-plugin/common'; import { convertKueryToElasticSearchQuery } from '../../../../common/lib/kuery'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useSourcererDataView } from '../../../../sourcerer/containers'; @@ -37,7 +37,7 @@ export type SetRuleQuery = ({ }: { index: string[]; queryBar: FieldValueQueryBar; - eqlOptions?: EqlOptionsSelected; + eqlOptions?: EqlOptions; }) => void; export const useRuleFromTimeline = (setRuleQuery: SetRuleQuery): RuleFromTimeline => { 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 cf2c3264f3150..2f0cf92737f4a 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 @@ -50,7 +50,7 @@ import type { RequiredFieldInput, } from '../../../../../common/api/detection_engine/model/rule_schema'; import type { SortOrder } from '../../../../../common/api/detection_engine'; -import type { EqlOptionsSelected } from '../../../../../common/search_strategy'; +import type { EqlOptions } from '../../../../../common/search_strategy'; import type { RuleResponseAction, ResponseAction, @@ -164,7 +164,7 @@ export interface DefineStepRule { threatIndex: ThreatIndex; threatQueryBar: FieldValueQueryBar; threatMapping: ThreatMapping; - eqlOptions: EqlOptionsSelected; + eqlOptions: EqlOptions; dataSourceType: DataSourceType; newTermsFields: string[]; historyWindowSize: string; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/index.tsx index 54b5a4a9aae2f..f925a1d83d136 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/index.tsx @@ -5,36 +5,28 @@ * 2.0. */ -import { isEmpty, isEqual } from 'lodash'; -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { isEqual } from 'lodash'; +import React, { memo, useCallback, useEffect, useMemo } from 'react'; +import { EuiOutsideClickDetector } from '@elastic/eui'; import { useDispatch } from 'react-redux'; import { css } from '@emotion/css'; -import type { - EqlOptionsSelected, - FieldsEqlOptions, -} from '../../../../../../common/search_strategy'; +import type { EqlOptions } from '../../../../../../common/search_strategy'; import { useSourcererDataView } from '../../../../../sourcerer/containers'; import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; import { SourcererScopeName } from '../../../../../sourcerer/store/model'; -import { EqlQueryBar } from '../../../../../detection_engine/rule_creation_ui/components/eql_query_bar'; - -import { - debounceAsync, - eqlValidator, -} from '../../../../../detection_engine/rule_creation_ui/components/eql_query_bar/validators'; +import { EqlQueryEdit } from '../../../../../detection_engine/rule_creation/components/eql_query_edit'; import type { FieldValueQueryBar } from '../../../../../detection_engine/rule_creation_ui/components/query_bar'; -import type { FormSchema } from '../../../../../shared_imports'; -import { Form, UseField, useForm, useFormData } from '../../../../../shared_imports'; +import type { FormSchema, FormSubmitHandler } from '../../../../../shared_imports'; +import { Form, UseField, useForm } from '../../../../../shared_imports'; import { timelineActions } from '../../../../store'; -import * as i18n from '../translations'; import { getEqlOptions } from './selectors'; interface TimelineEqlQueryBar { index: string[]; eqlQueryBar: FieldValueQueryBar; - eqlOptions: EqlOptionsSelected; + eqlOptions: EqlOptions; } const defaultValues = { @@ -55,13 +47,6 @@ const schema: FormSchema = { eqlOptions: { fieldsToValidateOnChange: ['eqlOptions', 'eqlQueryBar'], }, - eqlQueryBar: { - validations: [ - { - validator: debounceAsync(eqlValidator, 300), - }, - ], - }, }; const hiddenUseFieldClassName = css` @@ -71,11 +56,8 @@ const hiddenUseFieldClassName = css` // eslint-disable-next-line react/display-name export const EqlQueryBarTimeline = memo(({ timelineId }: { timelineId: string }) => { const dispatch = useDispatch(); - const isInit = useRef(true); - const [isQueryBarValid, setIsQueryBarValid] = useState(false); - const [isQueryBarValidating, setIsQueryBarValidating] = useState(false); const getOptionsSelected = useMemo(() => getEqlOptions(), []); - const optionsSelected = useDeepEqualSelector((state) => getOptionsSelected(state, timelineId)); + const eqlOptions = useDeepEqualSelector((state) => getOptionsSelected(state, timelineId)); const { loading: indexPatternsLoading, @@ -89,127 +71,117 @@ export const EqlQueryBarTimeline = memo(({ timelineId }: { timelineId: string }) index: [...selectedPatterns].sort(), eqlQueryBar: { ...defaultValues.eqlQueryBar, - query: { query: optionsSelected.query ?? '', language: 'eql' }, + query: { query: eqlOptions.query ?? '', language: 'eql' }, }, + eqlOptions, }), - [optionsSelected.query, selectedPatterns] + [eqlOptions, selectedPatterns] + ); + + const handleSubmit = useCallback>( + async (formData, isValid) => { + if (!isValid) { + return; + } + + if (eqlOptions.query !== `${formData.eqlQueryBar.query.query}`) { + dispatch( + timelineActions.updateEqlOptions({ + id: timelineId, + field: 'query', + value: `${formData.eqlQueryBar.query.query}`, + }) + ); + } + + for (const fieldName of Object.keys(formData.eqlOptions) as Array< + keyof typeof formData.eqlOptions + >) { + if (formData.eqlOptions[fieldName] !== eqlOptions[fieldName]) { + dispatch( + timelineActions.updateEqlOptions({ + id: timelineId, + field: fieldName, + value: formData.eqlOptions[fieldName], + }) + ); + } + } + }, + [dispatch, timelineId, eqlOptions] ); const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, + onSubmit: handleSubmit, }); - const { getFields, setFieldValue } = form; - - const onOptionsChange = useCallback( - (field: FieldsEqlOptions, value: string | undefined) => { - dispatch( - timelineActions.updateEqlOptions({ - id: timelineId, - field, - value, - }) - ); - setFieldValue('eqlOptions', { ...optionsSelected, [field]: value }); - }, - [dispatch, optionsSelected, setFieldValue, timelineId] - ); - - const [{ eqlQueryBar: formEqlQueryBar }] = useFormData({ - form, - watch: ['eqlQueryBar'], - }); - - const prevEqlQuery = useRef(''); + const { getFields } = form; + const handleOutsideEqlQueryEditClick = useCallback(() => form.submit(), [form]); - const optionsData = useMemo(() => { - const fields = Object.values(sourcererDataView.fields || {}); + // Reset the form when new EQL Query came from the state + useEffect(() => { + getFields().eqlQueryBar.setValue({ + ...defaultValues.eqlQueryBar, + query: { query: eqlOptions.query ?? '', language: 'eql' }, + }); + }, [getFields, eqlOptions.query]); - return isEmpty(fields) - ? { - keywordFields: [], - dateFields: [], - nonDateFields: [], - } - : { - keywordFields: fields - .filter((f) => f.esTypes?.includes('keyword')) - .map((f) => ({ label: f.name })), - dateFields: fields.filter((f) => f.type === 'date').map((f) => ({ label: f.name })), - nonDateFields: fields.filter((f) => f.type !== 'date').map((f) => ({ label: f.name })), - }; - }, [sourcererDataView]); + // Reset the form when new EQL Options came from the state + useEffect(() => { + getFields().eqlOptions.setValue({ + eventCategoryField: eqlOptions.eventCategoryField, + tiebreakerField: eqlOptions.tiebreakerField, + timestampField: eqlOptions.timestampField, + size: eqlOptions.size, + }); + }, [ + getFields, + eqlOptions.eventCategoryField, + eqlOptions.tiebreakerField, + eqlOptions.timestampField, + eqlOptions.size, + ]); useEffect(() => { const { index: indexField } = getFields(); const newIndexValue = [...selectedPatterns].sort(); const indexFieldValue = (indexField.value as string[]).sort(); + if (!isEqual(indexFieldValue, newIndexValue)) { indexField.setValue(newIndexValue); } }, [getFields, selectedPatterns]); - useEffect(() => { - const { eqlQueryBar } = getFields(); - if (isInit.current) { - isInit.current = false; - setIsQueryBarValidating(true); - eqlQueryBar.setValue({ - ...defaultValues.eqlQueryBar, - query: { query: optionsSelected.query ?? '', language: 'eql' }, - }); - } - return () => { - isInit.current = true; - }; - }, [getFields, optionsSelected.query]); - - useEffect(() => { - if ( - formEqlQueryBar != null && - prevEqlQuery.current !== formEqlQueryBar.query.query && - isQueryBarValid && - !isQueryBarValidating - ) { - prevEqlQuery.current = formEqlQueryBar.query.query; - dispatch( - timelineActions.updateEqlOptions({ - id: timelineId, - field: 'query', - value: `${formEqlQueryBar.query.query}`, - }) - ); - setIsQueryBarValid(false); - setIsQueryBarValidating(false); - } - }, [dispatch, formEqlQueryBar, isQueryBarValid, isQueryBarValidating, timelineId]); + const dataView = useMemo( + () => ({ + ...sourcererDataView, + title: sourcererDataView.title ?? '', + fields: Object.values(sourcererDataView.fields || {}), + }), + [sourcererDataView] + ); + /* Force casting `sourcererDataView` to `DataViewBase` is required since EqlQueryEdit + accepts DataViewBase but `useSourcererDataView()` returns `DataViewSpec`. + + When using `UseField` with `EqlQueryBar` such casting isn't required by TS since + `UseField` component props are types as `Record`. */ return (
- - + + + ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/selectors.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/selectors.tsx index e533ef9175178..eccaf88591983 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/selectors.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/selectors.tsx @@ -13,12 +13,7 @@ export const getEqlOptions = () => selectTimeline, (timeline) => timeline?.eqlOptions ?? { - eventCategoryField: [{ label: 'event.category' }], - tiebreakerField: [ - { - label: '', - }, - ], + eventCategoryField: 'event.category', timestampField: [ { label: '@timestamp', diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 6c40ca1b7dfd1..0301f0123c30f 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -41,7 +41,7 @@ import { TimelineId } from '../../../common/types/timeline'; import { useRouteSpy } from '../../common/utils/route/use_route_spy'; import { activeTimeline } from './active_timeline_context'; import type { - EqlOptionsSelected, + EqlOptions, TimelineEqlResponse, } from '../../../common/search_strategy/timeline/events/eql'; import { useTrackHttpRequest } from '../../common/lib/apm/use_track_http_request'; @@ -84,7 +84,7 @@ type TimelineResponse = T extends 'kuery' export interface UseTimelineEventsProps { dataViewId: string | null; endDate?: string; - eqlOptions?: EqlOptionsSelected; + eqlOptions?: EqlOptions; fields: string[]; filterQuery?: ESQuery | string; id: string; @@ -112,7 +112,7 @@ export const initSortDefault: TimelineRequestSortField[] = [ }, ]; -const deStructureEqlOptions = (eqlOptions?: EqlOptionsSelected) => ({ +const deStructureEqlOptions = (eqlOptions?: EqlOptions) => ({ ...(!isEmpty(eqlOptions?.eventCategoryField) ? { eventCategoryField: eqlOptions?.eventCategoryField, diff --git a/x-pack/plugins/security_solution/public/timelines/store/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/actions.ts index 976d35c030651..78f74ef4670e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/actions.ts @@ -188,7 +188,7 @@ export const toggleModalSaveTimeline = actionCreator<{ export const updateEqlOptions = actionCreator<{ id: string; field: FieldsEqlOptions; - value: string | undefined; + value: string | number | undefined; }>('UPDATE_EQL_OPTIONS_TIMELINE'); export const setEventsLoading = actionCreator<{ diff --git a/x-pack/plugins/security_solution/public/timelines/store/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/defaults.ts index 5dbe2d1b9c1e8..69d306be9b85f 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/defaults.ts @@ -32,7 +32,6 @@ export const timelineDefaults: SubsetTimelineModel & description: '', eqlOptions: { eventCategoryField: 'event.category', - tiebreakerField: '', timestampField: '@timestamp', query: '', size: 100, diff --git a/x-pack/plugins/security_solution/public/timelines/store/model.ts b/x-pack/plugins/security_solution/public/timelines/store/model.ts index 92c435f93cb43..f93d6b3f6b649 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/model.ts @@ -8,10 +8,7 @@ import type { Filter } from '@kbn/es-query'; import type { SavedSearch } from '@kbn/saved-search-plugin/common'; import type { SessionViewConfig } from '../../../common/types'; -import type { - EqlOptionsSelected, - TimelineNonEcsData, -} from '../../../common/search_strategy/timeline'; +import type { EqlOptions, TimelineNonEcsData } from '../../../common/search_strategy/timeline'; import type { TimelineTabs, ScrollToTopEvent, @@ -42,7 +39,7 @@ export interface TimelineModel { createdBy?: string; /** A summary of the events and notes in this timeline */ description: string; - eqlOptions: EqlOptionsSelected; + eqlOptions: EqlOptions; /** Type of event you want to see in this timeline */ eventType?: TimelineEventsType; /** A map of events in this timeline to the chronologically ordered notes (in this timeline) associated with the event */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts index 8a796b5db1e28..7caa0469eebeb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { get } from 'lodash'; +import { get, has } from 'lodash'; import type { RuleSchedule, DataSourceIndexPatterns, @@ -48,9 +48,13 @@ export const mapDiffableRuleFieldValueToRuleSchemaFormat = ( return transformedValue.value; } + if (!SUBFIELD_MAPPING[fieldName] && !has(diffableField, diffableRuleSubfieldName)) { + return diffableField; + } + // From the ThreeWayDiff, get the specific field that maps to the diffable rule field // Otherwise, the diffableField itself already matches the rule field, so retrieve that value. - const mappedField = get(diffableField, diffableRuleSubfieldName, diffableField); + const mappedField = get(diffableField, diffableRuleSubfieldName); return mappedField; }; @@ -81,9 +85,27 @@ export function mapRuleFieldToDiffableRuleField({ ruleType, fieldName, }: MapRuleFieldToDiffableRuleFieldParams): keyof AllFieldsDiff { + // Handle query, filters and language fields based on rule type + if (fieldName === 'query' || fieldName === 'language' || fieldName === 'filters') { + switch (ruleType) { + case 'query': + case 'saved_query': + return 'kql_query' as const; + case 'eql': + return 'eql_query'; + case 'esql': + return 'esql_query'; + default: + return 'kql_query'; + } + } + const diffableRuleFieldMap: Record = { building_block_type: 'building_block', saved_id: 'kql_query', + event_category_override: 'eql_query', + tiebreaker_field: 'eql_query', + timestamp_field: 'eql_query', threat_query: 'threat_query', threat_language: 'threat_query', threat_filters: 'threat_query', @@ -99,24 +121,27 @@ export function mapRuleFieldToDiffableRuleField({ timestamp_override_fallback_disabled: 'timestamp_override', }; - // Handle query, filters and language fields based on rule type - if (fieldName === 'query' || fieldName === 'language' || fieldName === 'filters') { - switch (ruleType) { - case 'query': - case 'saved_query': - return 'kql_query' as const; - case 'eql': - return 'eql_query'; - case 'esql': - return 'esql_query'; - default: - return 'kql_query'; - } - } - return diffableRuleFieldMap[fieldName] || fieldName; } +const SUBFIELD_MAPPING: Record = { + index: 'index_patterns', + data_view_id: 'data_view_id', + saved_id: 'saved_query_id', + event_category_override: 'event_category_override', + tiebreaker_field: 'tiebreaker_field', + timestamp_field: 'timestamp_field', + building_block_type: 'type', + rule_name_override: 'field_name', + timestamp_override: 'field_name', + timestamp_override_fallback_disabled: 'fallback_disabled', + timeline_id: 'timeline_id', + timeline_title: 'timeline_title', + interval: 'interval', + from: 'lookback', + to: 'lookback', +}; + /** * Maps a PrebuiltRuleAsset schema field name to its corresponding property * name within a DiffableRule group. @@ -134,22 +159,7 @@ export function mapRuleFieldToDiffableRuleField({ * */ export function mapRuleFieldToDiffableRuleSubfield(fieldName: string): string { - const fieldMapping: Record = { - index: 'index_patterns', - data_view_id: 'data_view_id', - saved_id: 'saved_query_id', - building_block_type: 'type', - rule_name_override: 'field_name', - timestamp_override: 'field_name', - timestamp_override_fallback_disabled: 'fallback_disabled', - timeline_id: 'timeline_id', - timeline_title: 'timeline_title', - interval: 'interval', - from: 'lookback', - to: 'lookback', - }; - - return fieldMapping[fieldName] || fieldName; + return SUBFIELD_MAPPING[fieldName] || fieldName; } type TransformValuesReturnType = diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/calculate_rule_fields_diff.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/calculate_rule_fields_diff.ts index bde52596667d2..5730af03789d1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/calculate_rule_fields_diff.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/calculate_rule_fields_diff.ts @@ -239,9 +239,6 @@ const eqlFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor = { type: ruleTypeDiffAlgorithm, eql_query: eqlQueryDiffAlgorithm, data_source: dataSourceDiffAlgorithm, - event_category_override: singleLineStringDiffAlgorithm, - timestamp_field: singleLineStringDiffAlgorithm, - tiebreaker_field: singleLineStringDiffAlgorithm, alert_suppression: simpleDiffAlgorithm, }; diff --git a/x-pack/plugins/timelines/common/index.ts b/x-pack/plugins/timelines/common/index.ts index 9b289f481a2d5..0a96a22fb2679 100644 --- a/x-pack/plugins/timelines/common/index.ts +++ b/x-pack/plugins/timelines/common/index.ts @@ -42,8 +42,8 @@ export type { BeatFields, BrowserFields, CursorType, - EqlOptionsData, - EqlOptionsSelected, + EqlFieldsComboBoxOptions, + EqlOptions, FieldsEqlOptions, FieldInfo, IndexField, diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/eql/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/eql/index.ts index 66758bbcb94d7..8636a55941042 100644 --- a/x-pack/plugins/timelines/common/search_strategy/timeline/events/eql/index.ts +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/eql/index.ts @@ -22,13 +22,13 @@ export interface TimelineEqlResponse extends EqlSearchStrategyResponse; } -export interface EqlOptionsData { +export interface EqlFieldsComboBoxOptions { keywordFields: EuiComboBoxOptionOption[]; dateFields: EuiComboBoxOptionOption[]; nonDateFields: EuiComboBoxOptionOption[]; } -export interface EqlOptionsSelected { +export interface EqlOptions { eventCategoryField?: string; tiebreakerField?: string; timestampField?: string; @@ -36,4 +36,4 @@ export interface EqlOptionsSelected { size?: number; } -export type FieldsEqlOptions = keyof EqlOptionsSelected; +export type FieldsEqlOptions = keyof EqlOptions; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 2da357aa68461..4dd27be3e100a 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -38073,7 +38073,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoAriaLabel": "Ouvrir une fenêtre contextuelle d'aide", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipContent": "Consultez {createEsqlRuleTypeLink} pour commencer à utiliser les règles ES|QL.", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipLink": "documentation", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryFieldRequiredError": "Une requête ES|QL est requise.", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryLabel": "Requête ES|QL", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldAnomalyThresholdLabel": "Seuil de score d'anomalie", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldMachineLearningJobIdLabel": "Tâche de Machine Learning", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b564cac6e8c13..27aa31560060f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -38040,7 +38040,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoAriaLabel": "ヘルプポップオーバーを開く", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipContent": "ES|QL ルールの使用を開始するには、{createEsqlRuleTypeLink}を確認してください。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipLink": "ドキュメンテーション", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryFieldRequiredError": "ES|QLクエリは必須です。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryLabel": "ES|QLクエリ", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldAnomalyThresholdLabel": "異常スコアしきい値", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldMachineLearningJobIdLabel": "機械学習ジョブ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 06b1b01869f63..4fe95da457b5a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -37435,7 +37435,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoAriaLabel": "打开帮助弹出框", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipContent": "请访问我们的{createEsqlRuleTypeLink}以开始使用 ES|QL 规则。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlInfoTooltipLink": "文档", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryFieldRequiredError": "ES|QL 查询必填。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.esqlQueryLabel": "ES|QL 查询", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldAnomalyThresholdLabel": "异常分数阈值", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldMachineLearningJobIdLabel": "Machine Learning 作业",