) => {
+ 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 && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+});
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 0000000000000..5221e170574b3
--- /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 0000000000000..e3ba836c0b669
--- /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 d7e438f49af36..c6376c34c768f 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 0000000000000..4bec5778cd775
--- /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 0000000000000..6f369cf19432f
--- /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 c8b3d3f527270..db7cb5aeac8f0 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 093842f5e6c24..03beee8ab373e 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 0000000000000..b167807a6edaa
--- /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 0000000000000..2d793c89e48f1
--- /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 0000000000000..1a031abc56f35
--- /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 0000000000000..5000a79287fc0
--- /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 9da89c5980831..f72008cbdffe1 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 f6716482f50f9..e2135b9a3aefa 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 6e1eca6d42eff..e55fe13e6c9a0 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 d343c3db04da6..2039307691321 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 88ee8346c8ab2..a450d082cb85d 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 (