diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts.cy.ts index ad939fbad5d44..38751115823f8 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts.cy.ts @@ -5,6 +5,12 @@ * 2.0. */ +import { + RESPONSE_ACTIONS_ITEM_0, + RESPONSE_ACTIONS_ITEM_1, + RESPONSE_ACTIONS_ITEM_2, + OSQUERY_RESPONSE_ACTION_ADD_BUTTON, +} from '../../tasks/response_actions'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; import { login } from '../../tasks/login'; import { @@ -12,6 +18,7 @@ import { findFormFieldByRowsLabelAndType, inputQuery, submitQuery, + typeInECSFieldInput, } from '../../tasks/live_query'; import { preparePack } from '../../tasks/packs'; import { closeModalIfVisible } from '../../tasks/integrations'; @@ -60,26 +67,96 @@ describe('Alert Event Details', () => { cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'true'); }); - it('enables to add detection action with osquery', () => { + it('adds response actations with osquery with proper validation and form values', () => { cy.visit('/app/security/rules'); cy.contains(RULE_NAME).click(); cy.contains('Edit rule settings').click(); cy.getBySel('edit-rule-actions-tab').wait(500).click(); - cy.contains('Perform no actions').get('select').select('On each rule execution'); cy.contains('Response actions are run on each rule execution'); - cy.getBySel('.osquery-ResponseActionTypeSelectOption').click(); - cy.get(LIVE_QUERY_EDITOR); + cy.getBySel(OSQUERY_RESPONSE_ACTION_ADD_BUTTON).click(); + cy.getBySel(RESPONSE_ACTIONS_ITEM_0).within(() => { + cy.get(LIVE_QUERY_EDITOR); + }); cy.contains('Save changes').click(); - cy.contains('Query is a required field'); - inputQuery('select * from uptime'); - cy.wait(1000); // wait for the validation to trigger - cypress is way faster than users ;) + cy.getBySel(RESPONSE_ACTIONS_ITEM_0).within(() => { + cy.contains('Query is a required field'); + inputQuery('select * from uptime1'); + }); + + cy.getBySel(OSQUERY_RESPONSE_ACTION_ADD_BUTTON).click(); + + cy.getBySel(RESPONSE_ACTIONS_ITEM_1).within(() => { + cy.contains('Run a set of queries in a pack').click(); + }); + cy.contains('Save changes').click(); + cy.getBySel('response-actions-error') + .within(() => { + cy.contains(' Pack is a required field'); + }) + .should('exist'); + cy.contains('Pack is a required field'); + cy.getBySel(RESPONSE_ACTIONS_ITEM_1).within(() => { + cy.getBySel('comboBoxInput').type('testpack{downArrow}{enter}'); + }); + + cy.getBySel(OSQUERY_RESPONSE_ACTION_ADD_BUTTON).click(); + + cy.getBySel(RESPONSE_ACTIONS_ITEM_2).within(() => { + cy.get(LIVE_QUERY_EDITOR); + cy.contains('Query is a required field'); + inputQuery('select * from uptime'); + cy.contains('Advanced').click(); + typeInECSFieldInput('message{downArrow}{enter}'); + cy.getBySel('osqueryColumnValueSelect').type('days{downArrow}{enter}'); + cy.wait(1000); // wait for the validation to trigger - cypress is way faster than users ;) + }); // getSavedQueriesDropdown().type(`users{downArrow}{enter}`); cy.contains('Save changes').click(); cy.contains(`${RULE_NAME} was saved`).should('exist'); + cy.getBySel('toastCloseButton').click(); cy.contains('Edit rule settings').click(); cy.getBySel('edit-rule-actions-tab').wait(500).click(); cy.contains('select * from uptime'); + cy.getBySel(RESPONSE_ACTIONS_ITEM_0).within(() => { + cy.contains('select * from uptime1'); + }); + cy.getBySel(RESPONSE_ACTIONS_ITEM_2).within(() => { + cy.contains('select * from uptime'); + cy.contains('Log message optimized for viewing in a log viewer'); + cy.contains('Days of uptime'); + }); + cy.getBySel(RESPONSE_ACTIONS_ITEM_1).within(() => { + cy.contains('testpack'); + cy.getBySel('comboBoxInput').type('{backspace}{enter}'); + }); + cy.getBySel(RESPONSE_ACTIONS_ITEM_0).within(() => { + cy.contains('select * from uptime1'); + cy.getBySel('remove-response-action').click(); + }); + cy.getBySel(RESPONSE_ACTIONS_ITEM_0).within(() => { + cy.contains('Search for a pack to run'); + cy.contains('Pack is a required field'); + cy.getBySel('comboBoxInput').type('testpack{downArrow}{enter}'); + }); + cy.getBySel(RESPONSE_ACTIONS_ITEM_1).within(() => { + cy.contains('select * from uptime'); + cy.contains('Log message optimized for viewing in a log viewer'); + cy.contains('Days of uptime'); + }); + cy.contains('Save changes').click(); + cy.contains(`${RULE_NAME} was saved`).should('exist'); + cy.getBySel('toastCloseButton').click(); + cy.contains('Edit rule settings').click(); + cy.getBySel('edit-rule-actions-tab').wait(500).click(); + cy.getBySel(RESPONSE_ACTIONS_ITEM_0).within(() => { + cy.contains('testpack'); + }); + cy.getBySel(RESPONSE_ACTIONS_ITEM_1).within(() => { + cy.contains('select * from uptime'); + cy.contains('Log message optimized for viewing in a log viewer'); + cy.contains('Days of uptime'); + }); }); it('should be able to run live query and add to timeline (-depending on the previous test)', () => { diff --git a/x-pack/plugins/osquery/cypress/tasks/response_actions.ts b/x-pack/plugins/osquery/cypress/tasks/response_actions.ts new file mode 100644 index 0000000000000..2c9d726648340 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/tasks/response_actions.ts @@ -0,0 +1,12 @@ +/* + * 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 const RESPONSE_ACTIONS_ITEM_0 = 'response-actions-list-item-0'; +export const RESPONSE_ACTIONS_ITEM_1 = 'response-actions-list-item-1'; +export const RESPONSE_ACTIONS_ITEM_2 = 'response-actions-list-item-2'; + +export const OSQUERY_RESPONSE_ACTION_ADD_BUTTON = 'osquery-response-action-type-selection-option'; diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index 775ede6671e3c..2449195300910 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -34,6 +34,7 @@ export interface LiveQueryFormFields { savedQueryId?: string | null; ecs_mapping: ECSMapping; packId: string[]; + queryType: 'query' | 'pack'; } interface DefaultLiveQueryFormFields { @@ -95,7 +96,6 @@ const LiveQueryFormComponent: React.FC = ({ ); const [showSavedQueryFlyout, setShowSavedQueryFlyout] = useState(false); - const [queryType, setQueryType] = useState('query'); const [isLive, setIsLive] = useState(false); const queryState = getFieldState('query'); @@ -103,6 +103,7 @@ const LiveQueryFormComponent: React.FC = ({ const handleShowSaveQueryFlyout = useCallback(() => setShowSavedQueryFlyout(true), []); const handleCloseSaveQueryFlyout = useCallback(() => setShowSavedQueryFlyout(false), []); + const { queryType } = watchedValues; const { data, isLoading, @@ -241,7 +242,7 @@ const LiveQueryFormComponent: React.FC = ({ } if (defaultValue?.packId && canRunPacks) { - setQueryType('pack'); + setValue('queryType', 'pack'); if (!isPackDataFetched) return; const selectedPackOption = find(packsData?.data, ['id', defaultValue.packId]); @@ -261,11 +262,11 @@ const LiveQueryFormComponent: React.FC = ({ } if (canRunSingleQuery) { - return setQueryType('query'); + return setValue('queryType', 'query'); } if (canRunPacks) { - return setQueryType('pack'); + return setValue('queryType', 'pack'); } } }, [canRunPacks, canRunSingleQuery, defaultValue, isPackDataFetched, packsData?.data, setValue]); @@ -293,12 +294,7 @@ const LiveQueryFormComponent: React.FC = ({ {queryField && ( - + )} {!hideAgentsField && ( diff --git a/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx b/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx index 2f87362dd602e..05f9e947fe841 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx @@ -8,7 +8,7 @@ import { isEmpty } from 'lodash'; import type { EuiAccordionProps } from '@elastic/eui'; import { EuiCodeBlock, EuiFormRow, EuiAccordion, EuiSpacer } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { useController, useFormContext } from 'react-hook-form'; import { i18n } from '@kbn/i18n'; @@ -30,8 +30,8 @@ const StyledEuiCodeBlock = styled(EuiCodeBlock)` `; export interface LiveQueryQueryFieldProps { - disabled?: boolean; handleSubmitForm?: () => void; + disabled?: boolean; } const LiveQueryQueryFieldComponent: React.FC = ({ @@ -42,7 +42,7 @@ const LiveQueryQueryFieldComponent: React.FC = ({ const [advancedContentState, setAdvancedContentState] = useState('closed'); const permissions = useKibana().services.application.capabilities.osquery; - const queryType = watch('queryType', 'query'); + const [ecsMapping, queryType] = watch(['ecs_mapping', 'queryType']); const { field: { onChange, value }, @@ -60,6 +60,12 @@ const LiveQueryQueryFieldComponent: React.FC = ({ defaultValue: '', }); + useEffect(() => { + if (!isEmpty(ecsMapping) && advancedContentState === 'closed') { + setAdvancedContentState('open'); + } + }, [advancedContentState, ecsMapping]); + const handleSavedQueryChange: SavedQueriesDropdownProps['onChange'] = useCallback( (savedQuery) => { if (savedQuery) { diff --git a/x-pack/plugins/osquery/public/live_queries/form/query_pack_selectable.tsx b/x-pack/plugins/osquery/public/live_queries/form/query_pack_selectable.tsx index cc32a8c15cd43..97618369dfd81 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/query_pack_selectable.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/query_pack_selectable.tsx @@ -9,6 +9,7 @@ import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; +import { useController } from 'react-hook-form'; const StyledEuiCard = styled(EuiCard)` padding: 16px 92px 16px 16px !important; @@ -49,28 +50,29 @@ const StyledEuiCard = styled(EuiCard)` `; interface QueryPackSelectableProps { - queryType: string; - setQueryType: (type: string) => void; canRunSingleQuery: boolean; canRunPacks: boolean; - resetFormFields?: () => void; } export const QueryPackSelectable = ({ - queryType, - setQueryType, canRunSingleQuery, canRunPacks, - resetFormFields, }: QueryPackSelectableProps) => { + const { + field: { value: queryType, onChange: setQueryType }, + } = useController({ + name: 'queryType', + defaultValue: 'query', + rules: { + deps: ['packId', 'query'], + }, + }); + const handleChange = useCallback( (type) => { setQueryType(type); - if (resetFormFields) { - resetFormFields(); - } }, - [resetFormFields, setQueryType] + [setQueryType] ); const queryCardSelectable = useMemo( () => ({ diff --git a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx index e659d544cf5de..fb25e145176b5 100644 --- a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx @@ -18,7 +18,6 @@ import { reduce, trim, get, - reject, } from 'lodash'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { EuiComboBoxProps, EuiComboBoxOptionOption } from '@elastic/eui'; @@ -40,7 +39,7 @@ import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; -import type { InternalFieldErrors, UseFieldArrayRemove, UseFormReturn } from 'react-hook-form'; +import type { FieldErrors, UseFieldArrayRemove, UseFormReturn } from 'react-hook-form'; import { useForm, useController, useFieldArray, useFormContext } from 'react-hook-form'; import type { ECSMapping } from '@kbn/osquery-io-ts-types'; @@ -594,6 +593,7 @@ const OsqueryColumnFieldComponent: React.FC = ({ idAria={idAria} helpText={selectedOptions[0]?.value?.description} {...euiFieldProps} + data-test-subj="osqueryColumnValueSelect" options={(resultTypeField.value === 'field' && euiFieldProps.options) || EMPTY_ARRAY} /> @@ -731,12 +731,14 @@ interface OsqueryColumn { export const ECSMappingEditorField = React.memo(({ euiFieldProps }: ECSMappingEditorFieldProps) => { const { + setError, + clearErrors, watch: watchRoot, register: registerRoot, setValue: setValueRoot, - formState: { errors: errorsRoot }, } = useFormContext<{ query: string; ecs_mapping: ECSMapping }>(); + const latestErrors = useRef | undefined>(undefined); const [query, ecsMapping] = watchRoot(['query', 'ecs_mapping']); const { control, trigger, watch, formState, resetField, getFieldState } = useForm<{ ecsMappingArray: ECSMappingArray; @@ -759,14 +761,20 @@ export const ECSMappingEditorField = React.memo(({ euiFieldProps }: ECSMappingEd const [osquerySchemaOptions, setOsquerySchemaOptions] = useState([]); useEffect(() => { - registerRoot('ecs_mapping', { - validate: () => { - const nonEmptyErrors = reject(ecsMappingArrayState.error, isEmpty) as InternalFieldErrors[]; + registerRoot('ecs_mapping'); + }, [registerRoot]); - return !nonEmptyErrors.length; - }, - }); - }, [ecsMappingArrayState.error, errorsRoot, registerRoot]); + useEffect(() => { + if (!deepEqual(latestErrors.current, formState.errors.ecsMappingArray)) { + // @ts-expect-error update types + latestErrors.current = formState.errors.ecsMappingArray; + if (formState.errors.ecsMappingArray?.length && formState.errors.ecsMappingArray[0]?.key) { + setError('ecs_mapping', formState.errors.ecsMappingArray[0].key); + } else { + clearErrors('ecs_mapping'); + } + } + }, [formState, setError, clearErrors]); useEffect(() => { const subscription = watchRoot((data, payload) => { diff --git a/x-pack/plugins/osquery/public/shared_components/lazy_osquery_action_params_form.tsx b/x-pack/plugins/osquery/public/shared_components/lazy_osquery_action_params_form.tsx index 00e1901e025a9..64f09909357a9 100644 --- a/x-pack/plugins/osquery/public/shared_components/lazy_osquery_action_params_form.tsx +++ b/x-pack/plugins/osquery/public/shared_components/lazy_osquery_action_params_form.tsx @@ -6,41 +6,26 @@ */ import React, { lazy, Suspense } from 'react'; -import type { ArrayItem } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '../query_client'; +import type { OsqueryResponseActionsParamsFormProps } from './osquery_response_action_type'; -interface LazyOsqueryActionParamsFormProps { - item: ArrayItem; - formRef: React.RefObject; -} -interface ResponseActionValidatorRef { - validation: { - [key: string]: () => Promise; - }; -} - -const GhostFormField = () => <>; +const OsqueryResponseActionParamsForm = lazy(() => import('./osquery_response_action_type')); export const getLazyOsqueryResponseActionTypeForm = // eslint-disable-next-line react/display-name - () => (props: LazyOsqueryActionParamsFormProps) => { - const { item, formRef } = props; - const OsqueryResponseActionParamsForm = lazy(() => import('./osquery_response_action_type')); + () => (props: OsqueryResponseActionsParamsFormProps) => { + const { onError, defaultValues, onChange } = props; return ( - <> - - - - - - - + + + + + ); }; diff --git a/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx b/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx index 73e6bdb889475..402dacf7a6542 100644 --- a/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx +++ b/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx @@ -50,7 +50,7 @@ const OsqueryActionComponent: React.FC = ({ body={

Promise; - }; +interface OsqueryResponseActionsValues { + savedQueryId?: string | null; + id?: string; + ecsMapping?: ECSMapping; + query?: string; + packId?: string; + queries?: Array<{ + id: string; + ecs_mapping: ECSMapping; + query: string; + }>; } interface OsqueryResponseActionsParamsFormFields { @@ -38,88 +38,53 @@ interface OsqueryResponseActionsParamsFormFields { ecs_mapping: ECSMapping; query: string; packId?: string[]; - queries?: Array<{ + queries: Array<{ id: string; ecs_mapping: ECSMapping; query: string; }>; + queryType: 'query' | 'pack'; +} + +export interface OsqueryResponseActionsParamsFormProps { + defaultValues?: OsqueryResponseActionsValues; + onChange: (data: OsqueryResponseActionsValues) => void; + onError: (error: FieldErrors) => void; } -const OsqueryResponseActionParamsFormComponent = forwardRef< - ResponseActionValidatorRef, - OsqueryResponseActionsParamsFormProps ->(({ item }, ref) => { - const { updateFieldValues } = useFormContext(); - const [data] = useFormData({ watch: [item.path] }); - const { params: defaultParams } = get(data, item.path); +const OsqueryResponseActionParamsFormComponent = ({ + defaultValues, + onError, + onChange, +}: OsqueryResponseActionsParamsFormProps) => { const uniqueId = useMemo(() => uuid.v4(), []); const hooksForm = useHookForm({ - defaultValues: defaultParams + mode: 'all', + defaultValues: defaultValues ? { - ...omit(defaultParams, ['ecsMapping', 'packId']), - ecs_mapping: defaultParams.ecsMapping ?? {}, - packId: [defaultParams.packId] ?? [], + ...omit(defaultValues, ['ecsMapping', 'packId']), + ecs_mapping: defaultValues.ecsMapping, + packId: defaultValues.packId ? [defaultValues.packId] : [], + queryType: defaultValues.packId ? 'pack' : 'query', } : { ecs_mapping: {}, id: uniqueId, + queryType: 'query', }, }); - const { watch, register, formState, handleSubmit, reset } = hooksForm; - const { errors, isValid } = formState; + const { watch, register, formState } = hooksForm; - const watchedValues = watch(); + const [packId, queryType, queries, id] = watch(['packId', 'queryType', 'queries', 'id']); const { data: packData } = usePack({ - packId: watchedValues?.packId?.[0], - skip: !watchedValues?.packId?.[0], + packId: packId?.[0], + skip: !packId?.[0], }); - const [queryType, setQueryType] = useState( - !isEmpty(defaultParams?.queries) ? 'pack' : 'query' - ); - const onSubmit = useCallback( - async (formData) => { - updateFieldValues({ - [item.path]: - queryType === 'pack' - ? { - actionTypeId: OSQUERY_TYPE, - params: { - id: formData.id, - packId: formData?.packId?.length ? formData?.packId[0] : undefined, - queries: packData - ? map(packData.queries, (query, queryId: string) => ({ - ...query, - id: queryId, - })) - : formData.queries, - }, - } - : { - actionTypeId: OSQUERY_TYPE, - params: { - id: formData.id, - savedQueryId: formData.savedQueryId, - query: formData.query, - ecsMapping: formData.ecs_mapping, - }, - }, - }); - }, - [updateFieldValues, item.path, packData, queryType] - ); useEffect(() => { - // @ts-expect-error update types - if (ref && ref.current) { - // @ts-expect-error update types - ref.current.validation[item.id] = async () => { - await handleSubmit(onSubmit)(); - - return isEmpty(errors); - }; - } - }, [errors, handleSubmit, isValid, item.id, onSubmit, ref, watchedValues]); + onError(formState.errors); + }, [onError, formState]); useEffect(() => { register('savedQueryId'); @@ -127,10 +92,31 @@ const OsqueryResponseActionParamsFormComponent = forwardRef< }, [register]); useEffect(() => { - const subscription = watch(() => handleSubmit(onSubmit)()); + const subscription = watch((formData) => { + onChange( + // @ts-expect-error update types + formData.queryType === 'pack' + ? { + id: formData.id, + packId: formData?.packId?.length ? formData?.packId[0] : undefined, + queries: packData + ? map(packData.queries, (query, queryId: string) => ({ + ...query, + id: queryId, + })) + : formData.queries, + } + : { + id: formData.id, + savedQueryId: formData.savedQueryId, + query: formData.query, + ecsMapping: formData.ecs_mapping, + } + ); + }); return () => subscription.unsubscribe(); - }, [handleSubmit, onSubmit, watch]); + }, [onChange, packData, watch]); const permissions = useKibana().services.application.capabilities.osquery; @@ -151,34 +137,26 @@ const OsqueryResponseActionParamsFormComponent = forwardRef< const queryDetails = useMemo( () => ({ - queries: watchedValues.queries, - action_id: watchedValues.id, + queries, + action_id: id, agents: [], }), - [watchedValues.id, watchedValues.queries] + [id, queries] ); return ( <> - + {queryType === 'query' && } {queryType === 'pack' && ( - + )} ); -}); +}; const OsqueryResponseActionParamsForm = React.memo(OsqueryResponseActionParamsFormComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/osquery_tab.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/osquery_tab.tsx index acadbd6746d70..a20f0b2701a29 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/osquery_tab.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/osquery_tab.tsx @@ -80,7 +80,7 @@ export const useOsqueryTab = ({ titleSize="xs" body={ ( body={

{'osquery'}, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/osquery/osquery_response_action.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/osquery/osquery_response_action.tsx index d9b7d61677742..955a2f43966f7 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/osquery/osquery_response_action.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/osquery/osquery_response_action.tsx @@ -8,25 +8,29 @@ import React, { useMemo } from 'react'; import { EuiCode, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { ResponseActionValidatorRef } from '../response_actions_form'; +import { useIsMounted } from '@kbn/securitysolution-hook-utils'; +import { ResponseActionFormField } from './osquery_response_action_form_field'; import type { ArrayItem } from '../../../shared_imports'; import { useKibana } from '../../../common/lib/kibana'; import { NOT_AVAILABLE, PERMISSION_DENIED, SHORT_EMPTY_TITLE } from './translations'; +import { UseField } from '../../../shared_imports'; -interface IProps { +interface OsqueryResponseActionProps { item: ArrayItem; - formRef: React.RefObject; } -export const OsqueryResponseAction = React.memo((props: IProps) => { +const GhostFormField = () => <>; + +export const OsqueryResponseAction = React.memo((props: OsqueryResponseActionProps) => { const { osquery, application } = useKibana().services; const OsqueryForm = useMemo( () => osquery?.OsqueryResponseActionTypeForm, [osquery?.OsqueryResponseActionTypeForm] ); + const isMounted = useIsMounted(); if (osquery) { - const { disabled, permissionDenied } = osquery?.fetchInstallationStatus(); + const { disabled, permissionDenied } = osquery.fetchInstallationStatus(); const disabledOsqueryPermission = !( application?.capabilities?.osquery?.writeLiveQueries || (application?.capabilities?.osquery?.runSavedQueries && @@ -37,6 +41,7 @@ export const OsqueryResponseAction = React.memo((props: IProps) => { if (permissionDenied || disabledOsqueryPermission) { return ( <> + {PERMISSION_DENIED}} titleSize="xs" @@ -44,12 +49,10 @@ export const OsqueryResponseAction = React.memo((props: IProps) => { body={

osquery, + osquery: {'osquery'}, }} />

@@ -61,16 +64,26 @@ export const OsqueryResponseAction = React.memo((props: IProps) => { if (disabled) { return ( - {SHORT_EMPTY_TITLE}} - titleSize="xs" - body={

{NOT_AVAILABLE}

} - /> + <> + + {SHORT_EMPTY_TITLE}} + titleSize="xs" + body={

{NOT_AVAILABLE}

} + /> + ); } - if (OsqueryForm) { - return ; + + if (isMounted() && OsqueryForm) { + return ( + + ); } } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/osquery/osquery_response_action_form_field.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/osquery/osquery_response_action_form_field.tsx new file mode 100644 index 0000000000000..ada8c4a81e50b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/osquery/osquery_response_action_form_field.tsx @@ -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 type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import React, { useCallback, useMemo } from 'react'; +import { isEmpty, map } from 'lodash'; +import { useKibana } from '../../../common/lib/kibana'; + +export const ResponseActionFormField = React.memo(({ field }: { field: FieldHook }) => { + const { setErrors, clearErrors, value, setValue } = field; + const { osquery } = useKibana().services; + + const OsqueryForm = useMemo( + () => osquery?.OsqueryResponseActionTypeForm, + [osquery?.OsqueryResponseActionTypeForm] + ); + + const handleError = useCallback( + (newErrors) => { + if (isEmpty(newErrors)) { + clearErrors(); + } else { + setErrors(map(newErrors, (error) => ({ message: error.message }))); + } + }, + [setErrors, clearErrors] + ); + + // @ts-expect-error update types + return ; +}); +ResponseActionFormField.displayName = 'ResponseActionFormField'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_add_button.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_add_button.tsx index ce246ac9902c5..8352d6b150bc2 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_add_button.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_add_button.tsx @@ -74,7 +74,7 @@ export const ResponseActionAddButton = ({ handleAddActionType(item)} > diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_type_form.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_type_form.tsx index 68114f6a6f557..3074f40b38fc9 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_type_form.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_type_form.tsx @@ -16,21 +16,28 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; +import styled from 'styled-components'; + import { RESPONSE_ACTION_TYPES } from '../../../common/detection_engine/rule_response_actions/schemas'; -import type { ResponseActionValidatorRef } from './response_actions_form'; import { OsqueryResponseAction } from './osquery/osquery_response_action'; import { getActionDetails } from './constants'; import { useFormData } from '../../shared_imports'; import type { ArrayItem } from '../../shared_imports'; -interface IProps { +interface ResponseActionTypeFormProps { item: ArrayItem; onDeleteAction: (id: number) => void; - formRef: React.RefObject; } -export const ResponseActionTypeForm = React.memo((props: IProps) => { - const { item, onDeleteAction, formRef } = props; +const StyledEuiAccordion = styled(EuiAccordion)` + background: ${({ theme }) => theme.eui.euiColorLightestShade}; + + .euiAccordion__buttonContent { + padding: ${({ theme }) => theme.eui.euiSizeM}; + } +`; + +const ResponseActionTypeFormComponent = ({ item, onDeleteAction }: ResponseActionTypeFormProps) => { const [_isOpen, setIsOpen] = useState(true); const [data] = useFormData(); @@ -38,11 +45,11 @@ export const ResponseActionTypeForm = React.memo((props: IProps) => { const getResponseActionTypeForm = useCallback(() => { if (action?.actionTypeId === RESPONSE_ACTION_TYPES.OSQUERY) { - return ; + return ; } // Place for other ResponseActionTypes return null; - }, [action?.actionTypeId, formRef, item]); + }, [action?.actionTypeId, item]); const handleDelete = useCallback(() => { onDeleteAction(item.id); @@ -51,16 +58,12 @@ export const ResponseActionTypeForm = React.memo((props: IProps) => { const renderButtonContent = useMemo(() => { const { logo, name } = getActionDetails(action?.actionTypeId); return ( - + - - - {name} - - + {name} ); @@ -69,6 +72,7 @@ export const ResponseActionTypeForm = React.memo((props: IProps) => { const renderExtraContent = useMemo(() => { return ( { /> ); }, [handleDelete]); + return ( - {getResponseActionTypeForm()} - + ); -}); +}; + +ResponseActionTypeFormComponent.displayName = 'ResponseActionTypeForm'; -ResponseActionTypeForm.displayName = 'ResponseActionTypeForm'; +export const ResponseActionTypeForm = React.memo(ResponseActionTypeFormComponent); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.test.tsx index db02d8df961ee..0b4c33892d569 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.test.tsx @@ -5,32 +5,32 @@ * 2.0. */ -import React, { useRef } from 'react'; +import React from 'react'; import { render } from '@testing-library/react'; +import { ThemeProvider } from 'styled-components'; import { ResponseActionsForm } from './response_actions_form'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import type { ArrayItem } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { getMockTheme } from '../../common/lib/kibana/kibana_react.mock'; const renderWithContext = (Element: React.ReactElement) => { - return render({Element}); + const mockTheme = getMockTheme({ eui: { euiColorLightestShade: '#F5F7FA' } }); + + return render( + + {Element} + + ); }; describe('ResponseActionsForm', () => { const Component = (props: { items: ArrayItem[] }) => { const { form } = useForm(); - const saveClickRef = useRef<{ onSaveClick: () => Promise | null }>({ - onSaveClick: () => null, - }); return (
- + ); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.tsx index d296a2fa60f44..aa3c36d9079b6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.tsx @@ -5,74 +5,107 @@ * 2.0. */ -import React, { useEffect, useMemo, useRef } from 'react'; -import { EuiSpacer } from '@elastic/eui'; -import { isEmpty, map, some } from 'lodash'; +import React, { useEffect, useMemo, useState } from 'react'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { map, reduce, upperFirst } from 'lodash'; +import ReactMarkdown from 'react-markdown'; +import { css } from '@emotion/react'; +import { FORM_ERRORS_TITLE } from '../../detections/components/rules/rule_actions_field/translations'; import { ResponseActionsHeader } from './response_actions_header'; import { ResponseActionsList } from './response_actions_list'; - -import type { ArrayItem } from '../../shared_imports'; +import type { ArrayItem, FormHook } from '../../shared_imports'; import { useSupportedResponseActionTypes } from './use_supported_response_action_types'; -export interface ResponseActionValidatorRef { - validation: { - [key: string]: () => Promise; - }; -} +const FieldErrorsContainer = css` + margin-bottom: 0; +`; -interface IProps { +interface ResponseActionsFormProps { items: ArrayItem[]; addItem: () => void; removeItem: (id: number) => void; - saveClickRef: React.RefObject<{ - onSaveClick?: () => void; - }>; + form: FormHook; } -export const ResponseActionsForm = ({ items, addItem, removeItem, saveClickRef }: IProps) => { - const responseActionsValidationRef = useRef({ validation: {} }); +export const ResponseActionsForm = ({ + items, + addItem, + removeItem, + form, +}: ResponseActionsFormProps) => { const supportedResponseActionTypes = useSupportedResponseActionTypes(); + const [uiFieldErrors, setUIFieldErrors] = useState(null); + const fields = form.getFields(); + const errors = form.getErrors(); - useEffect(() => { - if (saveClickRef && saveClickRef.current) { - saveClickRef.current.onSaveClick = () => { - return validateResponseActions(); - }; - } - }, [saveClickRef]); - - const validateResponseActions = async () => { - if (!isEmpty(responseActionsValidationRef.current?.validation)) { - const response = await Promise.all( - map(responseActionsValidationRef.current?.validation, async (validation) => { - return validation(); - }) - ); - - return some(response, (val) => !val); - } - }; - - const form = useMemo(() => { + const formContent = useMemo(() => { if (!supportedResponseActionTypes?.length) { return null; } + return ( ); - }, [addItem, responseActionsValidationRef, items, removeItem, supportedResponseActionTypes]); + }, [addItem, items, removeItem, supportedResponseActionTypes]); + + useEffect(() => { + setUIFieldErrors(() => { + const fieldErrors = reduce>( + map(items, 'path'), + (acc, path) => { + if (fields[`${path}.params`]?.errors?.length) { + acc.push({ + type: upperFirst((fields[`${path}.actionTypeId`].value as string).substring(1)), + errors: map(fields[`${path}.params`].errors, 'message'), + }); + return acc; + } + + return acc; + }, + [] + ); + + return reduce( + fieldErrors, + (acc, error) => { + acc.push(`**${error.type}:**\n`); + error.errors.forEach((err) => { + acc.push(`- ${err}\n`); + }); + + return acc; + }, + [] as string[] + ).join('\n'); + }); + }, [fields, errors, items]); return ( <> - {form} + {uiFieldErrors?.length && form.isSubmitted ? ( + <> +

+ + {uiFieldErrors} + +

+ + + ) : null} + {formContent} ); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_list.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_list.tsx index 1561f5f537fca..cb4e500607304 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_list.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_list.tsx @@ -7,31 +7,23 @@ import React, { useCallback, useEffect, useRef, useMemo } from 'react'; import { EuiSpacer } from '@elastic/eui'; -import type { ResponseActionValidatorRef } from './response_actions_form'; import type { ResponseActionType } from './get_supported_response_actions'; import { ResponseActionAddButton } from './response_action_add_button'; import { ResponseActionTypeForm } from './response_action_type_form'; import type { ArrayItem } from '../../shared_imports'; import { UseField, useFormContext } from '../../shared_imports'; -interface IResponseActionsListProps { +interface ResponseActionsListProps { items: ArrayItem[]; removeItem: (id: number) => void; addItem: () => void; supportedResponseActionTypes: ResponseActionType[]; - formRef: React.RefObject; } const GhostFormField = () => <>; export const ResponseActionsList = React.memo( - ({ - items, - removeItem, - supportedResponseActionTypes, - addItem, - formRef, - }: IResponseActionsListProps) => { + ({ items, removeItem, supportedResponseActionTypes, addItem }: ResponseActionsListProps) => { const actionTypeIdRef = useRef(null); const updateActionTypeId = useCallback((id) => { actionTypeIdRef.current = id; @@ -56,22 +48,20 @@ export const ResponseActionsList = React.memo( actionTypeIdRef.current = null; } }, [context, items.length]); + return (
{items.map((actionItem, index) => { return (
- +
); })} + {renderButton}
); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index d233ebb67fb37..58dd95bcac0dd 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import { findIndex } from 'lodash/fp'; import type { FC } from 'react'; -import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { memo, useCallback, useEffect, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { ActionVariables } from '@kbn/triggers-actions-ui-plugin/public'; @@ -143,19 +143,7 @@ const StepRuleActionsComponent: FC = ({ [getFields, onSubmit] ); - const saveClickRef = useRef<{ onSaveClick: () => Promise | null }>({ - onSaveClick: () => null, - }); - const getData = useCallback(async () => { - const isResponseActionsInvalid = await saveClickRef.current.onSaveClick(); - if (isResponseActionsInvalid) { - return { - isValid: false, - data: getFormData(), - }; - } - const result = await submit(); return result?.isValid ? result @@ -218,7 +206,7 @@ const StepRuleActionsComponent: FC = ({ if (isQueryRule(ruleType)) { return ( - {(params) => } + {ResponseActionsForm} ); }