[Security Solution][Detections] Handle conflicts on alert status update (#75492)

* Proceed on conflict when updating alert status

* Handle conflicts

* Don't let the user retry

* Tweak error messages

* Fix route

* Update add exception modal

* Reapply changes after fixing conflicts

* Type errors

* types

* Fix remaining conflicts

* Fix tests

* More test fixes

* Simplify onConflict evaluation

* Add callback return types

* Update translation paths

* Add missing import

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Madison Caldwell 2020-09-04 13:11:51 -04:00 committed by GitHub
parent c778646318
commit a7326e683a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 145 additions and 60 deletions

View file

@ -283,6 +283,9 @@ export type Status = t.TypeOf<typeof status>;
export const job_status = t.keyof({ succeeded: null, failed: null, 'going to run': null });
export type JobStatus = t.TypeOf<typeof job_status>;
export const conflicts = t.keyof({ abort: null, proceed: null });
export type Conflicts = t.TypeOf<typeof conflicts>;
// TODO: Create a regular expression type or custom date math part type here
export const to = t.string;
export type To = t.TypeOf<typeof to>;

View file

@ -6,13 +6,14 @@
import * as t from 'io-ts';
import { signal_ids, signal_status_query, status } from '../common/schemas';
import { conflicts, signal_ids, signal_status_query, status } from '../common/schemas';
export const setSignalsStatusSchema = t.intersection([
t.type({
status,
}),
t.partial({
conflicts,
signal_ids,
query: signal_status_query,
}),

View file

@ -26,6 +26,7 @@ import {
CreateExceptionListItemSchema,
ExceptionListType,
} from '../../../../../public/lists_plugin_deps';
import * as i18nCommon from '../../../translations';
import * as i18n from './translations';
import * as sharedI18n from '../translations';
import { TimelineNonEcsData, Ecs } from '../../../../graphql/types';
@ -49,6 +50,7 @@ import {
} from '../helpers';
import { ErrorInfo, ErrorCallout } from '../error_callout';
import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules';
import { ExceptionsBuilderExceptionItem } from '../types';
export interface AddExceptionModalBaseProps {
ruleName: string;
@ -117,7 +119,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
Array<ExceptionListItemSchema | CreateExceptionListItemSchema>
>([]);
const [fetchOrCreateListError, setFetchOrCreateListError] = useState<ErrorInfo | null>(null);
const { addError, addSuccess } = useAppToasts();
const { addError, addSuccess, addWarning } = useAppToasts();
const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex();
const [
{ isLoading: isSignalIndexPatternLoading, indexPatterns: signalIndexPatterns },
@ -129,16 +131,26 @@ export const AddExceptionModal = memo(function AddExceptionModal({
);
const onError = useCallback(
(error: Error) => {
(error: Error): void => {
addError(error, { title: i18n.ADD_EXCEPTION_ERROR });
onCancel();
},
[addError, onCancel]
);
const onSuccess = useCallback(() => {
addSuccess(i18n.ADD_EXCEPTION_SUCCESS);
onConfirm(shouldCloseAlert, shouldBulkCloseAlert);
}, [addSuccess, onConfirm, shouldBulkCloseAlert, shouldCloseAlert]);
const onSuccess = useCallback(
(updated: number, conflicts: number): void => {
addSuccess(i18n.ADD_EXCEPTION_SUCCESS);
onConfirm(shouldCloseAlert, shouldBulkCloseAlert);
if (conflicts > 0) {
addWarning({
title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts),
text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts),
});
}
},
[addSuccess, addWarning, onConfirm, shouldBulkCloseAlert, shouldCloseAlert]
);
const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException(
{
@ -153,7 +165,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
exceptionItems,
}: {
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>;
}) => {
}): void => {
setExceptionItemsToAdd(exceptionItems);
},
[setExceptionItemsToAdd]
@ -186,7 +198,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
);
const handleFetchOrCreateExceptionListError = useCallback(
(error: Error, statusCode: number | null, message: string | null) => {
(error: Error, statusCode: number | null, message: string | null): void => {
setFetchOrCreateListError({
reason: error.message,
code: statusCode,
@ -205,7 +217,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
onSuccess: handleRuleChange,
});
const initialExceptionItems = useMemo(() => {
const initialExceptionItems = useMemo((): ExceptionsBuilderExceptionItem[] => {
if (exceptionListType === 'endpoint' && alertData !== undefined && ruleExceptionList) {
return defaultEndpointExceptionItems(
exceptionListType,
@ -218,7 +230,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
}
}, [alertData, exceptionListType, ruleExceptionList, ruleName]);
useEffect(() => {
useEffect((): void => {
if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) {
setShouldDisableBulkClose(
entryHasListType(exceptionItemsToAdd) ||
@ -234,34 +246,34 @@ export const AddExceptionModal = memo(function AddExceptionModal({
signalIndexPatterns,
]);
useEffect(() => {
useEffect((): void => {
if (shouldDisableBulkClose === true) {
setShouldBulkCloseAlert(false);
}
}, [shouldDisableBulkClose]);
const onCommentChange = useCallback(
(value: string) => {
(value: string): void => {
setComment(value);
},
[setComment]
);
const onCloseAlertCheckboxChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
(event: React.ChangeEvent<HTMLInputElement>): void => {
setShouldCloseAlert(event.currentTarget.checked);
},
[setShouldCloseAlert]
);
const onBulkCloseAlertCheckboxChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
(event: React.ChangeEvent<HTMLInputElement>): void => {
setShouldBulkCloseAlert(event.currentTarget.checked);
},
[setShouldBulkCloseAlert]
);
const retrieveAlertOsTypes = useCallback(() => {
const retrieveAlertOsTypes = useCallback((): string[] => {
const osDefaults = ['windows', 'macos'];
if (alertData) {
const osTypes = getMappedNonEcsValue({
@ -276,7 +288,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({
return osDefaults;
}, [alertData]);
const enrichExceptionItems = useCallback(() => {
const enrichExceptionItems = useCallback((): Array<
ExceptionListItemSchema | CreateExceptionListItemSchema
> => {
let enriched: Array<ExceptionListItemSchema | CreateExceptionListItemSchema> = [];
enriched =
comment !== ''
@ -289,7 +303,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
return enriched;
}, [comment, exceptionItemsToAdd, exceptionListType, retrieveAlertOsTypes]);
const onAddExceptionConfirm = useCallback(() => {
const onAddExceptionConfirm = useCallback((): void => {
if (addOrUpdateExceptionItems !== null) {
const alertIdToClose = shouldCloseAlert && alertData ? alertData.ecsData._id : undefined;
const bulkCloseIndex =
@ -306,7 +320,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
]);
const isSubmitButtonDisabled = useMemo(
() =>
(): boolean =>
fetchOrCreateListError != null ||
exceptionItemsToAdd.every((item) => item.entries.length === 0),
[fetchOrCreateListError, exceptionItemsToAdd]

View file

@ -49,7 +49,7 @@ describe('useAddOrUpdateException', () => {
const onError = jest.fn();
const onSuccess = jest.fn();
const alertIdToClose = 'idToClose';
const bulkCloseIndex = ['.signals'];
const bulkCloseIndex = ['.custom'];
const itemsToAdd: CreateExceptionListItemSchema[] = [
{
...getCreateExceptionListItemSchemaMock(),

View file

@ -5,6 +5,7 @@
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { UpdateDocumentByQueryResponse } from 'elasticsearch';
import { HttpStart } from '../../../../../../../src/core/public';
import {
@ -43,7 +44,7 @@ export type ReturnUseAddOrUpdateException = [
export interface UseAddOrUpdateExceptionProps {
http: HttpStart;
onError: (arg: Error, code: number | null, message: string | null) => void;
onSuccess: () => void;
onSuccess: (updated: number, conficts: number) => void;
}
/**
@ -122,8 +123,10 @@ export const useAddOrUpdateException = ({
) => {
try {
setIsLoading(true);
if (alertIdToClose !== null && alertIdToClose !== undefined) {
await updateAlertStatus({
let alertIdResponse: UpdateDocumentByQueryResponse | undefined;
let bulkResponse: UpdateDocumentByQueryResponse | undefined;
if (alertIdToClose != null) {
alertIdResponse = await updateAlertStatus({
query: getUpdateAlertsQuery([alertIdToClose]),
status: 'closed',
signal: abortCtrl.signal,
@ -139,7 +142,8 @@ export const useAddOrUpdateException = ({
prepareExceptionItemsForBulkClose(exceptionItemsToAddOrUpdate),
false
);
await updateAlertStatus({
bulkResponse = await updateAlertStatus({
query: {
query: filter,
},
@ -150,9 +154,18 @@ export const useAddOrUpdateException = ({
await addOrUpdateItems(exceptionItemsToAddOrUpdate);
// NOTE: there could be some overlap here... it's possible that the first response had conflicts
// but that the alert was closed in the second call. In this case, a conflict will be reported even
// though it was already resolved. I'm not sure that there's an easy way to solve this, but it should
// have minimal impact on the user... they'd see a warning that indicates a possible conflict, but the
// state of the alerts and their representation in the UI would be consistent.
const updated = (alertIdResponse?.updated ?? 0) + (bulkResponse?.updated ?? 0);
const conflicts =
alertIdResponse?.version_conflicts ?? 0 + (bulkResponse?.version_conflicts ?? 0);
if (isSubscribed) {
setIsLoading(false);
onSuccess();
onSuccess(updated, conflicts);
}
} catch (error) {
if (isSubscribed) {

View file

@ -14,13 +14,16 @@ jest.mock('../lib/kibana');
describe('useDeleteList', () => {
let addErrorMock: jest.Mock;
let addSuccessMock: jest.Mock;
let addWarningMock: jest.Mock;
beforeEach(() => {
addErrorMock = jest.fn();
addSuccessMock = jest.fn();
addWarningMock = jest.fn();
(useToasts as jest.Mock).mockImplementation(() => ({
addError: addErrorMock,
addSuccess: addSuccessMock,
addWarning: addWarningMock,
}));
});

View file

@ -10,7 +10,7 @@ import { ErrorToastOptions, ToastsStart, Toast } from '../../../../../../src/cor
import { useToasts } from '../lib/kibana';
import { isAppError, AppError } from '../utils/api';
export type UseAppToasts = Pick<ToastsStart, 'addSuccess'> & {
export type UseAppToasts = Pick<ToastsStart, 'addSuccess' | 'addWarning'> & {
api: ToastsStart;
addError: (error: unknown, options: ErrorToastOptions) => Toast;
};
@ -19,6 +19,7 @@ export const useAppToasts = (): UseAppToasts => {
const toasts = useToasts();
const addError = useRef(toasts.addError.bind(toasts)).current;
const addSuccess = useRef(toasts.addSuccess.bind(toasts)).current;
const addWarning = useRef(toasts.addWarning.bind(toasts)).current;
const addAppError = useCallback(
(error: AppError, options: ErrorToastOptions) =>
@ -44,5 +45,5 @@ export const useAppToasts = (): UseAppToasts => {
[addAppError, addError]
);
return { api: toasts, addError: _addError, addSuccess };
return { api: toasts, addError: _addError, addSuccess, addWarning };
};

View file

@ -61,3 +61,17 @@ export const EMPTY_ACTION_ENDPOINT_DESCRIPTION = i18n.translate(
'Protect your hosts with threat prevention, detection, and deep security data visibility.',
}
);
export const UPDATE_ALERT_STATUS_FAILED = (conflicts: number) =>
i18n.translate('xpack.securitySolution.pages.common.updateAlertStatusFailed', {
values: { conflicts },
defaultMessage:
'Failed to update { conflicts } {conflicts, plural, =1 {alert} other {alerts}}.',
});
export const UPDATE_ALERT_STATUS_FAILED_DETAILED = (updated: number, conflicts: number) =>
i18n.translate('xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed', {
values: { updated, conflicts },
defaultMessage: `{ updated } {updated, plural, =1 {alert was} other {alerts were}} updated successfully, but { conflicts } failed to update
because { conflicts, plural, =1 {it was} other {they were}} already being modified.`,
});

View file

@ -9,6 +9,7 @@
import dateMath from '@elastic/datemath';
import { get, getOr, isEmpty, find } from 'lodash/fp';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { TimelineId } from '../../../../common/types/timeline';
import { updateAlertStatus } from '../../containers/detection_engine/alerts/api';
@ -83,7 +84,18 @@ export const updateAlertStatusAction = async ({
// TODO: Only delete those that were successfully updated from updatedRules
setEventsDeleted({ eventIds: alertIds, isDeleted: true });
onAlertStatusUpdateSuccess(response.updated, selectedStatus);
if (response.version_conflicts > 0 && alertIds.length === 1) {
throw new Error(
i18n.translate(
'xpack.securitySolution.detectionEngine.alerts.updateAlertStatusFailedSingleAlert',
{
defaultMessage: 'Failed to update alert because it was already being modified.',
}
)
);
}
onAlertStatusUpdateSuccess(response.updated, response.version_conflicts, selectedStatus);
} catch (error) {
onAlertStatusUpdateFailure(selectedStatus, error);
} finally {

View file

@ -9,10 +9,10 @@ import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Dispatch } from 'redux';
import { Status } from '../../../../common/detection_engine/schemas/common/schemas';
import { Filter, esQuery } from '../../../../../../../src/plugins/data/public';
import { TimelineIdLiteral } from '../../../../common/types/timeline';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import { useFetchIndexPatterns } from '../../containers/detection_engine/rules/fetch_index_patterns';
import { StatefulEventsViewer } from '../../../common/components/events_viewer';
import { HeaderSection } from '../../../common/components/header_section';
@ -32,6 +32,7 @@ import {
} from './default_config';
import { FILTER_OPEN, AlertsTableFilterGroup } from './alerts_filter_group';
import { AlertsUtilityBar } from './alerts_utility_bar';
import * as i18nCommon from '../../../common/translations';
import * as i18n from './translations';
import {
SetEventsDeletedProps,
@ -90,6 +91,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
);
const kibana = useKibana();
const [, dispatchToaster] = useStateToaster();
const { addWarning } = useAppToasts();
const { initializeTimeline, setSelectAll, setIndexToAdd } = useManageTimeline();
const getGlobalQuery = useCallback(
@ -130,21 +132,29 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
);
const onAlertStatusUpdateSuccess = useCallback(
(count: number, status: Status) => {
let title: string;
switch (status) {
case 'closed':
title = i18n.CLOSED_ALERT_SUCCESS_TOAST(count);
break;
case 'open':
title = i18n.OPENED_ALERT_SUCCESS_TOAST(count);
break;
case 'in-progress':
title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(count);
(updated: number, conflicts: number, status: Status) => {
if (conflicts > 0) {
// Partial failure
addWarning({
title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts),
text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts),
});
} else {
let title: string;
switch (status) {
case 'closed':
title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated);
break;
case 'open':
title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated);
break;
case 'in-progress':
title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated);
}
displaySuccessToast(title, dispatchToaster);
}
displaySuccessToast(title, dispatchToaster);
},
[dispatchToaster]
[addWarning, dispatchToaster]
);
const onAlertStatusUpdateFailure = useCallback(

View file

@ -15,6 +15,7 @@ import {
} from '@elastic/eui';
import styled from 'styled-components';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { TimelineId } from '../../../../../common/types/timeline';
import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants';
import { Status, Type } from '../../../../../common/detection_engine/schemas/common/schemas';
@ -32,6 +33,7 @@ import {
AddExceptionModalBaseProps,
} from '../../../../common/components/exceptions/add_exception_modal';
import { getMappedNonEcsValue } from '../../../../common/components/exceptions/helpers';
import * as i18nCommon from '../../../../common/translations';
import * as i18n from '../translations';
import {
useStateToaster,
@ -72,6 +74,8 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
);
const eventId = ecsRowData._id;
const { addWarning } = useAppToasts();
const onButtonClick = useCallback(() => {
setPopover(!isPopoverOpen);
}, [isPopoverOpen]);
@ -124,22 +128,30 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
);
const onAlertStatusUpdateSuccess = useCallback(
(count: number, newStatus: Status) => {
let title: string;
switch (newStatus) {
case 'closed':
title = i18n.CLOSED_ALERT_SUCCESS_TOAST(count);
break;
case 'open':
title = i18n.OPENED_ALERT_SUCCESS_TOAST(count);
break;
case 'in-progress':
title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(count);
(updated: number, conflicts: number, newStatus: Status) => {
if (conflicts > 0) {
// Partial failure
addWarning({
title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts),
text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts),
});
} else {
let title: string;
switch (newStatus) {
case 'closed':
title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated);
break;
case 'open':
title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated);
break;
case 'in-progress':
title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated);
}
displaySuccessToast(title, dispatchToaster);
}
displaySuccessToast(title, dispatchToaster);
setAlertStatus(newStatus);
},
[dispatchToaster]
[dispatchToaster, addWarning]
);
const onAlertStatusUpdateFailure = useCallback(

View file

@ -44,7 +44,7 @@ export interface UpdateAlertStatusActionProps {
selectedStatus: Status;
setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void;
setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void;
onAlertStatusUpdateSuccess: (count: number, status: Status) => void;
onAlertStatusUpdateSuccess: (updated: number, conflicts: number, status: Status) => void;
onAlertStatusUpdateFailure: (status: Status, error: Error) => void;
}

View file

@ -67,7 +67,7 @@ describe('Detections Alerts API', () => {
});
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/signals/status', {
body:
'{"status":"closed","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}',
'{"conflicts":"proceed","status":"closed","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}',
method: 'POST',
signal: abortCtrl.signal,
});
@ -81,7 +81,7 @@ describe('Detections Alerts API', () => {
});
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/signals/status', {
body:
'{"status":"open","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}',
'{"conflicts":"proceed","status":"open","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}',
method: 'POST',
signal: abortCtrl.signal,
});

View file

@ -58,7 +58,7 @@ export const updateAlertStatus = async ({
}: UpdateAlertStatusProps): Promise<UpdateDocumentByQueryResponse> =>
KibanaServices.get().http.fetch(DETECTION_ENGINE_SIGNALS_STATUS_URL, {
method: 'POST',
body: JSON.stringify({ status, ...query }),
body: JSON.stringify({ conflicts: 'proceed', status, ...query }),
signal,
});

View file

@ -28,11 +28,12 @@ export const setSignalsStatusRoute = (router: IRouter) => {
},
},
async (context, request, response) => {
const { signal_ids: signalIds, query, status } = request.body;
const { conflicts, signal_ids: signalIds, query, status } = request.body;
const clusterClient = context.core.elasticsearch.legacy.client;
const siemClient = context.securitySolution?.getAppClient();
const siemResponse = buildSiemResponse(response);
const validationErrors = setSignalStatusValidateTypeDependents(request.body);
if (validationErrors.length) {
return siemResponse.error({ statusCode: 400, body: validationErrors });
}
@ -55,6 +56,7 @@ export const setSignalsStatusRoute = (router: IRouter) => {
try {
const result = await clusterClient.callAsCurrentUser('updateByQuery', {
index: siemClient.getSignalsIndex(),
conflicts: conflicts ?? 'abort',
refresh: 'wait_for',
body: {
script: {