[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:
Pedro Jaramillo 2020-07-08 03:24:08 +02:00 committed by GitHub
parent 5f53597d75
commit 8facae7ad5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 2212 additions and 64 deletions

View file

@ -13,6 +13,12 @@ export { useFindLists } from './lists/hooks/use_find_lists';
export { useImportList } from './lists/hooks/use_import_list';
export { useDeleteList } from './lists/hooks/use_delete_list';
export { useExportList } from './lists/hooks/use_export_list';
export {
addExceptionListItem,
updateExceptionListItem,
fetchExceptionListById,
addExceptionList,
} from './exceptions/api';
export {
ExceptionList,
ExceptionIdentifiers,

View file

@ -37,6 +37,8 @@ import {
UpdateTimelineLoading,
} from './types';
import { Ecs } from '../../../graphql/types';
import { AddExceptionOnClick } from '../../../common/components/exceptions/add_exception_modal';
import { getMappedNonEcsValue } from '../../../common/components/exceptions/helpers';
export const buildAlertStatusFilter = (status: Status): Filter[] => [
{
@ -172,6 +174,13 @@ export const requiredFieldsForActions = [
'signal.rule.query',
'signal.rule.to',
'signal.rule.id',
// Endpoint exception fields
'file.path',
'file.Ext.code_signature.subject_name',
'file.Ext.code_signature.trusted',
'file.hash.sha1',
'host.os.family',
];
interface AlertActionArgs {
@ -188,6 +197,12 @@ interface AlertActionArgs {
status: Status;
timelineId: string;
updateTimelineIsLoading: UpdateTimelineLoading;
openAddExceptionModal: ({
exceptionListType,
alertData,
ruleName,
ruleId,
}: AddExceptionOnClick) => void;
}
export const getAlertActions = ({
@ -204,6 +219,7 @@ export const getAlertActions = ({
status,
timelineId,
updateTimelineIsLoading,
openAddExceptionModal,
}: AlertActionArgs): TimelineRowAction[] => {
const openAlertActionComponent: TimelineRowAction = {
ariaLabel: 'Open alert',
@ -289,5 +305,52 @@ export const getAlertActions = ({
...(FILTER_OPEN !== status ? [openAlertActionComponent] : []),
...(FILTER_CLOSED !== status ? [closeAlertActionComponent] : []),
...(FILTER_IN_PROGRESS !== status ? [inProgressAlertActionComponent] : []),
// TODO: disable this option if the alert is not an Endpoint alert
{
onClick: ({ ecsData, data }: TimelineRowActionOnClick) => {
const ruleNameValue = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' });
const ruleId = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' });
if (ruleId !== undefined && ruleId.length > 0) {
openAddExceptionModal({
ruleName: ruleNameValue ? ruleNameValue[0] : '',
ruleId: ruleId[0],
exceptionListType: 'endpoint',
alertData: {
ecsData,
nonEcsData: data,
},
});
}
},
id: 'addEndpointException',
isActionDisabled: () => !canUserCRUD || !hasIndexWrite,
dataTestSubj: 'add-endpoint-exception-menu-item',
ariaLabel: 'Add Endpoint Exception',
content: <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',
},
];
};

View file

@ -50,6 +50,10 @@ import {
} from '../../../common/components/toasters';
import { Ecs } from '../../../graphql/types';
import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers';
import {
AddExceptionModal,
AddExceptionOnClick,
} from '../../../common/components/exceptions/add_exception_modal';
interface OwnProps {
timelineId: TimelineIdLiteral;
@ -64,6 +68,13 @@ interface OwnProps {
type AlertsTableComponentProps = OwnProps & PropsFromRedux;
const addExceptionModalInitialState: AddExceptionOnClick = {
ruleName: '',
ruleId: '',
exceptionListType: 'detection',
alertData: undefined,
};
export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
timelineId,
canUserCRUD,
@ -92,6 +103,10 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
const [showClearSelectionAction, setShowClearSelectionAction] = useState(false);
const [filterGroup, setFilterGroup] = useState<Status>(FILTER_OPEN);
const [shouldShowAddExceptionModal, setShouldShowAddExceptionModal] = useState(false);
const [addExceptionModalState, setAddExceptionModalState] = useState<AddExceptionOnClick>(
addExceptionModalInitialState
);
const [{ browserFields, indexPatterns }] = useFetchIndexPatterns(
signalsIndex !== '' ? [signalsIndex] : []
);
@ -192,6 +207,21 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
[dispatchToaster]
);
const openAddExceptionModalCallback = useCallback(
({ ruleName, ruleId, exceptionListType, alertData }: AddExceptionOnClick) => {
if (alertData !== null && alertData !== undefined) {
setShouldShowAddExceptionModal(true);
setAddExceptionModalState({
ruleName,
ruleId,
exceptionListType,
alertData,
});
}
},
[setShouldShowAddExceptionModal, setAddExceptionModalState]
);
// Catches state change isSelectAllChecked->false upon user selection change to reset utility bar
useEffect(() => {
if (!isSelectAllChecked) {
@ -306,6 +336,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
status: filterGroup,
timelineId,
updateTimelineIsLoading,
openAddExceptionModal: openAddExceptionModalCallback,
}),
[
apolloClient,
@ -320,6 +351,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
updateTimelineIsLoading,
onAlertStatusUpdateSuccess,
onAlertStatusUpdateFailure,
openAddExceptionModalCallback,
]
);
const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]);
@ -360,6 +392,19 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
[onFilterGroupChangedCallback]
);
const closeAddExceptionModal = useCallback(() => {
setShouldShowAddExceptionModal(false);
setAddExceptionModalState(addExceptionModalInitialState);
}, [setShouldShowAddExceptionModal, setAddExceptionModalState]);
const onAddExceptionCancel = useCallback(() => {
closeAddExceptionModal();
}, [closeAddExceptionModal]);
const onAddExceptionConfirm = useCallback(() => {
closeAddExceptionModal();
}, [closeAddExceptionModal]);
if (loading || isEmpty(signalsIndex)) {
return (
<EuiPanel>
@ -370,16 +415,28 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
}
return (
<StatefulEventsViewer
defaultIndices={defaultIndices}
pageFilters={defaultFiltersMemo}
defaultModel={alertsDefaultModel}
end={to}
headerFilterGroup={headerFilterGroup}
id={timelineId}
start={from}
utilityBar={utilityBarCallback}
/>
<>
<StatefulEventsViewer
defaultIndices={defaultIndices}
pageFilters={defaultFiltersMemo}
defaultModel={alertsDefaultModel}
end={to}
headerFilterGroup={headerFilterGroup}
id={timelineId}
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}
/>
)}
</>
);
};

View file

@ -122,6 +122,20 @@ export const ACTION_INVESTIGATE_IN_TIMELINE = i18n.translate(
}
);
export const ACTION_ADD_EXCEPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.actions.addException',
{
defaultMessage: 'Add exception',
}
);
export const ACTION_ADD_ENDPOINT_EXCEPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.actions.addEndpointException',
{
defaultMessage: 'Add Endpoint exception',
}
);
export const CLOSED_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.securitySolution.detectionEngine.alerts.closedAlertSuccessToastMessage', {
values: { totalAlerts },

View file

@ -6,6 +6,7 @@
import {
AddRulesProps,
PatchRuleProps,
NewRule,
PrePackagedRulesStatusResponse,
BasicFetchProps,
@ -20,6 +21,9 @@ import { ruleMock, savedRuleMock, rulesMock } from '../mock';
export const addRule = async ({ rule, signal }: AddRulesProps): Promise<NewRule> =>
Promise.resolve(ruleMock);
export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise<NewRule> =>
Promise.resolve(ruleMock);
export const getPrePackagedRulesStatus = async ({
signal,
}: {

View file

@ -28,6 +28,7 @@ import {
ImportDataResponse,
PrePackagedRulesStatusResponse,
BulkRuleResponse,
PatchRuleProps,
} from './types';
import { KibanaServices } from '../../../../common/lib/kibana';
import * as i18n from '../../../pages/detection_engine/rules/translations';
@ -47,6 +48,21 @@ export const addRule = async ({ rule, signal }: AddRulesProps): Promise<NewRule>
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
*

View file

@ -22,6 +22,7 @@ import {
listArray,
listArrayOrUndefined,
} from '../../../../../common/detection_engine/schemas/types';
import { PatchRulesSchema } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema';
/**
* Params is an "record", since it is a type of AlertActionParams which is action templates.
@ -80,6 +81,11 @@ export interface AddRulesProps {
signal: AbortSignal;
}
export interface PatchRuleProps {
ruleProperties: PatchRulesSchema;
signal: AbortSignal;
}
const MetaRule = t.intersection([
t.type({
from: t.string,

View file

@ -439,6 +439,7 @@ export const RuleDetailsPageComponent: FC<PropsFromRedux> = ({
{ruleDetailTab === RuleDetailTabs.exceptions && (
<ExceptionsViewer
ruleId={ruleId ?? ''}
ruleName={rule?.name ?? ''}
availableListTypes={exceptionLists.allowedExceptionListTypes}
commentsAccordionId={'ruleDetailsTabExceptions'}
exceptionListsMeta={exceptionLists.lists}

View file

@ -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>
);
});

View file

@ -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>
);
});

View file

@ -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:",
}
);

View file

@ -16,6 +16,7 @@ import {
OperatorTypeEnum,
OperatorEnum,
CreateExceptionListItemSchema,
ExceptionListType,
} from '../../../../../public/lists_plugin_deps';
import { AndOrBadge } from '../../and_or_badge';
import { BuilderButtonOptions } from './builder_button_options';
@ -43,8 +44,8 @@ interface OnChangeProps {
}
interface ExceptionBuilderProps {
exceptionListItems: ExceptionListItemSchema[];
listType: 'detection' | 'endpoint';
exceptionListItems: ExceptionsBuilderExceptionItem[];
listType: ExceptionListType;
listId: string;
listNamespaceType: NamespaceType;
ruleName: string;

View file

@ -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>
);
});

View file

@ -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:",
}
);

View file

@ -6,7 +6,7 @@
import React from 'react';
import { EuiText, EuiCommentProps, EuiAvatar } from '@elastic/eui';
import { capitalize } from 'lodash';
import { capitalize, union } from 'lodash';
import moment from 'moment';
import uuid from 'uuid';
@ -24,6 +24,8 @@ import { EXCEPTION_OPERATORS, isOperator } from '../autocomplete/operators';
import { OperatorOption } from '../autocomplete/types';
import {
CommentsArray,
Comments,
CreateComments,
Entry,
ExceptionListItemSchema,
NamespaceType,
@ -33,11 +35,15 @@ import {
entriesNested,
createExceptionListItemSchema,
exceptionListItemSchema,
UpdateExceptionListItemSchema,
ExceptionListType,
} from '../../../lists_plugin_deps';
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
export const isListType = (item: BuilderEntry): item is EmptyListEntry =>
item.type === OperatorTypeEnum.LIST;
import { TimelineNonEcsData } from '../../../graphql/types';
import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard';
/**
* Returns the operator type, may not need this if using io-ts types
@ -161,6 +167,10 @@ export const getOperatingSystems = (tags: string[]): string => {
return osMatches;
};
export const getOsTagValues = (tags: string[]): string[] => {
return tags.filter((tag) => tag.startsWith('os:')).map((os) => os.substring(3).trim());
};
export const getTagsInclude = ({
tags,
regex,
@ -221,6 +231,13 @@ export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[]
event: i18n.COMMENT_EVENT,
timelineIcon: <EuiAvatar size="l" name={comment.created_by.toUpperCase()} />,
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 = (
@ -292,7 +309,7 @@ export const getNewExceptionItem = ({
namespaceType,
ruleName,
}: {
listType: 'detection' | 'endpoint';
listType: ExceptionListType;
listId: string;
namespaceType: NamespaceType;
ruleName: string;
@ -341,3 +358,159 @@ export const filterExceptionItems = (
[]
);
};
export const formatExceptionItemForUpdate = (
exceptionItem: ExceptionListItemSchema
): UpdateExceptionListItemSchema => {
const {
created_at,
created_by,
list_id,
tie_breaker_id,
updated_at,
updated_by,
...fieldsToUpdate
} = exceptionItem;
return {
...fieldsToUpdate,
};
};
export const enrichExceptionItemsWithComments = (
exceptionItems: Array<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' }) ?? [],
},
],
},
];
};

View file

@ -200,3 +200,17 @@ export const ADD_NESTED_DESCRIPTION = i18n.translate(
defaultMessage: 'Add nested condition',
}
);
export const ADD_COMMENT_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.exceptions.viewer.addCommentPlaceholder',
{
defaultMessage: 'Add a new comment...',
}
);
export const ADD_TO_CLIPBOARD = i18n.translate(
'xpack.securitySolution.exceptions.viewer.addToClipboard',
{
defaultMessage: 'Add to clipboard',
}
);

View file

@ -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]
);
});
});
});
});

View file

@ -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];
};

View file

@ -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);
});
});
});
});

View file

@ -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];
};

View file

@ -22,6 +22,8 @@ jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../../public/lists_plugin_deps');
describe('ExceptionsViewer', () => {
const ruleName = 'test rule';
beforeEach(() => {
(useKibana as jest.Mock).mockReturnValue({
services: {
@ -65,6 +67,7 @@ describe('ExceptionsViewer', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionsViewer
ruleId={'123'}
ruleName={ruleName}
exceptionListsMeta={[
{
id: '5b543420',
@ -86,6 +89,7 @@ describe('ExceptionsViewer', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionsViewer
ruleId={'123'}
ruleName={ruleName}
exceptionListsMeta={[]}
availableListTypes={[ExceptionListTypeEnum.DETECTION]}
commentsAccordionId="commentsAccordion"
@ -113,6 +117,7 @@ describe('ExceptionsViewer', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionsViewer
ruleId={'123'}
ruleName={ruleName}
exceptionListsMeta={[
{
id: '5b543420',

View file

@ -5,7 +5,7 @@
*/
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 * as i18n from '../translations';
@ -15,7 +15,7 @@ import { Panel } from '../../../../common/components/panel';
import { Loader } from '../../../../common/components/loader';
import { ExceptionsViewerHeader } from './exceptions_viewer_header';
import { ExceptionListItemIdentifiers, Filter } from '../types';
import { allExceptionItemsReducer, State } from './reducer';
import { allExceptionItemsReducer, State, ViewerModalName } from './reducer';
import {
useExceptionList,
ExceptionIdentifiers,
@ -27,6 +27,8 @@ import {
import { ExceptionsViewerPagination } from './exceptions_pagination';
import { ExceptionsViewerUtility } from './exceptions_utility';
import { ExceptionsViewerItems } from './exceptions_viewer_items';
import { EditExceptionModal } from '../edit_exception_modal';
import { AddExceptionModal } from '../add_exception_modal';
const initialState: State = {
filterOptions: { filter: '', showEndpointList: false, showDetectionsList: false, tags: [] },
@ -44,27 +46,23 @@ const initialState: State = {
loadingLists: [],
loadingItemIds: [],
isInitLoading: true,
isModalOpen: false,
currentModal: null,
exceptionListTypeToEdit: null,
};
enum ModalAction {
CREATE = 'CREATE',
EDIT = 'EDIT',
}
interface ExceptionsViewerProps {
ruleId: string;
ruleName: string;
exceptionListsMeta: ExceptionIdentifiers[];
availableListTypes: ExceptionListTypeEnum[];
commentsAccordionId: string;
onAssociateList?: (listId: string) => void;
}
const ExceptionsViewerComponent = ({
ruleId,
ruleName,
exceptionListsMeta,
availableListTypes,
onAssociateList,
commentsAccordionId,
}: ExceptionsViewerProps): JSX.Element => {
const { services } = useKibana();
@ -93,7 +91,9 @@ const ExceptionsViewerComponent = ({
loadingLists,
loadingItemIds,
isInitLoading,
isModalOpen,
currentModal,
exceptionToEdit,
exceptionListTypeToEdit,
},
dispatch,
] = useReducer(allExceptionItemsReducer(), { ...initialState, loadingLists: exceptionListsMeta });
@ -131,11 +131,11 @@ const ExceptionsViewerComponent = ({
}),
});
const setIsModalOpen = useCallback(
(isOpen: boolean): void => {
const setCurrentModal = useCallback(
(modalName: ViewerModalName): void => {
dispatch({
type: 'updateModalOpen',
isOpen,
modalName,
});
},
[dispatch]
@ -161,9 +161,13 @@ const ExceptionsViewerComponent = ({
const handleAddException = useCallback(
(type: ExceptionListTypeEnum): void => {
setIsModalOpen(true);
dispatch({
type: 'updateExceptionListTypeToEdit',
exceptionListType: type,
});
setCurrentModal('addModal');
},
[setIsModalOpen]
[setCurrentModal]
);
const handleEditException = useCallback(
@ -175,25 +179,15 @@ const ExceptionsViewerComponent = ({
exception,
});
setIsModalOpen(true);
setCurrentModal('editModal');
},
[setIsModalOpen]
[setCurrentModal]
);
const handleCloseExceptionModal = useCallback(
({ actionType, listId }): void => {
setIsModalOpen(false);
// TODO: This callback along with fetchList can probably get
// passed to the modal for it to call itself maybe
if (actionType === ModalAction.CREATE && listId != null && onAssociateList != null) {
onAssociateList(listId);
}
handleFetchList();
},
[setIsModalOpen, handleFetchList, onAssociateList]
);
const handleCloseExceptionModal = useCallback((): void => {
setCurrentModal(null);
handleFetchList();
}, [setCurrentModal, handleFetchList]);
const setLoadingItemIds = useCallback(
(items: ExceptionListItemIdentifiers[]): void => {
@ -254,16 +248,26 @@ const ExceptionsViewerComponent = ({
return (
<>
{isModalOpen && (
<EuiOverlayMask>
<EuiModal onClose={handleCloseExceptionModal}>
<EuiModalBody>
<EuiCodeBlock language="json" fontSize="m" paddingSize="m" overflowHeight={300}>
{`Modal goes here`}
</EuiCodeBlock>
</EuiModalBody>
</EuiModal>
</EuiOverlayMask>
{currentModal === 'editModal' &&
exceptionToEdit !== null &&
exceptionListTypeToEdit !== null && (
<EditExceptionModal
ruleName={ruleName}
exceptionListType={exceptionListTypeToEdit}
exceptionItem={exceptionToEdit}
onCancel={handleCloseExceptionModal}
onConfirm={handleCloseExceptionModal}
/>
)}
{currentModal === 'addModal' && exceptionListTypeToEdit != null && (
<AddExceptionModal
ruleName={ruleName}
ruleId={ruleId}
exceptionListType={exceptionListTypeToEdit}
onCancel={handleCloseExceptionModal}
onConfirm={handleCloseExceptionModal}
/>
)}
<Panel loading={isInitLoading || loadingList}>

View file

@ -6,11 +6,14 @@
import { FilterOptions, ExceptionsPagination, ExceptionListItemIdentifiers } from '../types';
import {
ExceptionList,
ExceptionListType,
ExceptionListItemSchema,
ExceptionIdentifiers,
Pagination,
} from '../../../../../public/lists_plugin_deps';
export type ViewerModalName = 'addModal' | 'editModal' | null;
export interface State {
filterOptions: FilterOptions;
pagination: ExceptionsPagination;
@ -22,7 +25,8 @@ export interface State {
loadingLists: ExceptionIdentifiers[];
loadingItemIds: ExceptionListItemIdentifiers[];
isInitLoading: boolean;
isModalOpen: boolean;
currentModal: ViewerModalName;
exceptionListTypeToEdit: ExceptionListType | null;
}
export type Action =
@ -39,9 +43,10 @@ export type Action =
allLists: ExceptionIdentifiers[];
}
| { type: 'updateIsInitLoading'; loading: boolean }
| { type: 'updateModalOpen'; isOpen: boolean }
| { type: 'updateModalOpen'; modalName: ViewerModalName }
| { type: 'updateExceptionToEdit'; exception: ExceptionListItemSchema }
| { type: 'updateLoadingItemIds'; items: ExceptionListItemIdentifiers[] };
| { type: 'updateLoadingItemIds'; items: ExceptionListItemIdentifiers[] }
| { type: 'updateExceptionListTypeToEdit'; exceptionListType: ExceptionListType | null };
export const allExceptionItemsReducer = () => (state: State, action: Action): State => {
switch (action.type) {
@ -116,15 +121,26 @@ export const allExceptionItemsReducer = () => (state: State, action: Action): St
};
}
case 'updateExceptionToEdit': {
const exception = action.exception;
const exceptionListToEdit = [state.endpointList, state.detectionsList].find((list) => {
return list !== null && exception.list_id === list.list_id;
});
return {
...state,
exceptionToEdit: action.exception,
exceptionListTypeToEdit: exceptionListToEdit ? exceptionListToEdit.type : null,
};
}
case 'updateModalOpen': {
return {
...state,
isModalOpen: action.isOpen,
currentModal: action.modalName,
};
}
case 'updateExceptionListTypeToEdit': {
return {
...state,
exceptionListTypeToEdit: action.exceptionListType,
};
}
default:

View file

@ -10,6 +10,10 @@ export {
usePersistExceptionItem,
usePersistExceptionList,
useFindLists,
addExceptionListItem,
updateExceptionListItem,
fetchExceptionListById,
addExceptionList,
ExceptionIdentifiers,
ExceptionList,
Pagination,
@ -18,9 +22,13 @@ export {
export {
ListSchema,
CommentsArray,
CreateCommentsArray,
Comments,
CreateComments,
ExceptionListSchema,
ExceptionListItemSchema,
CreateExceptionListItemSchema,
UpdateExceptionListItemSchema,
Entry,
EntryExists,
EntryNested,
@ -39,4 +47,5 @@ export {
entriesNested,
entriesExists,
entriesList,
ExceptionListType,
} from '../../lists/common/schemas';

View file

@ -19,12 +19,13 @@ import { EventsLoading, EventsTd, EventsTdContent, EventsTdGroupActions } from '
import { eventHasNotes, getPinTooltip } from '../helpers';
import * as i18n from '../translations';
import { OnRowSelected } from '../../events';
import { Ecs } from '../../../../../graphql/types';
import { Ecs, TimelineNonEcsData } from '../../../../../graphql/types';
import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers';
export interface TimelineRowActionOnClick {
eventId: string;
ecsData: Ecs;
data: TimelineNonEcsData[];
}
export interface TimelineRowAction {

View file

@ -145,7 +145,7 @@ export const EventColumnView = React.memo<Props>(
isDisabled={
action.isActionDisabled != null ? action.isActionDisabled(ecsData) : false
}
onClick={() => action.onClick({ eventId: id, ecsData })}
onClick={() => action.onClick({ eventId: id, ecsData, data })}
/>
</EuiToolTip>
</EventsTdContent>,
@ -164,7 +164,7 @@ export const EventColumnView = React.memo<Props>(
}
icon={action.iconType}
key={action.id}
onClick={() => onClickCb(() => action.onClick({ eventId: id, ecsData }))}
onClick={() => onClickCb(() => action.onClick({ eventId: id, ecsData, data }))}
>
{action.content}
</EuiContextMenuItem>,
@ -195,7 +195,7 @@ export const EventColumnView = React.memo<Props>(
</EventsTdContent>,
]
: grouped.icon;
}, [button, closePopover, id, onClickCb, ecsData, timelineActions, isPopoverOpen]);
}, [button, closePopover, id, onClickCb, data, ecsData, timelineActions, isPopoverOpen]);
return (
<EventsTrData data-test-subj="event-column-view">