From 32fb4348a99de3d8f5f2601d66ea3a9bec049258 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Fri, 30 Aug 2024 14:33:27 +0200 Subject: [PATCH] [Security Solution] Editing rules independently of source data (#180407) (#191487) ## Summary Addresses https://github.com/elastic/kibana/issues/180407 Addresses https://github.com/elastic/kibana/issues/178611 With these changes we allow user to create and update a rule even if there are certain query bar validation error exist. Right now, we will make any non-syntax validation errors in `EQL` and `ES|QL` rules types to be non-blocking during the rule creation and rule updating workflows. ### Screenshot of the EQL rule creation workflow with existing non-blocking validation errors: https://github.com/user-attachments/assets/06b7f76c-e600-4a99-8ead-1445d429e9d3 ### Screenshot of the EQL rule updating workflow with existing non-blocking validation errors: https://github.com/user-attachments/assets/9b35e113-b127-487b-bc23-afecf704db9d ## UPDATE After discussing confirmation modal with @approksiu, we decided to simplify it and show only title with generic description to avoid too be too literal in the modal. User can see the full error description during rule creation/editing workflows in the query bar where we show each validation error as part of the query bar form item. Screenshot 2024-08-28 at 12 50 14 ### Some test cases for local testing
Create EQL rule with missing data source #### Steps: 1. Open rules management page 2. Click create new rule button 3. Select EQL rule type 4. Set non-existing index in index patterns field 5. Add some valid EQL query (for example `any where true`) 6. Continue with other steps 7. Click create rule button **Expected**: You will see the confirmation modal that warns user about potentially failing rule executions. Clicking `Confirm` button will create a rule.
Create EQL rule with missing data field #### Steps: 1. Open rules management page 2. Click create new rule button 3. Select EQL rule type 4. Set existing indices in index patterns field 5. Add some valid EQL query referring non-existing data field (for example `any where agent.non_existing_field`) 6. Continue with other steps 7. Click create rule button **Expected**: You will see the confirmation modal that warns user about potentially failing rule executions. Clicking `Confirm` button will create a rule.
Create EQL rule with syntax error in the query #### Steps: 1. Open rules management page 2. Click create new rule button 3. Select EQL rule type 4. Set existing indices in index patterns field 5. Add some syntactically invalid EQL query (for example `hello world`) **Expected**: The continue button does not allow user to proceed to the About step due to existing syntax error.
Create ES|QL rule with missing data source #### Steps: 1. Open rules management page 2. Click create new rule button 3. Select ES|QL rule type 4. Add some valid ES|QL query with non-existing data source (for example `from non-existing-index-* metadata _id, _version, _index | SORT @timestamp`) 6. Continue with other steps 7. Click create rule button **Expected**: You will see the confirmation modal that warns user about potentially failing rule executions. Clicking `Confirm` button will create a rule.
Create ES|QL rule with missing data field #### Steps: 1. Open rules management page 2. Click create new rule button 3. Select ES|QL rule type 4. Add some valid ES|QL query with non-existing data field (for example `from logs-* metadata _id, _version, _index | SORT agent.non_existing_field`) 6. Continue with other steps 7. Click create rule button **Expected**: You will see the confirmation modal that warns user about potentially failing rule executions. Clicking `Confirm` button will create a rule.
Create ES|QL rule with syntax error in the query #### Steps: 1. Open rules management page 2. Click create new rule button 3. Select ES|QL rule type 4. Add some syntactically invalid ES|QL query (for example `hello world`) **Expected**: The continue button does not allow user to proceed to the About step due to existing syntax error.
Same behaviour applies to the rule updating workflow. For example, you can try to install one of the EQL or ES|QL rules that point to non-existing data source or uses non-existing data field. User can still update (add rule actions) to such installed pre-built rules. ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials * https://github.com/elastic/security-docs/issues/5758 - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed * [Detection Engine - Cypress](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6831) (100 ESS & 100 Serverless) * [Rule Management - Cypress](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6830) (100 ESS & 100 Serverless) --------- Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> --- .../public/common/hooks/eql/api.ts | 16 +- .../components/eql_query_bar/validators.ts | 27 +- .../save_with_errors_confirmation/index.tsx | 41 +++ .../translations.ts | 36 +++ .../rule_creation_ui/pages/form.test.ts | 291 ++++++++++++++++++ .../rule_creation_ui/pages/form.tsx | 91 +++++- .../pages/rule_creation/index.tsx | 283 +++++++++++------ .../pages/rule_editing/index.tsx | 104 +++++-- .../rule_creation_ui/pages/translations.ts | 6 + .../rule_creation/common_flows.cy.ts | 2 - .../rule_creation/eql_rule.cy.ts | 20 +- .../rule_creation/esql_rule.cy.ts | 16 + .../rule_edit/eql_query_rule.cy.ts | 47 +++ .../rule_edit/esql_rule.cy.ts | 30 +- .../cypress/screens/create_new_rule.ts | 4 + .../cypress/tasks/create_new_rule.ts | 10 + .../cypress/tasks/edit_rule.ts | 12 + 17 files changed, 898 insertions(+), 138 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.test.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/eql_query_rule.cy.ts 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 921d4fde4bfd0..569344297f319 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 @@ -36,6 +36,17 @@ interface Params { options: Omit | undefined; } +export interface EqlResponseError { + code: EQL_ERROR_CODES; + messages?: string[]; + error?: Error; +} + +export interface ValidateEqlResponse { + valid: boolean; + error?: EqlResponseError; +} + export const validateEql = async ({ data, dataViewTitle, @@ -43,10 +54,7 @@ export const validateEql = async ({ signal, runtimeMappings, options, -}: Params): Promise<{ - valid: boolean; - error?: { code: EQL_ERROR_CODES; messages?: string[]; error?: Error }; -}> => { +}: Params): Promise => { try { const { rawResponse: response } = await firstValueFrom( data.search.search( 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 index 04ea9bbc43356..8cd9a4d60745e 100644 --- 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 @@ -12,6 +12,7 @@ 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'; @@ -47,6 +48,23 @@ export const debounceAsync = => { + 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> => { @@ -86,13 +104,8 @@ export const eqlValidator = async ( options: eqlOptions, }); - if (response?.valid === false) { - return { - code: response.error?.code, - message: '', - messages: response.error?.messages, - error: response.error?.error, - }; + if (response?.valid === false && response.error) { + return transformEqlResponseErrorToValidationError(response.error); } } catch (error) { return { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/index.tsx new file mode 100644 index 0000000000000..839513bf0e34c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/index.tsx @@ -0,0 +1,41 @@ +/* + * 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 { EuiConfirmModal } from '@elastic/eui'; + +import * as i18n from './translations'; + +interface SaveWithErrorsModalProps { + errors: string[]; + onCancel: () => void; + onConfirm: () => void; +} + +const SaveWithErrorsModalComponent = ({ + errors, + onCancel, + onConfirm, +}: SaveWithErrorsModalProps) => { + return ( + + <>{i18n.SAVE_WITH_ERRORS_MODAL_MESSAGE(errors.length)} + + ); +}; + +export const SaveWithErrorsModal = React.memo(SaveWithErrorsModalComponent); +SaveWithErrorsModal.displayName = 'SaveWithErrorsModal'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/translations.ts new file mode 100644 index 0000000000000..e470b06c7e829 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/save_with_errors_confirmation/translations.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SAVE_WITH_ERRORS_MODAL_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.saveWithErrorsModalTitle', + { + defaultMessage: 'This rule has validation errors', + } +); + +export const SAVE_WITH_ERRORS_CANCEL_BUTTON = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.saveWithErrorsCancelButton', + { + defaultMessage: 'Cancel', + } +); + +export const SAVE_WITH_ERRORS_CONFIRM_BUTTON = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.saveWithErrorsConfirmButton', + { + defaultMessage: 'Confirm', + } +); + +export const SAVE_WITH_ERRORS_MODAL_MESSAGE = (errorsCount: number) => + i18n.translate('xpack.securitySolution.detectionEngine.createRule.saveWithErrorsModalMessage', { + defaultMessage: + 'This rule has {errorsCount} validation {errorsCount, plural, one {error} other {errors}} which can lead to failed rule executions, save anyway?', + values: { errorsCount }, + }); 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 new file mode 100644 index 0000000000000..ca1e5042eac80 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/form.test.ts @@ -0,0 +1,291 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; + +import type { FormData, FormHook, ValidationError } from '../../../shared_imports'; +import { ERROR_CODES as ESQL_ERROR_CODES } from '../../rule_creation/logic/esql_validator'; +import { EQL_ERROR_CODES } from '../../../common/hooks/eql/api'; +import type { + AboutStepRule, + ActionsStepRule, + DefineStepRule, + ScheduleStepRule, +} from '../../../detections/pages/detection_engine/rules/types'; + +import { useRuleFormsErrors } from './form'; +import { transformEqlResponseErrorToValidationError } from '../components/eql_query_bar/validators'; + +const getFormWithErrorsMock = (fields: { + [key: string]: { errors: Array> }; +}) => { + return { + getFields: () => fields, + } as unknown as FormHook; +}; + +describe('useRuleFormsErrors', () => { + describe('EQL query validation errors', () => { + 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], + }, + }); + + const { getRuleFormsErrors } = result.current; + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ defineStepForm }); + + expect(blockingErrors).toEqual(["line 1:5: missing 'where' at 'demo'"]); + expect(nonBlockingErrors).toEqual([]); + }); + + 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], + }, + }); + + const { getRuleFormsErrors } = result.current; + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ defineStepForm }); + + expect(blockingErrors).toEqual([]); + expect(nonBlockingErrors).toEqual([ + 'Query bar: index_not_found_exception Found 1 problem line -1:-1: Unknown index [*,-*]', + ]); + }); + + 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], + }, + }); + + const { getRuleFormsErrors } = result.current; + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ defineStepForm }); + + expect(blockingErrors).toEqual([]); + expect(nonBlockingErrors).toEqual([ + 'Query bar: Found 2 problems\nline 1:1: Unknown column [event.category]\nline 1:13: Unknown column [event.name]', + ]); + }); + + 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], + }, + }); + + const { getRuleFormsErrors } = result.current; + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ defineStepForm }); + + expect(blockingErrors).toEqual([]); + expect(nonBlockingErrors).toEqual([ + 'Query bar: An error occurred while validating your EQL query', + ]); + }); + + 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], + }, + }); + const aboutStepForm = getFormWithErrorsMock({ + name: { + errors: [ + { + message: 'Required field', + }, + ], + }, + }); + + const { getRuleFormsErrors } = result.current; + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ + defineStepForm, + aboutStepForm, + }); + + expect(blockingErrors).toEqual(['Required field']); + expect(nonBlockingErrors).toEqual(['Query bar: Missing data source']); + }); + }); + + describe('ES|QL query validation errors', () => { + it('should return blocking error in case of syntax validation error', async () => { + const { result } = renderHook(() => useRuleFormsErrors()); + + const validationError = { + code: ESQL_ERROR_CODES.INVALID_SYNTAX, + message: 'Broken ES|QL syntax', + }; + const defineStepForm = getFormWithErrorsMock({ + queryBar: { + errors: [validationError], + }, + }); + + const { getRuleFormsErrors } = result.current; + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ defineStepForm }); + + expect(blockingErrors).toEqual(['Broken ES|QL syntax']); + expect(nonBlockingErrors).toEqual([]); + }); + + it('should return blocking error in case of missed ES|QL metadata validation error', async () => { + const { result } = renderHook(() => useRuleFormsErrors()); + + const validationError = { + code: ESQL_ERROR_CODES.ERR_MISSING_ID_FIELD_FROM_RESULT, + message: 'Metadata is missing', + }; + const defineStepForm = getFormWithErrorsMock({ + queryBar: { + errors: [validationError], + }, + }); + + const { getRuleFormsErrors } = result.current; + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ defineStepForm }); + + expect(blockingErrors).toEqual(['Metadata is missing']); + expect(nonBlockingErrors).toEqual([]); + }); + + it('should return non-blocking error in case of missing data field validation error', async () => { + const { result } = renderHook(() => useRuleFormsErrors()); + + const validationError = { + code: ESQL_ERROR_CODES.INVALID_ESQL, + message: 'Unknown column [hello.world]', + }; + const defineStepForm = getFormWithErrorsMock({ + queryBar: { + errors: [validationError], + }, + }); + + const { getRuleFormsErrors } = result.current; + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ defineStepForm }); + + expect(blockingErrors).toEqual([]); + expect(nonBlockingErrors).toEqual(['Query bar: Unknown column [hello.world]']); + }); + }); + + describe('general cases', () => { + it('should not return blocking and non-blocking errors in case there are none exist', async () => { + const { result } = renderHook(() => useRuleFormsErrors()); + + const defineStepForm = getFormWithErrorsMock({ queryBar: { errors: [] } }); + const aboutStepForm = getFormWithErrorsMock({ name: { errors: [] } }); + const scheduleStepForm = getFormWithErrorsMock({ + interval: { errors: [] }, + }); + const actionsStepForm = getFormWithErrorsMock({ actions: { errors: [] } }); + + const { getRuleFormsErrors } = result.current; + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ + defineStepForm, + aboutStepForm, + scheduleStepForm, + actionsStepForm, + }); + + expect(blockingErrors).toEqual([]); + expect(nonBlockingErrors).toEqual([]); + }); + + it('should not return all errors', async () => { + const { result } = renderHook(() => useRuleFormsErrors()); + + const esqlValidationError = { + code: ESQL_ERROR_CODES.INVALID_ESQL, + message: 'Missing index [logs*]', + }; + const groupByValidationError = { + message: 'Number of grouping fields must be at most 3', + }; + + const defineStepForm = getFormWithErrorsMock({ + queryBar: { errors: [esqlValidationError] }, + groupByFields: { errors: [groupByValidationError] }, + }); + const aboutStepForm = getFormWithErrorsMock({ + name: { + errors: [ + { + message: 'Required field', + }, + ], + }, + }); + const scheduleStepForm = getFormWithErrorsMock({ + interval: { errors: [] }, + }); + const actionsStepForm = getFormWithErrorsMock({ + actions: { + errors: [ + { + message: 'Missing webhook connector', + }, + ], + }, + }); + + const { getRuleFormsErrors } = result.current; + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ + defineStepForm, + aboutStepForm, + scheduleStepForm, + actionsStepForm, + }); + + expect(blockingErrors).toEqual([ + 'Number of grouping fields must be at most 3', + 'Required field', + 'Missing webhook connector', + ]); + expect(nonBlockingErrors).toEqual(['Query bar: Missing index [logs*]']); + }); + }); +}); 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 690dedafaf852..90b302c3bc904 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import type { DataViewBase } from '@kbn/es-query'; import { isThreatMatchRule } from '../../../../common/detection_engine/utils'; import type { @@ -16,6 +16,7 @@ import type { } from '../../../detections/pages/detection_engine/rules/types'; import { DataSourceType } from '../../../detections/pages/detection_engine/rules/types'; 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'; @@ -26,6 +27,9 @@ import { import { schema as scheduleRuleSchema } from '../components/step_schedule_rule/schema'; import { getSchema as getActionsRuleSchema } from '../../rule_creation/components/step_rule_actions/get_schema'; import { useFetchIndex } from '../../../common/containers/source'; +import { ERROR_CODES as ESQL_ERROR_CODES } from '../../rule_creation/logic/esql_validator'; +import { EQL_ERROR_CODES } from '../../../common/hooks/eql/api'; +import * as i18n from './translations'; export interface UseRuleFormsProps { defineStepDefault: DefineStepRule; @@ -156,3 +160,88 @@ export const useRuleIndexPattern = ({ }, [dataSourceType, isIndexPatternLoading, data, dataViewId, initIndexPattern]); return { indexPattern, isIndexPatternLoading, browserFields }; }; + +export interface UseRuleFormsErrors { + defineStepForm?: FormHook; + aboutStepForm?: FormHook; + scheduleStepForm?: FormHook; + actionsStepForm?: FormHook; +} + +const getFieldErrorMessages = (fieldError: ValidationError) => { + if (fieldError.message.length > 0) { + return [fieldError.message]; + } else if (Array.isArray(fieldError.messages)) { + // EQL validation can return multiple errors and thus we store them in a custom `messages` field on `ValidationError` object. + // Here we double check that `messages` is in fact an array and the content is of type `string`, otherwise we stringify it. + return fieldError.messages.map((message) => + typeof message === 'string' ? message : JSON.stringify(message) + ); + } + return []; +}; + +const NON_BLOCKING_QUERY_BAR_ERROR_CODES = [ + ESQL_ERROR_CODES.INVALID_ESQL, + EQL_ERROR_CODES.FAILED_REQUEST, + EQL_ERROR_CODES.INVALID_EQL, + EQL_ERROR_CODES.MISSING_DATA_SOURCE, +]; + +const isNonBlockingQueryBarErrorCode = (errorCode?: string) => { + return !!NON_BLOCKING_QUERY_BAR_ERROR_CODES.find((code) => code === errorCode); +}; + +const NON_BLOCKING_ERROR_CODES = [...NON_BLOCKING_QUERY_BAR_ERROR_CODES]; + +const isNonBlockingErrorCode = (errorCode?: string) => { + return !!NON_BLOCKING_ERROR_CODES.find((code) => code === errorCode); +}; + +const transformValidationError = ({ + errorCode, + errorMessage, +}: { + errorCode?: string; + errorMessage: string; +}) => { + if (isNonBlockingQueryBarErrorCode(errorCode)) { + return i18n.QUERY_BAR_VALIDATION_ERROR(errorMessage); + } + return errorMessage; +}; + +export const useRuleFormsErrors = () => { + const getRuleFormsErrors = useCallback( + ({ defineStepForm, aboutStepForm, scheduleStepForm, actionsStepForm }: UseRuleFormsErrors) => { + const blockingErrors: string[] = []; + const nonBlockingErrors: string[] = []; + + for (const [_, fieldHook] of Object.entries(defineStepForm?.getFields() ?? {})) { + fieldHook.errors.forEach((fieldError) => { + const messages = getFieldErrorMessages(fieldError); + if (isNonBlockingErrorCode(fieldError.code)) { + nonBlockingErrors.push( + ...messages.map((message) => + transformValidationError({ errorCode: fieldError.code, errorMessage: message }) + ) + ); + } else { + blockingErrors.push(...messages); + } + }); + } + + const blockingForms = [aboutStepForm, scheduleStepForm, actionsStepForm]; + blockingForms.forEach((form) => { + for (const [_, fieldHook] of Object.entries(form?.getFields() ?? {})) { + blockingErrors.push(...fieldHook.errors.map((fieldError) => fieldError.message)); + } + }); + return { blockingErrors, nonBlockingErrors }; + }, + [] + ); + + return { getRuleFormsErrors }; +}; 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 6e93d6927b6be..500fedb4d0005 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 @@ -80,8 +80,9 @@ import { RulePreview } from '../../components/rule_preview'; import { getIsRulePreviewDisabled } from '../../components/rule_preview/helpers'; import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs'; import { NextStep } from '../../components/next_step'; -import { useRuleForms, useRuleIndexPattern } from '../form'; +import { useRuleForms, useRuleFormsErrors, useRuleIndexPattern } from '../form'; import { CustomHeaderPageMemo } from '..'; +import { SaveWithErrorsModal } from '../../components/save_with_errors_confirmation'; const MyEuiPanel = styled(EuiPanel)<{ zindex?: number; @@ -210,6 +211,12 @@ const CreateRulePageComponent: React.FC = () => { const [isQueryBarValid, setIsQueryBarValid] = useState(false); const [isThreatQueryBarValid, setIsThreatQueryBarValid] = useState(false); + const [isSaveWithErrorsModalVisible, setIsSaveWithErrorsModalVisible] = useState(false); + const [enableRuleAfterConfirmation, setEnableRuleAfterConfirmation] = useState(false); + const [nonBlockingRuleErrors, setNonBlockingRuleErrors] = useState([]); + + const { getRuleFormsErrors } = useRuleFormsErrors(); + const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep }); const esqlIndex = useEsqlIndex(defineStepData.queryBar.query.query, ruleType); @@ -328,89 +335,165 @@ const CreateRulePageComponent: React.FC = () => { const validateStep = useCallback( async (step: RuleStep) => { switch (step) { - case RuleStep.defineRule: - return defineStepForm.validate(); - case RuleStep.aboutRule: - return aboutStepForm.validate(); - case RuleStep.scheduleRule: - return scheduleStepForm.validate(); - case RuleStep.ruleActions: - return actionsStepForm.validate(); + case RuleStep.defineRule: { + const valid = await defineStepForm.validate(); + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ defineStepForm }); + return { valid, blockingErrors, nonBlockingErrors }; + } + case RuleStep.aboutRule: { + const valid = await aboutStepForm.validate(); + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ aboutStepForm }); + return { valid, blockingErrors, nonBlockingErrors }; + } + case RuleStep.scheduleRule: { + const valid = await scheduleStepForm.validate(); + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ scheduleStepForm }); + return { valid, blockingErrors, nonBlockingErrors }; + } + case RuleStep.ruleActions: { + const valid = await actionsStepForm.validate(); + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ actionsStepForm }); + return { valid, blockingErrors, nonBlockingErrors }; + } } }, - [aboutStepForm, actionsStepForm, defineStepForm, scheduleStepForm] - ); + [aboutStepForm, actionsStepForm, defineStepForm, getRuleFormsErrors, scheduleStepForm] + ); + + const validateEachStep = useCallback(async () => { + const { + valid: defineStepFormValid, + blockingErrors: defineStepBlockingErrors, + nonBlockingErrors: defineStepNonBlockingErrors, + } = await validateStep(RuleStep.defineRule); + const { + valid: aboutStepFormValid, + blockingErrors: aboutStepBlockingErrors, + nonBlockingErrors: aboutStepNonBlockingErrors, + } = await validateStep(RuleStep.aboutRule); + const { + valid: scheduleStepFormValid, + blockingErrors: scheduleStepBlockingErrors, + nonBlockingErrors: scheduleStepNonBlockingErrors, + } = await validateStep(RuleStep.scheduleRule); + const { + valid: actionsStepFormValid, + blockingErrors: actionsStepBlockingErrors, + nonBlockingErrors: actionsStepNonBlockingErrors, + } = await validateStep(RuleStep.ruleActions); + const valid = + defineStepFormValid && aboutStepFormValid && scheduleStepFormValid && actionsStepFormValid; + + const blockingErrors = [ + ...defineStepBlockingErrors, + ...aboutStepBlockingErrors, + ...scheduleStepBlockingErrors, + ...actionsStepBlockingErrors, + ]; + const nonBlockingErrors = [ + ...defineStepNonBlockingErrors, + ...aboutStepNonBlockingErrors, + ...scheduleStepNonBlockingErrors, + ...actionsStepNonBlockingErrors, + ]; + + return { valid, blockingErrors, nonBlockingErrors }; + }, [validateStep]); const editStep = useCallback( async (step: RuleStep) => { - const valid = await validateStep(activeStep); - - if (valid) { + const { valid, blockingErrors } = await validateStep(activeStep); + if (valid || !blockingErrors.length) { goToStep(step); } }, - [activeStep, validateStep, goToStep] + [validateStep, activeStep, goToStep] ); - const submitRule = useCallback( - async (step: RuleStep, enabled: boolean) => { - const valid = await validateStep(step); - - if (valid) { - const localDefineStepData: DefineStepRule = defineFieldsTransform({ - ...defineStepForm.getFormData(), - eqlOptions: eqlOptionsSelected, - }); - const localAboutStepData = aboutStepForm.getFormData(); - const localScheduleStepData = scheduleStepForm.getFormData(); - const localActionsStepData = actionsStepForm.getFormData(); - const startMlJobsIfNeeded = async () => { - if (!isMlRule(ruleType) || !enabled) { - return; - } - await startMlJobs(localDefineStepData.machineLearningJobId); - }; - const [, createdRule] = await Promise.all([ - startMlJobsIfNeeded(), - createRule( - formatRule( - localDefineStepData, - localAboutStepData, - localScheduleStepData, - { - ...localActionsStepData, - enabled, - }, - triggersActionsUi.actionTypeRegistry - ) - ), - ]); - - addSuccess(i18n.SUCCESSFULLY_CREATED_RULES(createdRule.name)); - - navigateToApp(APP_UI_ID, { - deepLinkId: SecurityPageName.rules, - path: getRuleDetailsUrl(createdRule.id), - }); - } + const createRuleFromFormData = useCallback( + async (enabled: boolean) => { + const localDefineStepData: DefineStepRule = defineFieldsTransform({ + ...defineStepForm.getFormData(), + eqlOptions: eqlOptionsSelected, + }); + const localAboutStepData = aboutStepForm.getFormData(); + const localScheduleStepData = scheduleStepForm.getFormData(); + const localActionsStepData = actionsStepForm.getFormData(); + const startMlJobsIfNeeded = async () => { + if (!isMlRule(ruleType) || !enabled) { + return; + } + await startMlJobs(localDefineStepData.machineLearningJobId); + }; + const [, createdRule] = await Promise.all([ + startMlJobsIfNeeded(), + createRule( + formatRule( + localDefineStepData, + localAboutStepData, + localScheduleStepData, + { + ...localActionsStepData, + enabled, + }, + triggersActionsUi.actionTypeRegistry + ) + ), + ]); + + addSuccess(i18n.SUCCESSFULLY_CREATED_RULES(createdRule.name)); + + navigateToApp(APP_UI_ID, { + deepLinkId: SecurityPageName.rules, + path: getRuleDetailsUrl(createdRule.id), + }); }, [ - validateStep, - defineStepForm, - eqlOptionsSelected, aboutStepForm, - scheduleStepForm, actionsStepForm, - createRule, addSuccess, + createRule, + defineFieldsTransform, + defineStepForm, + eqlOptionsSelected, navigateToApp, ruleType, + scheduleStepForm, startMlJobs, - defineFieldsTransform, triggersActionsUi.actionTypeRegistry, ] ); + const showSaveWithErrorsModal = useCallback(() => setIsSaveWithErrorsModalVisible(true), []); + const closeSaveWithErrorsModal = useCallback(() => setIsSaveWithErrorsModalVisible(false), []); + const onConfirmSaveWithErrors = useCallback(async () => { + closeSaveWithErrorsModal(); + await createRuleFromFormData(enableRuleAfterConfirmation); + }, [closeSaveWithErrorsModal, createRuleFromFormData, enableRuleAfterConfirmation]); + + const submitRule = useCallback( + async (enabled: boolean) => { + const { valid, blockingErrors, nonBlockingErrors } = await validateEachStep(); + if (valid) { + // There are no validation errors, thus proceed to rule creation + await createRuleFromFormData(enabled); + return; + } + + if (blockingErrors.length > 0) { + // There are blocking validation errors, thus do not allow user to create a rule + return; + } + if (nonBlockingErrors.length > 0) { + // There are non-blocking validation errors, thus confirm that user understand that this can cause rule failures + setEnableRuleAfterConfirmation(enabled); + setNonBlockingRuleErrors(nonBlockingErrors); + showSaveWithErrorsModal(); + } + }, + [createRuleFromFormData, showSaveWithErrorsModal, validateEachStep] + ); + const defineRuleButtonType = activeStep === RuleStep.defineRule ? 'active' : defineStepForm.isValid ? 'valid' : 'passive'; const defineRuleButton = useMemo( @@ -418,14 +501,11 @@ const CreateRulePageComponent: React.FC = () => { [defineRuleButtonType] ); const defineRuleNextStep = useCallback(async () => { - const valid = await defineStepForm.validate(); - if (valid) { - const nextStep = getNextStep(RuleStep.defineRule); - if (nextStep) { - goToStep(nextStep); - } + const nextStep = getNextStep(RuleStep.defineRule); + if (nextStep) { + await editStep(nextStep); } - }, [defineStepForm, goToStep]); + }, [editStep]); const aboutRuleButtonType = activeStep === RuleStep.aboutRule ? 'active' : aboutStepForm.isValid ? 'valid' : 'passive'; @@ -434,14 +514,11 @@ const CreateRulePageComponent: React.FC = () => { [aboutRuleButtonType] ); const aboutRuleNextStep = useCallback(async () => { - const valid = await aboutStepForm.validate(); - if (valid) { - const nextStep = getNextStep(RuleStep.aboutRule); - if (nextStep) { - goToStep(nextStep); - } + const nextStep = getNextStep(RuleStep.aboutRule); + if (nextStep) { + await editStep(nextStep); } - }, [aboutStepForm, goToStep]); + }, [editStep]); const scheduleRuleButtonType = activeStep === RuleStep.scheduleRule @@ -454,14 +531,11 @@ const CreateRulePageComponent: React.FC = () => { [scheduleRuleButtonType] ); const scheduleRuleNextStep = useCallback(async () => { - const valid = await scheduleStepForm.validate(); - if (valid) { - const nextStep = getNextStep(RuleStep.scheduleRule); - if (nextStep) { - goToStep(nextStep); - } + const nextStep = getNextStep(RuleStep.scheduleRule); + if (nextStep) { + await editStep(nextStep); } - }, [scheduleStepForm, goToStep]); + }, [editStep]); const actionsRuleButtonType = activeStep === RuleStep.ruleActions ? 'active' : actionsStepForm.isValid ? 'valid' : 'passive'; @@ -470,10 +544,10 @@ const CreateRulePageComponent: React.FC = () => { [actionsRuleButtonType] ); const submitRuleDisabled = useCallback(() => { - submitRule(RuleStep.ruleActions, false); + submitRule(false); }, [submitRule]); const submitRuleEnabled = useCallback(() => { - submitRule(RuleStep.ruleActions, true); + submitRule(true); }, [submitRule]); const memoDefineStepReadOnly = useMemo( @@ -559,7 +633,11 @@ const CreateRulePageComponent: React.FC = () => { ); const memoDefineStepExtraAction = useMemo( () => - defineStepForm.isValid && ( + // During rule creation we would like to hide the edit button if user did not reach current step yet, + // thus we do `defineStepForm.isValid !== undefined` check which that the form validation has not been checked yet. + // Otherwise, we would like to show step edit button if user is currently at another step. + defineStepForm.isValid !== undefined && + activeStep !== RuleStep.defineRule && ( { {i18n.EDIT_RULE} ), - [defineStepForm.isValid, editStep] + [activeStep, defineStepForm.isValid, editStep] ); const memoAboutStepReadOnly = useMemo( @@ -629,7 +707,11 @@ const CreateRulePageComponent: React.FC = () => { ); const memoAboutStepExtraAction = useMemo( () => - aboutStepForm.isValid && ( + // During rule creation we would like to hide the edit button if user did not reach current step yet, + // thus we do `defineStepForm.isValid !== undefined` check which that the form validation has not been checked yet. + // Otherwise, we would like to show step edit button if user is currently at another step. + aboutStepForm.isValid !== undefined && + activeStep !== RuleStep.aboutRule && ( { {i18n.EDIT_RULE} ), - [aboutStepForm.isValid, editStep] + [aboutStepForm.isValid, activeStep, editStep] ); const memoStepScheduleRule = useMemo( @@ -682,12 +764,16 @@ const CreateRulePageComponent: React.FC = () => { ); const memoScheduleStepExtraAction = useMemo( () => - scheduleStepForm.isValid && ( + // During rule creation we would like to hide the edit button if user did not reach current step yet, + // thus we do `defineStepForm.isValid !== undefined` check which that the form validation has not been checked yet. + // Otherwise, we would like to show step edit button if user is currently at another step. + scheduleStepForm.isValid !== undefined && + activeStep !== RuleStep.scheduleRule && ( editStep(RuleStep.scheduleRule)}> {i18n.EDIT_RULE} ), - [editStep, scheduleStepForm.isValid] + [activeStep, editStep, scheduleStepForm.isValid] ); const memoStepRuleActions = useMemo( @@ -762,12 +848,16 @@ const CreateRulePageComponent: React.FC = () => { ); const memoActionsStepExtraAction = useMemo( () => - actionsStepForm.isValid && ( + // During rule creation we would like to hide the edit button if user did not reach current step yet, + // thus we do `defineStepForm.isValid !== undefined` check which that the form validation has not been checked yet. + // Otherwise, we would like to show step edit button if user is currently at another step. + actionsStepForm.isValid !== undefined && + activeStep !== RuleStep.ruleActions && ( editStep(RuleStep.ruleActions)}> {i18n.EDIT_RULE} ), - [actionsStepForm.isValid, editStep] + [actionsStepForm.isValid, activeStep, editStep] ); const onToggleCollapsedMemo = useCallback( @@ -798,6 +888,13 @@ const CreateRulePageComponent: React.FC = () => { return ( <> + {isSaveWithErrorsModalVisible && ( + + )} {(EuiResizablePanel, EuiResizableButton, { togglePanel }) => { 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 deb58fbed49a3..1ecbde5b00d7b 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 @@ -67,9 +67,10 @@ import { import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions'; import { useGetSavedQuery } from '../../../../detections/pages/detection_engine/rules/use_get_saved_query'; -import { useRuleForms, useRuleIndexPattern } from '../form'; +import { useRuleForms, useRuleFormsErrors, useRuleIndexPattern } from '../form'; import { useEsqlIndex, useEsqlQueryForAboutStep } from '../../hooks'; import { CustomHeaderPageMemo } from '..'; +import { SaveWithErrorsModal } from '../../components/save_with_errors_confirmation'; const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { const [, dispatchToaster] = useStateToaster(); @@ -99,6 +100,9 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { const [isQueryBarValid, setIsQueryBarValid] = useState(false); const [isThreatQueryBarValid, setIsThreatQueryBarValid] = useState(false); + const [isSaveWithErrorsModalVisible, setIsSaveWithErrorsModalVisible] = useState(false); + const [nonBlockingRuleErrors, setNonBlockingRuleErrors] = useState([]); + useEffect(() => { const fetchDataViews = async () => { const dataViewsRefs = await dataServices.dataViews.getIdsWithTitle(); @@ -149,6 +153,8 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { actionsStepDefault: ruleActionsData, }); + const { getRuleFormsErrors } = useRuleFormsErrors(); + const esqlQueryForAboutStep = useEsqlQueryForAboutStep({ defineStepData, activeStep }); const esqlIndex = useEsqlIndex(defineStepData.queryBar.query.query, defineStepData.ruleType); @@ -386,7 +392,50 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { const { startTransaction } = useStartTransaction(); + const saveChanges = useCallback(async () => { + startTransaction({ name: SINGLE_RULE_ACTIONS.SAVE }); + await updateRule({ + ...formatRule( + defineStepData, + aboutStepData, + scheduleStepData, + actionsStepData, + triggersActionsUi.actionTypeRegistry, + rule?.exceptions_list + ), + ...(ruleId ? { id: ruleId } : {}), + }); + + displaySuccessToast(i18n.SUCCESSFULLY_SAVED_RULE(rule?.name ?? ''), dispatchToaster); + navigateToApp(APP_UI_ID, { + deepLinkId: SecurityPageName.rules, + path: getRuleDetailsUrl(ruleId ?? ''), + }); + }, [ + aboutStepData, + actionsStepData, + defineStepData, + dispatchToaster, + navigateToApp, + rule?.exceptions_list, + rule?.name, + ruleId, + scheduleStepData, + startTransaction, + triggersActionsUi.actionTypeRegistry, + updateRule, + ]); + + const showSaveWithErrorsModal = useCallback(() => setIsSaveWithErrorsModalVisible(true), []); + const closeSaveWithErrorsModal = useCallback(() => setIsSaveWithErrorsModalVisible(false), []); + const onConfirmSaveWithErrors = useCallback(async () => { + closeSaveWithErrorsModal(); + await saveChanges(); + }, [closeSaveWithErrorsModal, saveChanges]); + const onSubmit = useCallback(async () => { + setNonBlockingRuleErrors([]); + const defineStepFormValid = await defineStepForm.validate(); const aboutStepFormValid = await aboutStepForm.validate(); const scheduleStepFormValid = await scheduleStepForm.validate(); @@ -398,41 +447,31 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { scheduleStepFormValid && actionsStepFormValid ) { - startTransaction({ name: SINGLE_RULE_ACTIONS.SAVE }); - await updateRule({ - ...formatRule( - defineStepData, - aboutStepData, - scheduleStepData, - actionsStepData, - triggersActionsUi.actionTypeRegistry, - rule?.exceptions_list - ), - ...(ruleId ? { id: ruleId } : {}), - }); + await saveChanges(); + return; + } - displaySuccessToast(i18n.SUCCESSFULLY_SAVED_RULE(rule?.name ?? ''), dispatchToaster); - navigateToApp(APP_UI_ID, { - deepLinkId: SecurityPageName.rules, - path: getRuleDetailsUrl(ruleId ?? ''), - }); + const { blockingErrors, nonBlockingErrors } = getRuleFormsErrors({ + defineStepForm, + aboutStepForm, + scheduleStepForm, + actionsStepForm, + }); + if (blockingErrors.length > 0) { + return; + } + if (nonBlockingErrors.length > 0) { + setNonBlockingRuleErrors(nonBlockingErrors); + showSaveWithErrorsModal(); } }, [ defineStepForm, aboutStepForm, scheduleStepForm, actionsStepForm, - startTransaction, - updateRule, - defineStepData, - aboutStepData, - scheduleStepData, - actionsStepData, - rule, - ruleId, - dispatchToaster, - navigateToApp, - triggersActionsUi.actionTypeRegistry, + getRuleFormsErrors, + saveChanges, + showSaveWithErrorsModal, ]); const onTabClick = useCallback(async (tab: EuiTabbedContentTab) => { @@ -488,6 +527,13 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { return ( <> + {isSaveWithErrorsModalVisible && ( + + )} {(EuiResizablePanel, EuiResizableButton, { togglePanel }) => { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/translations.ts index e602b8be712c2..77ea9438f66dc 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/translations.ts @@ -13,3 +13,9 @@ export const RULE_PREVIEW_TITLE = i18n.translate( defaultMessage: 'Rule preview', } ); + +export const QUERY_BAR_VALIDATION_ERROR = (validationError: string) => + i18n.translate('xpack.securitySolution.detectionEngine.createRule.validationError', { + values: { validationError }, + defaultMessage: 'Query bar: {validationError}', + }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts index 0c4885ad35352..438743dd01a70 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts @@ -8,7 +8,6 @@ import { ruleFields } from '../../../../data/detection_engine'; import { ABOUT_CONTINUE_BTN, - ABOUT_EDIT_BUTTON, CUSTOM_QUERY_INPUT, DEFINE_CONTINUE_BUTTON, DEFINE_EDIT_BUTTON, @@ -100,7 +99,6 @@ describe('Common rule creation flows', { tags: ['@ess', '@serverless'] }, () => cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click(); // expect about step to populate - cy.get(ABOUT_EDIT_BUTTON).click(); cy.get(RULE_NAME_INPUT).invoke('val').should('eql', ruleFields.ruleName); cy.get(ABOUT_CONTINUE_BTN).should('exist').click(); cy.get(SCHEDULE_CONTINUE_BUTTON).click(); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/eql_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/eql_rule.cy.ts index 2b0b9f3fdab61..a14218fcdda56 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/eql_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/eql_rule.cy.ts @@ -46,10 +46,13 @@ import { getDetails } from '../../../../tasks/rule_details'; import { expectNumberOfRules, goToRuleDetailsOf } from '../../../../tasks/alerts_detection_rules'; import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; import { + continueFromDefineStep, createAndEnableRule, + createRuleWithNonBlockingErrors, fillAboutRuleAndContinue, fillDefineEqlRuleAndContinue, fillScheduleRuleAndContinue, + getDefineContinueButton, getIndexPatternClearButton, getRuleIndexInput, selectEqlRuleType, @@ -225,6 +228,8 @@ describe('EQL rules', { tags: ['@ess', '@serverless'] }, () => { }); describe('EQL query validation', () => { + const rule = getEqlRule(); + it('validates missing data source', () => { login(); visit(CREATE_RULE_URL); @@ -236,14 +241,20 @@ describe('EQL rules', { tags: ['@ess', '@serverless'] }, () => { cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('be.visible'); cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).type('any where true'); + const expectedValidationError = `index_not_found_exception\n\tCaused by:\n\t\tverification_exception: Found 1 problem\nline -1:-1: Unknown index [*,-*]\n\tRoot causes:\n\t\tverification_exception: Found 1 problem\nline -1:-1: Unknown index [*,-*]`; cy.get(EQL_QUERY_VALIDATION_ERROR).should('be.visible'); cy.get(EQL_QUERY_VALIDATION_ERROR).should('have.text', '1'); cy.get(EQL_QUERY_VALIDATION_ERROR).click(); cy.get(EQL_QUERY_VALIDATION_ERROR_CONTENT).should('be.visible'); cy.get(EQL_QUERY_VALIDATION_ERROR_CONTENT).should( 'have.text', - `EQL Validation Errorsindex_not_found_exception\n\tCaused by:\n\t\tverification_exception: Found 1 problem\nline -1:-1: Unknown index [*,-*]\n\tRoot causes:\n\t\tverification_exception: Found 1 problem\nline -1:-1: Unknown index [*,-*]` + `EQL Validation Errors${expectedValidationError}` ); + continueFromDefineStep(); + + fillAboutRuleAndContinue(rule); + fillScheduleRuleAndContinue(rule); + createRuleWithNonBlockingErrors(); }); it('validates missing data fields', () => { @@ -263,6 +274,11 @@ describe('EQL rules', { tags: ['@ess', '@serverless'] }, () => { 'have.text', 'EQL Validation ErrorsFound 1 problem\nline 1:11: Unknown column [field1]' ); + continueFromDefineStep(); + + fillAboutRuleAndContinue(rule); + fillScheduleRuleAndContinue(rule); + createRuleWithNonBlockingErrors(); }); it('validates syntax errors', () => { @@ -282,6 +298,8 @@ describe('EQL rules', { tags: ['@ess', '@serverless'] }, () => { 'have.text', `EQL Validation Errorsline 1:6: extraneous input 'any' expecting 'where'` ); + continueFromDefineStep(); + getDefineContinueButton().should('exist'); }); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts index 348133b1d2802..3727c2ccbd501 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/esql_rule.cy.ts @@ -49,6 +49,7 @@ import { fillAboutRuleMinimumAndContinue, skipScheduleRuleAction, interceptEsqlQueryFieldsRequest, + createRuleWithNonBlockingErrors, } from '../../../../tasks/create_new_rule'; import { login } from '../../../../tasks/login'; import { visit } from '../../../../tasks/navigation'; @@ -191,6 +192,21 @@ describe( `Error validating ES|QL: "SyntaxError: extraneous input 'test' expecting "` ); }); + + it('shows confirmation modal about existing non-blocking validation errors', function () { + const nonExistingDataSourceQuery = 'from fake* metadata _id, _version, _index | limit 5'; + selectEsqlRuleType(); + fillEsqlQueryBar(nonExistingDataSourceQuery); + getDefineContinueButton().click(); + + fillRuleName(); + fillDescription(); + getAboutContinueButton().click(); + + fillScheduleRuleAndContinue(rule); + + createRuleWithNonBlockingErrors(); + }); }); describe('ES|QL investigation fields', () => { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/eql_query_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/eql_query_rule.cy.ts new file mode 100644 index 0000000000000..994dbb2eb8ce8 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/eql_query_rule.cy.ts @@ -0,0 +1,47 @@ +/* + * 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 { getEqlRule } from '../../../../objects/rule'; + +import { createRule } from '../../../../tasks/api_calls/rules'; +import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; +import { + saveEditedRuleWithNonBlockingErrors, + visitEditRulePage, +} from '../../../../tasks/edit_rule'; +import { login } from '../../../../tasks/login'; + +describe('EQL query rules', { tags: ['@ess', '@serverless'] }, () => { + context('Editing rule with non-blocking query validation errors', () => { + beforeEach(() => { + login(); + deleteAlertsAndRules(); + }); + + it('should allow user to save a rule and show confirmation modal when data source does not exist', () => { + const rule = { + ...getEqlRule(), + index: ['fake*'], + }; + createRule(rule).then((createdRule) => { + visitEditRulePage(createdRule.body.id); + saveEditedRuleWithNonBlockingErrors(); + }); + }); + + it('should allow user to save a rule and show confirmation modal when data field does not exist', () => { + const rule = { + ...getEqlRule(), + query: 'any where hello.world', + }; + createRule(rule).then((createdRule) => { + visitEditRulePage(createdRule.body.id); + saveEditedRuleWithNonBlockingErrors(); + }); + }); + }); +}); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts index 43655b1358b29..a180e1a0b50c2 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/esql_rule.cy.ts @@ -43,7 +43,11 @@ import { login } from '../../../../tasks/login'; import { editFirstRule } from '../../../../tasks/alerts_detection_rules'; -import { saveEditedRule } from '../../../../tasks/edit_rule'; +import { + saveEditedRule, + saveEditedRuleWithNonBlockingErrors, + visitEditRulePage, +} from '../../../../tasks/edit_rule'; import { visit } from '../../../../tasks/navigation'; const rule = getEsqlRule(); @@ -192,5 +196,29 @@ describe( }); }); }); + + describe('Editing rule with non-blocking query validation errors', () => { + it('should allow user to save a rule and show confirmation modal when data source does not exist', () => { + const esqlRule = { + ...rule, + query: 'from fake-* metadata _id, _version, _index | keep agent.*,_id | eval test_id=_id', + }; + createRule(esqlRule).then((createdRule) => { + visitEditRulePage(createdRule.body.id); + saveEditedRuleWithNonBlockingErrors(); + }); + }); + + it('should allow user to save a rule and show confirmation modal when data field does not exist', () => { + const esqlRule = { + ...rule, + query: 'from auditbeat-* metadata _id, _version, _index | keep hello.world', + }; + createRule(esqlRule).then((createdRule) => { + visitEditRulePage(createdRule.body.id); + saveEditedRuleWithNonBlockingErrors(); + }); + }); + }); } ); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts b/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts index 4e6df1ed3a750..72d1104985d77 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts @@ -56,6 +56,10 @@ export const CREATE_AND_ENABLE_BTN = '[data-test-subj="create-enable"]'; export const CREATE_WITHOUT_ENABLING_BTN = '[data-test-subj="create-enabled-false"]'; +export const SAVE_WITH_ERRORS_MODAL = '[data-test-subj="save-with-errors-confirmation-modal"]'; + +export const SAVE_WITH_ERRORS_MODAL_CONFIRM_BTN = '[data-test-subj="confirmModalConfirmButton"]'; + export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; export const CUSTOM_QUERY_BAR = '[data-test-subj="detectionEngineStepDefineRuleQueryBar"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts index 4251ef8ee0ec8..045c40da4b2cb 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts @@ -128,6 +128,8 @@ import { MAX_SIGNALS_INPUT, SETUP_GUIDE_TEXTAREA, RELATED_INTEGRATION_COMBO_BOX_INPUT, + SAVE_WITH_ERRORS_MODAL, + SAVE_WITH_ERRORS_MODAL_CONFIRM_BTN, } from '../screens/create_new_rule'; import { INDEX_SELECTOR, @@ -162,6 +164,14 @@ export const createRuleWithoutEnabling = () => { cy.get(CREATE_WITHOUT_ENABLING_BTN).should('not.exist'); }; +export const createRuleWithNonBlockingErrors = () => { + cy.get(CREATE_AND_ENABLE_BTN).click(); + cy.get(SAVE_WITH_ERRORS_MODAL).should('exist'); + cy.get(SAVE_WITH_ERRORS_MODAL_CONFIRM_BTN).first().click(); + cy.get(SAVE_WITH_ERRORS_MODAL).should('not.exist'); + cy.get(CREATE_AND_ENABLE_BTN).should('not.exist'); +}; + export const fillAboutRule = (rule: RuleCreateProps) => { cy.get(RULE_NAME_INPUT).clear({ force: true }); cy.get(RULE_NAME_INPUT).type(rule.name, { force: true }); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/edit_rule.ts b/x-pack/test/security_solution_cypress/cypress/tasks/edit_rule.ts index 14c9ef05aa878..4ea919231e08f 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/edit_rule.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/edit_rule.ts @@ -5,6 +5,10 @@ * 2.0. */ +import { + SAVE_WITH_ERRORS_MODAL, + SAVE_WITH_ERRORS_MODAL_CONFIRM_BTN, +} from '../screens/create_new_rule'; import { BACK_TO_RULE_DETAILS, EDIT_SUBMIT_BUTTON } from '../screens/edit_rule'; import { editRuleUrl } from '../urls/edit_rule'; import { visit } from './navigation'; @@ -18,6 +22,14 @@ export const saveEditedRule = () => { cy.get(EDIT_SUBMIT_BUTTON).should('not.exist'); }; +export const saveEditedRuleWithNonBlockingErrors = () => { + cy.get(EDIT_SUBMIT_BUTTON).click(); + cy.get(SAVE_WITH_ERRORS_MODAL).should('exist'); + cy.get(SAVE_WITH_ERRORS_MODAL_CONFIRM_BTN).first().click(); + cy.get(SAVE_WITH_ERRORS_MODAL).should('not.exist'); + cy.get(EDIT_SUBMIT_BUTTON).should('not.exist'); +}; + export const goBackToRuleDetails = () => { cy.get(BACK_TO_RULE_DETAILS).should('exist').click(); cy.get(BACK_TO_RULE_DETAILS).should('not.exist');