[Security Solution][Exceptions] - Exception Modal Part I (#70639)
* adds 2 menu items to alert page, progress on exception modal * adds enriching * remove unused useExceptionList() * implements some types * move add exception modal files * Exception builder changes to support latest schema * Changes to lists plugin schemas and fix api bug Needed to make the schemas more forgiving. Before this change they required name, description, etc for creation and update. The update item API was using the wrong url. * Adding and editing exceptions working - Modifies add_exception_modal component - Creates edit_exception_modal component - Creates shared comments component - Creates use_add_exception api hook for adding or editing exceptions - Updates viewer code to support adding and editing exceptions - Updates alerts table code to use updated version of add_exception_modal * fixes duplicate types * updates os tag input * fixes comment style * removes checkbox programatically * grahpql updates to expose exceptions_list * Add fetch_or_create_exception_list hook * fixes data population * refactor use_add_exception hook, add tests * fix rebase issues, pending updates to edit modal * fix edit modal and default endpoint exceptions * adds second checkbox * adds signal index stuff * switches boolean logic * fix some type errors * remove unnecesary code * fixes checkbox logic in edit modal * fixes recursive prop passing * addresses comments/fixes types * Revert schema type changes * type fixes * fixes regular exception modal * fix more type errors, remove console log * fix tests * move add exception hook, lint * close alert checkbox closes alert * address PR comments * add type to patch rule call, fix ts errors * fix lint * fix merge problems after conflict * Address PR comments * undo graphql type change Co-authored-by: Davis Plumlee <davis.plumlee@elastic.co>
This commit is contained in:
parent
5f53597d75
commit
8facae7ad5
26 changed files with 2212 additions and 64 deletions
|
@ -13,6 +13,12 @@ export { useFindLists } from './lists/hooks/use_find_lists';
|
||||||
export { useImportList } from './lists/hooks/use_import_list';
|
export { useImportList } from './lists/hooks/use_import_list';
|
||||||
export { useDeleteList } from './lists/hooks/use_delete_list';
|
export { useDeleteList } from './lists/hooks/use_delete_list';
|
||||||
export { useExportList } from './lists/hooks/use_export_list';
|
export { useExportList } from './lists/hooks/use_export_list';
|
||||||
|
export {
|
||||||
|
addExceptionListItem,
|
||||||
|
updateExceptionListItem,
|
||||||
|
fetchExceptionListById,
|
||||||
|
addExceptionList,
|
||||||
|
} from './exceptions/api';
|
||||||
export {
|
export {
|
||||||
ExceptionList,
|
ExceptionList,
|
||||||
ExceptionIdentifiers,
|
ExceptionIdentifiers,
|
||||||
|
|
|
@ -37,6 +37,8 @@ import {
|
||||||
UpdateTimelineLoading,
|
UpdateTimelineLoading,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { Ecs } from '../../../graphql/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[] => [
|
export const buildAlertStatusFilter = (status: Status): Filter[] => [
|
||||||
{
|
{
|
||||||
|
@ -172,6 +174,13 @@ export const requiredFieldsForActions = [
|
||||||
'signal.rule.query',
|
'signal.rule.query',
|
||||||
'signal.rule.to',
|
'signal.rule.to',
|
||||||
'signal.rule.id',
|
'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 {
|
interface AlertActionArgs {
|
||||||
|
@ -188,6 +197,12 @@ interface AlertActionArgs {
|
||||||
status: Status;
|
status: Status;
|
||||||
timelineId: string;
|
timelineId: string;
|
||||||
updateTimelineIsLoading: UpdateTimelineLoading;
|
updateTimelineIsLoading: UpdateTimelineLoading;
|
||||||
|
openAddExceptionModal: ({
|
||||||
|
exceptionListType,
|
||||||
|
alertData,
|
||||||
|
ruleName,
|
||||||
|
ruleId,
|
||||||
|
}: AddExceptionOnClick) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAlertActions = ({
|
export const getAlertActions = ({
|
||||||
|
@ -204,6 +219,7 @@ export const getAlertActions = ({
|
||||||
status,
|
status,
|
||||||
timelineId,
|
timelineId,
|
||||||
updateTimelineIsLoading,
|
updateTimelineIsLoading,
|
||||||
|
openAddExceptionModal,
|
||||||
}: AlertActionArgs): TimelineRowAction[] => {
|
}: AlertActionArgs): TimelineRowAction[] => {
|
||||||
const openAlertActionComponent: TimelineRowAction = {
|
const openAlertActionComponent: TimelineRowAction = {
|
||||||
ariaLabel: 'Open alert',
|
ariaLabel: 'Open alert',
|
||||||
|
@ -289,5 +305,52 @@ export const getAlertActions = ({
|
||||||
...(FILTER_OPEN !== status ? [openAlertActionComponent] : []),
|
...(FILTER_OPEN !== status ? [openAlertActionComponent] : []),
|
||||||
...(FILTER_CLOSED !== status ? [closeAlertActionComponent] : []),
|
...(FILTER_CLOSED !== status ? [closeAlertActionComponent] : []),
|
||||||
...(FILTER_IN_PROGRESS !== status ? [inProgressAlertActionComponent] : []),
|
...(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: <EuiText size="m">{i18n.ACTION_ADD_ENDPOINT_EXCEPTION}</EuiText>,
|
||||||
|
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: <EuiText size="m">{i18n.ACTION_ADD_EXCEPTION}</EuiText>,
|
||||||
|
displayType: 'contextMenu',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
|
@ -50,6 +50,10 @@ import {
|
||||||
} from '../../../common/components/toasters';
|
} from '../../../common/components/toasters';
|
||||||
import { Ecs } from '../../../graphql/types';
|
import { Ecs } from '../../../graphql/types';
|
||||||
import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers';
|
import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers';
|
||||||
|
import {
|
||||||
|
AddExceptionModal,
|
||||||
|
AddExceptionOnClick,
|
||||||
|
} from '../../../common/components/exceptions/add_exception_modal';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
timelineId: TimelineIdLiteral;
|
timelineId: TimelineIdLiteral;
|
||||||
|
@ -64,6 +68,13 @@ interface OwnProps {
|
||||||
|
|
||||||
type AlertsTableComponentProps = OwnProps & PropsFromRedux;
|
type AlertsTableComponentProps = OwnProps & PropsFromRedux;
|
||||||
|
|
||||||
|
const addExceptionModalInitialState: AddExceptionOnClick = {
|
||||||
|
ruleName: '',
|
||||||
|
ruleId: '',
|
||||||
|
exceptionListType: 'detection',
|
||||||
|
alertData: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
|
export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
|
||||||
timelineId,
|
timelineId,
|
||||||
canUserCRUD,
|
canUserCRUD,
|
||||||
|
@ -92,6 +103,10 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
|
||||||
|
|
||||||
const [showClearSelectionAction, setShowClearSelectionAction] = useState(false);
|
const [showClearSelectionAction, setShowClearSelectionAction] = useState(false);
|
||||||
const [filterGroup, setFilterGroup] = useState<Status>(FILTER_OPEN);
|
const [filterGroup, setFilterGroup] = useState<Status>(FILTER_OPEN);
|
||||||
|
const [shouldShowAddExceptionModal, setShouldShowAddExceptionModal] = useState(false);
|
||||||
|
const [addExceptionModalState, setAddExceptionModalState] = useState<AddExceptionOnClick>(
|
||||||
|
addExceptionModalInitialState
|
||||||
|
);
|
||||||
const [{ browserFields, indexPatterns }] = useFetchIndexPatterns(
|
const [{ browserFields, indexPatterns }] = useFetchIndexPatterns(
|
||||||
signalsIndex !== '' ? [signalsIndex] : []
|
signalsIndex !== '' ? [signalsIndex] : []
|
||||||
);
|
);
|
||||||
|
@ -192,6 +207,21 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
|
||||||
[dispatchToaster]
|
[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
|
// Catches state change isSelectAllChecked->false upon user selection change to reset utility bar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSelectAllChecked) {
|
if (!isSelectAllChecked) {
|
||||||
|
@ -306,6 +336,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
|
||||||
status: filterGroup,
|
status: filterGroup,
|
||||||
timelineId,
|
timelineId,
|
||||||
updateTimelineIsLoading,
|
updateTimelineIsLoading,
|
||||||
|
openAddExceptionModal: openAddExceptionModalCallback,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
apolloClient,
|
apolloClient,
|
||||||
|
@ -320,6 +351,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
|
||||||
updateTimelineIsLoading,
|
updateTimelineIsLoading,
|
||||||
onAlertStatusUpdateSuccess,
|
onAlertStatusUpdateSuccess,
|
||||||
onAlertStatusUpdateFailure,
|
onAlertStatusUpdateFailure,
|
||||||
|
openAddExceptionModalCallback,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]);
|
const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]);
|
||||||
|
@ -360,6 +392,19 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
|
||||||
[onFilterGroupChangedCallback]
|
[onFilterGroupChangedCallback]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const closeAddExceptionModal = useCallback(() => {
|
||||||
|
setShouldShowAddExceptionModal(false);
|
||||||
|
setAddExceptionModalState(addExceptionModalInitialState);
|
||||||
|
}, [setShouldShowAddExceptionModal, setAddExceptionModalState]);
|
||||||
|
|
||||||
|
const onAddExceptionCancel = useCallback(() => {
|
||||||
|
closeAddExceptionModal();
|
||||||
|
}, [closeAddExceptionModal]);
|
||||||
|
|
||||||
|
const onAddExceptionConfirm = useCallback(() => {
|
||||||
|
closeAddExceptionModal();
|
||||||
|
}, [closeAddExceptionModal]);
|
||||||
|
|
||||||
if (loading || isEmpty(signalsIndex)) {
|
if (loading || isEmpty(signalsIndex)) {
|
||||||
return (
|
return (
|
||||||
<EuiPanel>
|
<EuiPanel>
|
||||||
|
@ -370,16 +415,28 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatefulEventsViewer
|
<>
|
||||||
defaultIndices={defaultIndices}
|
<StatefulEventsViewer
|
||||||
pageFilters={defaultFiltersMemo}
|
defaultIndices={defaultIndices}
|
||||||
defaultModel={alertsDefaultModel}
|
pageFilters={defaultFiltersMemo}
|
||||||
end={to}
|
defaultModel={alertsDefaultModel}
|
||||||
headerFilterGroup={headerFilterGroup}
|
end={to}
|
||||||
id={timelineId}
|
headerFilterGroup={headerFilterGroup}
|
||||||
start={from}
|
id={timelineId}
|
||||||
utilityBar={utilityBarCallback}
|
start={from}
|
||||||
/>
|
utilityBar={utilityBarCallback}
|
||||||
|
/>
|
||||||
|
{shouldShowAddExceptionModal === true && addExceptionModalState.alertData !== null && (
|
||||||
|
<AddExceptionModal
|
||||||
|
ruleName={addExceptionModalState.ruleName}
|
||||||
|
ruleId={addExceptionModalState.ruleId}
|
||||||
|
exceptionListType={addExceptionModalState.exceptionListType}
|
||||||
|
alertData={addExceptionModalState.alertData}
|
||||||
|
onCancel={onAddExceptionCancel}
|
||||||
|
onConfirm={onAddExceptionConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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) =>
|
export const CLOSED_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
|
||||||
i18n.translate('xpack.securitySolution.detectionEngine.alerts.closedAlertSuccessToastMessage', {
|
i18n.translate('xpack.securitySolution.detectionEngine.alerts.closedAlertSuccessToastMessage', {
|
||||||
values: { totalAlerts },
|
values: { totalAlerts },
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AddRulesProps,
|
AddRulesProps,
|
||||||
|
PatchRuleProps,
|
||||||
NewRule,
|
NewRule,
|
||||||
PrePackagedRulesStatusResponse,
|
PrePackagedRulesStatusResponse,
|
||||||
BasicFetchProps,
|
BasicFetchProps,
|
||||||
|
@ -20,6 +21,9 @@ import { ruleMock, savedRuleMock, rulesMock } from '../mock';
|
||||||
export const addRule = async ({ rule, signal }: AddRulesProps): Promise<NewRule> =>
|
export const addRule = async ({ rule, signal }: AddRulesProps): Promise<NewRule> =>
|
||||||
Promise.resolve(ruleMock);
|
Promise.resolve(ruleMock);
|
||||||
|
|
||||||
|
export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise<NewRule> =>
|
||||||
|
Promise.resolve(ruleMock);
|
||||||
|
|
||||||
export const getPrePackagedRulesStatus = async ({
|
export const getPrePackagedRulesStatus = async ({
|
||||||
signal,
|
signal,
|
||||||
}: {
|
}: {
|
||||||
|
|
|
@ -28,6 +28,7 @@ import {
|
||||||
ImportDataResponse,
|
ImportDataResponse,
|
||||||
PrePackagedRulesStatusResponse,
|
PrePackagedRulesStatusResponse,
|
||||||
BulkRuleResponse,
|
BulkRuleResponse,
|
||||||
|
PatchRuleProps,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { KibanaServices } from '../../../../common/lib/kibana';
|
import { KibanaServices } from '../../../../common/lib/kibana';
|
||||||
import * as i18n from '../../../pages/detection_engine/rules/translations';
|
import * as i18n from '../../../pages/detection_engine/rules/translations';
|
||||||
|
@ -47,6 +48,21 @@ export const addRule = async ({ rule, signal }: AddRulesProps): Promise<NewRule>
|
||||||
signal,
|
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<NewRule> =>
|
||||||
|
KibanaServices.get().http.fetch<NewRule>(DETECTION_ENGINE_RULES_URL, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(ruleProperties),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches all rules from the Detection Engine API
|
* Fetches all rules from the Detection Engine API
|
||||||
*
|
*
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {
|
||||||
listArray,
|
listArray,
|
||||||
listArrayOrUndefined,
|
listArrayOrUndefined,
|
||||||
} from '../../../../../common/detection_engine/schemas/types';
|
} 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.
|
* Params is an "record", since it is a type of AlertActionParams which is action templates.
|
||||||
|
@ -80,6 +81,11 @@ export interface AddRulesProps {
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PatchRuleProps {
|
||||||
|
ruleProperties: PatchRulesSchema;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
const MetaRule = t.intersection([
|
const MetaRule = t.intersection([
|
||||||
t.type({
|
t.type({
|
||||||
from: t.string,
|
from: t.string,
|
||||||
|
|
|
@ -439,6 +439,7 @@ export const RuleDetailsPageComponent: FC<PropsFromRedux> = ({
|
||||||
{ruleDetailTab === RuleDetailTabs.exceptions && (
|
{ruleDetailTab === RuleDetailTabs.exceptions && (
|
||||||
<ExceptionsViewer
|
<ExceptionsViewer
|
||||||
ruleId={ruleId ?? ''}
|
ruleId={ruleId ?? ''}
|
||||||
|
ruleName={rule?.name ?? ''}
|
||||||
availableListTypes={exceptionLists.allowedExceptionListTypes}
|
availableListTypes={exceptionLists.allowedExceptionListTypes}
|
||||||
commentsAccordionId={'ruleDetailsTabExceptions'}
|
commentsAccordionId={'ruleDetailsTabExceptions'}
|
||||||
exceptionListsMeta={exceptionLists.lists}
|
exceptionListsMeta={exceptionLists.lists}
|
||||||
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
/*
|
||||||
|
* 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, useMemo } from 'react';
|
||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
import {
|
||||||
|
EuiTextArea,
|
||||||
|
EuiFlexGroup,
|
||||||
|
EuiFlexItem,
|
||||||
|
EuiAvatar,
|
||||||
|
EuiAccordion,
|
||||||
|
EuiCommentList,
|
||||||
|
EuiCommentProps,
|
||||||
|
EuiText,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import { Comments } from '../../../lists_plugin_deps';
|
||||||
|
import * as i18n from './translations';
|
||||||
|
import { useCurrentUser } from '../../lib/kibana';
|
||||||
|
import { getFormattedComments } from './helpers';
|
||||||
|
|
||||||
|
interface AddExceptionCommentsProps {
|
||||||
|
exceptionItemComments?: Comments[];
|
||||||
|
newCommentValue: string;
|
||||||
|
newCommentOnChange: (value: string) => 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<HTMLTextAreaElement>) => {
|
||||||
|
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 (
|
||||||
|
<EuiText size="s" data-test-subj="addExceptionCommentsAccordionButton">
|
||||||
|
{!shouldShowComments
|
||||||
|
? i18n.COMMENTS_SHOW(exceptionItemComments.length)
|
||||||
|
: i18n.COMMENTS_HIDE(exceptionItemComments.length)}
|
||||||
|
</EuiText>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [shouldShowComments, exceptionItemComments]);
|
||||||
|
|
||||||
|
const formattedComments = useMemo((): EuiCommentProps[] => {
|
||||||
|
if (exceptionItemComments && exceptionItemComments.length > 0) {
|
||||||
|
return getFormattedComments(exceptionItemComments);
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [exceptionItemComments]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{shouldShowAccordion && (
|
||||||
|
<CommentAccordion
|
||||||
|
id={'add-exception-comments-accordion'}
|
||||||
|
buttonClassName={COMMENT_ACCORDION_BUTTON_CLASS_NAME}
|
||||||
|
buttonContent={commentsAccordionTitle}
|
||||||
|
data-test-subj="addExceptionCommentsAccordion"
|
||||||
|
onToggle={(isOpen) => handleTriggerOnClick(isOpen)}
|
||||||
|
>
|
||||||
|
<EuiCommentList comments={formattedComments} />
|
||||||
|
</CommentAccordion>
|
||||||
|
)}
|
||||||
|
<EuiFlexGroup gutterSize={'none'}>
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<MyAvatar
|
||||||
|
name={currentUser !== null ? currentUser.username.toUpperCase() ?? '' : ''}
|
||||||
|
size="l"
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem grow={1}>
|
||||||
|
<EuiTextArea
|
||||||
|
placeholder={i18n.ADD_COMMENT_PLACEHOLDER}
|
||||||
|
aria-label="Use aria labels when no actual label is in use"
|
||||||
|
value={newCommentValue}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
fullWidth={true}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
|
@ -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<ExceptionListItemSchema | CreateExceptionListItemSchema>
|
||||||
|
>([]);
|
||||||
|
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<ExceptionListItemSchema | CreateExceptionListItemSchema>;
|
||||||
|
}) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
setShouldCloseAlert(event.currentTarget.checked);
|
||||||
|
},
|
||||||
|
[setShouldCloseAlert]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onBulkCloseAlertCheckboxChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setShouldBulkCloseAlert(event.currentTarget.checked);
|
||||||
|
},
|
||||||
|
[setShouldBulkCloseAlert]
|
||||||
|
);
|
||||||
|
|
||||||
|
const enrichExceptionItems = useCallback(() => {
|
||||||
|
let enriched: Array<ExceptionListItemSchema | CreateExceptionListItemSchema> = [];
|
||||||
|
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 (
|
||||||
|
<EuiOverlayMask>
|
||||||
|
<Modal onClose={onCancel} data-test-subj="add-exception-modal">
|
||||||
|
<ModalHeader>
|
||||||
|
<EuiModalHeaderTitle>{i18n.ADD_EXCEPTION}</EuiModalHeaderTitle>
|
||||||
|
<ModalHeaderSubtitle className="eui-textTruncate" title={ruleName}>
|
||||||
|
{ruleName}
|
||||||
|
</ModalHeaderSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
{fetchOrCreateListError === true && (
|
||||||
|
<EuiCallOut title={i18n.ADD_EXCEPTION_FETCH_ERROR_TITLE} color="danger" iconType="alert">
|
||||||
|
<p>{i18n.ADD_EXCEPTION_FETCH_ERROR}</p>
|
||||||
|
</EuiCallOut>
|
||||||
|
)}
|
||||||
|
{fetchOrCreateListError === false && isLoadingExceptionList === true && (
|
||||||
|
<Loader data-test-subj="loadingAddExceptionModal" size="xl" />
|
||||||
|
)}
|
||||||
|
{fetchOrCreateListError === false &&
|
||||||
|
!isSignalIndexLoading &&
|
||||||
|
!indexPatternLoading &&
|
||||||
|
!isLoadingExceptionList &&
|
||||||
|
ruleExceptionList && (
|
||||||
|
<>
|
||||||
|
<ModalBodySection className="builder-section">
|
||||||
|
<EuiText>{i18n.EXCEPTION_BUILDER_INFO}</EuiText>
|
||||||
|
<EuiSpacer />
|
||||||
|
<ExceptionBuilder
|
||||||
|
exceptionListItems={initialExceptionItems}
|
||||||
|
listType={exceptionListType}
|
||||||
|
listId={ruleExceptionList.list_id}
|
||||||
|
listNamespaceType={ruleExceptionList.namespace_type}
|
||||||
|
ruleName={ruleName}
|
||||||
|
indexPatternConfig={indexPatternConfig()}
|
||||||
|
isLoading={false}
|
||||||
|
isOrDisabled={false}
|
||||||
|
isAndDisabled={false}
|
||||||
|
data-test-subj="alert-exception-builder"
|
||||||
|
id-aria="alert-exception-builder"
|
||||||
|
onChange={handleBuilderOnChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EuiSpacer />
|
||||||
|
|
||||||
|
{exceptionListType === 'endpoint' && (
|
||||||
|
<>
|
||||||
|
<EuiText size="s">{i18n.ENDPOINT_QUARANTINE_TEXT}</EuiText>
|
||||||
|
<EuiSpacer />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddExceptionComments
|
||||||
|
newCommentValue={comment}
|
||||||
|
newCommentOnChange={onCommentChange}
|
||||||
|
/>
|
||||||
|
</ModalBodySection>
|
||||||
|
<EuiHorizontalRule />
|
||||||
|
<ModalBodySection>
|
||||||
|
{alertData !== undefined && (
|
||||||
|
<EuiFormRow>
|
||||||
|
<EuiCheckbox
|
||||||
|
id="close-alert-on-add-add-exception-checkbox"
|
||||||
|
label="Close this alert"
|
||||||
|
checked={shouldCloseAlert}
|
||||||
|
onChange={onCloseAlertCheckboxChange}
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
)}
|
||||||
|
<EuiFormRow>
|
||||||
|
<EuiCheckbox
|
||||||
|
id="bulk-close-alert-on-add-add-exception-checkbox"
|
||||||
|
label={i18n.BULK_CLOSE_LABEL}
|
||||||
|
checked={shouldBulkCloseAlert}
|
||||||
|
onChange={onBulkCloseAlertCheckboxChange}
|
||||||
|
disabled={shouldDisableBulkClose}
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
</ModalBodySection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EuiModalFooter>
|
||||||
|
<EuiButtonEmpty onClick={onCancel}>{i18n.CANCEL}</EuiButtonEmpty>
|
||||||
|
|
||||||
|
<EuiButton
|
||||||
|
onClick={onAddExceptionConfirm}
|
||||||
|
isLoading={addExceptionIsLoading}
|
||||||
|
isDisabled={isSubmitButtonDisabled()}
|
||||||
|
fill
|
||||||
|
>
|
||||||
|
{i18n.ADD_EXCEPTION}
|
||||||
|
</EuiButton>
|
||||||
|
</EuiModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</EuiOverlayMask>
|
||||||
|
);
|
||||||
|
});
|
|
@ -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:",
|
||||||
|
}
|
||||||
|
);
|
|
@ -16,6 +16,7 @@ import {
|
||||||
OperatorTypeEnum,
|
OperatorTypeEnum,
|
||||||
OperatorEnum,
|
OperatorEnum,
|
||||||
CreateExceptionListItemSchema,
|
CreateExceptionListItemSchema,
|
||||||
|
ExceptionListType,
|
||||||
} from '../../../../../public/lists_plugin_deps';
|
} from '../../../../../public/lists_plugin_deps';
|
||||||
import { AndOrBadge } from '../../and_or_badge';
|
import { AndOrBadge } from '../../and_or_badge';
|
||||||
import { BuilderButtonOptions } from './builder_button_options';
|
import { BuilderButtonOptions } from './builder_button_options';
|
||||||
|
@ -43,8 +44,8 @@ interface OnChangeProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExceptionBuilderProps {
|
interface ExceptionBuilderProps {
|
||||||
exceptionListItems: ExceptionListItemSchema[];
|
exceptionListItems: ExceptionsBuilderExceptionItem[];
|
||||||
listType: 'detection' | 'endpoint';
|
listType: ExceptionListType;
|
||||||
listId: string;
|
listId: string;
|
||||||
listNamespaceType: NamespaceType;
|
listNamespaceType: NamespaceType;
|
||||||
ruleName: string;
|
ruleName: string;
|
||||||
|
|
|
@ -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<ExceptionListItemSchema | CreateExceptionListItemSchema>
|
||||||
|
>([]);
|
||||||
|
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<ExceptionListItemSchema | CreateExceptionListItemSchema>;
|
||||||
|
}) => {
|
||||||
|
setExceptionItemsToAdd(exceptionItems);
|
||||||
|
},
|
||||||
|
[setExceptionItemsToAdd]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCommentChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setComment(value);
|
||||||
|
},
|
||||||
|
[setComment]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onBulkCloseAlertCheckboxChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setShouldBulkCloseAlert(event.currentTarget.checked);
|
||||||
|
},
|
||||||
|
[setShouldBulkCloseAlert]
|
||||||
|
);
|
||||||
|
|
||||||
|
const enrichExceptionItems = useCallback(() => {
|
||||||
|
let enriched: Array<ExceptionListItemSchema | CreateExceptionListItemSchema> = [];
|
||||||
|
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 (
|
||||||
|
<EuiOverlayMask>
|
||||||
|
<Modal onClose={onCancel} data-test-subj="add-exception-modal">
|
||||||
|
<ModalHeader>
|
||||||
|
<EuiModalHeaderTitle>{i18n.EDIT_EXCEPTION}</EuiModalHeaderTitle>
|
||||||
|
<ModalHeaderSubtitle className="eui-textTruncate" title={ruleName}>
|
||||||
|
{ruleName}
|
||||||
|
</ModalHeaderSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
{!isSignalIndexLoading && (
|
||||||
|
<>
|
||||||
|
<ModalBodySection className="builder-section">
|
||||||
|
<ExceptionBuilder
|
||||||
|
exceptionListItems={[exceptionItem]}
|
||||||
|
listType={exceptionListType}
|
||||||
|
listId={exceptionItem.list_id}
|
||||||
|
listNamespaceType={exceptionItem.namespace_type}
|
||||||
|
ruleName={ruleName}
|
||||||
|
isLoading={false}
|
||||||
|
isOrDisabled={false}
|
||||||
|
isAndDisabled={false}
|
||||||
|
data-test-subj="edit-exception-modal-builder"
|
||||||
|
id-aria="edit-exception-modal-builder"
|
||||||
|
onChange={handleBuilderOnChange}
|
||||||
|
indexPatternConfig={indexPatternConfig()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EuiSpacer />
|
||||||
|
|
||||||
|
{exceptionListType === 'endpoint' && (
|
||||||
|
<>
|
||||||
|
<EuiText size="s">{i18n.ENDPOINT_QUARANTINE_TEXT}</EuiText>
|
||||||
|
<EuiSpacer />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddExceptionComments
|
||||||
|
exceptionItemComments={exceptionItem.comments}
|
||||||
|
newCommentValue={comment}
|
||||||
|
newCommentOnChange={onCommentChange}
|
||||||
|
/>
|
||||||
|
</ModalBodySection>
|
||||||
|
<EuiHorizontalRule />
|
||||||
|
<ModalBodySection>
|
||||||
|
<EuiFormRow>
|
||||||
|
<EuiCheckbox
|
||||||
|
id="close-alert-on-add-add-exception-checkbox"
|
||||||
|
label={i18n.BULK_CLOSE_LABEL}
|
||||||
|
checked={shouldBulkCloseAlert}
|
||||||
|
onChange={onBulkCloseAlertCheckboxChange}
|
||||||
|
disabled={shouldDisableBulkClose}
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
</ModalBodySection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EuiModalFooter>
|
||||||
|
<EuiButtonEmpty onClick={onCancel}>{i18n.CANCEL}</EuiButtonEmpty>
|
||||||
|
|
||||||
|
<EuiButton onClick={onEditExceptionConfirm} isLoading={addExceptionIsLoading} fill>
|
||||||
|
{i18n.EDIT_EXCEPTION}
|
||||||
|
</EuiButton>
|
||||||
|
</EuiModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</EuiOverlayMask>
|
||||||
|
);
|
||||||
|
});
|
|
@ -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:",
|
||||||
|
}
|
||||||
|
);
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { EuiText, EuiCommentProps, EuiAvatar } from '@elastic/eui';
|
import { EuiText, EuiCommentProps, EuiAvatar } from '@elastic/eui';
|
||||||
import { capitalize } from 'lodash';
|
import { capitalize, union } from 'lodash';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
|
|
||||||
|
@ -24,6 +24,8 @@ import { EXCEPTION_OPERATORS, isOperator } from '../autocomplete/operators';
|
||||||
import { OperatorOption } from '../autocomplete/types';
|
import { OperatorOption } from '../autocomplete/types';
|
||||||
import {
|
import {
|
||||||
CommentsArray,
|
CommentsArray,
|
||||||
|
Comments,
|
||||||
|
CreateComments,
|
||||||
Entry,
|
Entry,
|
||||||
ExceptionListItemSchema,
|
ExceptionListItemSchema,
|
||||||
NamespaceType,
|
NamespaceType,
|
||||||
|
@ -33,11 +35,15 @@ import {
|
||||||
entriesNested,
|
entriesNested,
|
||||||
createExceptionListItemSchema,
|
createExceptionListItemSchema,
|
||||||
exceptionListItemSchema,
|
exceptionListItemSchema,
|
||||||
|
UpdateExceptionListItemSchema,
|
||||||
|
ExceptionListType,
|
||||||
} from '../../../lists_plugin_deps';
|
} from '../../../lists_plugin_deps';
|
||||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||||
|
|
||||||
export const isListType = (item: BuilderEntry): item is EmptyListEntry =>
|
export const isListType = (item: BuilderEntry): item is EmptyListEntry =>
|
||||||
item.type === OperatorTypeEnum.LIST;
|
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
|
* 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;
|
return osMatches;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getOsTagValues = (tags: string[]): string[] => {
|
||||||
|
return tags.filter((tag) => tag.startsWith('os:')).map((os) => os.substring(3).trim());
|
||||||
|
};
|
||||||
|
|
||||||
export const getTagsInclude = ({
|
export const getTagsInclude = ({
|
||||||
tags,
|
tags,
|
||||||
regex,
|
regex,
|
||||||
|
@ -221,6 +231,13 @@ export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[]
|
||||||
event: i18n.COMMENT_EVENT,
|
event: i18n.COMMENT_EVENT,
|
||||||
timelineIcon: <EuiAvatar size="l" name={comment.created_by.toUpperCase()} />,
|
timelineIcon: <EuiAvatar size="l" name={comment.created_by.toUpperCase()} />,
|
||||||
children: <EuiText size="s">{comment.comment}</EuiText>,
|
children: <EuiText size="s">{comment.comment}</EuiText>,
|
||||||
|
actions: (
|
||||||
|
<WithCopyToClipboard
|
||||||
|
data-test-subj="copy-to-clipboard"
|
||||||
|
text={comment.comment}
|
||||||
|
titleSummary={i18n.ADD_TO_CLIPBOARD}
|
||||||
|
/>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const getFormattedBuilderEntries = (
|
export const getFormattedBuilderEntries = (
|
||||||
|
@ -292,7 +309,7 @@ export const getNewExceptionItem = ({
|
||||||
namespaceType,
|
namespaceType,
|
||||||
ruleName,
|
ruleName,
|
||||||
}: {
|
}: {
|
||||||
listType: 'detection' | 'endpoint';
|
listType: ExceptionListType;
|
||||||
listId: string;
|
listId: string;
|
||||||
namespaceType: NamespaceType;
|
namespaceType: NamespaceType;
|
||||||
ruleName: string;
|
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<ExceptionListItemSchema | CreateExceptionListItemSchema>,
|
||||||
|
comments: Array<Comments | CreateComments>
|
||||||
|
): Array<ExceptionListItemSchema | CreateExceptionListItemSchema> => {
|
||||||
|
return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
comments,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enrichExceptionItemsWithOS = (
|
||||||
|
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>,
|
||||||
|
osTypes: string[]
|
||||||
|
): Array<ExceptionListItemSchema | CreateExceptionListItemSchema> => {
|
||||||
|
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<ExceptionListItemSchema | CreateExceptionListItemSchema>
|
||||||
|
) => {
|
||||||
|
for (const { entries } of exceptionItems) {
|
||||||
|
for (const exceptionEntry of entries ?? []) {
|
||||||
|
if (getOperatorType(exceptionEntry) === 'list') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const entryHasNonEcsType = (
|
||||||
|
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>,
|
||||||
|
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' }) ?? [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
|
@ -200,3 +200,17 @@ export const ADD_NESTED_DESCRIPTION = i18n.translate(
|
||||||
defaultMessage: 'Add nested condition',
|
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',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -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<ReturnType<typeof alertsApi.updateAlertStatus>>;
|
||||||
|
let addExceptionListItem: jest.SpyInstance<ReturnType<typeof listsApi.addExceptionListItem>>;
|
||||||
|
let updateExceptionListItem: jest.SpyInstance<ReturnType<
|
||||||
|
typeof listsApi.updateExceptionListItem
|
||||||
|
>>;
|
||||||
|
let addOrUpdateItemsArgs: Parameters<AddOrUpdateExceptionItemsFunc>;
|
||||||
|
let render: () => RenderHookResult<UseAddOrUpdateExceptionProps, ReturnUseAddOrUpdateException>;
|
||||||
|
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<keyof UpdateExceptionListItemSchema>).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<UseAddOrUpdateExceptionProps, ReturnUseAddOrUpdateException>['result'];
|
||||||
|
}) => Promise<ReturnUseAddOrUpdateException[1]> = 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<UseAddOrUpdateExceptionProps, ReturnUseAddOrUpdateException>(() =>
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<ExceptionListItemSchema | CreateExceptionListItemSchema>,
|
||||||
|
alertIdToClose?: string
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
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<AddOrUpdateExceptionItemsFunc | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isSubscribed = true;
|
||||||
|
const abortCtrl = new AbortController();
|
||||||
|
|
||||||
|
const addOrUpdateItems = async (
|
||||||
|
exceptionItemsToAddOrUpdate: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>
|
||||||
|
): Promise<void> => {
|
||||||
|
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<Promise<ExceptionListItemSchema>> = [];
|
||||||
|
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];
|
||||||
|
};
|
|
@ -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<ReturnType<typeof rulesApi.fetchRuleById>>;
|
||||||
|
let patchRule: jest.SpyInstance<ReturnType<typeof rulesApi.patchRule>>;
|
||||||
|
let addExceptionList: jest.SpyInstance<ReturnType<typeof listsApi.addExceptionList>>;
|
||||||
|
let fetchExceptionListById: jest.SpyInstance<ReturnType<typeof listsApi.fetchExceptionListById>>;
|
||||||
|
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<UseFetchOrCreateRuleExceptionListProps, ReturnUseFetchOrCreateRuleExceptionList>(
|
||||||
|
() =>
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<ExceptionListSchema | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isSubscribed = true;
|
||||||
|
const abortCtrl = new AbortController();
|
||||||
|
|
||||||
|
async function createExceptionList(ruleResponse: Rule): Promise<ExceptionListSchema> {
|
||||||
|
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<ExceptionListSchema> {
|
||||||
|
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<Rule> {
|
||||||
|
return fetchRuleById({
|
||||||
|
id: ruleId,
|
||||||
|
signal: abortCtrl.signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRuleExceptionLists(ruleResponse: Rule): Promise<ExceptionListSchema[]> {
|
||||||
|
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];
|
||||||
|
};
|
|
@ -22,6 +22,8 @@ jest.mock('../../../../common/lib/kibana');
|
||||||
jest.mock('../../../../../public/lists_plugin_deps');
|
jest.mock('../../../../../public/lists_plugin_deps');
|
||||||
|
|
||||||
describe('ExceptionsViewer', () => {
|
describe('ExceptionsViewer', () => {
|
||||||
|
const ruleName = 'test rule';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(useKibana as jest.Mock).mockReturnValue({
|
(useKibana as jest.Mock).mockReturnValue({
|
||||||
services: {
|
services: {
|
||||||
|
@ -65,6 +67,7 @@ describe('ExceptionsViewer', () => {
|
||||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||||
<ExceptionsViewer
|
<ExceptionsViewer
|
||||||
ruleId={'123'}
|
ruleId={'123'}
|
||||||
|
ruleName={ruleName}
|
||||||
exceptionListsMeta={[
|
exceptionListsMeta={[
|
||||||
{
|
{
|
||||||
id: '5b543420',
|
id: '5b543420',
|
||||||
|
@ -86,6 +89,7 @@ describe('ExceptionsViewer', () => {
|
||||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||||
<ExceptionsViewer
|
<ExceptionsViewer
|
||||||
ruleId={'123'}
|
ruleId={'123'}
|
||||||
|
ruleName={ruleName}
|
||||||
exceptionListsMeta={[]}
|
exceptionListsMeta={[]}
|
||||||
availableListTypes={[ExceptionListTypeEnum.DETECTION]}
|
availableListTypes={[ExceptionListTypeEnum.DETECTION]}
|
||||||
commentsAccordionId="commentsAccordion"
|
commentsAccordionId="commentsAccordion"
|
||||||
|
@ -113,6 +117,7 @@ describe('ExceptionsViewer', () => {
|
||||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||||
<ExceptionsViewer
|
<ExceptionsViewer
|
||||||
ruleId={'123'}
|
ruleId={'123'}
|
||||||
|
ruleName={ruleName}
|
||||||
exceptionListsMeta={[
|
exceptionListsMeta={[
|
||||||
{
|
{
|
||||||
id: '5b543420',
|
id: '5b543420',
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useEffect, useReducer } from 'react';
|
import React, { useCallback, useMemo, useEffect, useReducer } from 'react';
|
||||||
import { EuiOverlayMask, EuiModal, EuiModalBody, EuiCodeBlock, EuiSpacer } from '@elastic/eui';
|
import { EuiSpacer } from '@elastic/eui';
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
|
|
||||||
import * as i18n from '../translations';
|
import * as i18n from '../translations';
|
||||||
|
@ -15,7 +15,7 @@ import { Panel } from '../../../../common/components/panel';
|
||||||
import { Loader } from '../../../../common/components/loader';
|
import { Loader } from '../../../../common/components/loader';
|
||||||
import { ExceptionsViewerHeader } from './exceptions_viewer_header';
|
import { ExceptionsViewerHeader } from './exceptions_viewer_header';
|
||||||
import { ExceptionListItemIdentifiers, Filter } from '../types';
|
import { ExceptionListItemIdentifiers, Filter } from '../types';
|
||||||
import { allExceptionItemsReducer, State } from './reducer';
|
import { allExceptionItemsReducer, State, ViewerModalName } from './reducer';
|
||||||
import {
|
import {
|
||||||
useExceptionList,
|
useExceptionList,
|
||||||
ExceptionIdentifiers,
|
ExceptionIdentifiers,
|
||||||
|
@ -27,6 +27,8 @@ import {
|
||||||
import { ExceptionsViewerPagination } from './exceptions_pagination';
|
import { ExceptionsViewerPagination } from './exceptions_pagination';
|
||||||
import { ExceptionsViewerUtility } from './exceptions_utility';
|
import { ExceptionsViewerUtility } from './exceptions_utility';
|
||||||
import { ExceptionsViewerItems } from './exceptions_viewer_items';
|
import { ExceptionsViewerItems } from './exceptions_viewer_items';
|
||||||
|
import { EditExceptionModal } from '../edit_exception_modal';
|
||||||
|
import { AddExceptionModal } from '../add_exception_modal';
|
||||||
|
|
||||||
const initialState: State = {
|
const initialState: State = {
|
||||||
filterOptions: { filter: '', showEndpointList: false, showDetectionsList: false, tags: [] },
|
filterOptions: { filter: '', showEndpointList: false, showDetectionsList: false, tags: [] },
|
||||||
|
@ -44,27 +46,23 @@ const initialState: State = {
|
||||||
loadingLists: [],
|
loadingLists: [],
|
||||||
loadingItemIds: [],
|
loadingItemIds: [],
|
||||||
isInitLoading: true,
|
isInitLoading: true,
|
||||||
isModalOpen: false,
|
currentModal: null,
|
||||||
|
exceptionListTypeToEdit: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum ModalAction {
|
|
||||||
CREATE = 'CREATE',
|
|
||||||
EDIT = 'EDIT',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExceptionsViewerProps {
|
interface ExceptionsViewerProps {
|
||||||
ruleId: string;
|
ruleId: string;
|
||||||
|
ruleName: string;
|
||||||
exceptionListsMeta: ExceptionIdentifiers[];
|
exceptionListsMeta: ExceptionIdentifiers[];
|
||||||
availableListTypes: ExceptionListTypeEnum[];
|
availableListTypes: ExceptionListTypeEnum[];
|
||||||
commentsAccordionId: string;
|
commentsAccordionId: string;
|
||||||
onAssociateList?: (listId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExceptionsViewerComponent = ({
|
const ExceptionsViewerComponent = ({
|
||||||
ruleId,
|
ruleId,
|
||||||
|
ruleName,
|
||||||
exceptionListsMeta,
|
exceptionListsMeta,
|
||||||
availableListTypes,
|
availableListTypes,
|
||||||
onAssociateList,
|
|
||||||
commentsAccordionId,
|
commentsAccordionId,
|
||||||
}: ExceptionsViewerProps): JSX.Element => {
|
}: ExceptionsViewerProps): JSX.Element => {
|
||||||
const { services } = useKibana();
|
const { services } = useKibana();
|
||||||
|
@ -93,7 +91,9 @@ const ExceptionsViewerComponent = ({
|
||||||
loadingLists,
|
loadingLists,
|
||||||
loadingItemIds,
|
loadingItemIds,
|
||||||
isInitLoading,
|
isInitLoading,
|
||||||
isModalOpen,
|
currentModal,
|
||||||
|
exceptionToEdit,
|
||||||
|
exceptionListTypeToEdit,
|
||||||
},
|
},
|
||||||
dispatch,
|
dispatch,
|
||||||
] = useReducer(allExceptionItemsReducer(), { ...initialState, loadingLists: exceptionListsMeta });
|
] = useReducer(allExceptionItemsReducer(), { ...initialState, loadingLists: exceptionListsMeta });
|
||||||
|
@ -131,11 +131,11 @@ const ExceptionsViewerComponent = ({
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const setIsModalOpen = useCallback(
|
const setCurrentModal = useCallback(
|
||||||
(isOpen: boolean): void => {
|
(modalName: ViewerModalName): void => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'updateModalOpen',
|
type: 'updateModalOpen',
|
||||||
isOpen,
|
modalName,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
|
@ -161,9 +161,13 @@ const ExceptionsViewerComponent = ({
|
||||||
|
|
||||||
const handleAddException = useCallback(
|
const handleAddException = useCallback(
|
||||||
(type: ExceptionListTypeEnum): void => {
|
(type: ExceptionListTypeEnum): void => {
|
||||||
setIsModalOpen(true);
|
dispatch({
|
||||||
|
type: 'updateExceptionListTypeToEdit',
|
||||||
|
exceptionListType: type,
|
||||||
|
});
|
||||||
|
setCurrentModal('addModal');
|
||||||
},
|
},
|
||||||
[setIsModalOpen]
|
[setCurrentModal]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEditException = useCallback(
|
const handleEditException = useCallback(
|
||||||
|
@ -175,25 +179,15 @@ const ExceptionsViewerComponent = ({
|
||||||
exception,
|
exception,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsModalOpen(true);
|
setCurrentModal('editModal');
|
||||||
},
|
},
|
||||||
[setIsModalOpen]
|
[setCurrentModal]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseExceptionModal = useCallback(
|
const handleCloseExceptionModal = useCallback((): void => {
|
||||||
({ actionType, listId }): void => {
|
setCurrentModal(null);
|
||||||
setIsModalOpen(false);
|
handleFetchList();
|
||||||
|
}, [setCurrentModal, handleFetchList]);
|
||||||
// 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 setLoadingItemIds = useCallback(
|
const setLoadingItemIds = useCallback(
|
||||||
(items: ExceptionListItemIdentifiers[]): void => {
|
(items: ExceptionListItemIdentifiers[]): void => {
|
||||||
|
@ -254,16 +248,26 @@ const ExceptionsViewerComponent = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isModalOpen && (
|
{currentModal === 'editModal' &&
|
||||||
<EuiOverlayMask>
|
exceptionToEdit !== null &&
|
||||||
<EuiModal onClose={handleCloseExceptionModal}>
|
exceptionListTypeToEdit !== null && (
|
||||||
<EuiModalBody>
|
<EditExceptionModal
|
||||||
<EuiCodeBlock language="json" fontSize="m" paddingSize="m" overflowHeight={300}>
|
ruleName={ruleName}
|
||||||
{`Modal goes here`}
|
exceptionListType={exceptionListTypeToEdit}
|
||||||
</EuiCodeBlock>
|
exceptionItem={exceptionToEdit}
|
||||||
</EuiModalBody>
|
onCancel={handleCloseExceptionModal}
|
||||||
</EuiModal>
|
onConfirm={handleCloseExceptionModal}
|
||||||
</EuiOverlayMask>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentModal === 'addModal' && exceptionListTypeToEdit != null && (
|
||||||
|
<AddExceptionModal
|
||||||
|
ruleName={ruleName}
|
||||||
|
ruleId={ruleId}
|
||||||
|
exceptionListType={exceptionListTypeToEdit}
|
||||||
|
onCancel={handleCloseExceptionModal}
|
||||||
|
onConfirm={handleCloseExceptionModal}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Panel loading={isInitLoading || loadingList}>
|
<Panel loading={isInitLoading || loadingList}>
|
||||||
|
|
|
@ -6,11 +6,14 @@
|
||||||
import { FilterOptions, ExceptionsPagination, ExceptionListItemIdentifiers } from '../types';
|
import { FilterOptions, ExceptionsPagination, ExceptionListItemIdentifiers } from '../types';
|
||||||
import {
|
import {
|
||||||
ExceptionList,
|
ExceptionList,
|
||||||
|
ExceptionListType,
|
||||||
ExceptionListItemSchema,
|
ExceptionListItemSchema,
|
||||||
ExceptionIdentifiers,
|
ExceptionIdentifiers,
|
||||||
Pagination,
|
Pagination,
|
||||||
} from '../../../../../public/lists_plugin_deps';
|
} from '../../../../../public/lists_plugin_deps';
|
||||||
|
|
||||||
|
export type ViewerModalName = 'addModal' | 'editModal' | null;
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
filterOptions: FilterOptions;
|
filterOptions: FilterOptions;
|
||||||
pagination: ExceptionsPagination;
|
pagination: ExceptionsPagination;
|
||||||
|
@ -22,7 +25,8 @@ export interface State {
|
||||||
loadingLists: ExceptionIdentifiers[];
|
loadingLists: ExceptionIdentifiers[];
|
||||||
loadingItemIds: ExceptionListItemIdentifiers[];
|
loadingItemIds: ExceptionListItemIdentifiers[];
|
||||||
isInitLoading: boolean;
|
isInitLoading: boolean;
|
||||||
isModalOpen: boolean;
|
currentModal: ViewerModalName;
|
||||||
|
exceptionListTypeToEdit: ExceptionListType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
|
@ -39,9 +43,10 @@ export type Action =
|
||||||
allLists: ExceptionIdentifiers[];
|
allLists: ExceptionIdentifiers[];
|
||||||
}
|
}
|
||||||
| { type: 'updateIsInitLoading'; loading: boolean }
|
| { type: 'updateIsInitLoading'; loading: boolean }
|
||||||
| { type: 'updateModalOpen'; isOpen: boolean }
|
| { type: 'updateModalOpen'; modalName: ViewerModalName }
|
||||||
| { type: 'updateExceptionToEdit'; exception: ExceptionListItemSchema }
|
| { 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 => {
|
export const allExceptionItemsReducer = () => (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
@ -116,15 +121,26 @@ export const allExceptionItemsReducer = () => (state: State, action: Action): St
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'updateExceptionToEdit': {
|
case 'updateExceptionToEdit': {
|
||||||
|
const exception = action.exception;
|
||||||
|
const exceptionListToEdit = [state.endpointList, state.detectionsList].find((list) => {
|
||||||
|
return list !== null && exception.list_id === list.list_id;
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
exceptionToEdit: action.exception,
|
exceptionToEdit: action.exception,
|
||||||
|
exceptionListTypeToEdit: exceptionListToEdit ? exceptionListToEdit.type : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'updateModalOpen': {
|
case 'updateModalOpen': {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isModalOpen: action.isOpen,
|
currentModal: action.modalName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'updateExceptionListTypeToEdit': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
exceptionListTypeToEdit: action.exceptionListType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -10,6 +10,10 @@ export {
|
||||||
usePersistExceptionItem,
|
usePersistExceptionItem,
|
||||||
usePersistExceptionList,
|
usePersistExceptionList,
|
||||||
useFindLists,
|
useFindLists,
|
||||||
|
addExceptionListItem,
|
||||||
|
updateExceptionListItem,
|
||||||
|
fetchExceptionListById,
|
||||||
|
addExceptionList,
|
||||||
ExceptionIdentifiers,
|
ExceptionIdentifiers,
|
||||||
ExceptionList,
|
ExceptionList,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
@ -18,9 +22,13 @@ export {
|
||||||
export {
|
export {
|
||||||
ListSchema,
|
ListSchema,
|
||||||
CommentsArray,
|
CommentsArray,
|
||||||
|
CreateCommentsArray,
|
||||||
|
Comments,
|
||||||
|
CreateComments,
|
||||||
ExceptionListSchema,
|
ExceptionListSchema,
|
||||||
ExceptionListItemSchema,
|
ExceptionListItemSchema,
|
||||||
CreateExceptionListItemSchema,
|
CreateExceptionListItemSchema,
|
||||||
|
UpdateExceptionListItemSchema,
|
||||||
Entry,
|
Entry,
|
||||||
EntryExists,
|
EntryExists,
|
||||||
EntryNested,
|
EntryNested,
|
||||||
|
@ -39,4 +47,5 @@ export {
|
||||||
entriesNested,
|
entriesNested,
|
||||||
entriesExists,
|
entriesExists,
|
||||||
entriesList,
|
entriesList,
|
||||||
|
ExceptionListType,
|
||||||
} from '../../lists/common/schemas';
|
} from '../../lists/common/schemas';
|
||||||
|
|
|
@ -19,12 +19,13 @@ import { EventsLoading, EventsTd, EventsTdContent, EventsTdGroupActions } from '
|
||||||
import { eventHasNotes, getPinTooltip } from '../helpers';
|
import { eventHasNotes, getPinTooltip } from '../helpers';
|
||||||
import * as i18n from '../translations';
|
import * as i18n from '../translations';
|
||||||
import { OnRowSelected } from '../../events';
|
import { OnRowSelected } from '../../events';
|
||||||
import { Ecs } from '../../../../../graphql/types';
|
import { Ecs, TimelineNonEcsData } from '../../../../../graphql/types';
|
||||||
import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers';
|
import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers';
|
||||||
|
|
||||||
export interface TimelineRowActionOnClick {
|
export interface TimelineRowActionOnClick {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
ecsData: Ecs;
|
ecsData: Ecs;
|
||||||
|
data: TimelineNonEcsData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineRowAction {
|
export interface TimelineRowAction {
|
||||||
|
|
|
@ -145,7 +145,7 @@ export const EventColumnView = React.memo<Props>(
|
||||||
isDisabled={
|
isDisabled={
|
||||||
action.isActionDisabled != null ? action.isActionDisabled(ecsData) : false
|
action.isActionDisabled != null ? action.isActionDisabled(ecsData) : false
|
||||||
}
|
}
|
||||||
onClick={() => action.onClick({ eventId: id, ecsData })}
|
onClick={() => action.onClick({ eventId: id, ecsData, data })}
|
||||||
/>
|
/>
|
||||||
</EuiToolTip>
|
</EuiToolTip>
|
||||||
</EventsTdContent>,
|
</EventsTdContent>,
|
||||||
|
@ -164,7 +164,7 @@ export const EventColumnView = React.memo<Props>(
|
||||||
}
|
}
|
||||||
icon={action.iconType}
|
icon={action.iconType}
|
||||||
key={action.id}
|
key={action.id}
|
||||||
onClick={() => onClickCb(() => action.onClick({ eventId: id, ecsData }))}
|
onClick={() => onClickCb(() => action.onClick({ eventId: id, ecsData, data }))}
|
||||||
>
|
>
|
||||||
{action.content}
|
{action.content}
|
||||||
</EuiContextMenuItem>,
|
</EuiContextMenuItem>,
|
||||||
|
@ -195,7 +195,7 @@ export const EventColumnView = React.memo<Props>(
|
||||||
</EventsTdContent>,
|
</EventsTdContent>,
|
||||||
]
|
]
|
||||||
: grouped.icon;
|
: grouped.icon;
|
||||||
}, [button, closePopover, id, onClickCb, ecsData, timelineActions, isPopoverOpen]);
|
}, [button, closePopover, id, onClickCb, data, ecsData, timelineActions, isPopoverOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventsTrData data-test-subj="event-column-view">
|
<EventsTrData data-test-subj="event-column-view">
|
||||||
|
|
Loading…
Reference in a new issue