diff --git a/x-pack/plugins/lists/public/index.tsx b/x-pack/plugins/lists/public/index.tsx index 1ea24123ccb9ac..72bd46d6e2ce8e 100644 --- a/x-pack/plugins/lists/public/index.tsx +++ b/x-pack/plugins/lists/public/index.tsx @@ -13,6 +13,12 @@ export { useFindLists } from './lists/hooks/use_find_lists'; export { useImportList } from './lists/hooks/use_import_list'; export { useDeleteList } from './lists/hooks/use_delete_list'; export { useExportList } from './lists/hooks/use_export_list'; +export { + addExceptionListItem, + updateExceptionListItem, + fetchExceptionListById, + addExceptionList, +} from './exceptions/api'; export { ExceptionList, ExceptionIdentifiers, diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx index d21551287fb3ed..697dff40129823 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx @@ -37,6 +37,8 @@ import { UpdateTimelineLoading, } from './types'; import { Ecs } from '../../../graphql/types'; +import { AddExceptionOnClick } from '../../../common/components/exceptions/add_exception_modal'; +import { getMappedNonEcsValue } from '../../../common/components/exceptions/helpers'; export const buildAlertStatusFilter = (status: Status): Filter[] => [ { @@ -172,6 +174,13 @@ export const requiredFieldsForActions = [ 'signal.rule.query', 'signal.rule.to', 'signal.rule.id', + + // Endpoint exception fields + 'file.path', + 'file.Ext.code_signature.subject_name', + 'file.Ext.code_signature.trusted', + 'file.hash.sha1', + 'host.os.family', ]; interface AlertActionArgs { @@ -188,6 +197,12 @@ interface AlertActionArgs { status: Status; timelineId: string; updateTimelineIsLoading: UpdateTimelineLoading; + openAddExceptionModal: ({ + exceptionListType, + alertData, + ruleName, + ruleId, + }: AddExceptionOnClick) => void; } export const getAlertActions = ({ @@ -204,6 +219,7 @@ export const getAlertActions = ({ status, timelineId, updateTimelineIsLoading, + openAddExceptionModal, }: AlertActionArgs): TimelineRowAction[] => { const openAlertActionComponent: TimelineRowAction = { ariaLabel: 'Open alert', @@ -289,5 +305,52 @@ export const getAlertActions = ({ ...(FILTER_OPEN !== status ? [openAlertActionComponent] : []), ...(FILTER_CLOSED !== status ? [closeAlertActionComponent] : []), ...(FILTER_IN_PROGRESS !== status ? [inProgressAlertActionComponent] : []), + // TODO: disable this option if the alert is not an Endpoint alert + { + onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { + const ruleNameValue = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); + const ruleId = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); + if (ruleId !== undefined && ruleId.length > 0) { + openAddExceptionModal({ + ruleName: ruleNameValue ? ruleNameValue[0] : '', + ruleId: ruleId[0], + exceptionListType: 'endpoint', + alertData: { + ecsData, + nonEcsData: data, + }, + }); + } + }, + id: 'addEndpointException', + isActionDisabled: () => !canUserCRUD || !hasIndexWrite, + dataTestSubj: 'add-endpoint-exception-menu-item', + ariaLabel: 'Add Endpoint Exception', + content: {i18n.ACTION_ADD_ENDPOINT_EXCEPTION}, + displayType: 'contextMenu', + }, + { + onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { + const ruleNameValue = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); + const ruleId = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); + if (ruleId !== undefined && ruleId.length > 0) { + openAddExceptionModal({ + ruleName: ruleNameValue ? ruleNameValue[0] : '', + ruleId: ruleId[0], + exceptionListType: 'detection', + alertData: { + ecsData, + nonEcsData: data, + }, + }); + } + }, + id: 'addException', + isActionDisabled: () => !canUserCRUD || !hasIndexWrite, + dataTestSubj: 'add-exception-menu-item', + ariaLabel: 'Add Exception', + content: {i18n.ACTION_ADD_EXCEPTION}, + displayType: 'contextMenu', + }, ]; }; diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx index b655c100a1c6f7..6cbf69f409dc45 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx @@ -50,6 +50,10 @@ import { } from '../../../common/components/toasters'; import { Ecs } from '../../../graphql/types'; import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; +import { + AddExceptionModal, + AddExceptionOnClick, +} from '../../../common/components/exceptions/add_exception_modal'; interface OwnProps { timelineId: TimelineIdLiteral; @@ -64,6 +68,13 @@ interface OwnProps { type AlertsTableComponentProps = OwnProps & PropsFromRedux; +const addExceptionModalInitialState: AddExceptionOnClick = { + ruleName: '', + ruleId: '', + exceptionListType: 'detection', + alertData: undefined, +}; + export const AlertsTableComponent: React.FC = ({ timelineId, canUserCRUD, @@ -92,6 +103,10 @@ export const AlertsTableComponent: React.FC = ({ const [showClearSelectionAction, setShowClearSelectionAction] = useState(false); const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); + const [shouldShowAddExceptionModal, setShouldShowAddExceptionModal] = useState(false); + const [addExceptionModalState, setAddExceptionModalState] = useState( + addExceptionModalInitialState + ); const [{ browserFields, indexPatterns }] = useFetchIndexPatterns( signalsIndex !== '' ? [signalsIndex] : [] ); @@ -192,6 +207,21 @@ export const AlertsTableComponent: React.FC = ({ [dispatchToaster] ); + const openAddExceptionModalCallback = useCallback( + ({ ruleName, ruleId, exceptionListType, alertData }: AddExceptionOnClick) => { + if (alertData !== null && alertData !== undefined) { + setShouldShowAddExceptionModal(true); + setAddExceptionModalState({ + ruleName, + ruleId, + exceptionListType, + alertData, + }); + } + }, + [setShouldShowAddExceptionModal, setAddExceptionModalState] + ); + // Catches state change isSelectAllChecked->false upon user selection change to reset utility bar useEffect(() => { if (!isSelectAllChecked) { @@ -306,6 +336,7 @@ export const AlertsTableComponent: React.FC = ({ status: filterGroup, timelineId, updateTimelineIsLoading, + openAddExceptionModal: openAddExceptionModalCallback, }), [ apolloClient, @@ -320,6 +351,7 @@ export const AlertsTableComponent: React.FC = ({ updateTimelineIsLoading, onAlertStatusUpdateSuccess, onAlertStatusUpdateFailure, + openAddExceptionModalCallback, ] ); const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]); @@ -360,6 +392,19 @@ export const AlertsTableComponent: React.FC = ({ [onFilterGroupChangedCallback] ); + const closeAddExceptionModal = useCallback(() => { + setShouldShowAddExceptionModal(false); + setAddExceptionModalState(addExceptionModalInitialState); + }, [setShouldShowAddExceptionModal, setAddExceptionModalState]); + + const onAddExceptionCancel = useCallback(() => { + closeAddExceptionModal(); + }, [closeAddExceptionModal]); + + const onAddExceptionConfirm = useCallback(() => { + closeAddExceptionModal(); + }, [closeAddExceptionModal]); + if (loading || isEmpty(signalsIndex)) { return ( @@ -370,16 +415,28 @@ export const AlertsTableComponent: React.FC = ({ } return ( - + <> + + {shouldShowAddExceptionModal === true && addExceptionModalState.alertData !== null && ( + + )} + ); }; diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/translations.ts index 390d6a8a2dd8dc..c9cf4e484910cd 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/translations.ts @@ -122,6 +122,20 @@ export const ACTION_INVESTIGATE_IN_TIMELINE = i18n.translate( } ); +export const ACTION_ADD_EXCEPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.actions.addException', + { + defaultMessage: 'Add exception', + } +); + +export const ACTION_ADD_ENDPOINT_EXCEPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.actions.addEndpointException', + { + defaultMessage: 'Add Endpoint exception', + } +); + export const CLOSED_ALERT_SUCCESS_TOAST = (totalAlerts: number) => i18n.translate('xpack.securitySolution.detectionEngine.alerts.closedAlertSuccessToastMessage', { values: { totalAlerts }, diff --git a/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/__mocks__/api.ts b/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/__mocks__/api.ts index 6c9964af254303..3275391f3f074f 100644 --- a/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/__mocks__/api.ts @@ -6,6 +6,7 @@ import { AddRulesProps, + PatchRuleProps, NewRule, PrePackagedRulesStatusResponse, BasicFetchProps, @@ -20,6 +21,9 @@ import { ruleMock, savedRuleMock, rulesMock } from '../mock'; export const addRule = async ({ rule, signal }: AddRulesProps): Promise => Promise.resolve(ruleMock); +export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise => + Promise.resolve(ruleMock); + export const getPrePackagedRulesStatus = async ({ signal, }: { diff --git a/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/api.ts index d59f709bbafc78..66be5397c72c18 100644 --- a/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/api.ts @@ -28,6 +28,7 @@ import { ImportDataResponse, PrePackagedRulesStatusResponse, BulkRuleResponse, + PatchRuleProps, } from './types'; import { KibanaServices } from '../../../../common/lib/kibana'; import * as i18n from '../../../pages/detection_engine/rules/translations'; @@ -47,6 +48,21 @@ export const addRule = async ({ rule, signal }: AddRulesProps): Promise signal, }); +/** + * Patch provided Rule + * + * @param ruleProperties to patch + * @param signal to cancel request + * + * @throws An error if response is not OK + */ +export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise => + KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_URL, { + method: 'PATCH', + body: JSON.stringify(ruleProperties), + signal, + }); + /** * Fetches all rules from the Detection Engine API * diff --git a/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/types.ts index e77d0cfccd2629..c03d19eaf771e8 100644 --- a/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/rules/types.ts @@ -22,6 +22,7 @@ import { listArray, listArrayOrUndefined, } from '../../../../../common/detection_engine/schemas/types'; +import { PatchRulesSchema } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema'; /** * Params is an "record", since it is a type of AlertActionParams which is action templates. @@ -80,6 +81,11 @@ export interface AddRulesProps { signal: AbortSignal; } +export interface PatchRuleProps { + ruleProperties: PatchRulesSchema; + signal: AbortSignal; +} + const MetaRule = t.intersection([ t.type({ from: t.string, diff --git a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx index 28a9c26f6b2ed4..bb6ef92a059cb7 100644 --- a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx @@ -439,6 +439,7 @@ export const RuleDetailsPageComponent: FC = ({ {ruleDetailTab === RuleDetailTabs.exceptions && ( void; +} + +const COMMENT_ACCORDION_BUTTON_CLASS_NAME = 'exceptionCommentAccordionButton'; + +const MyAvatar = styled(EuiAvatar)` + ${({ theme }) => css` + margin-right: ${theme.eui.paddingSizes.m}; + `} +`; + +const CommentAccordion = styled(EuiAccordion)` + ${({ theme }) => css` + .${COMMENT_ACCORDION_BUTTON_CLASS_NAME} { + color: ${theme.eui.euiColorPrimary}; + padding: ${theme.eui.paddingSizes.m} 0; + } + `} +`; + +export const AddExceptionComments = memo(function AddExceptionComments({ + exceptionItemComments, + newCommentValue, + newCommentOnChange, +}: AddExceptionCommentsProps) { + const [shouldShowComments, setShouldShowComments] = useState(false); + const currentUser = useCurrentUser(); + + const handleOnChange = useCallback( + (event: React.ChangeEvent) => { + newCommentOnChange(event.target.value); + }, + [newCommentOnChange] + ); + + const handleTriggerOnClick = useCallback((isOpen: boolean) => { + setShouldShowComments(isOpen); + }, []); + + const shouldShowAccordion: boolean = useMemo(() => { + return exceptionItemComments != null && exceptionItemComments.length > 0; + }, [exceptionItemComments]); + + const commentsAccordionTitle = useMemo(() => { + if (exceptionItemComments && exceptionItemComments.length > 0) { + return ( + + {!shouldShowComments + ? i18n.COMMENTS_SHOW(exceptionItemComments.length) + : i18n.COMMENTS_HIDE(exceptionItemComments.length)} + + ); + } else { + return null; + } + }, [shouldShowComments, exceptionItemComments]); + + const formattedComments = useMemo((): EuiCommentProps[] => { + if (exceptionItemComments && exceptionItemComments.length > 0) { + return getFormattedComments(exceptionItemComments); + } else { + return []; + } + }, [exceptionItemComments]); + + return ( +
+ {shouldShowAccordion && ( + handleTriggerOnClick(isOpen)} + > + + + )} + + + + + + + + +
+ ); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx new file mode 100644 index 00000000000000..5221e170574b37 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -0,0 +1,348 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useEffect, useState, useCallback, useMemo } from 'react'; +import styled, { css } from 'styled-components'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalFooter, + EuiOverlayMask, + EuiButton, + EuiButtonEmpty, + EuiHorizontalRule, + EuiCheckbox, + EuiSpacer, + EuiFormRow, + EuiCallOut, + EuiText, +} from '@elastic/eui'; +import { alertsIndexPattern } from '../../../../../common/endpoint/constants'; +import { + ExceptionListItemSchema, + CreateExceptionListItemSchema, + ExceptionListType, +} from '../../../../../public/lists_plugin_deps'; +import * as i18n from './translations'; +import { TimelineNonEcsData, Ecs } from '../../../../graphql/types'; +import { useKibana } from '../../../lib/kibana'; +import { errorToToaster, displaySuccessToast, useStateToaster } from '../../toasters'; +import { ExceptionBuilder } from '../builder'; +import { Loader } from '../../loader'; +import { useAddOrUpdateException } from '../use_add_exception'; +import { useSignalIndex } from '../../../../alerts/containers/detection_engine/alerts/use_signal_index'; +import { useFetchOrCreateRuleExceptionList } from '../use_fetch_or_create_rule_exception_list'; +import { AddExceptionComments } from '../add_exception_comments'; +import { + enrichExceptionItemsWithComments, + enrichExceptionItemsWithOS, + defaultEndpointExceptionItems, + entryHasListType, + entryHasNonEcsType, +} from '../helpers'; +import { useFetchIndexPatterns } from '../../../../alerts/containers/detection_engine/rules'; + +export interface AddExceptionOnClick { + ruleName: string; + ruleId: string; + exceptionListType: ExceptionListType; + alertData?: { + ecsData: Ecs; + nonEcsData: TimelineNonEcsData[]; + }; +} + +interface AddExceptionModalProps { + ruleName: string; + ruleId: string; + exceptionListType: ExceptionListType; + alertData?: { + ecsData: Ecs; + nonEcsData: TimelineNonEcsData[]; + }; + onCancel: () => void; + onConfirm: () => void; +} + +const Modal = styled(EuiModal)` + ${({ theme }) => css` + width: ${theme.eui.euiBreakpoints.m}; + `} +`; + +const ModalHeader = styled(EuiModalHeader)` + ${({ theme }) => css` + flex-direction: column; + align-items: flex-start; + `} +`; + +const ModalHeaderSubtitle = styled.div` + ${({ theme }) => css` + color: ${theme.eui.euiColorMediumShade}; + `} +`; + +const ModalBodySection = styled.section` + ${({ theme }) => css` + padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; + + &.builder-section { + overflow-y: scroll; + } + `} +`; + +export const AddExceptionModal = memo(function AddExceptionModal({ + ruleName, + ruleId, + exceptionListType, + alertData, + onCancel, + onConfirm, +}: AddExceptionModalProps) { + const { http } = useKibana().services; + const [comment, setComment] = useState(''); + const [shouldCloseAlert, setShouldCloseAlert] = useState(false); + const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); + const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); + const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< + Array + >([]); + const [fetchOrCreateListError, setFetchOrCreateListError] = useState(false); + const [, dispatchToaster] = useStateToaster(); + const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); + + const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( + signalIndexName !== null ? [signalIndexName] : [] + ); + + const onError = useCallback( + (error: Error) => { + errorToToaster({ title: i18n.ADD_EXCEPTION_ERROR, error, dispatchToaster }); + onCancel(); + }, + [dispatchToaster, onCancel] + ); + const onSuccess = useCallback(() => { + displaySuccessToast(i18n.ADD_EXCEPTION_SUCCESS, dispatchToaster); + onConfirm(); + }, [dispatchToaster, onConfirm]); + + const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( + { + http, + onSuccess, + onError, + } + ); + + const handleBuilderOnChange = useCallback( + ({ + exceptionItems, + }: { + exceptionItems: Array; + }) => { + setExceptionItemsToAdd(exceptionItems); + }, + [setExceptionItemsToAdd] + ); + + const onFetchOrCreateExceptionListError = useCallback( + (error: Error) => { + setFetchOrCreateListError(true); + }, + [setFetchOrCreateListError] + ); + const [isLoadingExceptionList, ruleExceptionList] = useFetchOrCreateRuleExceptionList({ + http, + ruleId, + exceptionListType, + onError: onFetchOrCreateExceptionListError, + }); + + const initialExceptionItems = useMemo(() => { + if (exceptionListType === 'endpoint' && alertData !== undefined && ruleExceptionList) { + return defaultEndpointExceptionItems( + exceptionListType, + ruleExceptionList.list_id, + ruleName, + alertData.nonEcsData + ); + } else { + return []; + } + }, [alertData, exceptionListType, ruleExceptionList, ruleName]); + + useEffect(() => { + if (indexPatternLoading === false && isSignalIndexLoading === false) { + setShouldDisableBulkClose( + entryHasListType(exceptionItemsToAdd) || + entryHasNonEcsType(exceptionItemsToAdd, indexPatterns) + ); + } + }, [ + setShouldDisableBulkClose, + exceptionItemsToAdd, + indexPatternLoading, + isSignalIndexLoading, + indexPatterns, + ]); + + const onCommentChange = useCallback( + (value: string) => { + setComment(value); + }, + [setComment] + ); + + const onCloseAlertCheckboxChange = useCallback( + (event: React.ChangeEvent) => { + setShouldCloseAlert(event.currentTarget.checked); + }, + [setShouldCloseAlert] + ); + + const onBulkCloseAlertCheckboxChange = useCallback( + (event: React.ChangeEvent) => { + setShouldBulkCloseAlert(event.currentTarget.checked); + }, + [setShouldBulkCloseAlert] + ); + + const enrichExceptionItems = useCallback(() => { + let enriched: Array = []; + enriched = + comment !== '' + ? enrichExceptionItemsWithComments(exceptionItemsToAdd, [{ comment }]) + : exceptionItemsToAdd; + if (exceptionListType === 'endpoint') { + const osTypes = alertData ? ['windows'] : ['windows', 'macos', 'linux']; + enriched = enrichExceptionItemsWithOS(enriched, osTypes); + } + return enriched; + }, [comment, exceptionItemsToAdd, exceptionListType, alertData]); + + const onAddExceptionConfirm = useCallback(() => { + if (addOrUpdateExceptionItems !== null) { + if (shouldCloseAlert && alertData) { + addOrUpdateExceptionItems(enrichExceptionItems(), alertData.ecsData._id); + } else { + addOrUpdateExceptionItems(enrichExceptionItems()); + } + } + }, [addOrUpdateExceptionItems, enrichExceptionItems, shouldCloseAlert, alertData]); + + const isSubmitButtonDisabled = useCallback( + () => fetchOrCreateListError || exceptionItemsToAdd.length === 0, + [fetchOrCreateListError, exceptionItemsToAdd] + ); + + const indexPatternConfig = useCallback(() => { + if (exceptionListType === 'endpoint') { + return [alertsIndexPattern]; + } + return signalIndexName ? [signalIndexName] : []; + }, [exceptionListType, signalIndexName]); + + return ( + + + + {i18n.ADD_EXCEPTION} + + {ruleName} + + + + {fetchOrCreateListError === true && ( + +

{i18n.ADD_EXCEPTION_FETCH_ERROR}

+
+ )} + {fetchOrCreateListError === false && isLoadingExceptionList === true && ( + + )} + {fetchOrCreateListError === false && + !isSignalIndexLoading && + !indexPatternLoading && + !isLoadingExceptionList && + ruleExceptionList && ( + <> + + {i18n.EXCEPTION_BUILDER_INFO} + + + + + + {exceptionListType === 'endpoint' && ( + <> + {i18n.ENDPOINT_QUARANTINE_TEXT} + + + )} + + + + + + {alertData !== undefined && ( + + + + )} + + + + + + )} + + + {i18n.CANCEL} + + + {i18n.ADD_EXCEPTION} + + +
+
+ ); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts new file mode 100644 index 00000000000000..e3ba836c0b6699 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.addException.cancel', { + defaultMessage: 'Cancel', +}); + +export const ADD_EXCEPTION = i18n.translate( + 'xpack.securitySolution.exceptions.addException.addException', + { + defaultMessage: 'Add Exception', + } +); + +export const ADD_EXCEPTION_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.addException.error', + { + defaultMessage: 'Failed to add exception', + } +); + +export const ADD_EXCEPTION_SUCCESS = i18n.translate( + 'xpack.securitySolution.exceptions.addException.success', + { + defaultMessage: 'Successfully added exception', + } +); + +export const ADD_EXCEPTION_FETCH_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.addException.fetchError.title', + { + defaultMessage: 'Error', + } +); + +export const ADD_EXCEPTION_FETCH_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.addException.fetchError', + { + defaultMessage: 'Error fetching exception list', + } +); + +export const ENDPOINT_QUARANTINE_TEXT = i18n.translate( + 'xpack.securitySolution.exceptions.addException.endpointQuarantineText', + { + defaultMessage: + 'Any file in quarantine on any endpoint that matches the attribute(s) selected will automatically be restored to its original location', + } +); + +export const BULK_CLOSE_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.addException.bulkCloseLabel', + { + defaultMessage: 'Close all alerts that match attributes in this exception', + } +); + +export const EXCEPTION_BUILDER_INFO = i18n.translate( + 'xpack.securitySolution.exceptions.addException.infoLabel', + { + defaultMessage: "Alerts are generated when the rule's conditions are met, except when:", + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx index d7e438f49af36f..c6376c34c768fb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -16,6 +16,7 @@ import { OperatorTypeEnum, OperatorEnum, CreateExceptionListItemSchema, + ExceptionListType, } from '../../../../../public/lists_plugin_deps'; import { AndOrBadge } from '../../and_or_badge'; import { BuilderButtonOptions } from './builder_button_options'; @@ -43,8 +44,8 @@ interface OnChangeProps { } interface ExceptionBuilderProps { - exceptionListItems: ExceptionListItemSchema[]; - listType: 'detection' | 'endpoint'; + exceptionListItems: ExceptionsBuilderExceptionItem[]; + listType: ExceptionListType; listId: string; listNamespaceType: NamespaceType; ruleName: string; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx new file mode 100644 index 00000000000000..4bec5778cd7751 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -0,0 +1,257 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useState, useCallback, useEffect } from 'react'; +import styled, { css } from 'styled-components'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalFooter, + EuiOverlayMask, + EuiButton, + EuiButtonEmpty, + EuiHorizontalRule, + EuiCheckbox, + EuiSpacer, + EuiFormRow, + EuiText, +} from '@elastic/eui'; +import { alertsIndexPattern } from '../../../../../common/endpoint/constants'; +import { useFetchIndexPatterns } from '../../../../alerts/containers/detection_engine/rules'; +import { useSignalIndex } from '../../../../alerts/containers/detection_engine/alerts/use_signal_index'; +import { + ExceptionListItemSchema, + CreateExceptionListItemSchema, + ExceptionListType, +} from '../../../../../public/lists_plugin_deps'; +import * as i18n from './translations'; +import { useKibana } from '../../../lib/kibana'; +import { errorToToaster, displaySuccessToast, useStateToaster } from '../../toasters'; +import { ExceptionBuilder } from '../builder'; +import { useAddOrUpdateException } from '../use_add_exception'; +import { AddExceptionComments } from '../add_exception_comments'; +import { + enrichExceptionItemsWithComments, + enrichExceptionItemsWithOS, + getOsTagValues, + entryHasListType, + entryHasNonEcsType, +} from '../helpers'; + +interface EditExceptionModalProps { + ruleName: string; + exceptionItem: ExceptionListItemSchema; + exceptionListType: ExceptionListType; + onCancel: () => void; + onConfirm: () => void; +} + +const Modal = styled(EuiModal)` + ${({ theme }) => css` + width: ${theme.eui.euiBreakpoints.m}; + `} +`; + +const ModalHeader = styled(EuiModalHeader)` + ${({ theme }) => css` + flex-direction: column; + align-items: flex-start; + `} +`; + +const ModalHeaderSubtitle = styled.div` + ${({ theme }) => css` + color: ${theme.eui.euiColorMediumShade}; + `} +`; + +const ModalBodySection = styled.section` + ${({ theme }) => css` + padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; + + &.builder-section { + overflow-y: scroll; + } + `} +`; + +export const EditExceptionModal = memo(function EditExceptionModal({ + ruleName, + exceptionItem, + exceptionListType, + onCancel, + onConfirm, +}: EditExceptionModalProps) { + const { http } = useKibana().services; + const [comment, setComment] = useState(''); + const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); + const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); + const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< + Array + >([]); + const [, dispatchToaster] = useStateToaster(); + const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); + + const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( + signalIndexName !== null ? [signalIndexName] : [] + ); + + const onError = useCallback( + (error) => { + errorToToaster({ title: i18n.EDIT_EXCEPTION_ERROR, error, dispatchToaster }); + onCancel(); + }, + [dispatchToaster, onCancel] + ); + const onSuccess = useCallback(() => { + displaySuccessToast(i18n.EDIT_EXCEPTION_SUCCESS, dispatchToaster); + onConfirm(); + }, [dispatchToaster, onConfirm]); + + const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( + { + http, + onSuccess, + onError, + } + ); + + useEffect(() => { + if (indexPatternLoading === false && isSignalIndexLoading === false) { + setShouldDisableBulkClose( + entryHasListType(exceptionItemsToAdd) || + entryHasNonEcsType(exceptionItemsToAdd, indexPatterns) + ); + } + }, [ + setShouldDisableBulkClose, + exceptionItemsToAdd, + indexPatternLoading, + isSignalIndexLoading, + indexPatterns, + ]); + + const handleBuilderOnChange = useCallback( + ({ + exceptionItems, + }: { + exceptionItems: Array; + }) => { + setExceptionItemsToAdd(exceptionItems); + }, + [setExceptionItemsToAdd] + ); + + const onCommentChange = useCallback( + (value: string) => { + setComment(value); + }, + [setComment] + ); + + const onBulkCloseAlertCheckboxChange = useCallback( + (event: React.ChangeEvent) => { + setShouldBulkCloseAlert(event.currentTarget.checked); + }, + [setShouldBulkCloseAlert] + ); + + const enrichExceptionItems = useCallback(() => { + let enriched: Array = []; + enriched = enrichExceptionItemsWithComments(exceptionItemsToAdd, [ + ...(exceptionItem.comments ? exceptionItem.comments : []), + ...(comment !== '' ? [{ comment }] : []), + ]); + if (exceptionListType === 'endpoint') { + const osTypes = exceptionItem._tags ? getOsTagValues(exceptionItem._tags) : ['windows']; + enriched = enrichExceptionItemsWithOS(enriched, osTypes); + } + return enriched; + }, [exceptionItemsToAdd, exceptionItem, comment, exceptionListType]); + + const onEditExceptionConfirm = useCallback(() => { + if (addOrUpdateExceptionItems !== null) { + addOrUpdateExceptionItems(enrichExceptionItems()); + } + }, [addOrUpdateExceptionItems, enrichExceptionItems]); + + const indexPatternConfig = useCallback(() => { + if (exceptionListType === 'endpoint') { + return [alertsIndexPattern]; + } + return signalIndexName ? [signalIndexName] : []; + }, [exceptionListType, signalIndexName]); + + return ( + + + + {i18n.EDIT_EXCEPTION} + + {ruleName} + + + + {!isSignalIndexLoading && ( + <> + + + + + + {exceptionListType === 'endpoint' && ( + <> + {i18n.ENDPOINT_QUARANTINE_TEXT} + + + )} + + + + + + + + + + + )} + + + {i18n.CANCEL} + + + {i18n.EDIT_EXCEPTION} + + + + + ); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts new file mode 100644 index 00000000000000..6f369cf19432f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.editException.cancel', { + defaultMessage: 'Cancel', +}); + +export const EDIT_EXCEPTION = i18n.translate( + 'xpack.securitySolution.exceptions.editException.editException', + { + defaultMessage: 'Edit Exception', + } +); + +export const EDIT_EXCEPTION_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.editException.error', + { + defaultMessage: 'Failed to update exception', + } +); + +export const EDIT_EXCEPTION_SUCCESS = i18n.translate( + 'xpack.securitySolution.exceptions.editException.success', + { + defaultMessage: 'Successfully updated exception', + } +); + +export const BULK_CLOSE_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.editException.bulkCloseLabel', + { + defaultMessage: 'Close all alerts that match attributes in this exception', + } +); + +export const ENDPOINT_QUARANTINE_TEXT = i18n.translate( + 'xpack.securitySolution.exceptions.editException.endpointQuarantineText', + { + defaultMessage: + 'Any file in quarantine on any endpoint that matches the attribute(s) selected will automatically be restored to its original location', + } +); + +export const EXCEPTION_BUILDER_INFO = i18n.translate( + 'xpack.securitySolution.exceptions.addException.infoLabel', + { + defaultMessage: "Alerts are generated when the rule's conditions are met, except when:", + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index c8b3d3f5272708..db7cb5aeac8f09 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { EuiText, EuiCommentProps, EuiAvatar } from '@elastic/eui'; -import { capitalize } from 'lodash'; +import { capitalize, union } from 'lodash'; import moment from 'moment'; import uuid from 'uuid'; @@ -24,6 +24,8 @@ import { EXCEPTION_OPERATORS, isOperator } from '../autocomplete/operators'; import { OperatorOption } from '../autocomplete/types'; import { CommentsArray, + Comments, + CreateComments, Entry, ExceptionListItemSchema, NamespaceType, @@ -33,11 +35,15 @@ import { entriesNested, createExceptionListItemSchema, exceptionListItemSchema, + UpdateExceptionListItemSchema, + ExceptionListType, } from '../../../lists_plugin_deps'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; export const isListType = (item: BuilderEntry): item is EmptyListEntry => item.type === OperatorTypeEnum.LIST; +import { TimelineNonEcsData } from '../../../graphql/types'; +import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; /** * Returns the operator type, may not need this if using io-ts types @@ -161,6 +167,10 @@ export const getOperatingSystems = (tags: string[]): string => { return osMatches; }; +export const getOsTagValues = (tags: string[]): string[] => { + return tags.filter((tag) => tag.startsWith('os:')).map((os) => os.substring(3).trim()); +}; + export const getTagsInclude = ({ tags, regex, @@ -221,6 +231,13 @@ export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] event: i18n.COMMENT_EVENT, timelineIcon: , children: {comment.comment}, + actions: ( + + ), })); export const getFormattedBuilderEntries = ( @@ -292,7 +309,7 @@ export const getNewExceptionItem = ({ namespaceType, ruleName, }: { - listType: 'detection' | 'endpoint'; + listType: ExceptionListType; listId: string; namespaceType: NamespaceType; ruleName: string; @@ -341,3 +358,159 @@ export const filterExceptionItems = ( [] ); }; + +export const formatExceptionItemForUpdate = ( + exceptionItem: ExceptionListItemSchema +): UpdateExceptionListItemSchema => { + const { + created_at, + created_by, + list_id, + tie_breaker_id, + updated_at, + updated_by, + ...fieldsToUpdate + } = exceptionItem; + return { + ...fieldsToUpdate, + }; +}; + +export const enrichExceptionItemsWithComments = ( + exceptionItems: Array, + comments: Array +): Array => { + return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + return { + ...item, + comments, + }; + }); +}; + +export const enrichExceptionItemsWithOS = ( + exceptionItems: Array, + osTypes: string[] +): Array => { + const osTags = osTypes.map((os) => `os:${os}`); + return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + const newTags = item._tags ? union(item._tags, osTags) : [...osTags]; + return { + ...item, + _tags: newTags, + }; + }); +}; + +export const getMappedNonEcsValue = ({ + data, + fieldName, +}: { + data: TimelineNonEcsData[]; + fieldName: string; +}): string[] | undefined => { + const item = data.find((d) => d.field === fieldName); + if (item != null && item.value != null) { + return item.value; + } + return undefined; +}; + +export const entryHasListType = ( + exceptionItems: Array +) => { + for (const { entries } of exceptionItems) { + for (const exceptionEntry of entries ?? []) { + if (getOperatorType(exceptionEntry) === 'list') { + return true; + } + } + } + return false; +}; + +export const entryHasNonEcsType = ( + exceptionItems: Array, + indexPatterns: IIndexPattern +): boolean => { + if (exceptionItems.length === 0) { + return false; + } + for (const { entries } of exceptionItems) { + for (const exceptionEntry of entries ?? []) { + if (indexPatterns.fields.find(({ name }) => name === exceptionEntry.field) === undefined) { + return true; + } + } + } + return false; +}; + +export const defaultEndpointExceptionItems = ( + listType: ExceptionListType, + listId: string, + ruleName: string, + alertData: TimelineNonEcsData[] +): ExceptionsBuilderExceptionItem[] => { + const [filePath] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.path' }) ?? []; + const [signatureSigner] = + getMappedNonEcsValue({ data: alertData, fieldName: 'file.Ext.code_signature.subject_name' }) ?? + []; + const [signatureTrusted] = + getMappedNonEcsValue({ data: alertData, fieldName: 'file.Ext.code_signature.trusted' }) ?? []; + const [sha1Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha1' }) ?? []; + const namespaceType = 'agnostic'; + + return [ + { + ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'match', + value: filePath ?? '', + }, + ], + }, + { + ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), + entries: [ + { + field: 'file.Ext.code_signature.subject_name', + operator: 'included', + type: 'match', + value: signatureSigner ?? '', + }, + { + field: 'file.code_signature.trusted', + operator: 'included', + type: 'match', + value: signatureTrusted ?? '', + }, + ], + }, + { + ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), + entries: [ + { + field: 'file.hash.sha1', + operator: 'included', + type: 'match', + value: sha1Hash ?? '', + }, + ], + }, + { + ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), + entries: [ + { + field: 'event.category', + operator: 'included', + type: 'match_any', + value: getMappedNonEcsValue({ data: alertData, fieldName: 'event.category' }) ?? [], + }, + ], + }, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 093842f5e6c240..03beee8ab373e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -200,3 +200,17 @@ export const ADD_NESTED_DESCRIPTION = i18n.translate( defaultMessage: 'Add nested condition', } ); + +export const ADD_COMMENT_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.addCommentPlaceholder', + { + defaultMessage: 'Add a new comment...', + } +); + +export const ADD_TO_CLIPBOARD = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.addToClipboard', + { + defaultMessage: 'Add to clipboard', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx new file mode 100644 index 00000000000000..b167807a6edaa9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -0,0 +1,247 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { KibanaServices } from '../../../common/lib/kibana'; + +import * as alertsApi from '../../../alerts/containers/detection_engine/alerts/api'; +import * as listsApi from '../../../../../lists/public/exceptions/api'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { getCreateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/create_exception_list_item_schema.mock'; +import { getUpdateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/update_exception_list_item_schema.mock'; +import { createKibanaCoreStartMock } from '../../../common/mock/kibana_core'; +import { + ExceptionListItemSchema, + CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '../../../lists_plugin_deps'; +import { + useAddOrUpdateException, + UseAddOrUpdateExceptionProps, + ReturnUseAddOrUpdateException, + AddOrUpdateExceptionItemsFunc, +} from './use_add_exception'; + +const mockKibanaHttpService = createKibanaCoreStartMock().http; +const mockKibanaServices = KibanaServices.get as jest.Mock; +jest.mock('../../../common/lib/kibana'); + +const fetchMock = jest.fn(); +mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); + +describe('useAddOrUpdateException', () => { + let updateAlertStatus: jest.SpyInstance>; + let addExceptionListItem: jest.SpyInstance>; + let updateExceptionListItem: jest.SpyInstance>; + let addOrUpdateItemsArgs: Parameters; + let render: () => RenderHookResult; + const onError = jest.fn(); + const onSuccess = jest.fn(); + const alertIdToClose = 'idToClose'; + const itemsToAdd: CreateExceptionListItemSchema[] = [ + { + ...getCreateExceptionListItemSchemaMock(), + name: 'item to add 1', + }, + { + ...getCreateExceptionListItemSchemaMock(), + name: 'item to add 2', + }, + ]; + const itemsToUpdate: ExceptionListItemSchema[] = [ + { + ...getExceptionListItemSchemaMock(), + name: 'item to update 1', + }, + { + ...getExceptionListItemSchemaMock(), + name: 'item to update 2', + }, + ]; + const itemsToUpdateFormatted: UpdateExceptionListItemSchema[] = itemsToUpdate.map( + (item: ExceptionListItemSchema) => { + const formatted: UpdateExceptionListItemSchema = getUpdateExceptionListItemSchemaMock(); + const newObj = (Object.keys(formatted) as Array).reduce( + (acc, key) => { + return { + ...acc, + [key]: item[key], + }; + }, + {} as UpdateExceptionListItemSchema + ); + return newObj; + } + ); + + const itemsToAddOrUpdate = [...itemsToAdd, ...itemsToUpdate]; + + const waitForAddOrUpdateFunc: (arg: { + waitForNextUpdate: RenderHookResult< + UseAddOrUpdateExceptionProps, + ReturnUseAddOrUpdateException + >['waitForNextUpdate']; + rerender: RenderHookResult< + UseAddOrUpdateExceptionProps, + ReturnUseAddOrUpdateException + >['rerender']; + result: RenderHookResult['result']; + }) => Promise = async ({ + waitForNextUpdate, + rerender, + result, + }) => { + await waitForNextUpdate(); + rerender(); + expect(result.current[1]).not.toBeNull(); + return Promise.resolve(result.current[1]); + }; + + beforeEach(() => { + updateAlertStatus = jest.spyOn(alertsApi, 'updateAlertStatus'); + + addExceptionListItem = jest + .spyOn(listsApi, 'addExceptionListItem') + .mockResolvedValue(getExceptionListItemSchemaMock()); + + updateExceptionListItem = jest + .spyOn(listsApi, 'updateExceptionListItem') + .mockResolvedValue(getExceptionListItemSchemaMock()); + + addOrUpdateItemsArgs = [itemsToAddOrUpdate]; + render = () => + renderHook(() => + useAddOrUpdateException({ + http: mockKibanaHttpService, + onError, + onSuccess, + }) + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('initializes hook', async () => { + await act(async () => { + const { result, waitForNextUpdate } = render(); + await waitForNextUpdate(); + expect(result.current).toEqual([{ isLoading: false }, null]); + }); + }); + + describe('when alertIdToClose is not passed in', () => { + it('should not update the alert status', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(updateAlertStatus).not.toHaveBeenCalled(); + }); + }); + + it('creates new items', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(addExceptionListItem).toHaveBeenCalledTimes(2); + expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); + }); + }); + it('updates existing items', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(updateExceptionListItem).toHaveBeenCalledTimes(2); + expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( + itemsToUpdateFormatted[1] + ); + }); + }); + }); + + describe('when alertIdToClose is passed in', () => { + beforeEach(() => { + addOrUpdateItemsArgs = [itemsToAddOrUpdate, alertIdToClose]; + }); + it('should update the alert status', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(updateAlertStatus).toHaveBeenCalledTimes(1); + }); + }); + it('creates new items', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(addExceptionListItem).toHaveBeenCalledTimes(2); + expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); + }); + }); + it('updates existing items', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(updateExceptionListItem).toHaveBeenCalledTimes(2); + expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( + itemsToUpdateFormatted[1] + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx new file mode 100644 index 00000000000000..2d793c89e48f18 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useRef, useState } from 'react'; +import { HttpStart } from '../../../../../../../src/core/public'; + +import { + addExceptionListItem, + updateExceptionListItem, + ExceptionListItemSchema, + CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '../../../lists_plugin_deps'; +import { updateAlertStatus } from '../../../alerts/containers/detection_engine/alerts/api'; +import { getUpdateAlertsQuery } from '../../../alerts/components/alerts_table/actions'; +import { formatExceptionItemForUpdate } from './helpers'; + +/** + * Adds exception items to the list. Also optionally closes alerts. + * + * @param exceptionItemsToAddOrUpdate array of ExceptionListItemSchema to add or update + * @param alertIdToClose - optional string representing alert to close + * + */ +export type AddOrUpdateExceptionItemsFunc = ( + exceptionItemsToAddOrUpdate: Array, + alertIdToClose?: string +) => Promise; + +export type ReturnUseAddOrUpdateException = [ + { isLoading: boolean }, + AddOrUpdateExceptionItemsFunc | null +]; + +export interface UseAddOrUpdateExceptionProps { + http: HttpStart; + onError: (arg: Error) => void; + onSuccess: () => void; +} + +/** + * Hook for adding and updating an exception item + * + * @param http Kibana http service + * @param onError error callback + * @param onSuccess callback when all lists fetched successfully + * + */ +export const useAddOrUpdateException = ({ + http, + onError, + onSuccess, +}: UseAddOrUpdateExceptionProps): ReturnUseAddOrUpdateException => { + const [isLoading, setIsLoading] = useState(false); + const addOrUpdateException = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const addOrUpdateItems = async ( + exceptionItemsToAddOrUpdate: Array + ): Promise => { + const toAdd: CreateExceptionListItemSchema[] = []; + const toUpdate: UpdateExceptionListItemSchema[] = []; + exceptionItemsToAddOrUpdate.forEach( + (item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + if ('id' in item && item.id !== undefined) { + toUpdate.push(formatExceptionItemForUpdate(item)); + } else { + toAdd.push(item); + } + } + ); + + const promises: Array> = []; + toAdd.forEach((item: CreateExceptionListItemSchema) => { + promises.push( + addExceptionListItem({ + http, + listItem: item, + signal: abortCtrl.signal, + }) + ); + }); + toUpdate.forEach((item: UpdateExceptionListItemSchema) => { + promises.push( + updateExceptionListItem({ + http, + listItem: item, + signal: abortCtrl.signal, + }) + ); + }); + await Promise.all(promises); + }; + + const addOrUpdateExceptionItems: AddOrUpdateExceptionItemsFunc = async ( + exceptionItemsToAddOrUpdate, + alertIdToClose + ) => { + try { + setIsLoading(true); + if (alertIdToClose !== null && alertIdToClose !== undefined) { + await updateAlertStatus({ + query: getUpdateAlertsQuery([alertIdToClose]), + status: 'closed', + }); + } + + await addOrUpdateItems(exceptionItemsToAddOrUpdate); + + if (isSubscribed) { + setIsLoading(false); + onSuccess(); + } + } catch (error) { + if (isSubscribed) { + setIsLoading(false); + onError(error); + } + } + }; + + addOrUpdateException.current = addOrUpdateExceptionItems; + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [http, onSuccess, onError]); + + return [{ isLoading }, addOrUpdateException.current]; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx new file mode 100644 index 00000000000000..1a031abc56f356 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx @@ -0,0 +1,359 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; + +import * as rulesApi from '../../../alerts/containers/detection_engine/rules/api'; +import * as listsApi from '../../../../../lists/public/exceptions/api'; +import { getExceptionListSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_schema.mock'; +import { savedRuleMock } from '../../../alerts/containers/detection_engine/rules/mock'; +import { createKibanaCoreStartMock } from '../../mock/kibana_core'; +import { ExceptionListType } from '../../../lists_plugin_deps'; +import { ListArray } from '../../../../common/detection_engine/schemas/types'; +import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; +import { + useFetchOrCreateRuleExceptionList, + UseFetchOrCreateRuleExceptionListProps, + ReturnUseFetchOrCreateRuleExceptionList, +} from './use_fetch_or_create_rule_exception_list'; + +const mockKibanaHttpService = createKibanaCoreStartMock().http; +jest.mock('../../../alerts/containers/detection_engine/rules/api'); + +describe('useFetchOrCreateRuleExceptionList', () => { + let fetchRuleById: jest.SpyInstance>; + let patchRule: jest.SpyInstance>; + let addExceptionList: jest.SpyInstance>; + let fetchExceptionListById: jest.SpyInstance>; + let render: ( + listType?: UseFetchOrCreateRuleExceptionListProps['exceptionListType'] + ) => RenderHookResult< + UseFetchOrCreateRuleExceptionListProps, + ReturnUseFetchOrCreateRuleExceptionList + >; + const onError = jest.fn(); + const error = new Error('Something went wrong'); + const ruleId = 'myRuleId'; + const abortCtrl = new AbortController(); + const detectionListType: ExceptionListType = 'detection'; + const endpointListType: ExceptionListType = 'endpoint'; + const detectionExceptionList = { + ...getExceptionListSchemaMock(), + type: detectionListType, + }; + const endpointExceptionList = { + ...getExceptionListSchemaMock(), + type: endpointListType, + }; + const newDetectionExceptionList = { + ...detectionExceptionList, + name: 'new detection exception list', + }; + const newEndpointExceptionList = { + ...endpointExceptionList, + name: 'new endpoint exception list', + }; + const exceptionsListReferences: ListArray = getListArrayMock(); + const ruleWithExceptionLists = { + ...savedRuleMock, + exceptions_list: exceptionsListReferences, + }; + const ruleWithoutExceptionLists = { + ...savedRuleMock, + exceptions_list: undefined, + }; + + beforeEach(() => { + fetchRuleById = jest.spyOn(rulesApi, 'fetchRuleById').mockResolvedValue(ruleWithExceptionLists); + + patchRule = jest.spyOn(rulesApi, 'patchRule'); + + addExceptionList = jest + .spyOn(listsApi, 'addExceptionList') + .mockResolvedValue(newDetectionExceptionList); + + fetchExceptionListById = jest + .spyOn(listsApi, 'fetchExceptionListById') + .mockResolvedValue(detectionExceptionList); + + render = (listType = detectionListType) => + renderHook( + () => + useFetchOrCreateRuleExceptionList({ + http: mockKibanaHttpService, + ruleId, + exceptionListType: listType, + onError, + }) + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('initializes hook', async () => { + await act(async () => { + const { result, waitForNextUpdate } = render(); + await waitForNextUpdate(); + expect(result.current).toEqual([false, null]); + }); + }); + + it('sets isLoading to true while fetching', async () => { + await act(async () => { + const { result, waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual([true, null]); + }); + }); + + it('fetches the rule with the given ruleId', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(fetchRuleById).toHaveBeenCalledTimes(1); + expect(fetchRuleById).toHaveBeenCalledWith({ + id: ruleId, + signal: abortCtrl.signal, + }); + }); + }); + + describe('when the rule does not have exception list references', () => { + beforeEach(() => { + fetchRuleById = jest + .spyOn(rulesApi, 'fetchRuleById') + .mockResolvedValue(ruleWithoutExceptionLists); + }); + + it('does not fetch the exceptions lists', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(fetchExceptionListById).not.toHaveBeenCalled(); + }); + }); + it('should create a new exception list', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(addExceptionList).toHaveBeenCalledTimes(1); + }); + }); + it('should update the rule', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(patchRule).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe("when the rule has exception list references and 'detection' is passed in", () => { + it('fetches the exceptions lists', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(fetchExceptionListById).toHaveBeenCalledTimes(2); + }); + }); + it('does not create a new exception list', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(addExceptionList).not.toHaveBeenCalled(); + }); + }); + it('does not update the rule', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(patchRule).not.toHaveBeenCalled(); + }); + }); + it('should set the exception list to be the fetched list', async () => { + await act(async () => { + const { result, waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current[1]).toEqual(detectionExceptionList); + }); + }); + + describe("but the rule does not have a reference to 'detection' type exception list", () => { + beforeEach(() => { + fetchExceptionListById = jest + .spyOn(listsApi, 'fetchExceptionListById') + .mockResolvedValue(endpointExceptionList); + }); + + it('should create a new exception list', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(addExceptionList).toHaveBeenCalledTimes(1); + }); + }); + it('should update the rule', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(patchRule).toHaveBeenCalledTimes(1); + }); + }); + it('should set the exception list to be the newly created list', async () => { + await act(async () => { + const { result, waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current[1]).toEqual(newDetectionExceptionList); + }); + }); + }); + }); + + describe("when the rule has exception list references and 'endpoint' is passed in", () => { + beforeEach(() => { + fetchExceptionListById = jest + .spyOn(listsApi, 'fetchExceptionListById') + .mockResolvedValue(endpointExceptionList); + + addExceptionList = jest + .spyOn(listsApi, 'addExceptionList') + .mockResolvedValue(newEndpointExceptionList); + }); + + it('fetches the exceptions lists', async () => { + await act(async () => { + const { waitForNextUpdate } = render(endpointListType); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(fetchExceptionListById).toHaveBeenCalledTimes(2); + }); + }); + it('does not create a new exception list', async () => { + await act(async () => { + const { waitForNextUpdate } = render(endpointListType); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(addExceptionList).not.toHaveBeenCalled(); + }); + }); + it('does not update the rule', async () => { + await act(async () => { + const { waitForNextUpdate } = render(endpointListType); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(patchRule).not.toHaveBeenCalled(); + }); + }); + it('should set the exception list to be the fetched list', async () => { + await act(async () => { + const { result, waitForNextUpdate } = render(endpointListType); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current[1]).toEqual(endpointExceptionList); + }); + }); + + describe("but the rule does not have a reference to 'endpoint' type exception list", () => { + beforeEach(() => { + fetchExceptionListById = jest + .spyOn(listsApi, 'fetchExceptionListById') + .mockResolvedValue(detectionExceptionList); + }); + + it('should create a new exception list', async () => { + await act(async () => { + const { waitForNextUpdate } = render(endpointListType); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(addExceptionList).toHaveBeenCalledTimes(1); + }); + }); + it('should update the rule', async () => { + await act(async () => { + const { waitForNextUpdate } = render(endpointListType); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(patchRule).toHaveBeenCalledTimes(1); + }); + }); + it('should set the exception list to be the newly created list', async () => { + await act(async () => { + const { result, waitForNextUpdate } = render(endpointListType); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current[1]).toEqual(newEndpointExceptionList); + }); + }); + }); + }); + + describe('when rule api returns an error', () => { + beforeEach(() => { + fetchRuleById = jest.spyOn(rulesApi, 'fetchRuleById').mockRejectedValue(error); + }); + + it('exception list should be null', async () => { + await act(async () => { + const { result, waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current[1]).toBeNull(); + }); + }); + + it('isLoading should be false', async () => { + await act(async () => { + const { result, waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current[0]).toEqual(false); + }); + }); + + it('should call error callback', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx new file mode 100644 index 00000000000000..5000a79287fc01 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState } from 'react'; +import { HttpStart } from '../../../../../../../src/core/public'; + +import { + ExceptionListSchema, + CreateExceptionListSchema, +} from '../../../../../lists/common/schemas'; +import { Rule } from '../../../alerts/containers/detection_engine/rules/types'; +import { List, ListArray } from '../../../../common/detection_engine/schemas/types'; +import { fetchRuleById, patchRule } from '../../../alerts/containers/detection_engine/rules/api'; +import { fetchExceptionListById, addExceptionList } from '../../../lists_plugin_deps'; + +export type ReturnUseFetchOrCreateRuleExceptionList = [boolean, ExceptionListSchema | null]; + +export interface UseFetchOrCreateRuleExceptionListProps { + http: HttpStart; + ruleId: Rule['id']; + exceptionListType: ExceptionListSchema['type']; + onError: (arg: Error) => void; +} + +/** + * Hook for fetching or creating an exception list + * + * @param http Kibana http service + * @param ruleId id of the rule + * @param exceptionListType type of the exception list to be fetched or created + * @param onError error callback + * + */ +export const useFetchOrCreateRuleExceptionList = ({ + http, + ruleId, + exceptionListType, + onError, +}: UseFetchOrCreateRuleExceptionListProps): ReturnUseFetchOrCreateRuleExceptionList => { + const [isLoading, setIsLoading] = useState(false); + const [exceptionList, setExceptionList] = useState(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + async function createExceptionList(ruleResponse: Rule): Promise { + const exceptionListToCreate: CreateExceptionListSchema = { + name: ruleResponse.name, + description: ruleResponse.description, + type: exceptionListType, + namespace_type: exceptionListType === 'endpoint' ? 'agnostic' : 'single', + _tags: undefined, + tags: undefined, + list_id: exceptionListType === 'endpoint' ? 'endpoint_list' : undefined, + meta: undefined, + }; + try { + const newExceptionList = await addExceptionList({ + http, + list: exceptionListToCreate, + signal: abortCtrl.signal, + }); + return Promise.resolve(newExceptionList); + } catch (error) { + return Promise.reject(error); + } + } + async function createAndAssociateExceptionList( + ruleResponse: Rule + ): Promise { + const newExceptionList = await createExceptionList(ruleResponse); + + const newExceptionListReference = { + id: newExceptionList.id, + type: newExceptionList.type, + namespace_type: newExceptionList.namespace_type, + }; + const newExceptionListReferences: ListArray = [ + ...(ruleResponse.exceptions_list ?? []), + newExceptionListReference, + ]; + + await patchRule({ + ruleProperties: { + rule_id: ruleResponse.rule_id, + exceptions_list: newExceptionListReferences, + }, + signal: abortCtrl.signal, + }); + + return Promise.resolve(newExceptionList); + } + + async function fetchRule(): Promise { + return fetchRuleById({ + id: ruleId, + signal: abortCtrl.signal, + }); + } + + async function fetchRuleExceptionLists(ruleResponse: Rule): Promise { + const exceptionListReferences = ruleResponse.exceptions_list; + if (exceptionListReferences && exceptionListReferences.length > 0) { + const exceptionListPromises = exceptionListReferences.map( + (exceptionListReference: List) => { + return fetchExceptionListById({ + http, + id: exceptionListReference.id, + namespaceType: exceptionListReference.namespace_type, + signal: abortCtrl.signal, + }); + } + ); + return Promise.all(exceptionListPromises); + } else { + return Promise.resolve([]); + } + } + + async function fetchOrCreateRuleExceptionList() { + try { + setIsLoading(true); + const ruleResponse = await fetchRule(); + const exceptionLists = await fetchRuleExceptionLists(ruleResponse); + + let exceptionListToUse: ExceptionListSchema; + const matchingList = exceptionLists.find((list) => { + if (exceptionListType === 'endpoint') { + return list.type === exceptionListType && list.list_id === 'endpoint_list'; + } else { + return list.type === exceptionListType; + } + }); + if (matchingList !== undefined) { + exceptionListToUse = matchingList; + } else { + exceptionListToUse = await createAndAssociateExceptionList(ruleResponse); + } + + if (isSubscribed) { + setExceptionList(exceptionListToUse); + setIsLoading(false); + } + } catch (error) { + if (isSubscribed) { + setIsLoading(false); + setExceptionList(null); + onError(error); + } + } + } + + fetchOrCreateRuleExceptionList(); + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [http, ruleId, exceptionListType, onError]); + + return [isLoading, exceptionList]; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx index 9da89c59808318..f72008cbdffe14 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx @@ -22,6 +22,8 @@ jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../../public/lists_plugin_deps'); describe('ExceptionsViewer', () => { + const ruleName = 'test rule'; + beforeEach(() => { (useKibana as jest.Mock).mockReturnValue({ services: { @@ -65,6 +67,7 @@ describe('ExceptionsViewer', () => { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> void; } const ExceptionsViewerComponent = ({ ruleId, + ruleName, exceptionListsMeta, availableListTypes, - onAssociateList, commentsAccordionId, }: ExceptionsViewerProps): JSX.Element => { const { services } = useKibana(); @@ -93,7 +91,9 @@ const ExceptionsViewerComponent = ({ loadingLists, loadingItemIds, isInitLoading, - isModalOpen, + currentModal, + exceptionToEdit, + exceptionListTypeToEdit, }, dispatch, ] = useReducer(allExceptionItemsReducer(), { ...initialState, loadingLists: exceptionListsMeta }); @@ -131,11 +131,11 @@ const ExceptionsViewerComponent = ({ }), }); - const setIsModalOpen = useCallback( - (isOpen: boolean): void => { + const setCurrentModal = useCallback( + (modalName: ViewerModalName): void => { dispatch({ type: 'updateModalOpen', - isOpen, + modalName, }); }, [dispatch] @@ -161,9 +161,13 @@ const ExceptionsViewerComponent = ({ const handleAddException = useCallback( (type: ExceptionListTypeEnum): void => { - setIsModalOpen(true); + dispatch({ + type: 'updateExceptionListTypeToEdit', + exceptionListType: type, + }); + setCurrentModal('addModal'); }, - [setIsModalOpen] + [setCurrentModal] ); const handleEditException = useCallback( @@ -175,25 +179,15 @@ const ExceptionsViewerComponent = ({ exception, }); - setIsModalOpen(true); + setCurrentModal('editModal'); }, - [setIsModalOpen] + [setCurrentModal] ); - const handleCloseExceptionModal = useCallback( - ({ actionType, listId }): void => { - setIsModalOpen(false); - - // TODO: This callback along with fetchList can probably get - // passed to the modal for it to call itself maybe - if (actionType === ModalAction.CREATE && listId != null && onAssociateList != null) { - onAssociateList(listId); - } - - handleFetchList(); - }, - [setIsModalOpen, handleFetchList, onAssociateList] - ); + const handleCloseExceptionModal = useCallback((): void => { + setCurrentModal(null); + handleFetchList(); + }, [setCurrentModal, handleFetchList]); const setLoadingItemIds = useCallback( (items: ExceptionListItemIdentifiers[]): void => { @@ -254,16 +248,26 @@ const ExceptionsViewerComponent = ({ return ( <> - {isModalOpen && ( - - - - - {`Modal goes here`} - - - - + {currentModal === 'editModal' && + exceptionToEdit !== null && + exceptionListTypeToEdit !== null && ( + + )} + + {currentModal === 'addModal' && exceptionListTypeToEdit != null && ( + )} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts index f6716482f50f9f..e2135b9a3aefad 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts @@ -6,11 +6,14 @@ import { FilterOptions, ExceptionsPagination, ExceptionListItemIdentifiers } from '../types'; import { ExceptionList, + ExceptionListType, ExceptionListItemSchema, ExceptionIdentifiers, Pagination, } from '../../../../../public/lists_plugin_deps'; +export type ViewerModalName = 'addModal' | 'editModal' | null; + export interface State { filterOptions: FilterOptions; pagination: ExceptionsPagination; @@ -22,7 +25,8 @@ export interface State { loadingLists: ExceptionIdentifiers[]; loadingItemIds: ExceptionListItemIdentifiers[]; isInitLoading: boolean; - isModalOpen: boolean; + currentModal: ViewerModalName; + exceptionListTypeToEdit: ExceptionListType | null; } export type Action = @@ -39,9 +43,10 @@ export type Action = allLists: ExceptionIdentifiers[]; } | { type: 'updateIsInitLoading'; loading: boolean } - | { type: 'updateModalOpen'; isOpen: boolean } + | { type: 'updateModalOpen'; modalName: ViewerModalName } | { type: 'updateExceptionToEdit'; exception: ExceptionListItemSchema } - | { type: 'updateLoadingItemIds'; items: ExceptionListItemIdentifiers[] }; + | { type: 'updateLoadingItemIds'; items: ExceptionListItemIdentifiers[] } + | { type: 'updateExceptionListTypeToEdit'; exceptionListType: ExceptionListType | null }; export const allExceptionItemsReducer = () => (state: State, action: Action): State => { switch (action.type) { @@ -116,15 +121,26 @@ export const allExceptionItemsReducer = () => (state: State, action: Action): St }; } case 'updateExceptionToEdit': { + const exception = action.exception; + const exceptionListToEdit = [state.endpointList, state.detectionsList].find((list) => { + return list !== null && exception.list_id === list.list_id; + }); return { ...state, exceptionToEdit: action.exception, + exceptionListTypeToEdit: exceptionListToEdit ? exceptionListToEdit.type : null, }; } case 'updateModalOpen': { return { ...state, - isModalOpen: action.isOpen, + currentModal: action.modalName, + }; + } + case 'updateExceptionListTypeToEdit': { + return { + ...state, + exceptionListTypeToEdit: action.exceptionListType, }; } default: diff --git a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts index 6e1eca6d42efff..e55fe13e6c9a07 100644 --- a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts +++ b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts @@ -10,6 +10,10 @@ export { usePersistExceptionItem, usePersistExceptionList, useFindLists, + addExceptionListItem, + updateExceptionListItem, + fetchExceptionListById, + addExceptionList, ExceptionIdentifiers, ExceptionList, Pagination, @@ -18,9 +22,13 @@ export { export { ListSchema, CommentsArray, + CreateCommentsArray, + Comments, + CreateComments, ExceptionListSchema, ExceptionListItemSchema, CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, Entry, EntryExists, EntryNested, @@ -39,4 +47,5 @@ export { entriesNested, entriesExists, entriesList, + ExceptionListType, } from '../../lists/common/schemas'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index d343c3db04da6e..2039307691321d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -19,12 +19,13 @@ import { EventsLoading, EventsTd, EventsTdContent, EventsTdGroupActions } from ' import { eventHasNotes, getPinTooltip } from '../helpers'; import * as i18n from '../translations'; import { OnRowSelected } from '../../events'; -import { Ecs } from '../../../../../graphql/types'; +import { Ecs, TimelineNonEcsData } from '../../../../../graphql/types'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; export interface TimelineRowActionOnClick { eventId: string; ecsData: Ecs; + data: TimelineNonEcsData[]; } export interface TimelineRowAction { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index 88ee8346c8ab22..a450d082cb85de 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -145,7 +145,7 @@ export const EventColumnView = React.memo( isDisabled={ action.isActionDisabled != null ? action.isActionDisabled(ecsData) : false } - onClick={() => action.onClick({ eventId: id, ecsData })} + onClick={() => action.onClick({ eventId: id, ecsData, data })} /> , @@ -164,7 +164,7 @@ export const EventColumnView = React.memo( } icon={action.iconType} key={action.id} - onClick={() => onClickCb(() => action.onClick({ eventId: id, ecsData }))} + onClick={() => onClickCb(() => action.onClick({ eventId: id, ecsData, data }))} > {action.content} , @@ -195,7 +195,7 @@ export const EventColumnView = React.memo( , ] : grouped.icon; - }, [button, closePopover, id, onClickCb, ecsData, timelineActions, isPopoverOpen]); + }, [button, closePopover, id, onClickCb, data, ecsData, timelineActions, isPopoverOpen]); return (