[Security Solution][Exceptions] - Add exception list duplication options with and without expired items (#154991)

## Summary

Adds the following:

- Add the option to duplicate from the shared exception list management
actions dropdowns
  - User can select to include exception items with expired TTL
  - User can select to not include exception items with expired TTL 
  - Cypress tests added for both options
This commit is contained in:
Yara Tercero 2023-04-21 16:01:43 -07:00 committed by GitHub
parent fdc23f570e
commit 11155329cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2303 additions and 332 deletions

View file

@ -1876,6 +1876,27 @@ Object {
data-test-subj="RightSideMenuItemsMenuActionsActionItem2"
disabled=""
type="button"
>
<span
class="euiContextMenu__itemLayout"
>
<span
class="euiContextMenu__icon"
color="inherit"
data-euiicon-type="copy"
/>
<span
class="euiContextMenuItem__text"
>
Duplicate exception list
</span>
</span>
</button>
<button
class="euiContextMenuItem euiContextMenuItem--small euiContextMenuItem-isDisabled"
data-test-subj="RightSideMenuItemsMenuActionsActionItem3"
disabled=""
type="button"
>
<span
class="euiContextMenu__itemLayout"

View file

@ -32,6 +32,7 @@ interface ExceptionListHeaderComponentProps {
onDeleteList: () => void;
onManageRules: () => void;
onExportList: () => void;
onDuplicateList: () => void;
}
export interface BackOptions {
@ -54,6 +55,7 @@ const ExceptionListHeaderComponent: FC<ExceptionListHeaderComponentProps> = ({
onDeleteList,
onManageRules,
onExportList,
onDuplicateList,
}) => {
const { isModalVisible, listDetails, onEdit, onSave, onCancel } = useExceptionListHeader({
name,
@ -100,6 +102,7 @@ const ExceptionListHeaderComponent: FC<ExceptionListHeaderComponentProps> = ({
onDeleteList={onDeleteList}
onManageRules={onManageRules}
onExportList={onExportList}
onDuplicateList={onDuplicateList}
/>,
]}
breadcrumbs={[

View file

@ -17,6 +17,7 @@ const onExportList = jest.fn();
const onDeleteList = jest.fn();
const onManageRules = jest.fn();
const onNavigate = jest.fn();
const onDuplicateList = jest.fn();
jest.mock('./use_list_header');
describe('ExceptionListHeader', () => {
@ -40,6 +41,7 @@ describe('ExceptionListHeader', () => {
onExportList={onExportList}
onDeleteList={onDeleteList}
onManageRules={onManageRules}
onDuplicateList={onDuplicateList}
backOptions={{ pageId: '', path: '', onNavigate }}
/>
);
@ -70,6 +72,7 @@ describe('ExceptionListHeader', () => {
onExportList={onExportList}
onDeleteList={onDeleteList}
onManageRules={onManageRules}
onDuplicateList={onDuplicateList}
backOptions={{ pageId: '', path: '', onNavigate }}
/>
);
@ -79,6 +82,7 @@ describe('ExceptionListHeader', () => {
expect(wrapper.queryByTestId('RightSideMenuItemsMenuActionsActionItem1')).toBeEnabled();
expect(wrapper.queryByTestId('RightSideMenuItemsMenuActionsActionItem2')).toBeDisabled();
expect(wrapper.queryByTestId('RightSideMenuItemsMenuActionsActionItem3')).toBeDisabled();
expect(wrapper.queryByTestId('EditTitleIcon')).not.toBeInTheDocument();
});
it('should render the List Header with name, default description and actions', () => {
@ -93,6 +97,7 @@ describe('ExceptionListHeader', () => {
onExportList={onExportList}
onDeleteList={onDeleteList}
onManageRules={onManageRules}
onDuplicateList={onDuplicateList}
backOptions={{ pageId: '', path: '', onNavigate }}
/>
);
@ -123,6 +128,7 @@ describe('ExceptionListHeader', () => {
onExportList={onExportList}
onDeleteList={onDeleteList}
onManageRules={onManageRules}
onDuplicateList={onDuplicateList}
backOptions={{ pageId: '', path: '', onNavigate }}
/>
);
@ -148,6 +154,7 @@ describe('ExceptionListHeader', () => {
onExportList={onExportList}
onDeleteList={onDeleteList}
onManageRules={onManageRules}
onDuplicateList={onDuplicateList}
backOptions={{ pageId: '', path: 'test-path', onNavigate }}
/>
);

View file

@ -528,6 +528,26 @@ Object {
class="euiContextMenuItem euiContextMenuItem--small"
data-test-subj="MenuActionsActionItem2"
type="button"
>
<span
class="euiContextMenu__itemLayout"
>
<span
class="euiContextMenu__icon"
color="inherit"
data-euiicon-type="copy"
/>
<span
class="euiContextMenuItem__text"
>
Duplicate exception list
</span>
</span>
</button>
<button
class="euiContextMenuItem euiContextMenuItem--small"
data-test-subj="MenuActionsActionItem3"
type="button"
>
<span
class="euiContextMenu__itemLayout"
@ -671,7 +691,7 @@ Object {
}
`;
exports[`MenuItems should render delete action disabled 1`] = `
exports[`MenuItems should render delete action disabled when "canUserEditList" is "false" 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
@ -809,6 +829,27 @@ Object {
data-test-subj="MenuActionsActionItem2"
disabled=""
type="button"
>
<span
class="euiContextMenu__itemLayout"
>
<span
class="euiContextMenu__icon"
color="inherit"
data-euiicon-type="copy"
/>
<span
class="euiContextMenuItem__text"
>
Duplicate exception list
</span>
</span>
</button>
<button
class="euiContextMenuItem euiContextMenuItem--small euiContextMenuItem-isDisabled"
data-test-subj="MenuActionsActionItem3"
disabled=""
type="button"
>
<span
class="euiContextMenu__itemLayout"

View file

@ -21,6 +21,7 @@ interface MenuItemsProps {
onDeleteList: () => void;
onManageRules: () => void;
onExportList: () => void;
onDuplicateList: () => void;
}
const MenuItemsComponent: FC<MenuItemsProps> = ({
@ -32,6 +33,7 @@ const MenuItemsComponent: FC<MenuItemsProps> = ({
onDeleteList,
onManageRules,
onExportList,
onDuplicateList,
}) => {
const referencedLinks = useMemo(
() =>
@ -100,6 +102,15 @@ const MenuItemsComponent: FC<MenuItemsProps> = ({
},
{
key: '2',
icon: 'copy',
label: i18n.EXCEPTION_LIST_HEADER_DUPLICATE_ACTION,
onClick: () => {
if (typeof onDuplicateList === 'function') onDuplicateList();
},
disabled: !canUserEditList,
},
{
key: '3',
icon: 'trash',
label: i18n.EXCEPTION_LIST_HEADER_DELETE_ACTION,
onClick: () => {

View file

@ -15,6 +15,7 @@ import { securityLinkAnchorComponentMock } from '../../mocks/security_link_compo
const onExportList = jest.fn();
const onDeleteList = jest.fn();
const onManageRules = jest.fn();
const onDuplicateList = jest.fn();
describe('MenuItems', () => {
it('should render linkedRules, manageRules and menuActions', () => {
const wrapper = render(
@ -24,6 +25,7 @@ describe('MenuItems', () => {
securityLinkAnchorComponent={securityLinkAnchorComponentMock}
onExportList={onExportList}
onDeleteList={onDeleteList}
onDuplicateList={onDuplicateList}
onManageRules={onManageRules}
/>
);
@ -40,6 +42,7 @@ describe('MenuItems', () => {
securityLinkAnchorComponent={securityLinkAnchorComponentMock}
onExportList={onExportList}
onDeleteList={onDeleteList}
onDuplicateList={onDuplicateList}
onManageRules={onManageRules}
/>
);
@ -55,6 +58,7 @@ describe('MenuItems', () => {
securityLinkAnchorComponent={securityLinkAnchorComponentMock}
onExportList={onExportList}
onDeleteList={onDeleteList}
onDuplicateList={onDuplicateList}
onManageRules={onManageRules}
/>
);
@ -62,8 +66,9 @@ describe('MenuItems', () => {
expect(wrapper).toMatchSnapshot();
expect(wrapper.getByTestId('MenuActionsActionItem1')).toBeEnabled();
expect(wrapper.getByTestId('MenuActionsActionItem2')).toBeEnabled();
expect(wrapper.getByTestId('MenuActionsActionItem3')).toBeEnabled();
});
it('should render delete action disabled', () => {
it('should render delete action disabled when "canUserEditList" is "false"', () => {
const wrapper = render(
<MenuItems
isReadonly={false}
@ -72,12 +77,15 @@ describe('MenuItems', () => {
securityLinkAnchorComponent={securityLinkAnchorComponentMock}
onExportList={onExportList}
onDeleteList={onDeleteList}
onDuplicateList={onDuplicateList}
onManageRules={onManageRules}
/>
);
fireEvent.click(wrapper.getByTestId('MenuActionsButtonIcon'));
expect(wrapper).toMatchSnapshot();
expect(wrapper.getByTestId('MenuActionsActionItem1')).toBeEnabled();
expect(wrapper.getByTestId('MenuActionsActionItem2')).toBeDisabled();
expect(wrapper.getByTestId('MenuActionsActionItem3')).toBeDisabled();
});
it('should not render Manage rules', () => {
const wrapper = render(
@ -88,6 +96,7 @@ describe('MenuItems', () => {
securityLinkAnchorComponent={securityLinkAnchorComponentMock}
onExportList={onExportList}
onDeleteList={onDeleteList}
onDuplicateList={onDuplicateList}
onManageRules={onManageRules}
/>
);
@ -102,6 +111,7 @@ describe('MenuItems', () => {
securityLinkAnchorComponent={securityLinkAnchorComponentMock}
onExportList={onExportList}
onDeleteList={onDeleteList}
onDuplicateList={onDuplicateList}
onManageRules={onManageRules}
/>
);
@ -116,6 +126,7 @@ describe('MenuItems', () => {
securityLinkAnchorComponent={securityLinkAnchorComponentMock}
onExportList={onExportList}
onDeleteList={onDeleteList}
onDuplicateList={onDuplicateList}
onManageRules={onManageRules}
/>
);
@ -132,12 +143,31 @@ describe('MenuItems', () => {
securityLinkAnchorComponent={securityLinkAnchorComponentMock}
onExportList={onExportList}
onDeleteList={onDeleteList}
onDuplicateList={onDuplicateList}
onManageRules={onManageRules}
/>
);
fireEvent.click(wrapper.getByTestId('MenuActionsButtonIcon'));
fireEvent.click(wrapper.getByTestId('MenuActionsActionItem3'));
expect(onDeleteList).toHaveBeenCalled();
});
it('should call onDuplicateList', () => {
const wrapper = render(
<MenuItems
isReadonly={false}
linkedRules={rules}
securityLinkAnchorComponent={securityLinkAnchorComponentMock}
onExportList={onExportList}
onDeleteList={onDeleteList}
onDuplicateList={onDuplicateList}
onManageRules={onManageRules}
/>
);
fireEvent.click(wrapper.getByTestId('MenuActionsButtonIcon'));
fireEvent.click(wrapper.getByTestId('MenuActionsActionItem2'));
expect(onDeleteList).toHaveBeenCalled();
expect(onDuplicateList).toHaveBeenCalled();
});
});

View file

@ -67,6 +67,12 @@ export const EXCEPTION_LIST_HEADER_DELETE_ACTION = i18n.translate(
defaultMessage: 'Delete exception list',
}
);
export const EXCEPTION_LIST_HEADER_DUPLICATE_ACTION = i18n.translate(
'exceptionList-components.exception_list_header_duplicate_action',
{
defaultMessage: 'Duplicate exception list',
}
);
export const EXCEPTION_LIST_HEADER_LINK_RULES_BUTTON = i18n.translate(
'exceptionList-components.exception_list_header_link_rules_button',
{

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { LIST_ID, NAMESPACE_TYPE } from '../../constants/index.mock';
import { DuplicateExceptionListQuerySchema } from '.';
export const getDuplicateExceptionListQuerySchemaMock = (): DuplicateExceptionListQuerySchema => ({
list_id: LIST_ID,
namespace_type: NAMESPACE_TYPE,
include_expired_exceptions: 'true',
});

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { left } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import { DuplicateExceptionListQuerySchema, duplicateExceptionListQuerySchema } from '.';
import { getDuplicateExceptionListQuerySchemaMock } from './index.mock';
describe('duplicate_exceptionList_query_schema', () => {
test('it should validate a typical lists request', () => {
const payload = getDuplicateExceptionListQuerySchemaMock();
const decoded = duplicateExceptionListQuerySchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should default namespace_type to "single" if an undefined given for namespacetype', () => {
const payload = getDuplicateExceptionListQuerySchemaMock();
delete payload.namespace_type;
const decoded = duplicateExceptionListQuerySchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(message.schema).toEqual({
include_expired_exceptions: 'true',
list_id: 'some-list-id',
namespace_type: 'single',
});
});
test('it should NOT accept an undefined for an list_id', () => {
const payload = getDuplicateExceptionListQuerySchemaMock();
// @ts-expect-error
delete payload.list_id;
const decoded = duplicateExceptionListQuerySchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "list_id"',
]);
expect(message.schema).toEqual({});
});
test('it should not allow an extra key to be sent in', () => {
const payload: DuplicateExceptionListQuerySchema & {
extraKey?: string;
} = getDuplicateExceptionListQuerySchemaMock();
payload.extraKey = 'some new value';
const decoded = duplicateExceptionListQuerySchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']);
expect(message.schema).toEqual({});
});
});

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { NamespaceType } from '../../common';
import { includeExpiredExceptionsOrUndefined } from '../../common/include_expired_exceptions';
import { list_id } from '../../common/list_id';
import { namespace_type } from '../../common/namespace_type';
export const duplicateExceptionListQuerySchema = t.exact(
t.type({
list_id,
namespace_type,
include_expired_exceptions: includeExpiredExceptionsOrUndefined,
})
);
export type DuplicateExceptionListQuerySchema = t.OutputOf<
typeof duplicateExceptionListQuerySchema
>;
// This type is used after a decode since some things are defaults after a decode.
export type DuplicateExceptionListQuerySchemaDecoded = Omit<
t.TypeOf<typeof duplicateExceptionListQuerySchema>,
'namespace_type'
> & {
namespace_type: NamespaceType;
};

View file

@ -16,6 +16,7 @@ export * from './delete_exception_list_schema';
export * from './delete_exception_list_item_schema';
export * from './delete_list_item_schema';
export * from './delete_list_schema';
export * from './duplicate_exception_list_query_schema';
export * from './export_exception_list_query_schema';
export * from './export_list_item_query_schema';
export * from './find_endpoint_list_item_schema';

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import type { HttpStart } from '@kbn/core-http-browser';
import type { NotificationsStart } from '@kbn/core-notifications-browser';
import type { Filter } from '@kbn/es-query';
import { NamespaceType } from '../common/default_namespace';
import { ExceptionListType, ExceptionListTypeEnum } from '../common/exception_list';
@ -20,13 +22,22 @@ import { UpdateExceptionListSchema } from '../request/update_exception_list_sche
import { ExceptionListItemSchema } from '../response/exception_list_item_schema';
import { ExceptionListSchema } from '../response/exception_list_schema';
// TODO: Replace these with kbn packaged versions once we have those available to us
// These originally came from this location below before moving them to this hacked "any" types:
// import { HttpStart, NotificationsStart } from '../../../../../src/core/public';
interface HttpStart {
fetch: <T>(...args: any) => any;
interface BaseParams {
http: HttpStart;
signal: AbortSignal;
}
export interface DuplicateExceptionListProps extends BaseParams {
listId: string;
namespaceType: NamespaceType;
includeExpiredExceptions: boolean;
}
export interface ApiListDuplicateProps
extends Omit<DuplicateExceptionListProps, 'http' | 'signal'> {
onError: (err: Error) => void;
onSuccess: (newList: ExceptionListSchema) => void;
}
type NotificationsStart = any;
export interface ExceptionListFilter {
name?: string | null;

View file

@ -14,7 +14,9 @@
"@kbn/securitysolution-io-ts-types",
"@kbn/securitysolution-io-ts-utils",
"@kbn/securitysolution-list-constants",
"@kbn/es-query"
"@kbn/es-query",
"@kbn/core-http-browser",
"@kbn/core-notifications-browser"
],
"exclude": [
"target/**/*",

View file

@ -32,6 +32,7 @@ import {
GetExceptionFilterFromExceptionListIdsProps,
GetExceptionFilterFromExceptionsProps,
ExceptionFilterResponse,
DuplicateExceptionListProps,
} from '@kbn/securitysolution-io-ts-list-types';
import {
@ -617,3 +618,31 @@ export const getExceptionFilterFromExceptions = async ({
}),
signal,
});
/**
* Duplicate an ExceptionList and its items by providing a ExceptionList list_id
*
* @param http Kibana http service
* @param includeExpiredExceptions boolean for including exception items with expired TTL
* @param listId ExceptionList LIST_ID (not id)
* @param namespaceType ExceptionList namespace_type
* @param signal to cancel request
*
* @throws An error if response is not OK
*/
export const duplicateExceptionList = async ({
http,
includeExpiredExceptions,
listId,
namespaceType,
signal,
}: DuplicateExceptionListProps): Promise<ExceptionListSchema> =>
http.fetch<ExceptionListSchema>(`${EXCEPTION_LIST_URL}/_duplicate`, {
method: 'POST',
query: {
list_id: listId,
namespace_type: namespaceType,
include_expired_exceptions: includeExpiredExceptions,
},
signal,
});

View file

@ -17,17 +17,12 @@ import type {
ApiListExportProps,
ApiCallGetExceptionFilterFromIdsMemoProps,
ApiCallGetExceptionFilterFromExceptionsMemoProps,
ApiListDuplicateProps,
} from '@kbn/securitysolution-io-ts-list-types';
import * as Api from '@kbn/securitysolution-list-api';
// TODO: Replace these with kbn packaged versions once we have those available to us
// These originally came from this location below before moving them to this hacked "any" types:
// import { HttpStart, NotificationsStart } from '../../../../../src/core/public';
interface HttpStart {
fetch: <T>(...args: any) => any;
}
import { getIdsAndNamespaces } from '@kbn/securitysolution-list-utils';
import type { HttpStart } from '@kbn/core-http-browser';
import { transformInput, transformNewItemOutput, transformOutput } from '../transforms';
export interface ExceptionsApi {
@ -39,6 +34,7 @@ export interface ExceptionsApi {
}) => Promise<ExceptionListItemSchema>;
deleteExceptionItem: (arg: ApiCallMemoProps) => Promise<void>;
deleteExceptionList: (arg: ApiCallMemoProps) => Promise<void>;
duplicateExceptionList: (arg: ApiListDuplicateProps) => Promise<void>;
getExceptionItem: (
arg: ApiCallMemoProps & { onSuccess: (arg: ExceptionListItemSchema) => void }
) => Promise<void>;
@ -110,6 +106,28 @@ export const useApi = (http: HttpStart): ExceptionsApi => {
onError(error);
}
},
async duplicateExceptionList({
includeExpiredExceptions,
listId,
namespaceType,
onError,
onSuccess,
}: ApiListDuplicateProps): Promise<void> {
const abortCtrl = new AbortController();
try {
const newList = await Api.duplicateExceptionList({
http,
includeExpiredExceptions,
listId,
namespaceType,
signal: abortCtrl.signal,
});
onSuccess(newList);
} catch (error) {
onError(error);
}
},
async exportExceptionList({
id,
includeExpiredExceptions,

View file

@ -17,6 +17,7 @@
"@kbn/securitysolution-list-utils",
"@kbn/securitysolution-utils",
"@kbn/core-http-browser-mocks",
"@kbn/core-http-browser",
],
"exclude": [
"target/**/*",

View file

@ -11,6 +11,7 @@ import {
addExceptionListItem,
deleteExceptionListById,
deleteExceptionListItemById,
duplicateExceptionList,
exportExceptionList,
fetchExceptionListById,
fetchExceptionListItemById,
@ -728,4 +729,30 @@ describe('Exceptions Lists API', () => {
expect(exceptionResponse).toEqual(blob);
});
});
describe('#duplicateExceptionList', () => {
beforeEach(() => {
httpMock.fetch.mockResolvedValue(getExceptionListSchemaMock());
});
test('it invokes "duplicateExceptionList" with expected url and body values', async () => {
await duplicateExceptionList({
http: httpMock,
includeExpiredExceptions: false,
listId: 'my_list',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/_duplicate', {
method: 'POST',
query: {
include_expired_exceptions: false,
list_id: 'my_list',
namespace_type: 'single',
},
signal: abortCtrl.signal,
});
});
});
});

View file

@ -12,6 +12,7 @@ import type {
AddExceptionListItemProps,
ApiCallByIdProps,
ApiCallByListIdProps,
DuplicateExceptionListProps,
UpdateExceptionListItemProps,
} from '@kbn/securitysolution-io-ts-list-types';
import { coreMock } from '@kbn/core/public/mocks';
@ -462,4 +463,61 @@ describe('useApi', () => {
});
});
});
describe('duplicateExceptionList', () => {
test('it invokes "onSuccess" when duplication does not throw', async () => {
const onSuccessMock = jest.fn();
const spyOnDuplicateExceptionList = jest
.spyOn(api, 'duplicateExceptionList')
.mockResolvedValue(getExceptionListSchemaMock());
await act(async () => {
const { result, waitForNextUpdate } = renderHook<HttpStart, ExceptionsApi>(() =>
useApi(mockKibanaHttpService)
);
await waitForNextUpdate();
await result.current.duplicateExceptionList({
includeExpiredExceptions: false,
listId: 'my_list',
namespaceType: 'single',
onError: jest.fn(),
onSuccess: onSuccessMock,
});
const expected: DuplicateExceptionListProps = {
http: mockKibanaHttpService,
includeExpiredExceptions: false,
listId: 'my_list',
namespaceType: 'single',
signal: new AbortController().signal,
};
expect(spyOnDuplicateExceptionList).toHaveBeenCalledWith(expected);
expect(onSuccessMock).toHaveBeenCalled();
});
});
test('invokes "onError" callback if "duplicateExceptionList" fails', async () => {
const mockError = new Error('failed to duplicate item');
jest.spyOn(api, 'duplicateExceptionList').mockRejectedValue(mockError);
await act(async () => {
const { result, waitForNextUpdate } = renderHook<HttpStart, ExceptionsApi>(() =>
useApi(mockKibanaHttpService)
);
await waitForNextUpdate();
await result.current.duplicateExceptionList({
includeExpiredExceptions: false,
listId: 'my_list',
namespaceType: 'single',
onError: onErrorMock,
onSuccess: jest.fn(),
});
expect(onErrorMock).toHaveBeenCalledWith(mockError);
});
});
});
});

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { transformError } from '@kbn/securitysolution-es-utils';
import {
DuplicateExceptionListQuerySchemaDecoded,
duplicateExceptionListQuerySchema,
exceptionListSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { validate } from '@kbn/securitysolution-io-ts-utils';
import { EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants';
import type { ListsPluginRouter } from '../types';
import { buildRouteValidation, buildSiemResponse, getExceptionListClient } from './utils';
export const duplicateExceptionsRoute = (router: ListsPluginRouter): void => {
router.post(
{
options: {
tags: ['access:lists-all'],
},
path: `${EXCEPTION_LIST_URL}/_duplicate`,
validate: {
query: buildRouteValidation<
typeof duplicateExceptionListQuerySchema,
DuplicateExceptionListQuerySchemaDecoded
>(duplicateExceptionListQuerySchema),
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
try {
const {
list_id: listId,
namespace_type: namespaceType,
include_expired_exceptions: includeExpiredExceptionsString,
} = request.query;
const exceptionListsClient = await getExceptionListClient(context);
// fetch list container
const listToDuplicate = await exceptionListsClient.getExceptionList({
id: undefined,
listId,
namespaceType,
});
if (listToDuplicate == null) {
return siemResponse.error({
body: `exception list id: "${listId}" does not exist`,
statusCode: 404,
});
}
// Defaults to including expired exceptions if query param is not present
const includeExpiredExceptions =
includeExpiredExceptionsString !== undefined
? includeExpiredExceptionsString === 'true'
: true;
const duplicatedList = await exceptionListsClient.duplicateExceptionListAndItems({
includeExpiredExceptions,
list: listToDuplicate,
namespaceType,
});
if (duplicatedList == null) {
return siemResponse.error({
body: `unable to duplicate exception list with list_id: ${listId} - action not allowed`,
statusCode: 405,
});
}
const [validated, errors] = validate(duplicatedList, exceptionListSchema);
if (errors != null) {
return siemResponse.error({ body: errors, statusCode: 500 });
} else {
return response.ok({ body: validated ?? {} });
}
} catch (err) {
const error = transformError(err);
return siemResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
}
);
};

View file

@ -18,6 +18,7 @@ export * from './delete_exception_list_item_route';
export * from './delete_list_index_route';
export * from './delete_list_item_route';
export * from './delete_list_route';
export * from './duplicate_exception_list_route';
export * from './export_exception_list_route';
export * from './export_list_item_route';
export * from './find_endpoint_list_item_route';

View file

@ -22,6 +22,7 @@ import {
deleteListIndexRoute,
deleteListItemRoute,
deleteListRoute,
duplicateExceptionsRoute,
exportExceptionsRoute,
exportListItemRoute,
findEndpointListItemRoute,
@ -87,6 +88,7 @@ export const initRoutes = (router: ListsPluginRouter, config: ConfigType): void
updateExceptionListRoute(router);
deleteExceptionListRoute(router);
findExceptionListRoute(router);
duplicateExceptionsRoute(router);
// exception list items
createExceptionListItemRoute(router);

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import {
getDetectionsExceptionListSchemaMock,
getTrustedAppsListSchemaMock,
} from '../../../common/schemas/response/exception_list_schema.mock';
import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock';
import { findExceptionListsItemPointInTimeFinder } from './find_exception_list_items_point_in_time_finder';
import { duplicateExceptionListAndItems } from './duplicate_exception_list';
import { getExceptionList } from './get_exception_list';
import { createExceptionList } from './create_exception_list';
jest.mock('./get_exception_list');
jest.mock('./create_exception_list');
jest.mock('./bulk_create_exception_list_items');
jest.mock('./find_exception_list_items_point_in_time_finder');
const mockCurrentTime = new Date('2023-02-01T10:20:30Z');
describe('duplicateExceptionListAndItems', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(mockCurrentTime);
});
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
test('should return null exception list is not of type "detection" or "rule_default"', async () => {
(getExceptionList as jest.Mock).mockResolvedValue(getTrustedAppsListSchemaMock());
const result = await duplicateExceptionListAndItems({
includeExpiredExceptions: true,
list: getTrustedAppsListSchemaMock(),
namespaceType: 'single',
savedObjectsClient: savedObjectsClientMock.create(),
user: 'test-user',
});
expect(result).toBeNull();
});
test('should duplicate a list with expired exceptions', async () => {
(getExceptionList as jest.Mock).mockResolvedValue(getDetectionsExceptionListSchemaMock());
(createExceptionList as jest.Mock).mockResolvedValue({
...getDetectionsExceptionListSchemaMock(),
list_id: 'exception_list_id_dupe',
name: 'Test [Duplicate]',
});
(findExceptionListsItemPointInTimeFinder as jest.Mock).mockImplementationOnce(
({ executeFunctionOnStream }) => {
executeFunctionOnStream({ data: [getExceptionListItemSchemaMock()] });
}
);
await duplicateExceptionListAndItems({
includeExpiredExceptions: true,
list: getDetectionsExceptionListSchemaMock(),
namespaceType: 'single',
savedObjectsClient: savedObjectsClientMock.create(),
user: 'test-user',
});
expect(findExceptionListsItemPointInTimeFinder).toHaveBeenCalledWith({
executeFunctionOnStream: expect.any(Function),
filter: [],
listId: ['exception_list_id'],
maxSize: 10000,
namespaceType: ['single'],
perPage: undefined,
savedObjectsClient: expect.any(Object),
sortField: undefined,
sortOrder: undefined,
});
});
test('should duplicate a list without expired exceptions', async () => {
(getExceptionList as jest.Mock).mockResolvedValue(getDetectionsExceptionListSchemaMock());
(createExceptionList as jest.Mock).mockResolvedValue({
...getDetectionsExceptionListSchemaMock(),
list_id: 'exception_list_id_dupe',
name: 'Test [Duplicate]',
});
(findExceptionListsItemPointInTimeFinder as jest.Mock).mockImplementationOnce(
({ executeFunctionOnStream }) => {
executeFunctionOnStream({ data: [getExceptionListItemSchemaMock()] });
}
);
await duplicateExceptionListAndItems({
includeExpiredExceptions: false,
list: getDetectionsExceptionListSchemaMock(),
namespaceType: 'single',
savedObjectsClient: savedObjectsClientMock.create(),
user: 'test-user',
});
expect(findExceptionListsItemPointInTimeFinder).toHaveBeenCalledWith({
executeFunctionOnStream: expect.any(Function),
filter: [
'(exception-list.attributes.expire_time > "2023-02-01T10:20:30.000Z" OR NOT exception-list.attributes.expire_time: *)',
],
listId: ['exception_list_id'],
maxSize: 10000,
namespaceType: ['single'],
perPage: undefined,
savedObjectsClient: expect.any(Object),
sortField: undefined,
sortOrder: undefined,
});
});
});

View file

@ -12,13 +12,12 @@ import {
ExceptionListSchema,
ExceptionListTypeEnum,
FoundExceptionListItemSchema,
ListId,
NamespaceType,
} from '@kbn/securitysolution-io-ts-list-types';
import { getSavedObjectType } from '@kbn/securitysolution-list-utils';
import { findExceptionListsItemPointInTimeFinder } from './find_exception_list_items_point_in_time_finder';
import { bulkCreateExceptionListItems } from './bulk_create_exception_list_items';
import { getExceptionList } from './get_exception_list';
import { createExceptionList } from './create_exception_list';
const LISTS_ABLE_TO_DUPLICATE = [
@ -26,48 +25,38 @@ const LISTS_ABLE_TO_DUPLICATE = [
ExceptionListTypeEnum.RULE_DEFAULT.toString(),
];
interface CreateExceptionListOptions {
listId: ListId;
interface DuplicateExceptionListOptions {
list: ExceptionListSchema;
savedObjectsClient: SavedObjectsClientContract;
namespaceType: NamespaceType;
user: string;
includeExpiredExceptions: boolean;
}
export const duplicateExceptionListAndItems = async ({
listId,
includeExpiredExceptions,
list,
savedObjectsClient,
namespaceType,
user,
}: CreateExceptionListOptions): Promise<ExceptionListSchema> => {
}: DuplicateExceptionListOptions): Promise<ExceptionListSchema | null> => {
// Generate a new static listId
const newListId = uuidv4();
// fetch list container
const listToDuplicate = await getExceptionList({
id: undefined,
listId,
namespaceType,
savedObjectsClient,
});
if (listToDuplicate == null) {
throw new Error(`Exception list to duplicat of list_id:${listId} not found.`);
}
if (!LISTS_ABLE_TO_DUPLICATE.includes(listToDuplicate.type)) {
throw new Error(`Exception list of type:${listToDuplicate.type} cannot be duplicated.`);
if (!LISTS_ABLE_TO_DUPLICATE.includes(list.type)) {
return null;
}
const newlyCreatedList = await createExceptionList({
description: listToDuplicate.description,
immutable: listToDuplicate.immutable,
description: list.description,
immutable: list.immutable,
listId: newListId,
meta: listToDuplicate.meta,
name: listToDuplicate.name,
namespaceType: listToDuplicate.namespace_type,
meta: list.meta,
name: `${list.name} [Duplicate]`,
namespaceType: list.namespace_type,
savedObjectsClient,
tags: listToDuplicate.tags,
type: listToDuplicate.type,
tags: list.tags,
type: list.type,
user,
version: 1,
});
@ -96,10 +85,16 @@ export const duplicateExceptionListAndItems = async ({
});
itemsToBeDuplicated = [...itemsToBeDuplicated, ...transformedItems];
};
const savedObjectPrefix = getSavedObjectType({ namespaceType });
const filter = includeExpiredExceptions
? []
: [
`(${savedObjectPrefix}.attributes.expire_time > "${new Date().toISOString()}" OR NOT ${savedObjectPrefix}.attributes.expire_time: *)`,
];
await findExceptionListsItemPointInTimeFinder({
executeFunctionOnStream,
filter: [],
listId: [listId],
filter,
listId: [list.list_id],
maxSize: 10000,
namespaceType: [namespaceType],
perPage: undefined,

View file

@ -317,17 +317,20 @@ export class ExceptionListClient {
/**
* Create the Trusted Apps Agnostic list if it does not yet exist (`null` is returned if it does exist)
* @param options.listId the "list_id" of the exception list
* @param options.list the "list" to be duplicated
* @param options.namespaceType saved object namespace (single | agnostic)
* @param options.includeExpiredExceptions include or exclude expired TTL exception items
* @returns The exception list schema or null if it does not exist
*/
public duplicateExceptionListAndItems = async ({
listId,
list,
namespaceType,
includeExpiredExceptions,
}: DuplicateExceptionListOptions): Promise<ExceptionListSchema | null> => {
const { savedObjectsClient, user } = this;
return duplicateExceptionListAndItems({
listId,
includeExpiredExceptions,
list,
namespaceType,
savedObjectsClient,
user,
@ -1051,6 +1054,7 @@ export class ExceptionListClient {
* @param options.listId the "list_id" of an exception list
* @param options.id the "id" of an exception list
* @param options.namespaceType saved object namespace (single | agnostic)
* @param options.includeExpiredExceptions include or exclude expired TTL exception items
* @returns the ndjson of the list and items to export or null if none exists
*/
public exportExceptionListAndItems = async ({

View file

@ -19,6 +19,7 @@ import type {
EntriesArray,
ExceptionListItemType,
ExceptionListItemTypeOrUndefined,
ExceptionListSchema,
ExceptionListType,
ExceptionListTypeOrUndefined,
ExpireTimeOrUndefined,
@ -295,10 +296,12 @@ export interface CreateEndpointListItemOptions {
* {@link ExceptionListClient.duplicateExceptionListAndItems}
*/
export interface DuplicateExceptionListOptions {
/** The single list id to do the search against */
listId: ListId;
/** The list to be duplicated */
list: ExceptionListSchema;
/** saved object namespace (single | agnostic) */
namespaceType: NamespaceType;
/** determines whether exception items with an expired TTL are included in duplication */
includeExpiredExceptions: boolean;
}
/**

View file

@ -133,7 +133,10 @@ describe('Perform bulk action request schema', () => {
const payload: PerformBulkActionRequestBody = {
query: 'name: test',
action: BulkActionType.duplicate,
[BulkActionType.duplicate]: { include_exceptions: false },
[BulkActionType.duplicate]: {
include_exceptions: false,
include_expired_exceptions: false,
},
};
const message = retrieveValidationMessage(payload);
expect(getPaths(left(message.errors))).toEqual([]);

View file

@ -136,6 +136,7 @@ export const BulkActionEditPayload = t.union([
const bulkActionDuplicatePayload = t.exact(
t.type({
include_exceptions: t.boolean,
include_expired_exceptions: t.boolean,
})
);

View file

@ -7,5 +7,6 @@
export enum DuplicateOptions {
withExceptions = 'withExceptions',
withExceptionsExcludeExpiredExceptions = 'withExceptionsExcludeExpiredExceptions',
withoutExceptions = 'withoutExceptions',
}

View file

@ -0,0 +1,132 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
waitForRulesTableToBeLoaded,
goToTheRuleDetailsOf,
selectNumberOfRules,
duplicateSelectedRulesWithoutExceptions,
expectManagementTableRules,
duplicateSelectedRulesWithExceptions,
duplicateSelectedRulesWithNonExpiredExceptions,
} from '../../tasks/alerts_detection_rules';
import { goToExceptionsTab, viewExpiredExceptionItems } from '../../tasks/rule_details';
import { login, visitWithoutDateRange } from '../../tasks/login';
import { SECURITY_DETECTIONS_RULES_URL } from '../../urls/navigation';
import { createRule } from '../../tasks/api_calls/rules';
import { cleanKibana, resetRulesTableState, deleteAlertsAndRules } from '../../tasks/common';
import { getNewRule } from '../../objects/rule';
import { esArchiverResetKibana } from '../../tasks/es_archiver';
import { createRuleExceptionItem } from '../../tasks/api_calls/exceptions';
import { EXCEPTION_CARD_ITEM_NAME } from '../../screens/exceptions';
import {
assertExceptionItemsExists,
assertNumberOfExceptionItemsExists,
} from '../../tasks/exceptions';
const RULE_NAME = 'Custom rule for bulk actions';
const prePopulatedIndexPatterns = ['index-1-*', 'index-2-*'];
const prePopulatedTags = ['test-default-tag-1', 'test-default-tag-2'];
const defaultRuleData = {
index: prePopulatedIndexPatterns,
tags: prePopulatedTags,
};
const expiredDate = new Date(Date.now() - 1000000).toISOString();
const futureDate = new Date(Date.now() + 1000000).toISOString();
const EXPIRED_EXCEPTION_ITEM_NAME = 'Sample exception item';
const NON_EXPIRED_EXCEPTION_ITEM_NAME = 'Sample exception item with future expiration';
describe('Detection rules, bulk duplicate', () => {
before(() => {
cleanKibana();
login();
});
beforeEach(() => {
// Make sure persisted rules table state is cleared
resetRulesTableState();
deleteAlertsAndRules();
esArchiverResetKibana();
createRule(getNewRule({ name: RULE_NAME, ...defaultRuleData, rule_id: '1' })).then(
(response) => {
createRuleExceptionItem(response.body.id, [
{
description: 'Exception item for rule default exception list',
entries: [
{
field: 'user.name',
operator: 'included',
type: 'match',
value: 'some value',
},
],
name: EXPIRED_EXCEPTION_ITEM_NAME,
type: 'simple',
expire_time: expiredDate,
},
{
description: 'Exception item for rule default exception list',
entries: [
{
field: 'user.name',
operator: 'included',
type: 'match',
value: 'some value',
},
],
name: NON_EXPIRED_EXCEPTION_ITEM_NAME,
type: 'simple',
expire_time: futureDate,
},
]);
}
);
visitWithoutDateRange(SECURITY_DETECTIONS_RULES_URL);
waitForRulesTableToBeLoaded();
});
it('Duplicates rules', () => {
selectNumberOfRules(1);
duplicateSelectedRulesWithoutExceptions();
expectManagementTableRules([`${RULE_NAME} [Duplicate]`]);
});
describe('With exceptions', () => {
it('Duplicates rules with expired exceptions', () => {
selectNumberOfRules(1);
duplicateSelectedRulesWithExceptions();
expectManagementTableRules([`${RULE_NAME} [Duplicate]`]);
goToTheRuleDetailsOf(`${RULE_NAME} [Duplicate]`);
goToExceptionsTab();
assertExceptionItemsExists(EXCEPTION_CARD_ITEM_NAME, [NON_EXPIRED_EXCEPTION_ITEM_NAME]);
viewExpiredExceptionItems();
assertExceptionItemsExists(EXCEPTION_CARD_ITEM_NAME, [EXPIRED_EXCEPTION_ITEM_NAME]);
});
it('Duplicates rules with exceptions, excluding expired exceptions', () => {
selectNumberOfRules(1);
duplicateSelectedRulesWithNonExpiredExceptions();
expectManagementTableRules([`${RULE_NAME} [Duplicate]`]);
goToTheRuleDetailsOf(`${RULE_NAME} [Duplicate]`);
goToExceptionsTab();
assertExceptionItemsExists(EXCEPTION_CARD_ITEM_NAME, [NON_EXPIRED_EXCEPTION_ITEM_NAME]);
viewExpiredExceptionItems();
assertNumberOfExceptionItemsExists(0);
});
});
});

View file

@ -58,12 +58,12 @@ import { INDICATOR_MATCH_ROW_RENDER, PROVIDER_BADGE } from '../../screens/timeli
import { investigateFirstAlertInTimeline } from '../../tasks/alerts';
import {
duplicateFirstRule,
duplicateSelectedRules,
duplicateRuleFromMenu,
goToRuleDetails,
selectNumberOfRules,
checkDuplicatedRule,
expectNumberOfRules,
duplicateSelectedRulesWithExceptions,
} from '../../tasks/alerts_detection_rules';
import { createRule } from '../../tasks/api_calls/rules';
import { loadPrepackagedTimelineTemplates } from '../../tasks/api_calls/timelines';
@ -544,7 +544,7 @@ describe('indicator match', () => {
it("Allows the rule to be duplicated from the table's bulk actions", () => {
selectNumberOfRules(1);
duplicateSelectedRules();
duplicateSelectedRulesWithExceptions();
checkDuplicatedRule();
});

View file

@ -15,7 +15,7 @@ import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../..
import { EXCEPTIONS_URL } from '../../../urls/navigation';
import {
deleteExceptionListWithRuleReferenceByListId,
deleteExceptionListWithoutRuleReference,
deleteExceptionListWithoutRuleReferenceByListId,
exportExceptionList,
searchForExceptionList,
waitForExceptionsTableToBeLoaded,
@ -153,7 +153,7 @@ describe('Exceptions Table', () => {
// just checking number of lists shown
cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3');
deleteExceptionListWithoutRuleReference();
deleteExceptionListWithoutRuleReferenceByListId(getExceptionList1().list_id);
// Using cy.contains because we do not care about the exact text,
// just checking number of lists shown

View file

@ -9,7 +9,10 @@ import { esArchiverResetKibana } from '../../../tasks/es_archiver';
import { cleanKibana } from '../../../tasks/common';
import { ROLES } from '../../../../common/test';
import { getExceptionList } from '../../../objects/exception';
import { EXCEPTIONS_TABLE_SHOWING_LISTS } from '../../../screens/exceptions';
import {
EXCEPTIONS_OVERFLOW_ACTIONS_BTN,
EXCEPTIONS_TABLE_SHOWING_LISTS,
} from '../../../screens/exceptions';
import { createExceptionList, deleteExceptionList } from '../../../tasks/api_calls/exceptions';
import {
dismissCallOut,
@ -56,4 +59,8 @@ describe('All exception lists - read only', () => {
getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist');
});
});
it('Exception list actions should be disabled', () => {
cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().should('be.disabled');
});
});

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { ROLES } from '../../../../common/test';
import { getExceptionList, expectedExportedExceptionList } from '../../../objects/exception';
import { getNewRule } from '../../../objects/rule';
@ -14,8 +13,11 @@ import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../..
import { EXCEPTIONS_URL } from '../../../urls/navigation';
import {
assertExceptionListsExists,
duplicateSharedExceptionListFromListsManagementPageByListId,
findSharedExceptionListItemsByName,
deleteExceptionListWithoutRuleReferenceByListId,
deleteExceptionListWithRuleReferenceByListId,
deleteExceptionListWithoutRuleReference,
exportExceptionList,
waitForExceptionsTableToBeLoaded,
createSharedExceptionList,
@ -24,135 +26,211 @@ import {
} from '../../../tasks/exceptions_table';
import {
EXCEPTIONS_LIST_MANAGEMENT_NAME,
EXCEPTIONS_OVERFLOW_ACTIONS_BTN,
EXCEPTIONS_TABLE_SHOWING_LISTS,
} from '../../../screens/exceptions';
import { createExceptionList } from '../../../tasks/api_calls/exceptions';
import { createExceptionList, createExceptionListItem } from '../../../tasks/api_calls/exceptions';
import { esArchiverResetKibana } from '../../../tasks/es_archiver';
import { assertNumberOfExceptionItemsExists } from '../../../tasks/exceptions';
import { TOASTER } from '../../../screens/alerts_detection_rules';
const EXCEPTION_LIST_NAME = 'My shared list';
const EXCEPTION_LIST_TO_DUPLICATE_NAME = 'A test list 2';
const EXCEPTION_LIST_ITEM_NAME = 'Sample Exception List Item 1';
const EXCEPTION_LIST_ITEM_NAME_2 = 'Sample Exception List Item 2';
const getExceptionList1 = () => ({
...getExceptionList(),
name: EXCEPTION_LIST_NAME,
list_id: 'exception_list_1',
});
const getExceptionList2 = () => ({
...getExceptionList(),
name: 'Test list 2',
name: EXCEPTION_LIST_TO_DUPLICATE_NAME,
list_id: 'exception_list_2',
});
const expiredDate = new Date(Date.now() - 1000000).toISOString();
const futureDate = new Date(Date.now() + 1000000).toISOString();
describe('Manage shared exception list', () => {
before(() => {
esArchiverResetKibana();
login();
describe('Create/Export/Delete', () => {
before(() => {
esArchiverResetKibana();
login();
createRule(getNewRule({ name: 'Another rule' }));
createRule(getNewRule({ name: 'Another rule' }));
// Create exception list associated with a rule
createExceptionList(getExceptionList2(), getExceptionList2().list_id).then((response) =>
createRule(
getNewRule({
exceptions_list: [
{
id: response.body.id,
list_id: getExceptionList2().list_id,
type: getExceptionList2().type,
namespace_type: getExceptionList2().namespace_type,
},
],
})
)
);
// Create exception list not used by any rules
createExceptionList(getExceptionList1(), getExceptionList1().list_id).as(
'exceptionListResponse'
);
});
beforeEach(() => {
visitWithoutDateRange(EXCEPTIONS_URL);
waitForExceptionsTableToBeLoaded();
});
it('Export exception list', function () {
cy.intercept(/(\/api\/exception_lists\/_export)/).as('export');
exportExceptionList(getExceptionList1().list_id);
cy.wait('@export').then(({ response }) => {
cy.wrap(response?.body).should(
'eql',
expectedExportedExceptionList(this.exceptionListResponse)
// Create exception list associated with a rule
createExceptionList(getExceptionList2(), getExceptionList2().list_id).then((response) =>
createRule(
getNewRule({
exceptions_list: [
{
id: response.body.id,
list_id: getExceptionList2().list_id,
type: getExceptionList2().type,
namespace_type: getExceptionList2().namespace_type,
},
],
})
)
);
cy.get(TOASTER).should(
'have.text',
`Exception list "${EXCEPTION_LIST_NAME}" exported successfully`
// Create exception list not used by any rules
createExceptionList(getExceptionList1(), getExceptionList1().list_id).as(
'exceptionListResponse'
);
});
beforeEach(() => {
visitWithoutDateRange(EXCEPTIONS_URL);
waitForExceptionsTableToBeLoaded();
});
it('Export exception list', function () {
cy.intercept(/(\/api\/exception_lists\/_export)/).as('export');
exportExceptionList(getExceptionList1().list_id);
cy.wait('@export').then(({ response }) => {
cy.wrap(response?.body).should(
'eql',
expectedExportedExceptionList(this.exceptionListResponse)
);
cy.get(TOASTER).should(
'have.text',
`Exception list "${EXCEPTION_LIST_NAME}" exported successfully`
);
});
});
it('Link rules to shared exception list', function () {
assertNumberLinkedRules(getExceptionList2().list_id, '1');
linkRulesToExceptionList(getExceptionList2().list_id, 1);
assertNumberLinkedRules(getExceptionList2().list_id, '2');
});
it('Create exception list', function () {
createSharedExceptionList(
{ name: 'Newly created list', description: 'This is my list.' },
true
);
// After creation - directed to list detail page
cy.get(EXCEPTIONS_LIST_MANAGEMENT_NAME).should('have.text', 'Newly created list');
});
it('Delete exception list without rule reference', () => {
// Using cy.contains because we do not care about the exact text,
// just checking number of lists shown
cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '4');
deleteExceptionListWithoutRuleReferenceByListId(getExceptionList1().list_id);
// Using cy.contains because we do not care about the exact text,
// just checking number of lists shown
cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3');
});
it('Deletes exception list with rule reference', () => {
waitForPageWithoutDateRange(EXCEPTIONS_URL);
waitForExceptionsTableToBeLoaded();
// Using cy.contains because we do not care about the exact text,
// just checking number of lists shown
cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3');
deleteExceptionListWithRuleReferenceByListId(getExceptionList2().list_id);
// Using cy.contains because we do not care about the exact text,
// just checking number of lists shown
cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2');
});
});
it('Link rules to shared exception list', function () {
assertNumberLinkedRules(getExceptionList2().list_id, '1');
linkRulesToExceptionList(getExceptionList2().list_id, 1);
assertNumberLinkedRules(getExceptionList2().list_id, '2');
});
describe('Duplicate', () => {
beforeEach(() => {
esArchiverResetKibana();
login();
it('Create exception list', function () {
createSharedExceptionList({ name: EXCEPTION_LIST_NAME, description: 'This is my list.' }, true);
// Create exception list associated with a rule
createExceptionList(getExceptionList2(), getExceptionList2().list_id);
// After creation - directed to list detail page
cy.get(EXCEPTIONS_LIST_MANAGEMENT_NAME).should('have.text', EXCEPTION_LIST_NAME);
});
createExceptionListItem(getExceptionList2().list_id, {
list_id: getExceptionList2().list_id,
item_id: 'simple_list_item_1',
tags: [],
type: 'simple',
description: 'Test exception item',
name: EXCEPTION_LIST_ITEM_NAME,
namespace_type: 'single',
entries: [
{
field: 'host.name',
operator: 'included',
type: 'match_any',
value: ['some host', 'another host'],
},
],
expire_time: expiredDate,
});
createExceptionListItem(getExceptionList2().list_id, {
list_id: getExceptionList2().list_id,
item_id: 'simple_list_item_2',
tags: [],
type: 'simple',
description: 'Test exception item',
name: EXCEPTION_LIST_ITEM_NAME_2,
namespace_type: 'single',
entries: [
{
field: 'host.name',
operator: 'included',
type: 'match_any',
value: ['some host', 'another host'],
},
],
expire_time: futureDate,
});
it('Delete exception list without rule reference', () => {
// Using cy.contains because we do not care about the exact text,
// just checking number of lists shown
cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '4');
visitWithoutDateRange(EXCEPTIONS_URL);
waitForExceptionsTableToBeLoaded();
});
deleteExceptionListWithoutRuleReference();
it('Duplicate exception list with expired items', function () {
duplicateSharedExceptionListFromListsManagementPageByListId(
getExceptionList2().list_id,
true
);
// Using cy.contains because we do not care about the exact text,
// just checking number of lists shown
cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3');
});
// After duplication - check for new list
assertExceptionListsExists([`${EXCEPTION_LIST_TO_DUPLICATE_NAME} [Duplicate]`]);
it('Deletes exception list with rule reference', () => {
waitForPageWithoutDateRange(EXCEPTIONS_URL);
waitForExceptionsTableToBeLoaded();
findSharedExceptionListItemsByName(`${EXCEPTION_LIST_TO_DUPLICATE_NAME} [Duplicate]`, [
EXCEPTION_LIST_ITEM_NAME,
EXCEPTION_LIST_ITEM_NAME_2,
]);
// Using cy.contains because we do not care about the exact text,
// just checking number of lists shown
cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3');
assertNumberOfExceptionItemsExists(2);
});
deleteExceptionListWithRuleReferenceByListId(getExceptionList2().list_id);
it('Duplicate exception list without expired items', function () {
duplicateSharedExceptionListFromListsManagementPageByListId(
getExceptionList2().list_id,
false
);
// Using cy.contains because we do not care about the exact text,
// just checking number of lists shown
cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2');
});
});
describe('Manage shared exception list - read only', () => {
before(() => {
// First we login as a privileged user to create exception list
esArchiverResetKibana();
login(ROLES.platform_engineer);
visitWithoutDateRange(EXCEPTIONS_URL, ROLES.platform_engineer);
createExceptionList(getExceptionList(), getExceptionList().list_id);
// Then we login as read-only user to test.
login(ROLES.reader);
visitWithoutDateRange(EXCEPTIONS_URL, ROLES.reader);
waitForExceptionsTableToBeLoaded();
cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`);
});
it('Exception list actions should be disabled', () => {
cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().should('be.disabled');
// After duplication - check for new list
assertExceptionListsExists([`${EXCEPTION_LIST_TO_DUPLICATE_NAME} [Duplicate]`]);
findSharedExceptionListItemsByName(`${EXCEPTION_LIST_TO_DUPLICATE_NAME} [Duplicate]`, [
EXCEPTION_LIST_ITEM_NAME_2,
]);
assertNumberOfExceptionItemsExists(1);
});
});
});

View file

@ -31,6 +31,15 @@ export interface ExceptionListItem {
tags: string[];
type: 'simple';
entries: Array<{ field: string; operator: string; type: string; value: string[] }>;
expire_time?: string;
}
export interface RuleExceptionItem {
description: string;
name: string;
type: 'simple';
entries: Array<{ field: string; operator: string; type: string; value: string[] | string }>;
expire_time: string | undefined;
}
export const getExceptionList = (): ExceptionList => ({

View file

@ -38,6 +38,13 @@ export const DELETE_RULE_BULK_BTN = '[data-test-subj="deleteRuleBulk"]';
export const DUPLICATE_RULE_BULK_BTN = '[data-test-subj="duplicateRuleBulk"]';
export const DUPLICATE_WITH_EXCEPTIONS_OPTION = '[data-test-subj="withExceptions"] label';
export const DUPLICATE_WITH_EXCEPTIONS_WITHOUT_EXPIRED_OPTION =
'[data-test-subj="withExceptionsExcludeExpiredExceptions"] label';
export const DUPLICATE_WITHOUT_EXCEPTIONS_OPTION = '[data-test-subj="withoutExceptions"] label';
export const RULE_SEARCH_FIELD = '[data-test-subj="ruleSearchField"]';
export const EXPORT_ACTION_BTN = '[data-test-subj="exportRuleAction"]';

View file

@ -55,7 +55,14 @@ export const EXCEPTIONS_TABLE_LINK_RULES_BTN =
export const EXCEPTIONS_TABLE_EXPORT_MODAL_BTN =
'[data-test-subj="sharedListOverflowCardActionItemExport"]';
export const EXCEPTIONS_TABLE_EXPORT_CONFIRM_BTN = '[data-test-subj="confirmModalConfirmButton"]';
export const EXCEPTIONS_TABLE_DUPLICATE_BTN =
'[data-test-subj="sharedListOverflowCardActionItemDuplicate"]';
export const EXCEPTIONS_TABLE_EXPIRED_EXCEPTION_ITEMS_MODAL_CONFIRM_BTN =
'[data-test-subj="confirmModalConfirmButton"]';
export const INCLUDE_EXPIRED_EXCEPTION_ITEMS_SWITCH =
'[data-test-subj="includeExpiredExceptionsConfirmationModalSwitch"]';
export const EXCEPTIONS_TABLE_SEARCH_CLEAR =
'[data-test-subj="allExceptionListsPanel"] button.euiFormControlLayoutClearButton';
@ -179,3 +186,5 @@ export const EXCEPTIONS_LIST_EDIT_DETAILS_SAVE_BTN = '[data-test-subj="editModal
export const EXCEPTIONS_LIST_DETAILS_HEADER =
'[data-test-subj="exceptionListManagementPageHeader"]';
export const EXCEPTION_LIST_DETAILS_CARD_ITEM_NAME = '[data-test-subj="exceptionItemCardHeader"]';

View file

@ -39,6 +39,12 @@ export const DETAILS_TITLE = '.euiDescriptionList__title';
export const EXCEPTIONS_TAB = 'a[data-test-subj="navigation-rule_exceptions"]';
export const EXCEPTIONS_TAB_EXPIRED_FILTER = '[data-test-subj="expired"]';
export const EXCEPTIONS_TAB_ACTIVE_FILTER = '[data-test-subj="active"]';
export const EXCEPTIONS_ITEM_CONTAINER = '[data-test-subj="exceptionsContainer"]';
export const FALSE_POSITIVES_DETAILS = 'False positive examples';
export const INDEX_PATTERNS_DETAILS = 'Index patterns';

View file

@ -62,6 +62,9 @@ import {
DISABLED_RULES_BTN,
REFRESH_RULES_TABLE_BUTTON,
RULE_LAST_RUN,
DUPLICATE_WITHOUT_EXCEPTIONS_OPTION,
DUPLICATE_WITH_EXCEPTIONS_OPTION,
DUPLICATE_WITH_EXCEPTIONS_WITHOUT_EXPIRED_OPTION,
} from '../screens/alerts_detection_rules';
import type { RULES_MONITORING_TABLE } from '../screens/alerts_detection_rules';
import { EUI_CHECKBOX } from '../screens/common/controls';
@ -145,10 +148,27 @@ export const deleteRuleFromDetailsPage = () => {
.should(($el) => expect($el).to.be.not.visible);
};
export const duplicateSelectedRules = () => {
export const duplicateSelectedRulesWithoutExceptions = () => {
cy.log('Duplicate selected rules');
cy.get(BULK_ACTIONS_BTN).click({ force: true });
cy.get(DUPLICATE_RULE_BULK_BTN).click();
cy.get(DUPLICATE_WITHOUT_EXCEPTIONS_OPTION).click();
cy.get(CONFIRM_DUPLICATE_RULE).click();
};
export const duplicateSelectedRulesWithExceptions = () => {
cy.log('Duplicate selected rules');
cy.get(BULK_ACTIONS_BTN).click({ force: true });
cy.get(DUPLICATE_RULE_BULK_BTN).click();
cy.get(DUPLICATE_WITH_EXCEPTIONS_OPTION).click();
cy.get(CONFIRM_DUPLICATE_RULE).click();
};
export const duplicateSelectedRulesWithNonExpiredExceptions = () => {
cy.log('Duplicate selected rules');
cy.get(BULK_ACTIONS_BTN).click({ force: true });
cy.get(DUPLICATE_RULE_BULK_BTN).click();
cy.get(DUPLICATE_WITH_EXCEPTIONS_WITHOUT_EXPIRED_OPTION).click();
cy.get(CONFIRM_DUPLICATE_RULE).click();
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { ExceptionList, ExceptionListItem } from '../../objects/exception';
import type { ExceptionList, ExceptionListItem, RuleExceptionItem } from '../../objects/exception';
export const createEndpointExceptionList = () =>
cy.request({
@ -59,6 +59,18 @@ export const createExceptionListItem = (
value: ['some host', 'another host'],
},
],
expire_time: exceptionListItem?.expire_time,
},
headers: { 'kbn-xsrf': 'cypress-creds' },
failOnStatusCode: false,
});
export const createRuleExceptionItem = (ruleId: string, exceptionListItems: RuleExceptionItem[]) =>
cy.request({
method: 'POST',
url: `/api/detection_engine/rules/${ruleId}/exceptions`,
body: {
items: exceptionListItems,
},
headers: { 'kbn-xsrf': 'cypress-creds' },
failOnStatusCode: false,

View file

@ -28,8 +28,24 @@ import {
EXCEPTION_FIELD_MAPPING_CONFLICTS_TOOLTIP,
EXCEPTION_FIELD_MAPPING_CONFLICTS_ACCORDION_ICON,
EXCEPTION_FIELD_MAPPING_CONFLICTS_DESCRIPTION,
EXCEPTION_ITEM_VIEWER_CONTAINER,
} from '../screens/exceptions';
export const assertNumberOfExceptionItemsExists = (numberOfItems: number) => {
cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', numberOfItems);
};
export const expectToContainItem = (container: string, itemName: string) => {
cy.log(`Expecting exception items table to contain '${itemName}'`);
cy.get(container).should('include.text', itemName);
};
export const assertExceptionItemsExists = (container: string, itemNames: string[]) => {
for (const itemName of itemNames) {
expectToContainItem(container, itemName);
}
};
export const addExceptionEntryFieldValueOfItemX = (
field: string,
itemIndex = 0,

View file

@ -14,7 +14,7 @@ import {
EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN,
EXCEPTIONS_TABLE_EXPORT_MODAL_BTN,
EXCEPTIONS_OVERFLOW_ACTIONS_BTN,
EXCEPTIONS_TABLE_EXPORT_CONFIRM_BTN,
EXCEPTIONS_TABLE_EXPIRED_EXCEPTION_ITEMS_MODAL_CONFIRM_BTN,
MANAGE_EXCEPTION_CREATE_BUTTON_MENU,
MANAGE_EXCEPTION_CREATE_LIST_BUTTON,
CREATE_SHARED_EXCEPTION_LIST_NAME_INPUT,
@ -26,12 +26,17 @@ import {
EXCEPTIONS_LIST_MANAGEMENT_EDIT_MODAL_DESCRIPTION_INPUT,
EXCEPTIONS_LIST_EDIT_DETAILS_SAVE_BTN,
EXCEPTIONS_LIST_DETAILS_HEADER,
EXCEPTIONS_TABLE_DUPLICATE_BTN,
EXCEPTIONS_TABLE_LIST_NAME,
INCLUDE_EXPIRED_EXCEPTION_ITEMS_SWITCH,
EXCEPTION_LIST_DETAILS_CARD_ITEM_NAME,
exceptionsTableListManagementListContainerByListId,
EXCEPTIONS_TABLE_LINK_RULES_BTN,
RULE_ACTION_LINK_RULE_SWITCH,
LINKED_RULES_BADGE,
MANAGE_RULES_SAVE,
} from '../screens/exceptions';
import { assertExceptionItemsExists } from './exceptions';
export const clearSearchSelection = () => {
cy.get(EXCEPTIONS_TABLE_SEARCH_CLEAR).first().click();
@ -46,7 +51,7 @@ export const exportExceptionList = (listId: string) => {
.find(EXCEPTIONS_OVERFLOW_ACTIONS_BTN)
.click();
cy.get(EXCEPTIONS_TABLE_EXPORT_MODAL_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_EXPORT_CONFIRM_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_EXPIRED_EXCEPTION_ITEMS_MODAL_CONFIRM_BTN).first().click();
};
export const assertNumberLinkedRules = (listId: string, numberOfRulesAsString: string) => {
@ -65,8 +70,10 @@ export const linkRulesToExceptionList = (listId: string, ruleSwitch: number = 0)
cy.get(MANAGE_RULES_SAVE).first().click();
};
export const deleteExceptionListWithoutRuleReference = () => {
cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().click();
export const deleteExceptionListWithoutRuleReferenceByListId = (listId: string) => {
cy.get(exceptionsTableListManagementListContainerByListId(listId))
.find(EXCEPTIONS_OVERFLOW_ACTIONS_BTN)
.click();
cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click();
cy.get(EXCEPTIONS_TABLE_MODAL).should('exist');
cy.get(EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN).first().click();
@ -117,10 +124,42 @@ export const createSharedExceptionList = (
}
};
export const expectToContainList = (listName: string) => {
cy.log(`Expecting exception lists table to contain '${listName}'`);
cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('include.text', listName);
};
export const assertExceptionListsExists = (listNames: string[]) => {
for (const listName of listNames) {
expectToContainList(listName);
}
};
export const duplicateSharedExceptionListFromListsManagementPageByListId = (
listId: string,
includeExpired: boolean
) => {
cy.log(`Duplicating list with list_id: '${listId}'`);
cy.get(exceptionsTableListManagementListContainerByListId(listId))
.find(EXCEPTIONS_OVERFLOW_ACTIONS_BTN)
.click();
cy.get(EXCEPTIONS_TABLE_DUPLICATE_BTN).first().click();
if (!includeExpired) {
cy.get(INCLUDE_EXPIRED_EXCEPTION_ITEMS_SWITCH).first().click();
}
cy.get(EXCEPTIONS_TABLE_EXPIRED_EXCEPTION_ITEMS_MODAL_CONFIRM_BTN).first().click();
};
export const waitForExceptionListDetailToBeLoaded = () => {
cy.get(EXCEPTIONS_LIST_DETAILS_HEADER).should('exist');
};
export const findSharedExceptionListItemsByName = (listName: string, itemNames: string[]) => {
cy.contains(listName).click();
waitForExceptionListDetailToBeLoaded();
assertExceptionItemsExists(EXCEPTION_LIST_DETAILS_CARD_ITEM_NAME, itemNames);
};
export const editExceptionLisDetails = ({
name,
description,

View file

@ -29,6 +29,8 @@ import {
ENDPOINT_EXCEPTIONS_TAB,
EDIT_RULE_SETTINGS_LINK,
BACK_TO_RULES_TABLE,
EXCEPTIONS_TAB_EXPIRED_FILTER,
EXCEPTIONS_TAB_ACTIVE_FILTER,
} from '../screens/rule_details';
import {
addExceptionConditions,
@ -105,6 +107,11 @@ export const goToExceptionsTab = () => {
cy.get(EXCEPTIONS_TAB).click();
};
export const viewExpiredExceptionItems = () => {
cy.get(EXCEPTIONS_TAB_EXPIRED_FILTER).click();
cy.get(EXCEPTIONS_TAB_ACTIVE_FILTER).click();
};
export const goToEndpointExceptionsTab = () => {
cy.get(ENDPOINT_EXCEPTIONS_TAB).should('exist');
cy.get(ENDPOINT_EXCEPTIONS_TAB).click();

View file

@ -46,21 +46,35 @@ const BulkActionDuplicateExceptionsConfirmationComponent = ({
defaultFocusedButton="confirm"
onCancel={onCancel}
>
<EuiText>
{i18n.MODAL_TEXT(rulesCount)}{' '}
<EuiIconTip content={i18n.DUPLICATE_TOOLTIP} position="bottom" />
</EuiText>
<EuiText>{i18n.MODAL_TEXT(rulesCount)}</EuiText>
<EuiSpacer />
<EuiRadioGroup
options={[
{
id: DuplicateOptions.withExceptions,
label: i18n.DUPLICATE_EXCEPTIONS_TEXT(rulesCount),
label: (
<EuiText size="s">
{i18n.DUPLICATE_EXCEPTIONS_INCLUDE_EXPIRED_EXCEPTIONS_LABEL(rulesCount)}
<EuiIconTip content={i18n.DUPLICATE_TOOLTIP} position="bottom" />
</EuiText>
),
'data-test-subj': DuplicateOptions.withExceptions,
},
{
id: DuplicateOptions.withExceptionsExcludeExpiredExceptions,
label: (
<EuiText size="s">
{i18n.DUPLICATE_EXCEPTIONS_TEXT(rulesCount)}
<EuiIconTip content={i18n.DUPLICATE_TOOLTIP} position="bottom" />
</EuiText>
),
'data-test-subj': DuplicateOptions.withExceptionsExcludeExpiredExceptions,
},
{
id: DuplicateOptions.withoutExceptions,
label: i18n.DUPLICATE_WITHOUT_EXCEPTIONS_TEXT(rulesCount),
'data-test-subj': DuplicateOptions.withoutExceptions,
},
]}
idSelected={selectedDuplicateOption}

View file

@ -138,8 +138,8 @@ export const bulkSetSchedule = {
export const bulkDuplicateRuleActions = {
MODAL_TITLE: (rulesCount: number): JSX.Element => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.modalTitle"
defaultMessage="Duplicate {rulesCount, plural, one {the rule} other {rules}} with exceptions?"
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exception.confirmation.modalTitle"
defaultMessage="Duplicate {rulesCount, plural, one {the rule} other {rules}}?"
values={{ rulesCount }}
/>
),
@ -147,7 +147,7 @@ export const bulkDuplicateRuleActions = {
MODAL_TEXT: (rulesCount: number): JSX.Element => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.modalBody"
defaultMessage="You are duplicating {rulesCount, plural, one {# selected rule} other {# selected rules}}, please select how you would like to duplicate the existing exceptions"
defaultMessage="You're duplicating {rulesCount, plural, one {# rule} other {# rules}}. Choose what to duplicate:"
values={{ rulesCount }}
/>
),
@ -155,7 +155,15 @@ export const bulkDuplicateRuleActions = {
DUPLICATE_EXCEPTIONS_TEXT: (rulesCount: number) => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.with"
defaultMessage="Duplicate {rulesCount, plural, one {rule} other {rules}} and their exceptions"
defaultMessage="The {rulesCount, plural, one {rule} other {rules}} and {rulesCount, plural, one {its} other {their}} active exceptions"
values={{ rulesCount }}
/>
),
DUPLICATE_EXCEPTIONS_INCLUDE_EXPIRED_EXCEPTIONS_LABEL: (rulesCount: number) => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.includeExpiredExceptionsCheckboxLabel"
defaultMessage="The {rulesCount, plural, one {rule} other {rules}} and all of {rulesCount, plural, one {its} other {their}} exceptions (active and expired)"
values={{ rulesCount }}
/>
),
@ -163,7 +171,7 @@ export const bulkDuplicateRuleActions = {
DUPLICATE_WITHOUT_EXCEPTIONS_TEXT: (rulesCount: number) => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.without"
defaultMessage="Only duplicate {rulesCount, plural, one {the rule} other {rules}}"
defaultMessage="Only the {rulesCount, plural, one {rule} other {rules}}"
values={{ rulesCount }}
/>
),
@ -186,7 +194,7 @@ export const bulkDuplicateRuleActions = {
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.tooltip',
{
defaultMessage:
' If you duplicate exceptions, then the shared exceptions list will be duplicated by reference and the default rule exception will be copied and created as a new one',
' If you choose to duplicate exceptions, the shared exceptions list will be duplicated by reference and the rule exceptions will be copied and created anew',
}
),
};

View file

@ -137,7 +137,13 @@ export const useBulkActions = ({
type: BulkActionType.duplicate,
duplicatePayload: {
include_exceptions:
modalDuplicationConfirmationResult === DuplicateOptions.withExceptions,
modalDuplicationConfirmationResult === DuplicateOptions.withExceptions ||
modalDuplicationConfirmationResult ===
DuplicateOptions.withExceptionsExcludeExpiredExceptions,
include_expired_exceptions: !(
modalDuplicationConfirmationResult ===
DuplicateOptions.withExceptionsExcludeExpiredExceptions
),
},
...(isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }),
});

View file

@ -77,7 +77,13 @@ export const useRulesTableActions = ({
ids: [rule.id],
duplicatePayload: {
include_exceptions:
modalDuplicationConfirmationResult === DuplicateOptions.withExceptions,
modalDuplicationConfirmationResult === DuplicateOptions.withExceptions ||
modalDuplicationConfirmationResult ===
DuplicateOptions.withExceptionsExcludeExpiredExceptions,
include_expired_exceptions: !(
modalDuplicationConfirmationResult ===
DuplicateOptions.withExceptionsExcludeExpiredExceptions
),
},
});
const createdRules = result?.attributes.results.created;

View file

@ -96,7 +96,13 @@ const RuleActionsOverflowComponent = ({
ids: [rule.id],
duplicatePayload: {
include_exceptions:
modalDuplicationConfirmationResult === DuplicateOptions.withExceptions,
modalDuplicationConfirmationResult === DuplicateOptions.withExceptions ||
modalDuplicationConfirmationResult ===
DuplicateOptions.withExceptionsExcludeExpiredExceptions,
include_expired_exceptions: !(
modalDuplicationConfirmationResult ===
DuplicateOptions.withExceptionsExcludeExpiredExceptions
),
},
});

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render } from '@testing-library/react';
import React from 'react';
import { ExceptionsListCard } from '.';
import { useListDetailsView } from '../../hooks';
import { useExceptionsListCard } from '../../hooks/use_exceptions_list.card';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { TestProviders } from '../../../common/mock';
jest.mock('../../hooks');
jest.mock('../../hooks/use_exceptions_list.card');
const getMockUseExceptionsListCard = () => ({
listId: 'my-list',
listName: 'Exception list',
listType: 'detection',
createdAt: '2023-02-01T10:20:30.000Z',
createdBy: 'elastic',
exceptions: [{ ...getExceptionListItemSchemaMock() }],
pagination: { pageIndex: 0, pageSize: 5, totalItemCount: 1 },
ruleReferences: {
'my-list': {
name: 'Exception list',
id: '345',
referenced_rules: [],
listId: 'my-list',
},
},
toggleAccordion: false,
openAccordionId: '123',
menuActionItems: [
{
key: 'Export',
icon: 'exportAction',
label: 'Export',
onClick: jest.fn(),
},
],
listRulesCount: '5',
listDescription: 'My exception list description',
exceptionItemsCount: jest.fn(),
onEditExceptionItem: jest.fn(),
onDeleteException: jest.fn(),
onPaginationChange: jest.fn(),
setToggleAccordion: jest.fn(),
exceptionViewerStatus: '',
showAddExceptionFlyout: false,
showEditExceptionFlyout: false,
exceptionToEdit: undefined,
onAddExceptionClick: jest.fn(),
handleConfirmExceptionFlyout: jest.fn(),
handleCancelExceptionItemFlyout: jest.fn(),
goToExceptionDetail: jest.fn(),
emptyViewerTitle: 'Empty View',
emptyViewerBody: 'This is the empty view description.',
emptyViewerButtonText: 'Take action',
handleCancelExpiredExceptionsModal: jest.fn(),
handleConfirmExpiredExceptionsModal: jest.fn(),
showIncludeExpiredExceptionsModal: false,
});
const getMockUseListDetailsView = () => ({
linkedRules: [],
showManageRulesFlyout: false,
showManageButtonLoader: false,
disableManageButton: false,
onManageRules: jest.fn(),
onSaveManageRules: jest.fn(),
onCancelManageRules: jest.fn(),
onRuleSelectionChange: jest.fn(),
});
describe('ExceptionsListCard', () => {
beforeEach(() => {
(useExceptionsListCard as jest.Mock).mockReturnValue(getMockUseExceptionsListCard());
(useListDetailsView as jest.Mock).mockReturnValue(getMockUseListDetailsView());
});
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
it('should display expired exception confirmation modal when "showIncludeExpiredExceptionsModal" is "true"', () => {
(useExceptionsListCard as jest.Mock).mockReturnValue({
...getMockUseExceptionsListCard(),
showIncludeExpiredExceptionsModal: true,
});
const wrapper = render(
<TestProviders>
<ExceptionsListCard
exceptionsList={{ ...getExceptionListSchemaMock(), rules: [] }}
handleDelete={jest.fn()}
handleExport={jest.fn()}
handleDuplicate={jest.fn()}
readOnly={false}
/>
</TestProviders>
);
expect(wrapper.getByTestId('includeExpiredExceptionsConfirmationModal')).toBeTruthy();
});
});

View file

@ -33,7 +33,7 @@ import { ListExceptionItems } from '../list_exception_items';
import { useListDetailsView } from '../../hooks';
import { useExceptionsListCard } from '../../hooks/use_exceptions_list.card';
import { ManageRules } from '../manage_rules';
import { ExportExceptionsListModal } from '../export_exceptions_list_modal';
import { IncludeExpiredExceptionsModal } from '../expired_exceptions_list_items_modal';
interface ExceptionsListCardProps {
exceptionsList: ExceptionListInfo;
@ -59,6 +59,17 @@ interface ExceptionsListCardProps {
name: string;
namespaceType: NamespaceType;
}) => () => Promise<void>;
handleDuplicate: ({
includeExpiredExceptions,
listId,
name,
namespaceType,
}: {
includeExpiredExceptions: boolean;
listId: string;
name: string;
namespaceType: NamespaceType;
}) => () => Promise<void>;
readOnly: boolean;
}
const buttonCss = css`
@ -78,7 +89,7 @@ const ListHeaderContainer = styled(EuiFlexGroup)`
text-align: initial;
`;
export const ExceptionsListCard = memo<ExceptionsListCardProps>(
({ exceptionsList, handleDelete, handleExport, readOnly }) => {
({ exceptionsList, handleDelete, handleExport, handleDuplicate, readOnly }) => {
const {
linkedRules,
showManageRulesFlyout,
@ -119,13 +130,14 @@ export const ExceptionsListCard = memo<ExceptionsListCardProps>(
emptyViewerTitle,
emptyViewerBody,
emptyViewerButtonText,
handleCancelExportModal,
handleConfirmExportModal,
showExportModal,
handleCancelExpiredExceptionsModal,
handleConfirmExpiredExceptionsModal,
showIncludeExpiredExceptionsModal,
} = useExceptionsListCard({
exceptionsList,
handleExport,
handleDelete,
handleDuplicate,
handleManageRules: onManageRules,
});
@ -259,10 +271,11 @@ export const ExceptionsListCard = memo<ExceptionsListCardProps>(
onRuleSelectionChange={onRuleSelectionChange}
/>
) : null}
{showExportModal ? (
<ExportExceptionsListModal
handleCloseModal={handleCancelExportModal}
onModalConfirm={handleConfirmExportModal}
{showIncludeExpiredExceptionsModal ? (
<IncludeExpiredExceptionsModal
handleCloseModal={handleCancelExpiredExceptionsModal}
onModalConfirm={handleConfirmExpiredExceptionsModal}
action={showIncludeExpiredExceptionsModal}
/>
) : null}
</EuiFlexGroup>

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { IncludeExpiredExceptionsModal } from '.';
import { fireEvent, render } from '@testing-library/react';
describe('IncludeExpiredExceptionsModal', () => {
const handleCloseModal = jest.fn();
const onModalConfirm = jest.fn();
it('should call handleCloseModal on cancel click', () => {
const wrapper = render(
<IncludeExpiredExceptionsModal
handleCloseModal={handleCloseModal}
onModalConfirm={onModalConfirm}
action={'export'}
/>
);
fireEvent.click(wrapper.getByTestId('confirmModalCancelButton'));
expect(handleCloseModal).toHaveBeenCalled();
});
it('should call onModalConfirm on confirm click', () => {
const wrapper = render(
<IncludeExpiredExceptionsModal
handleCloseModal={handleCloseModal}
onModalConfirm={onModalConfirm}
action={'duplicate'}
/>
);
fireEvent.click(wrapper.getByTestId('confirmModalConfirmButton'));
expect(onModalConfirm).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useState } from 'react';
import { EuiConfirmModal, EuiSpacer, EuiSwitch, EuiText } from '@elastic/eui';
import * as i18n from '../../translations';
export const CHECK_EXCEPTION_TTL_ACTION_TYPES = {
DUPLICATE: 'duplicate',
EXPORT: 'export',
} as const;
export type CheckExceptionTtlActionTypes =
typeof CHECK_EXCEPTION_TTL_ACTION_TYPES[keyof typeof CHECK_EXCEPTION_TTL_ACTION_TYPES];
interface IncludeExpiredExceptionsModalProps {
handleCloseModal: () => void;
onModalConfirm: (includeExpired: boolean) => void;
action: CheckExceptionTtlActionTypes;
}
export const IncludeExpiredExceptionsModal = memo<IncludeExpiredExceptionsModalProps>(
({ handleCloseModal, onModalConfirm, action }) => {
const [includeExpired, setIncludeExpired] = useState(true);
const handleSwitchChange = useCallback(() => {
setIncludeExpired(!includeExpired);
}, [setIncludeExpired, includeExpired]);
const handleConfirm = useCallback(() => {
onModalConfirm(includeExpired);
handleCloseModal();
}, [includeExpired, handleCloseModal, onModalConfirm]);
return (
<EuiConfirmModal
title={
action === CHECK_EXCEPTION_TTL_ACTION_TYPES.EXPORT
? i18n.EXPIRED_EXCEPTIONS_MODAL_EXPORT_TITLE
: i18n.EXPIRED_EXCEPTIONS_MODAL_DUPLICATE_TITLE
}
onCancel={handleCloseModal}
onConfirm={handleConfirm}
cancelButtonText={i18n.EXPIRED_EXCEPTIONS_MODAL_CANCEL_BUTTON}
confirmButtonText={
action === CHECK_EXCEPTION_TTL_ACTION_TYPES.EXPORT
? i18n.EXPIRED_EXCEPTIONS_MODAL_CONFIRM_EXPORT_BUTTON
: i18n.EXPIRED_EXCEPTIONS_MODAL_CONFIRM_DUPLICATE_BUTTON
}
defaultFocusedButton="confirm"
data-test-subj="includeExpiredExceptionsConfirmationModal"
>
<EuiText>
{action === CHECK_EXCEPTION_TTL_ACTION_TYPES.EXPORT
? i18n.EXPIRED_EXCEPTIONS_MODAL_EXPORT_DESCRIPTION
: i18n.EXPIRED_EXCEPTIONS_MODAL_DUPLICATE_DESCRIPTION}
</EuiText>
<EuiSpacer size="s" />
<EuiSwitch
label={i18n.EXPIRED_EXCEPTIONS_MODAL_INCLUDE_SWITCH_LABEL}
checked={includeExpired}
onChange={handleSwitchChange}
data-test-subj="includeExpiredExceptionsConfirmationModalSwitch"
/>
</EuiConfirmModal>
);
}
);
IncludeExpiredExceptionsModal.displayName = 'IncludeExpiredExceptionsModal';

View file

@ -1,50 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useState } from 'react';
import { EuiConfirmModal, EuiSwitch } from '@elastic/eui';
import * as i18n from '../../translations';
interface ExportExceptionsListModalProps {
handleCloseModal: () => void;
onModalConfirm: (includeExpired: boolean) => void;
}
export const ExportExceptionsListModal = memo<ExportExceptionsListModalProps>(
({ handleCloseModal, onModalConfirm }) => {
const [exportExpired, setExportExpired] = useState(true);
const handleSwitchChange = useCallback(() => {
setExportExpired(!exportExpired);
}, [setExportExpired, exportExpired]);
const handleConfirm = useCallback(() => {
onModalConfirm(exportExpired);
handleCloseModal();
}, [exportExpired, handleCloseModal, onModalConfirm]);
return (
<EuiConfirmModal
title={i18n.EXPORT_MODAL_TITLE}
onCancel={handleCloseModal}
onConfirm={handleConfirm}
cancelButtonText={i18n.EXPORT_MODAL_CANCEL_BUTTON}
confirmButtonText={i18n.EXPORT_MODAL_CONFIRM_BUTTON}
defaultFocusedButton="confirm"
>
<EuiSwitch
label={i18n.EXPORT_MODAL_INCLUDE_SWITCH_LABEL}
checked={exportExpired}
onChange={handleSwitchChange}
/>
</EuiConfirmModal>
);
}
);
ExportExceptionsListModal.displayName = 'ExportExceptionsListModal';

View file

@ -20,7 +20,15 @@ import type { ExceptionListInfo } from '../use_all_exception_lists';
import { useListExceptionItems } from '../use_list_exception_items';
import * as i18n from '../../translations';
import { checkIfListCannotBeEdited } from '../../utils/list.utils';
import type { CheckExceptionTtlActionTypes } from '../../components/expired_exceptions_list_items_modal';
import { CHECK_EXCEPTION_TTL_ACTION_TYPES } from '../../components/expired_exceptions_list_items_modal';
interface DuplicateListAction {
listId: string;
name: string;
namespaceType: NamespaceType;
includeExpiredExceptions: boolean;
}
interface ExportListAction {
id: string;
listId: string;
@ -37,6 +45,7 @@ export const useExceptionsListCard = ({
exceptionsList,
handleExport,
handleDelete,
handleDuplicate,
handleManageRules,
}: {
exceptionsList: ExceptionListInfo;
@ -48,13 +57,20 @@ export const useExceptionsListCard = ({
includeExpiredExceptions,
}: ExportListAction) => () => Promise<void>;
handleDelete: ({ id, listId, namespaceType }: ListAction) => () => Promise<void>;
handleDuplicate: ({
listId,
name,
namespaceType,
includeExpiredExceptions,
}: DuplicateListAction) => () => Promise<void>;
handleManageRules: () => void;
}) => {
const [viewerStatus, setViewerStatus] = useState<ViewerStatus | string>(ViewerStatus.LOADING);
const [exceptionToEdit, setExceptionToEdit] = useState<ExceptionListItemSchema>();
const [showAddExceptionFlyout, setShowAddExceptionFlyout] = useState(false);
const [showEditExceptionFlyout, setShowEditExceptionFlyout] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showIncludeExpiredExceptionsModal, setShowIncludeExpiredExceptionsModal] =
useState<CheckExceptionTtlActionTypes | null>(null);
const {
name: listName,
@ -135,10 +151,19 @@ export const useExceptionsListCard = ({
includeExpiredExceptions: true,
})();
} else {
setShowExportModal(true);
setShowIncludeExpiredExceptionsModal(CHECK_EXCEPTION_TTL_ACTION_TYPES.EXPORT);
}
},
},
{
key: 'Duplicate',
icon: 'copy',
label: i18n.DUPLICATE_EXCEPTION_LIST,
disabled: listCannotBeEdited,
onClick: (_: React.MouseEvent<Element, MouseEvent>) => {
setShowIncludeExpiredExceptionsModal(CHECK_EXCEPTION_TTL_ACTION_TYPES.DUPLICATE);
},
},
{
key: 'Delete',
icon: 'trash',
@ -163,16 +188,15 @@ export const useExceptionsListCard = ({
},
],
[
listCannotBeEdited,
listType,
handleExport,
exceptionsList.id,
exceptionsList.list_id,
exceptionsList.name,
exceptionsList.namespace_type,
handleDelete,
setShowExportModal,
listCannotBeEdited,
handleManageRules,
handleExport,
listType,
]
);
@ -197,24 +221,42 @@ export const useExceptionsListCard = ({
);
const onExportListClick = useCallback(() => {
setShowExportModal(true);
}, [setShowExportModal]);
setShowIncludeExpiredExceptionsModal(CHECK_EXCEPTION_TTL_ACTION_TYPES.EXPORT);
}, [setShowIncludeExpiredExceptionsModal]);
const handleCancelExportModal = () => {
setShowExportModal(false);
const handleCancelExpiredExceptionsModal = () => {
setShowIncludeExpiredExceptionsModal(null);
};
const handleConfirmExportModal = useCallback(
const handleConfirmExpiredExceptionsModal = useCallback(
(includeExpiredExceptions: boolean): void => {
handleExport({
id: exceptionsList.id,
listId: exceptionsList.list_id,
name: exceptionsList.name,
namespaceType: exceptionsList.namespace_type,
includeExpiredExceptions,
})();
if (showIncludeExpiredExceptionsModal === CHECK_EXCEPTION_TTL_ACTION_TYPES.EXPORT) {
handleExport({
id: exceptionsList.id,
listId: exceptionsList.list_id,
name: exceptionsList.name,
namespaceType: exceptionsList.namespace_type,
includeExpiredExceptions,
})();
}
if (showIncludeExpiredExceptionsModal === CHECK_EXCEPTION_TTL_ACTION_TYPES.DUPLICATE) {
handleDuplicate({
listId: exceptionsList.list_id,
name: exceptionsList.name,
namespaceType: exceptionsList.namespace_type,
includeExpiredExceptions,
})();
}
},
[handleExport, exceptionsList]
[
showIncludeExpiredExceptionsModal,
handleExport,
exceptionsList.id,
exceptionsList.list_id,
exceptionsList.name,
exceptionsList.namespace_type,
handleDuplicate,
]
);
// routes to x-pack/plugins/security_solution/public/exceptions/routes.tsx
@ -255,9 +297,9 @@ export const useExceptionsListCard = ({
emptyViewerTitle,
emptyViewerBody,
emptyViewerButtonText,
showExportModal,
showIncludeExpiredExceptionsModal,
onExportListClick,
handleCancelExportModal,
handleConfirmExportModal,
handleCancelExpiredExceptionsModal,
handleConfirmExpiredExceptionsModal,
};
};

View file

@ -51,7 +51,7 @@ export const useListDetailsView = (exceptionListId: string) => {
const { http, notifications } = services;
const { navigateToApp } = services.application;
const { exportExceptionList, deleteExceptionList } = useApi(http);
const { exportExceptionList, deleteExceptionList, duplicateExceptionList } = useApi(http);
const [{ loading: userInfoLoading, canUserCRUD, canUserREAD }] = useUserData();
@ -190,6 +190,35 @@ export const useListDetailsView = (exceptionListId: string) => {
[list, exportExceptionList, handleErrorStatus, toasts]
);
const onDuplicateList = useCallback(
async (includeExpiredExceptions: boolean) => {
try {
if (!list) return;
await duplicateExceptionList({
listId: list.list_id,
includeExpiredExceptions,
namespaceType: list.namespace_type,
onError: (error: Error) => handleErrorStatus(error),
onSuccess: (newList: ExceptionListSchema) => {
toasts?.addSuccess(i18n.EXCEPTION_LIST_DUPLICATED_SUCCESSFULLY(list.name));
navigateToApp(APP_UI_ID, {
deepLinkId: SecurityPageName.exceptions,
path: `/details/${newList.list_id}`,
});
},
});
} catch (error) {
handleErrorStatus(
error,
undefined,
i18n.EXCEPTION_DUPLICATE_ERROR,
i18n.EXCEPTION_DUPLICATE_ERROR_DESCRIPTION
);
}
},
[list, duplicateExceptionList, handleErrorStatus, toasts, navigateToApp]
);
const handleOnDownload = useCallback(() => {
setExportedList(undefined);
}, []);
@ -384,6 +413,7 @@ export const useListDetailsView = (exceptionListId: string) => {
refreshExceptions,
disableManageButton,
handleDelete,
onDuplicateList,
onEditListDetails,
onExportList,
onDeleteList,

View file

@ -25,7 +25,8 @@ import { AutoDownload } from '../../../common/components/auto_download/auto_down
import { ListWithSearch, ManageRules, ListDetailsLinkAnchor } from '../../components';
import { useListDetailsView } from '../../hooks';
import * as i18n from '../../translations';
import { ExportExceptionsListModal } from '../../components/export_exceptions_list_modal';
import type { CheckExceptionTtlActionTypes } from '../../components/expired_exceptions_list_items_modal';
import { IncludeExpiredExceptionsModal } from '../../components/expired_exceptions_list_items_modal';
export const ListsDetailViewComponent: FC = () => {
const { detailName: exceptionListId } = useParams<{
@ -52,6 +53,7 @@ export const ListsDetailViewComponent: FC = () => {
refreshExceptions,
disableManageButton,
onEditListDetails,
onDuplicateList,
onExportList,
onManageRules,
onSaveManageRules,
@ -62,20 +64,33 @@ export const ListsDetailViewComponent: FC = () => {
handleReferenceDelete,
} = useListDetailsView(exceptionListId);
const [showExportModal, setShowExportModal] = useState(false);
const [showIncludeExpiredExceptionItemsModal, setShowIncludeExpiredExceptionItemsModal] =
useState<CheckExceptionTtlActionTypes | null>(null);
const onModalClose = useCallback(() => setShowExportModal(false), [setShowExportModal]);
const onModalClose = useCallback(
() => setShowIncludeExpiredExceptionItemsModal(null),
[setShowIncludeExpiredExceptionItemsModal]
);
const onModalOpen = useCallback(() => setShowExportModal(true), [setShowExportModal]);
const onModalOpen = useCallback(
(actionType: CheckExceptionTtlActionTypes) => {
setShowIncludeExpiredExceptionItemsModal(actionType);
},
[setShowIncludeExpiredExceptionItemsModal]
);
const handleExportList = useCallback(() => {
if (list?.type === ExceptionListTypeEnum.ENDPOINT) {
onExportList(true);
} else {
onModalOpen();
onModalOpen('export');
}
}, [onModalOpen, list, onExportList]);
const handleDuplicateList = useCallback(() => {
onModalOpen('duplicate');
}, [onModalOpen]);
const detailsViewContent = useMemo(() => {
if (viewerStatus === ViewerStatus.ERROR)
return <EmptyViewerState isReadOnly={isReadOnly} viewerStatus={viewerStatus} />;
@ -99,6 +114,7 @@ export const ListsDetailViewComponent: FC = () => {
onExportList={handleExportList}
onDeleteList={handleDelete}
onManageRules={onManageRules}
onDuplicateList={handleDuplicateList}
dataTestSubj="exceptionListManagement"
/>
@ -125,47 +141,52 @@ export const ListsDetailViewComponent: FC = () => {
onRuleSelectionChange={onRuleSelectionChange}
/>
) : null}
{showExportModal && (
<ExportExceptionsListModal
onModalConfirm={onExportList}
{showIncludeExpiredExceptionItemsModal && (
<IncludeExpiredExceptionsModal
onModalConfirm={
showIncludeExpiredExceptionItemsModal === 'export' ? onExportList : onDuplicateList
}
handleCloseModal={onModalClose}
action={showIncludeExpiredExceptionItemsModal}
/>
)}
</>
);
}, [
canUserEditList,
disableManageButton,
exportedList,
handleOnDownload,
headerBackOptions,
invalidListId,
isLoading,
viewerStatus,
isReadOnly,
linkedRules,
isLoading,
invalidListId,
listName,
list,
listDescription,
listId,
listName,
linkedRules,
canUserEditList,
headerBackOptions,
onEditListDetails,
handleExportList,
handleDelete,
onManageRules,
handleDuplicateList,
exportedList,
handleOnDownload,
refreshExceptions,
referenceModalState.contentText,
referenceModalState.rulesReferences,
refreshExceptions,
showManageButtonLoader,
showManageRulesFlyout,
showReferenceErrorModal,
showExportModal,
viewerStatus,
onCancelManageRules,
onEditListDetails,
onExportList,
onManageRules,
onRuleSelectionChange,
onSaveManageRules,
handleCloseReferenceErrorModal,
handleDelete,
handleReferenceDelete,
showReferenceErrorModal,
showManageRulesFlyout,
showManageButtonLoader,
disableManageButton,
onSaveManageRules,
onCancelManageRules,
onRuleSelectionChange,
showIncludeExpiredExceptionItemsModal,
onExportList,
onDuplicateList,
onModalClose,
handleExportList,
]);
return (
<>

View file

@ -93,7 +93,7 @@ export const SharedLists = React.memo(() => {
application: { navigateToApp },
},
} = useKibana();
const { exportExceptionList, deleteExceptionList } = useApi(http);
const { exportExceptionList, deleteExceptionList, duplicateExceptionList } = useApi(http);
const [showReferenceErrorModal, setShowReferenceErrorModal] = useState(false);
const [referenceModalState, setReferenceModalState] = useState<ReferenceModalState>(
@ -262,6 +262,45 @@ export const SharedLists = React.memo(() => {
[]
);
const handleDuplicationError = useCallback(
(err: Error) => {
addError(err, { title: i18n.EXCEPTION_DUPLICATE_ERROR });
},
[addError]
);
const handleDuplicateSuccess = useCallback(
(name: string) => (): void => {
addSuccess(i18n.EXCEPTION_LIST_DUPLICATED_SUCCESSFULLY(name));
handleRefresh();
},
[addSuccess, handleRefresh]
);
const handleDuplicate = useCallback(
({
listId,
name,
namespaceType,
includeExpiredExceptions,
}: {
listId: string;
name: string;
namespaceType: NamespaceType;
includeExpiredExceptions: boolean;
}) =>
async () => {
await duplicateExceptionList({
includeExpiredExceptions,
listId,
namespaceType,
onError: handleDuplicationError,
onSuccess: handleDuplicateSuccess(name),
});
},
[duplicateExceptionList, handleDuplicateSuccess, handleDuplicationError]
);
const handleCloseReferenceErrorModal = useCallback((): void => {
setShowReferenceErrorModal(false);
setReferenceModalState({
@ -546,6 +585,7 @@ export const SharedLists = React.memo(() => {
exceptionsList={excList}
handleDelete={handleDelete}
handleExport={handleExport}
handleDuplicate={handleDuplicate}
/>
))}
</div>

View file

@ -167,3 +167,17 @@ export const EXCEPTION_EXPORT_ERROR_DESCRIPTION = i18n.translate(
defaultMessage: 'An error occurred exporting a list',
}
);
export const DUPLICATE_EXCEPTION_LIST = i18n.translate(
'xpack.securitySolution.exceptionsTable.duplicateExceptionList',
{
defaultMessage: 'Duplicate exception list',
}
);
export const EXCEPTION_DUPLICATE_ERROR_DESCRIPTION = i18n.translate(
'xpack.securitySolution.exceptionsTable.duplicateListDescription',
{
defaultMessage: 'An error occurred duplicating a list',
}
);

View file

@ -125,6 +125,19 @@ export const EXCEPTION_EXPORT_ERROR = i18n.translate(
}
);
export const EXCEPTION_LIST_DUPLICATED_SUCCESSFULLY = (listName: string) =>
i18n.translate('xpack.securitySolution.exceptions.list.duplicate_success', {
values: { listName },
defaultMessage: 'Exception list "{listName}" duplicated successfully',
});
export const EXCEPTION_DUPLICATE_ERROR = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.duplicateError',
{
defaultMessage: 'Exception list duplication error',
}
);
export const EXCEPTION_DELETE_ERROR = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.deleteError',
{
@ -371,29 +384,59 @@ export const SORT_BY_CREATE_AT = i18n.translate(
}
);
export const EXPORT_MODAL_CANCEL_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.exportModalCancelButton',
export const EXPIRED_EXCEPTIONS_MODAL_CANCEL_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.expiredExceptionModalCancelButton',
{
defaultMessage: 'Cancel',
}
);
export const EXPORT_MODAL_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.exportModalTitle',
export const EXPIRED_EXCEPTIONS_MODAL_EXPORT_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.expiredExceptionModalExportTitle',
{
defaultMessage: 'Export exception list',
defaultMessage: 'Export exception list?',
}
);
export const EXPORT_MODAL_INCLUDE_SWITCH_LABEL = i18n.translate(
'xpack.securitySolution.exceptions.exportModalIncludeSwitchLabel',
export const EXPIRED_EXCEPTIONS_MODAL_DUPLICATE_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.expiredExceptionModalDuplicateTitle',
{
defaultMessage: 'Duplicate exception list?',
}
);
export const EXPIRED_EXCEPTIONS_MODAL_DUPLICATE_DESCRIPTION = i18n.translate(
'xpack.securitySolution.exceptions.expiredExceptionModalIncludeDuplicateDescription',
{
defaultMessage:
'Youre duplicating an exception list. Switch the toggle off to exclude expired exceptions.',
}
);
export const EXPIRED_EXCEPTIONS_MODAL_EXPORT_DESCRIPTION = i18n.translate(
'xpack.securitySolution.exceptions.expiredExceptionModalIncludeExportDescription',
{
defaultMessage:
'Youre exporting an exception list. Switch the toggle off to exclude expired exceptions.',
}
);
export const EXPIRED_EXCEPTIONS_MODAL_INCLUDE_SWITCH_LABEL = i18n.translate(
'xpack.securitySolution.exceptions.expiredExceptionModalIncludeSwitchLabel',
{
defaultMessage: 'Include expired exceptions',
}
);
export const EXPORT_MODAL_CONFIRM_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.exportModalConfirmButton',
export const EXPIRED_EXCEPTIONS_MODAL_CONFIRM_DUPLICATE_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.expiredExceptionModalConfirmDuplicateButton',
{
defaultMessage: 'Duplicate',
}
);
export const EXPIRED_EXCEPTIONS_MODAL_CONFIRM_EXPORT_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.expiredExceptionModalConfirmExportButton',
{
defaultMessage: 'Export',
}

View file

@ -432,8 +432,10 @@ export const performBulkActionRoute = (
}
let shouldDuplicateExceptions = true;
let shouldDuplicateExpiredExceptions = true;
if (body.duplicate !== undefined) {
shouldDuplicateExceptions = body.duplicate.include_exceptions;
shouldDuplicateExpiredExceptions = body.duplicate.include_expired_exceptions;
}
const duplicateRuleToCreate = await duplicateRule({
@ -449,6 +451,7 @@ export const performBulkActionRoute = (
? await duplicateExceptions({
ruleId: rule.params.ruleId,
exceptionLists: rule.params.exceptionsList,
includeExpiredExceptions: shouldDuplicateExpiredExceptions,
exceptionsClient,
})
: [];

View file

@ -0,0 +1,163 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { duplicateExceptions } from './duplicate_exceptions';
import { getExceptionListClientMock } from '@kbn/lists-plugin/server/services/exception_lists/exception_list_client.mock';
import type { List } from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type { ExceptionListClient } from '@kbn/lists-plugin/server';
import { getDetectionsExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
jest.mock('uuid', () => ({
v4: jest.fn(),
}));
describe('duplicateExceptions', () => {
let exceptionsClient: ExceptionListClient;
beforeAll(() => {
exceptionsClient = getExceptionListClientMock();
exceptionsClient.duplicateExceptionListAndItems = jest.fn();
});
afterAll(() => {
jest.clearAllMocks();
});
it('returns empty array if no exceptions to duplicate', async () => {
const result = await duplicateExceptions({
ruleId: 'rule_123',
exceptionLists: [],
exceptionsClient,
includeExpiredExceptions: false,
});
expect(result).toEqual([]);
});
it('returns array referencing the same shared exception lists if no rule default exceptions included', async () => {
const sharedExceptionListReference: List = {
type: ExceptionListTypeEnum.DETECTION,
list_id: 'my_list',
namespace_type: 'single',
id: '1234',
};
const result = await duplicateExceptions({
ruleId: 'rule_123',
exceptionLists: [sharedExceptionListReference],
exceptionsClient,
includeExpiredExceptions: false,
});
expect(exceptionsClient.duplicateExceptionListAndItems).not.toHaveBeenCalled();
expect(result).toEqual([sharedExceptionListReference]);
});
it('duplicates rule default and shared exceptions', async () => {
const newDefaultRuleList = {
...getDetectionsExceptionListSchemaMock(),
type: ExceptionListTypeEnum.RULE_DEFAULT,
list_id: 'rule_default_list_dupe',
namespace_type: 'single',
id: '123-abc',
};
exceptionsClient.getExceptionList = jest.fn().mockResolvedValue({
...getDetectionsExceptionListSchemaMock(),
type: ExceptionListTypeEnum.RULE_DEFAULT,
list_id: 'rule_default_list',
namespace_type: 'single',
id: '5678',
});
exceptionsClient.duplicateExceptionListAndItems = jest
.fn()
.mockResolvedValue(newDefaultRuleList);
const sharedExceptionListReference: List = {
type: ExceptionListTypeEnum.DETECTION,
list_id: 'my_list',
namespace_type: 'single',
id: '1234',
};
const ruleDefaultListReference: List = {
type: ExceptionListTypeEnum.RULE_DEFAULT,
list_id: 'rule_default_list',
namespace_type: 'single',
id: '5678',
};
const result = await duplicateExceptions({
ruleId: 'rule_123',
exceptionLists: [sharedExceptionListReference, ruleDefaultListReference],
exceptionsClient,
includeExpiredExceptions: false,
});
expect(result).toEqual([
sharedExceptionListReference,
{
type: newDefaultRuleList.type,
namespace_type: newDefaultRuleList.namespace_type,
id: newDefaultRuleList.id,
list_id: newDefaultRuleList.list_id,
},
]);
});
it('throws error if rule default list to duplicate not found', async () => {
exceptionsClient.getExceptionList = jest.fn().mockResolvedValue(null);
const ruleDefaultListReference: List = {
type: ExceptionListTypeEnum.RULE_DEFAULT,
list_id: 'my_list',
namespace_type: 'single',
id: '1234',
};
await expect(() =>
duplicateExceptions({
ruleId: 'rule_123',
exceptionLists: [ruleDefaultListReference],
exceptionsClient,
includeExpiredExceptions: false,
})
).rejects.toMatchInlineSnapshot(
`[Error: Unable to duplicate rule default exceptions - unable to find their container with list_id: "my_list"]`
);
});
it('throws error if list duplication returns null', async () => {
exceptionsClient.getExceptionList = jest.fn().mockResolvedValue({
...getDetectionsExceptionListSchemaMock(),
type: ExceptionListTypeEnum.RULE_DEFAULT,
list_id: 'my_list',
namespace_type: 'single',
id: '1234',
});
exceptionsClient.duplicateExceptionListAndItems = jest.fn().mockResolvedValue(null);
const ruleDefaultListReference: List = {
type: ExceptionListTypeEnum.RULE_DEFAULT,
list_id: 'my_list',
namespace_type: 'single',
id: '1234',
};
await expect(() =>
duplicateExceptions({
ruleId: 'rule_123',
exceptionLists: [ruleDefaultListReference],
exceptionsClient,
includeExpiredExceptions: false,
})
).rejects.toMatchInlineSnapshot(
`[Error: Unable to duplicate rule default exception items for rule_id: rule_123]`
);
});
});

View file

@ -5,23 +5,42 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { ExceptionListClient } from '@kbn/lists-plugin/server';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type { RuleParams } from '../../../rule_schema';
const ERROR_DUPLICATING = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.cloneExceptions.errorDuplicatingList',
{
defaultMessage:
'Unable to duplicate rule default exceptions - unable to find their container with list_id:',
}
);
const ERROR_DUPLICATING_ITEMS = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.cloneExceptions.errorDuplicatingListItems',
{
defaultMessage: 'Unable to duplicate rule default exception items for rule_id:',
}
);
interface DuplicateExceptionsParams {
ruleId: RuleParams['ruleId'];
exceptionLists: RuleParams['exceptionsList'];
exceptionsClient: ExceptionListClient | undefined;
includeExpiredExceptions: boolean;
}
export const duplicateExceptions = async ({
ruleId,
exceptionLists,
exceptionsClient,
includeExpiredExceptions,
}: DuplicateExceptionsParams): Promise<RuleParams['exceptionsList']> => {
if (exceptionLists == null) {
if (exceptionLists == null || !exceptionLists.length) {
return [];
}
@ -37,24 +56,36 @@ export const duplicateExceptions = async ({
// For rule_default list (exceptions that live only on a single rule), we need
// to create a new rule_default list to assign to duplicated rule
if (ruleDefaultList != null && exceptionsClient != null) {
const ruleDefaultExceptionList = await exceptionsClient.duplicateExceptionListAndItems({
// fetch list container
const listToDuplicate = await exceptionsClient.getExceptionList({
id: undefined,
listId: ruleDefaultList.list_id,
namespaceType: ruleDefaultList.namespace_type,
});
if (ruleDefaultExceptionList == null) {
throw new Error(`Unable to duplicate rule default exception items for rule_id: ${ruleId}`);
}
if (listToDuplicate == null) {
throw new Error(`${ERROR_DUPLICATING} "${ruleDefaultList.list_id}"`);
} else {
const ruleDefaultExceptionList = await exceptionsClient.duplicateExceptionListAndItems({
list: listToDuplicate,
namespaceType: ruleDefaultList.namespace_type,
includeExpiredExceptions,
});
return [
...sharedLists,
{
id: ruleDefaultExceptionList.id,
list_id: ruleDefaultExceptionList.list_id,
namespace_type: ruleDefaultExceptionList.namespace_type,
type: ruleDefaultExceptionList.type,
},
];
if (ruleDefaultExceptionList == null) {
throw new Error(`${ERROR_DUPLICATING_ITEMS} ${ruleId}`);
}
return [
...sharedLists,
{
id: ruleDefaultExceptionList.id,
list_id: ruleDefaultExceptionList.list_id,
namespace_type: ruleDefaultExceptionList.namespace_type,
type: ruleDefaultExceptionList.type,
},
];
}
}
// If no rule_default list exists, we can just return

View file

@ -27824,7 +27824,6 @@
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.successToastDescription": "Désactivation réussie de {totalRules, plural, =1 {{totalRules} règle} other {{totalRules} règles}}",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastDescription": "Impossible de dupliquer {rulesCount, plural, =1 {# règle} other {# règles}}.",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.modalBody": "Vous dupliquez {rulesCount, plural, one {# règle sélectionnée} other {# règles sélectionnées}}. Veuillez choisir comment dupliquer les exceptions existantes",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.modalTitle": "Dupliquer {rulesCount, plural, one {la règle} other {les règles}} avec les exceptions ?",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.with": "Dupliquer {rulesCount, plural, one {la règle et ses} other {les règles et leurs}} exceptions",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.without": "Dupliquer uniquement {rulesCount, plural, one {la règle} other {les règles}}",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "Duplication réussie de {totalRules, plural, =1 {{totalRules} règle} other {{totalRules} règles}}",
@ -30891,10 +30890,6 @@
"xpack.securitySolution.exceptions.exceptionListsCloseImportFlyout": "Fermer",
"xpack.securitySolution.exceptions.exceptionListsFilePickerPrompt": "Sélectionner ou glisser-déposer plusieurs fichiers",
"xpack.securitySolution.exceptions.exceptionListsImportButton": "Importer la liste",
"xpack.securitySolution.exceptions.exportModalCancelButton": "Annuler",
"xpack.securitySolution.exceptions.exportModalConfirmButton": "Exporter",
"xpack.securitySolution.exceptions.exportModalIncludeSwitchLabel": "Inclure les exceptions ayant expiré",
"xpack.securitySolution.exceptions.exportModalTitle": "Exporter la liste d'exceptions",
"xpack.securitySolution.exceptions.fetchError": "Erreur lors de la récupération de la liste d'exceptions",
"xpack.securitySolution.exceptions.fetchingReferencesErrorToastTitle": "Erreur lors de la récupération des références d'exceptions",
"xpack.securitySolution.exceptions.list.exception.item.card.delete.label": "Supprimer une exception à une règle",

View file

@ -27804,7 +27804,6 @@
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.successToastDescription": "{totalRules, plural, =1 {{totalRules}個のルール} other {{totalRules}個のルール}}が正常に無効にされました",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastDescription": "{rulesCount, plural, =1 {#個のルール} other {#個のルール}}を複製できませんでした。",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.modalBody": "{rulesCount, plural, other {#個の選択したルール}}を複製しています。既存の例外を複製する方法を選択してください",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.modalTitle": "{rulesCount, plural, other {ルール}}と例外を複製しますか?",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.with": "{rulesCount, plural, other {ルール}}と例外を複製",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.without": "{rulesCount, plural, other {ルール}}のみを複製",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "{totalRules, plural, =1 {{totalRules}個のルール} other {{totalRules}個のルール}}が正常に複製されました",
@ -30870,10 +30869,6 @@
"xpack.securitySolution.exceptions.exceptionListsCloseImportFlyout": "閉じる",
"xpack.securitySolution.exceptions.exceptionListsFilePickerPrompt": "複数のファイルを選択するかドラッグしてください",
"xpack.securitySolution.exceptions.exceptionListsImportButton": "リストをインポート",
"xpack.securitySolution.exceptions.exportModalCancelButton": "キャンセル",
"xpack.securitySolution.exceptions.exportModalConfirmButton": "エクスポート",
"xpack.securitySolution.exceptions.exportModalIncludeSwitchLabel": "有効期限切れの例外を含める",
"xpack.securitySolution.exceptions.exportModalTitle": "例外リストのエクスポート",
"xpack.securitySolution.exceptions.fetchError": "例外リストの取得エラー",
"xpack.securitySolution.exceptions.fetchingReferencesErrorToastTitle": "例外参照の取得エラー",
"xpack.securitySolution.exceptions.list.exception.item.card.delete.label": "ルール例外の削除",

View file

@ -27819,7 +27819,6 @@
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.successToastDescription": "已成功禁用 {totalRules, plural, other {{totalRules} 个规则}}",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastDescription": "无法复制 {rulesCount, plural, other {# 个规则}}。",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.modalBody": "您正在复制 {rulesCount, plural, other {# 个选定规则}},请选择您希望如何复制现有例外",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.modalTitle": "复制存在例外的{rulesCount, plural, other {规则}}",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.with": "复制{rulesCount, plural, other {规则}}及其例外",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.without": "仅复制{rulesCount, plural, other {规则}}",
"xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "已成功复制 {totalRules, plural, other {{totalRules} 个规则}}",
@ -30886,10 +30885,6 @@
"xpack.securitySolution.exceptions.exceptionListsCloseImportFlyout": "关闭",
"xpack.securitySolution.exceptions.exceptionListsFilePickerPrompt": "选择或拖放多个文件",
"xpack.securitySolution.exceptions.exceptionListsImportButton": "导入列表",
"xpack.securitySolution.exceptions.exportModalCancelButton": "取消",
"xpack.securitySolution.exceptions.exportModalConfirmButton": "导出",
"xpack.securitySolution.exceptions.exportModalIncludeSwitchLabel": "包括已过期例外",
"xpack.securitySolution.exceptions.exportModalTitle": "导出例外列表",
"xpack.securitySolution.exceptions.fetchError": "提取例外列表时出错",
"xpack.securitySolution.exceptions.fetchingReferencesErrorToastTitle": "提取例外引用时出错",
"xpack.securitySolution.exceptions.list.exception.item.card.delete.label": "删除规则例外",

View file

@ -17,7 +17,10 @@ import {
BulkActionType,
BulkActionEditType,
} from '@kbn/security-solution-plugin/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { getCreateExceptionListDetectionSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_schema.mock';
import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants';
import { getCreateExceptionListItemMinimalSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_item_schema.mock';
import { deleteAllExceptions } from '../../../lists_api_integration/utils';
import {
binaryToString,
createLegacyRuleAction,
@ -35,6 +38,7 @@ import {
removeServerGeneratedProperties,
waitForRuleSuccess,
} from '../../utils';
import { FtrProviderContext } from '../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
@ -416,7 +420,7 @@ export default ({ getService }: FtrProviderContext): void => {
.send({
query: '',
action: BulkActionType.duplicate,
duplicate: { include_exceptions: false },
duplicate: { include_exceptions: false, include_expired_exceptions: false },
})
.expect(200);
@ -434,6 +438,216 @@ export default ({ getService }: FtrProviderContext): void => {
expect(rulesResponse.total).to.eql(2);
});
it('should duplicate rules with exceptions - expired exceptions included', async () => {
await deleteAllExceptions(supertest, log);
const expiredDate = new Date(Date.now() - 1000000).toISOString();
// create an exception list
const { body: exceptionList } = await supertest
.post(EXCEPTION_LIST_URL)
.set('kbn-xsrf', 'true')
.send(getCreateExceptionListDetectionSchemaMock())
.expect(200);
// create an exception list item
await supertest
.post(EXCEPTION_LIST_ITEM_URL)
.set('kbn-xsrf', 'true')
.send({ ...getCreateExceptionListItemMinimalSchemaMock(), expire_time: expiredDate })
.expect(200);
const ruleId = 'ruleId';
const ruleToDuplicate = {
...getSimpleRule(ruleId),
exceptions_list: [
{
type: exceptionList.type,
list_id: exceptionList.list_id,
id: exceptionList.id,
namespace_type: exceptionList.namespace_type,
},
],
};
const newRule = await createRule(supertest, log, ruleToDuplicate);
// add an exception item to the rule
await supertest
.post(`${DETECTION_ENGINE_RULES_URL}/${newRule.id}/exceptions`)
.set('kbn-xsrf', 'true')
.send({
items: [
{
description: 'Exception item for rule default exception list',
entries: [
{
field: 'some.not.nested.field',
operator: 'included',
type: 'match',
value: 'some value',
},
],
name: 'Sample exception item',
type: 'simple',
expire_time: expiredDate,
},
],
})
.expect(200);
const { body } = await postBulkAction()
.send({
query: '',
action: BulkActionType.duplicate,
duplicate: { include_exceptions: true, include_expired_exceptions: true },
})
.expect(200);
const { body: foundItems } = await supertest
.get(
`${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${body.attributes.results.created[0].exceptions_list[1].list_id}`
)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
// Item should have been duplicated, even if expired
expect(foundItems.total).to.eql(1);
expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 });
// Check that the duplicated rule is returned with the response
expect(body.attributes.results.created[0].name).to.eql(`${ruleToDuplicate.name} [Duplicate]`);
// Check that the exceptions are duplicated
expect(body.attributes.results.created[0].exceptions_list).to.eql([
{
type: exceptionList.type,
list_id: exceptionList.list_id,
id: exceptionList.id,
namespace_type: exceptionList.namespace_type,
},
{
id: body.attributes.results.created[0].exceptions_list[1].id,
list_id: body.attributes.results.created[0].exceptions_list[1].list_id,
namespace_type: 'single',
type: 'rule_default',
},
]);
// Check that the updates have been persisted
const { body: rulesResponse } = await supertest
.get(`${DETECTION_ENGINE_RULES_URL}/_find`)
.set('kbn-xsrf', 'true')
.expect(200);
expect(rulesResponse.total).to.eql(2);
});
it('should duplicate rules with exceptions - expired exceptions excluded', async () => {
await deleteAllExceptions(supertest, log);
const expiredDate = new Date(Date.now() - 1000000).toISOString();
// create an exception list
const { body: exceptionList } = await supertest
.post(EXCEPTION_LIST_URL)
.set('kbn-xsrf', 'true')
.send(getCreateExceptionListDetectionSchemaMock())
.expect(200);
// create an exception list item
await supertest
.post(EXCEPTION_LIST_ITEM_URL)
.set('kbn-xsrf', 'true')
.send({ ...getCreateExceptionListItemMinimalSchemaMock(), expire_time: expiredDate })
.expect(200);
const ruleId = 'ruleId';
const ruleToDuplicate = {
...getSimpleRule(ruleId),
exceptions_list: [
{
type: exceptionList.type,
list_id: exceptionList.list_id,
id: exceptionList.id,
namespace_type: exceptionList.namespace_type,
},
],
};
const newRule = await createRule(supertest, log, ruleToDuplicate);
// add an exception item to the rule
await supertest
.post(`${DETECTION_ENGINE_RULES_URL}/${newRule.id}/exceptions`)
.set('kbn-xsrf', 'true')
.send({
items: [
{
description: 'Exception item for rule default exception list',
entries: [
{
field: 'some.not.nested.field',
operator: 'included',
type: 'match',
value: 'some value',
},
],
name: 'Sample exception item',
type: 'simple',
expire_time: expiredDate,
},
],
})
.expect(200);
const { body } = await postBulkAction()
.send({
query: '',
action: BulkActionType.duplicate,
duplicate: { include_exceptions: true, include_expired_exceptions: false },
})
.expect(200);
const { body: foundItems } = await supertest
.get(
`${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${body.attributes.results.created[0].exceptions_list[1].list_id}`
)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
// Item should NOT have been duplicated, since it is expired
expect(foundItems.total).to.eql(0);
expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 });
// Check that the duplicated rule is returned with the response
expect(body.attributes.results.created[0].name).to.eql(`${ruleToDuplicate.name} [Duplicate]`);
// Check that the exceptions are duplicted
expect(body.attributes.results.created[0].exceptions_list).to.eql([
{
type: exceptionList.type,
list_id: exceptionList.list_id,
id: exceptionList.id,
namespace_type: exceptionList.namespace_type,
},
{
id: body.attributes.results.created[0].exceptions_list[1].id,
list_id: body.attributes.results.created[0].exceptions_list[1].list_id,
namespace_type: 'single',
type: 'rule_default',
},
]);
// Check that the updates have been persisted
const { body: rulesResponse } = await supertest
.get(`${DETECTION_ENGINE_RULES_URL}/_find`)
.set('kbn-xsrf', 'true')
.expect(200);
expect(rulesResponse.total).to.eql(2);
});
it('should duplicate rule with a legacy action', async () => {
const ruleId = 'ruleId';
const [connector, ruleToDuplicate] = await Promise.all([
@ -462,7 +676,7 @@ export default ({ getService }: FtrProviderContext): void => {
.send({
query: '',
action: BulkActionType.duplicate,
duplicate: { include_exceptions: false },
duplicate: { include_exceptions: false, include_expired_exceptions: false },
})
.expect(200);

View file

@ -0,0 +1,207 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import {
ENDPOINT_LIST_URL,
EXCEPTION_LIST_ITEM_URL,
EXCEPTION_LIST_URL,
} from '@kbn/securitysolution-list-constants';
import { getExceptionResponseMockWithoutAutoGeneratedValues } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
import { getCreateExceptionListDetectionSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_schema.mock';
import { getCreateExceptionListItemMinimalSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_item_schema.mock';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { deleteAllExceptions, removeExceptionListServerGeneratedProperties } from '../../utils';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertest');
const log = getService('log');
describe('duplicate_exception_lists', () => {
afterEach(async () => {
await deleteAllExceptions(supertest, log);
});
it('should duplicate a list with no exception items', async () => {
// create an exception list
await supertest
.post(EXCEPTION_LIST_URL)
.set('kbn-xsrf', 'true')
.send(getCreateExceptionListDetectionSchemaMock())
.expect(200);
const { body } = await supertest
.post(
`${EXCEPTION_LIST_URL}/_duplicate?list_id=${
getCreateExceptionListDetectionSchemaMock().list_id
}&namespace_type=single&include_expired_exceptions=true`
)
.set('kbn-xsrf', 'true')
.expect(200);
const bodyToCompare = removeExceptionListServerGeneratedProperties(body);
expect(bodyToCompare).to.eql({
...getExceptionResponseMockWithoutAutoGeneratedValues(),
type: 'detection',
list_id: body.list_id,
name: `${getCreateExceptionListDetectionSchemaMock().name} [Duplicate]`,
});
});
it('should duplicate a list and its items', async () => {
// create an exception list
await supertest
.post(EXCEPTION_LIST_URL)
.set('kbn-xsrf', 'true')
.send(getCreateExceptionListDetectionSchemaMock())
.expect(200);
await supertest
.post(EXCEPTION_LIST_ITEM_URL)
.set('kbn-xsrf', 'true')
.send({
...getCreateExceptionListItemMinimalSchemaMock(),
list_id: getCreateExceptionListDetectionSchemaMock().list_id,
})
.expect(200);
const { body: listBody } = await supertest
.post(
`${EXCEPTION_LIST_URL}/_duplicate?list_id=${
getCreateExceptionListDetectionSchemaMock().list_id
}&namespace_type=single&include_expired_exceptions=true`
)
.set('kbn-xsrf', 'true')
.expect(200);
const { body } = await supertest
.get(`${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${listBody.list_id}`)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
const listBodyToCompare = removeExceptionListServerGeneratedProperties(listBody);
expect(listBodyToCompare).to.eql({
...getExceptionResponseMockWithoutAutoGeneratedValues(),
type: 'detection',
list_id: listBody.list_id,
name: `${getCreateExceptionListDetectionSchemaMock().name} [Duplicate]`,
});
expect(body.total).to.eql(1);
});
it('should duplicate a list with expired exception items', async () => {
const expiredDate = new Date(Date.now() - 1000000).toISOString();
// create an exception list
await supertest
.post(EXCEPTION_LIST_URL)
.set('kbn-xsrf', 'true')
.send(getCreateExceptionListDetectionSchemaMock())
.expect(200);
await supertest
.post(EXCEPTION_LIST_ITEM_URL)
.set('kbn-xsrf', 'true')
.send({ ...getCreateExceptionListItemMinimalSchemaMock(), expire_time: expiredDate })
.expect(200);
const { body: listBody } = await supertest
.post(
`${EXCEPTION_LIST_URL}/_duplicate?list_id=${
getCreateExceptionListDetectionSchemaMock().list_id
}&namespace_type=single&include_expired_exceptions=true`
)
.set('kbn-xsrf', 'true')
.expect(200);
const { body } = await supertest
.get(`${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${listBody.list_id}`)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
expect(body.total).to.eql(1);
});
it('should duplicate a list and EXCLUDE expired exception items when "include_expired_exceptions" set to "false"', async () => {
const expiredDate = new Date(Date.now() - 1000000).toISOString();
// create an exception list
await supertest
.post(EXCEPTION_LIST_URL)
.set('kbn-xsrf', 'true')
.send(getCreateExceptionListDetectionSchemaMock())
.expect(200);
await supertest
.post(EXCEPTION_LIST_ITEM_URL)
.set('kbn-xsrf', 'true')
.send({
...getCreateExceptionListItemMinimalSchemaMock(),
list_id: getCreateExceptionListDetectionSchemaMock().list_id,
expire_time: expiredDate,
})
.expect(200);
const { body: listBody } = await supertest
.post(
`${EXCEPTION_LIST_URL}/_duplicate?list_id=${
getCreateExceptionListDetectionSchemaMock().list_id
}&namespace_type=single&include_expired_exceptions=false`
)
.set('kbn-xsrf', 'true')
.expect(200);
const { body } = await supertest
.get(`${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${listBody.list_id}`)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
expect(body.total).to.eql(0);
});
describe('error states', () => {
it('should cause a 409 if list does not exist', async () => {
const { body } = await supertest
.post(
`${EXCEPTION_LIST_URL}/_duplicate?list_id=exception_list_id&namespace_type=agnostic&include_expired_exceptions=true`
)
.set('kbn-xsrf', 'true')
.expect(404);
expect(body).to.eql({
message: 'exception list id: "exception_list_id" does not exist',
status_code: 404,
});
});
it('should cause a 405 if trying to duplicate a reserved exception list type', async () => {
// create an exception list
await supertest.post(ENDPOINT_LIST_URL).set('kbn-xsrf', 'true').expect(200);
const { body } = await supertest
.post(
`${EXCEPTION_LIST_URL}/_duplicate?list_id=endpoint_list&namespace_type=agnostic&include_expired_exceptions=true`
)
.set('kbn-xsrf', 'true')
.expect(405);
expect(body).to.eql({
message:
'unable to duplicate exception list with list_id: endpoint_list - action not allowed',
status_code: 405,
});
});
});
});
};

View file

@ -19,6 +19,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./update_list_items'));
loadTestFile(require.resolve('./delete_lists'));
loadTestFile(require.resolve('./delete_list_items'));
loadTestFile(require.resolve('./duplicate_exception_list'));
loadTestFile(require.resolve('./find_lists'));
loadTestFile(require.resolve('./find_list_items'));
loadTestFile(require.resolve('./find_lists_by_size'));