[Security Solutions][Detections] - Fix exception list table referential deletion (#87231)

### Summary

This PR concentrates on fixing the deletion on the exceptions list table view. This fix is intermediary and a more thorough, backend solution is needed. Currently, if you delete an exception list, it deletes the exception list SO, but does not remove references to it from rules. This PR allows for a quick fix conducting this logic client side.
This commit is contained in:
Yara Tercero 2021-01-05 12:49:53 -05:00 committed by GitHub
parent a4bbf470e2
commit 51efc19920
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 339 additions and 71 deletions

View file

@ -86,7 +86,7 @@ export interface ApiCallByIdProps {
export interface ApiCallMemoProps {
id: string;
namespaceType: NamespaceType;
onError: (arg: string[]) => void;
onError: (arg: Error) => void;
onSuccess: () => void;
}

View file

@ -1,6 +1,6 @@
{
"list_id": "detection_list_1",
"item_id": "simple_list_item_two_non-value_list",
"item_id": "simple_list_item_one_non-value_list",
"tags": [
"user added string for a tag",
"malware"

View file

@ -0,0 +1,28 @@
{
"list_id": "detection_list_2",
"item_id": "simple_list_item_two_non-value_list",
"tags": [
"user added string for a tag",
"malware"
],
"type": "simple",
"description": "This is a sample exception list item with two non-value list entries",
"name": "Sample Detection Exception List Item",
"os_types": [
"windows"
],
"comments": [],
"entries": [
{
"field": "actingProcess.file.signer",
"operator": "excluded",
"type": "exists"
},
{
"field": "host.name",
"operator": "included",
"type": "match_any",
"value": ["some host", "another host"]
}
]
}

View file

@ -0,0 +1,28 @@
{
"list_id": "detection_list_3",
"item_id": "simple_list_item_three_non-value_list",
"tags": [
"user added string for a tag",
"malware"
],
"type": "simple",
"description": "This is a sample exception list item with two non-value list entries",
"name": "Sample Detection Exception List Item",
"os_types": [
"windows"
],
"comments": [],
"entries": [
{
"field": "actingProcess.file.signer",
"operator": "excluded",
"type": "exists"
},
{
"field": "host.name",
"operator": "included",
"type": "match_any",
"value": ["some host", "another host"]
}
]
}

View file

@ -9,6 +9,7 @@ import React from 'react';
import { EuiButtonIcon, EuiBasicTableColumn, EuiToolTip } from '@elastic/eui';
import { History } from 'history';
import { Spacer } from '../../../../../../common/components/page';
import { NamespaceType } from '../../../../../../../../lists/common';
import { FormatUrl } from '../../../../../../common/components/link_to';
import { LinkAnchor } from '../../../../../../common/components/links';
@ -17,15 +18,10 @@ import { ExceptionListInfo } from './use_all_exception_lists';
import { getRuleDetailsUrl } from '../../../../../../common/components/link_to/redirect_to_detection_engine';
export type AllExceptionListsColumns = EuiBasicTableColumn<ExceptionListInfo>;
export type Func = (arg: {
id: string;
listId: string;
namespaceType: NamespaceType;
}) => () => void;
export const getAllExceptionListsColumns = (
onExport: Func,
onDelete: Func,
onExport: (arg: { id: string; listId: string; namespaceType: NamespaceType }) => () => void,
onDelete: (arg: { id: string; listId: string; namespaceType: NamespaceType }) => () => void,
history: History,
formatUrl: FormatUrl
): AllExceptionListsColumns[] => [
@ -64,8 +60,9 @@ export const getAllExceptionListsColumns = (
return (
<>
{value.map(({ id, name }, index) => (
<>
<Spacer key={id}>
<LinkAnchor
key={id}
data-test-subj="ruleName"
onClick={(ev: { preventDefault: () => void }) => {
ev.preventDefault();
@ -76,7 +73,7 @@ export const getAllExceptionListsColumns = (
{name}
</LinkAnchor>
{index !== value.length - 1 ? ', ' : ''}
</>
</Spacer>
))}
</>
);
@ -120,11 +117,7 @@ export const getAllExceptionListsColumns = (
render: ({ id, list_id: listId, namespace_type: namespaceType }: ExceptionListInfo) => (
<EuiButtonIcon
color="danger"
onClick={onDelete({
id,
listId,
namespaceType,
})}
onClick={onDelete({ id, listId, namespaceType })}
aria-label="Delete exception list"
iconType="trash"
/>

View file

@ -29,6 +29,8 @@ import { AllRulesUtilityBar } from '../utility_bar';
import { LastUpdatedAt } from '../../../../../../common/components/last_updated';
import { AllExceptionListsColumns, getAllExceptionListsColumns } from './columns';
import { useAllExceptionLists } from './use_all_exception_lists';
import { ReferenceErrorModal } from '../../../../../components/value_lists_management_modal/reference_error_modal';
import { patchRule } from '../../../../../containers/detection_engine/rules/api';
// Known lost battle with Eui :(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -48,12 +50,33 @@ interface ExceptionListsTableProps {
formatUrl: FormatUrl;
}
interface ReferenceModalState {
contentText: string;
rulesReferences: string[];
isLoading: boolean;
listId: string;
listNamespaceType: NamespaceType;
}
const exceptionReferenceModalInitialState: ReferenceModalState = {
contentText: '',
rulesReferences: [],
isLoading: false,
listId: '',
listNamespaceType: 'single',
};
export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
({ formatUrl, history, hasNoPermissions, loading }) => {
const {
services: { http, notifications },
} = useKibana();
const { exportExceptionList } = useApi(http);
const { exportExceptionList, deleteExceptionList } = useApi(http);
const [showReferenceErrorModal, setShowReferenceErrorModal] = useState(false);
const [referenceModalState, setReferenceModalState] = useState<ReferenceModalState>(
exceptionReferenceModalInitialState
);
const [filters, setFilters] = useState<ExceptionListFilter>({
name: null,
list_id: null,
@ -67,15 +90,36 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
notifications,
showTrustedApps: false,
});
const [loadingTableInfo, data] = useAllExceptionLists({
exceptionLists: exceptions ?? [],
});
const [loadingTableInfo, exceptionListsWithRuleRefs, exceptionsListsRef] = useAllExceptionLists(
{
exceptionLists: exceptions ?? [],
}
);
const [initLoading, setInitLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState(Date.now());
const [deletingListIds, setDeletingListIds] = useState<string[]>([]);
const [exportingListIds, setExportingListIds] = useState<string[]>([]);
const [exportDownload, setExportDownload] = useState<{ name?: string; blob?: Blob }>({});
const handleDeleteSuccess = useCallback(
(listId?: string) => () => {
notifications.toasts.addSuccess({
title: i18n.exceptionDeleteSuccessMessage(listId ?? referenceModalState.listId),
});
},
[notifications.toasts, referenceModalState.listId]
);
const handleDeleteError = useCallback(
(err: Error & { body?: { message: string } }): void => {
notifications.toasts.addError(err, {
title: i18n.EXCEPTION_DELETE_ERROR,
toastMessage: err.body != null ? err.body.message : err.message,
});
},
[notifications.toasts]
);
const handleDelete = useCallback(
({
id,
@ -88,14 +132,45 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
}) => async () => {
try {
setDeletingListIds((ids) => [...ids, id]);
if (refreshExceptions != null) {
await refreshExceptions();
}
if (exceptionsListsRef[id] != null && exceptionsListsRef[id].rules.length === 0) {
await deleteExceptionList({
id,
namespaceType,
onError: handleDeleteError,
onSuccess: handleDeleteSuccess(listId),
});
if (refreshExceptions != null) {
refreshExceptions();
}
} else {
setReferenceModalState({
contentText: i18n.referenceErrorMessage(exceptionsListsRef[id].rules.length),
rulesReferences: exceptionsListsRef[id].rules.map(({ name }) => name),
isLoading: true,
listId: id,
listNamespaceType: namespaceType,
});
setShowReferenceErrorModal(true);
}
// route to patch rules with associated exception list
} catch (error) {
notifications.toasts.addError(error, { title: i18n.EXCEPTION_DELETE_ERROR });
handleDeleteError(error);
} finally {
setDeletingListIds((ids) => [...ids.filter((_id) => _id !== id)]);
}
},
[notifications.toasts]
[
deleteExceptionList,
exceptionsListsRef,
handleDeleteError,
handleDeleteSuccess,
refreshExceptions,
]
);
const handleExportSuccess = useCallback(
@ -182,6 +257,67 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
setFilters(formattedFilter);
}, []);
const handleCloseReferenceErrorModal = useCallback((): void => {
setDeletingListIds([]);
setShowReferenceErrorModal(false);
setReferenceModalState({
contentText: '',
rulesReferences: [],
isLoading: false,
listId: '',
listNamespaceType: 'single',
});
}, []);
const handleReferenceDelete = useCallback(async (): Promise<void> => {
const exceptionListId = referenceModalState.listId;
const exceptionListNamespaceType = referenceModalState.listNamespaceType;
const relevantRules = exceptionsListsRef[exceptionListId].rules;
try {
await Promise.all(
relevantRules.map((rule) => {
const abortCtrl = new AbortController();
const exceptionLists = (rule.exceptions_list ?? []).filter(
({ id }) => id !== exceptionListId
);
return patchRule({
ruleProperties: {
rule_id: rule.rule_id,
exceptions_list: exceptionLists,
},
signal: abortCtrl.signal,
});
})
);
await deleteExceptionList({
id: exceptionListId,
namespaceType: exceptionListNamespaceType,
onError: handleDeleteError,
onSuccess: handleDeleteSuccess(),
});
} catch (err) {
handleDeleteError(err);
} finally {
setReferenceModalState(exceptionReferenceModalInitialState);
setDeletingListIds([]);
setShowReferenceErrorModal(false);
if (refreshExceptions != null) {
refreshExceptions();
}
}
}, [
referenceModalState.listId,
referenceModalState.listNamespaceType,
exceptionsListsRef,
deleteExceptionList,
handleDeleteError,
handleDeleteSuccess,
refreshExceptions,
]);
const paginationMemo = useMemo(
() => ({
pageIndex: pagination.page - 1,
@ -196,7 +332,7 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
setExportDownload({});
}, []);
const tableItems = (data ?? []).map((item) => ({
const tableItems = (exceptionListsWithRuleRefs ?? []).map((item) => ({
...item,
isDeleting: deletingListIds.includes(item.id),
isExporting: exportingListIds.includes(item.id),
@ -204,11 +340,6 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
return (
<>
<AutoDownload
blob={exportDownload.blob}
name={`${exportDownload.name}.ndjson`}
onDownload={handleOnDownload}
/>
<Panel loading={!initLoading && loadingTableInfo} data-test-subj="allExceptionListsPanel">
<>
{loadingTableInfo && (
@ -235,7 +366,7 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
/>
</HeaderSection>
{loadingTableInfo && !initLoading && (
{loadingTableInfo && !initLoading && !showReferenceErrorModal && (
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
)}
{initLoading ? (
@ -245,7 +376,7 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
<AllRulesUtilityBar
showBulkActions={false}
userHasNoPermissions={hasNoPermissions}
paginationTotal={data.length ?? 0}
paginationTotal={exceptionListsWithRuleRefs.length ?? 0}
numberSelectedItems={0}
onRefresh={handleRefresh}
/>
@ -263,9 +394,23 @@ export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
)}
</>
</Panel>
<AutoDownload
blob={exportDownload.blob}
name={`${exportDownload.name}.ndjson`}
onDownload={handleOnDownload}
/>
<ReferenceErrorModal
cancelText={i18n.REFERENCE_MODAL_CANCEL_BUTTON}
confirmText={i18n.REFERENCE_MODAL_CONFIRM_BUTTON}
contentText={referenceModalState.contentText}
onCancel={handleCloseReferenceErrorModal}
onClose={handleCloseReferenceErrorModal}
onConfirm={handleReferenceDelete}
references={referenceModalState.rulesReferences}
showModal={showReferenceErrorModal}
titleText={i18n.REFERENCE_MODAL_TITLE}
/>
</>
);
}
);
ExceptionListsTable.displayName = 'ExceptionListsTable';

View file

@ -96,3 +96,37 @@ export const EXCEPTION_DELETE_ERROR = i18n.translate(
defaultMessage: 'Error occurred deleting exception list',
}
);
export const exceptionDeleteSuccessMessage = (listId: string) =>
i18n.translate('xpack.securitySolution.exceptions.referenceModalSuccessDescription', {
defaultMessage: 'Exception list - {listId} - deleted successfully.',
values: { listId },
});
export const REFERENCE_MODAL_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.referenceModalTitle',
{
defaultMessage: 'Remove exception list',
}
);
export const REFERENCE_MODAL_CANCEL_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.referenceModalCancelButton',
{
defaultMessage: 'Cancel',
}
);
export const REFERENCE_MODAL_CONFIRM_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.referenceModalDeleteButton',
{
defaultMessage: 'Remove exception list',
}
);
export const referenceErrorMessage = (referenceCount: number) =>
i18n.translate('xpack.securitySolution.exceptions.referenceModalDescription', {
defaultMessage:
'This exception list is associated with ({referenceCount}) {referenceCount, plural, =1 {rule} other {rules}}. Removing this exception list will also remove its reference from the associated rules.',
values: { referenceCount },
});

View file

@ -4,16 +4,20 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Rule } from '../../../../../containers/detection_engine/rules';
import { ExceptionListSchema } from '../../../../../../../../lists/common';
import { fetchRules } from '../../../../../containers/detection_engine/rules/api';
export interface ExceptionListInfo extends ExceptionListSchema {
rules: Array<{ name: string; id: string }>;
rules: Rule[];
}
export type UseAllExceptionListsReturn = [boolean, ExceptionListInfo[]];
export type UseAllExceptionListsReturn = [
boolean,
ExceptionListInfo[],
Record<string, ExceptionListInfo>
];
/**
* Hook for preparing exception lists table info. For now, we need to do a table scan
@ -30,7 +34,46 @@ export const useAllExceptionLists = ({
exceptionLists: ExceptionListSchema[];
}): UseAllExceptionListsReturn => {
const [loading, setLoading] = useState(true);
const [exceptionsListInfo, setExceptionsListInfo] = useState<ExceptionListInfo[]>([]);
const [exceptions, setExceptions] = useState<ExceptionListInfo[]>([]);
const [exceptionsListsInfo, setExceptionsListInfo] = useState<Record<string, ExceptionListInfo>>(
{}
);
const handleExceptionsInfo = useCallback(
(rules: Rule[]): Record<string, ExceptionListInfo> => {
const listsSkeleton = exceptionLists.reduce<Record<string, ExceptionListInfo>>(
(acc, { id, ...rest }) => {
acc[id] = {
...rest,
id,
rules: [],
};
return acc;
},
{}
);
return rules.reduce<Record<string, ExceptionListInfo>>((acc, rule) => {
const ruleExceptionLists = rule.exceptions_list;
if (ruleExceptionLists != null && ruleExceptionLists.length > 0) {
ruleExceptionLists.forEach((ex) => {
const list = acc[ex.id];
if (list != null) {
acc[ex.id] = {
...list,
rules: [...list.rules, rule],
};
}
});
}
return acc;
}, listsSkeleton);
},
[exceptionLists]
);
useEffect(() => {
let isSubscribed = true;
@ -45,19 +88,6 @@ export const useAllExceptionLists = ({
try {
setLoading(true);
const listsSkeleton = exceptionLists.reduce<Record<string, ExceptionListInfo>>(
(acc, { id, ...rest }) => {
acc[id] = {
...rest,
id,
rules: [],
};
return acc;
},
{}
);
const { data: rules } = await fetchRules({
pagination: {
page: 1,
@ -67,29 +97,14 @@ export const useAllExceptionLists = ({
signal: abortCtrl.signal,
});
const updatedLists = rules.reduce<Record<string, ExceptionListInfo>>((acc, rule) => {
const exceptions = rule.exceptions_list;
if (exceptions != null && exceptions.length > 0) {
exceptions.forEach((ex) => {
const list = acc[ex.id];
if (list != null) {
acc[ex.id] = {
...list,
rules: [...list.rules, { id: rule.id, name: rule.name }],
};
}
});
}
return acc;
}, listsSkeleton);
const updatedLists = handleExceptionsInfo(rules);
const lists = Object.keys(updatedLists).map<ExceptionListInfo>(
(listKey) => updatedLists[listKey]
);
setExceptionsListInfo(lists);
setExceptions(lists);
setExceptionsListInfo(updatedLists);
if (isSubscribed) {
setLoading(false);
@ -107,7 +122,7 @@ export const useAllExceptionLists = ({
isSubscribed = false;
abortCtrl.abort();
};
}, [exceptionLists.length, exceptionLists]);
}, [exceptionLists.length, handleExceptionsInfo]);
return [loading, exceptionsListInfo];
return [loading, exceptions, exceptionsListsInfo];
};

View file

@ -0,0 +1,14 @@
{
"name": "Rule 2",
"description": "Sample rule with multiple exception list",
"rule_id": "query-with-multiple-exception-list",
"risk_score": 1,
"severity": "high",
"type": "query",
"query": "host.name: *",
"interval": "30s",
"exceptions_list": [
{ "id": "7b4e8f40-4f0c-11eb-865c-8bc292d0513c", "list_id": "detection_list_1", "namespace_type": "single", "type": "detection" },
{ "id": "e8ac0a60-4edd-11eb-865c-8bc292d0513c", "list_id": "detection_list_3", "namespace_type": "single", "type": "detection" }
]
}

View file

@ -0,0 +1,11 @@
{
"name": "Rule 1",
"description": "Sample rule with single exception list",
"rule_id": "query-with-single-exception-list",
"risk_score": 1,
"severity": "high",
"type": "query",
"query": "host.name: *",
"interval": "30s",
"exceptions_list": [{ "id": "7b4e8f40-4f0c-11eb-865c-8bc292d0513c", "list_id": "detection_list_1", "namespace_type": "single", "type": "detection" }]
}