Rule duplication with/without exceptions (#144782)

## Rule duplication with/without exceptions

Majority of work done by @yctercero in this
[branch](https://github.com/yctercero/kibana/tree/dupe)
Some integration tests are left, but PR is ready for review.

2 flow when you duplicate rule:

### Without exceptions 
Don't duplicate any exceptions

### With exceptions 
Shared exceptions should duplicate reference
Rule default exceptions are not duplicated by reference, but create a
copy of exceptions. So if you remove it from duplicate rules, the
original rule is not changed.




https://user-images.githubusercontent.com/7609147/200863319-4cb56749-42dd-42d8-8896-f45782c21838.mov


# TODO;

[] integrations tests
[] cypress tests

Co-authored-by: Yara Tercero <yara.tercero@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Khristinin Nikita 2022-11-15 19:43:40 +01:00 committed by GitHub
parent 2355535972
commit 4868e2118d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 818 additions and 146 deletions

View file

@ -0,0 +1,69 @@
/*
* 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 { SavedObjectsClientContract } from '@kbn/core/server';
import uuid from 'uuid';
import type {
CreateExceptionListItemSchema,
ExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { SavedObjectType, getSavedObjectType } from '@kbn/securitysolution-list-utils';
import { ExceptionListSoSchema } from '../../schemas/saved_objects';
import { transformSavedObjectToExceptionListItem } from './utils';
interface BulkCreateExceptionListItemsOptions {
items: CreateExceptionListItemSchema[];
savedObjectsClient: SavedObjectsClientContract;
user: string;
tieBreaker?: string;
}
export const bulkCreateExceptionListItems = async ({
items,
savedObjectsClient,
tieBreaker,
user,
}: BulkCreateExceptionListItemsOptions): Promise<ExceptionListItemSchema[]> => {
const formattedItems = items.map((item) => {
const savedObjectType = getSavedObjectType({ namespaceType: item.namespace_type ?? 'single' });
const dateNow = new Date().toISOString();
return {
attributes: {
comments: [],
created_at: dateNow,
created_by: user,
description: item.description,
entries: item.entries,
immutable: false,
item_id: item.item_id,
list_id: item.list_id,
list_type: 'item',
meta: item.meta,
name: item.name,
os_types: item.os_types,
tags: item.tags,
tie_breaker_id: tieBreaker ?? uuid.v4(),
type: item.type,
updated_by: user,
version: undefined,
},
type: savedObjectType,
} as { attributes: ExceptionListSoSchema; type: SavedObjectType };
});
const { saved_objects: savedObjects } =
await savedObjectsClient.bulkCreate<ExceptionListSoSchema>(formattedItems);
const result = savedObjects.map<ExceptionListItemSchema>((so) =>
transformSavedObjectToExceptionListItem({ savedObject: so })
);
return result;
};

View file

@ -0,0 +1,117 @@
/*
* 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 { SavedObjectsClientContract } from '@kbn/core/server';
import uuid from 'uuid';
import {
CreateExceptionListItemSchema,
ExceptionListSchema,
ExceptionListTypeEnum,
FoundExceptionListItemSchema,
ListId,
NamespaceType,
} from '@kbn/securitysolution-io-ts-list-types';
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 = [
ExceptionListTypeEnum.DETECTION.toString(),
ExceptionListTypeEnum.RULE_DEFAULT.toString(),
];
interface CreateExceptionListOptions {
listId: ListId;
savedObjectsClient: SavedObjectsClientContract;
namespaceType: NamespaceType;
user: string;
}
export const duplicateExceptionListAndItems = async ({
listId,
savedObjectsClient,
namespaceType,
user,
}: CreateExceptionListOptions): Promise<ExceptionListSchema> => {
// Generate a new static listId
const newListId = uuid.v4();
// 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.`);
}
const newlyCreatedList = await createExceptionList({
description: listToDuplicate.description,
immutable: listToDuplicate.immutable,
listId: newListId,
meta: listToDuplicate.meta,
name: listToDuplicate.name,
namespaceType: listToDuplicate.namespace_type,
savedObjectsClient,
tags: listToDuplicate.tags,
type: listToDuplicate.type,
user,
version: 1,
});
// fetch associated items
let itemsToBeDuplicated: CreateExceptionListItemSchema[] = [];
const executeFunctionOnStream = (response: FoundExceptionListItemSchema): void => {
const transformedItems = response.data.map((item) => {
// Generate a new static listId
const newItemId = uuid.v4();
return {
comments: [],
description: item.description,
entries: item.entries,
item_id: newItemId,
list_id: newlyCreatedList.list_id,
meta: item.meta,
name: item.name,
namespace_type: item.namespace_type,
os_types: item.os_types,
tags: item.tags,
type: item.type,
};
});
itemsToBeDuplicated = [...itemsToBeDuplicated, ...transformedItems];
};
await findExceptionListsItemPointInTimeFinder({
executeFunctionOnStream,
filter: [],
listId: [listId],
maxSize: 10000,
namespaceType: [namespaceType],
perPage: undefined,
savedObjectsClient,
sortField: undefined,
sortOrder: undefined,
});
await bulkCreateExceptionListItems({
items: itemsToBeDuplicated,
savedObjectsClient,
user,
});
return newlyCreatedList;
};

View file

@ -39,6 +39,7 @@ import type {
DeleteExceptionListItemByIdOptions,
DeleteExceptionListItemOptions,
DeleteExceptionListOptions,
DuplicateExceptionListOptions,
ExportExceptionListAndItemsOptions,
FindEndpointListItemOptions,
FindExceptionListItemOptions,
@ -95,6 +96,7 @@ import { findValueListExceptionListItems } from './find_value_list_exception_lis
import { findExceptionListsItemPointInTimeFinder } from './find_exception_list_items_point_in_time_finder';
import { findValueListExceptionListItemsPointInTimeFinder } from './find_value_list_exception_list_items_point_in_time_finder';
import { findExceptionListItemPointInTimeFinder } from './find_exception_list_item_point_in_time_finder';
import { duplicateExceptionListAndItems } from './duplicate_exception_list';
/**
* Class for use for exceptions that are with trusted applications or
@ -311,6 +313,25 @@ 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.namespaceType saved object namespace (single | agnostic)
* @returns The exception list schema or null if it does not exist
*/
public duplicateExceptionListAndItems = async ({
listId,
namespaceType,
}: DuplicateExceptionListOptions): Promise<ExceptionListSchema | null> => {
const { savedObjectsClient, user } = this;
return duplicateExceptionListAndItems({
listId,
namespaceType,
savedObjectsClient,
user,
});
};
/**
* This is the same as "updateExceptionListItem" except it applies specifically to the endpoint list and will
* auto-call the "createEndpointList" for you so that you have the best chance of the endpoint

View file

@ -287,6 +287,17 @@ export interface CreateEndpointListItemOptions {
type: ExceptionListItemType;
}
/**
* ExceptionListClient.duplicateExceptionListAndItems
* {@link ExceptionListClient.duplicateExceptionListAndItems}
*/
export interface DuplicateExceptionListOptions {
/** The single list id to do the search against */
listId: ListId;
/** saved object namespace (single | agnostic) */
namespaceType: NamespaceType;
}
/**
* ExceptionListClient.updateExceptionListItem
* {@link ExceptionListClient.updateExceptionListItem}

View file

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

View file

@ -131,6 +131,14 @@ export const BulkActionEditPayload = t.union([
BulkActionEditPayloadSchedule,
]);
const bulkActionDuplicatePayload = t.exact(
t.type({
include_exceptions: t.boolean,
})
);
export type BulkActionDuplicatePayload = t.TypeOf<typeof bulkActionDuplicatePayload>;
/**
* actions that modify rules attributes
*/
@ -164,12 +172,23 @@ export const PerformBulkActionRequestBody = t.intersection([
action: t.union([
t.literal(BulkActionType.delete),
t.literal(BulkActionType.disable),
t.literal(BulkActionType.duplicate),
t.literal(BulkActionType.enable),
t.literal(BulkActionType.export),
]),
})
),
t.intersection([
t.exact(
t.type({
action: t.literal(BulkActionType.duplicate),
})
),
t.exact(
t.partial({
[BulkActionType.duplicate]: bulkActionDuplicatePayload,
})
),
]),
t.exact(
t.type({
action: t.literal(BulkActionType.edit),

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export enum DuplicateOptions {
withExceptions = 'withExceptions',
withoutExceptions = 'withoutExceptions',
}

View file

@ -22,6 +22,7 @@ export const EDIT_RULE_ACTION_BTN = '[data-test-subj="editRuleAction"]';
export const DUPLICATE_RULE_ACTION_BTN = '[data-test-subj="duplicateRuleAction"]';
export const DUPLICATE_RULE_MENU_PANEL_BTN = '[data-test-subj="rules-details-duplicate-rule"]';
export const CONFIRM_DUPLICATE_RULE = '[data-test-subj="confirmModalConfirmButton"]';
export const ENABLE_RULE_BULK_BTN = '[data-test-subj="enableRuleBulk"]';

View file

@ -31,6 +31,7 @@ import {
DUPLICATE_RULE_ACTION_BTN,
DUPLICATE_RULE_MENU_PANEL_BTN,
DUPLICATE_RULE_BULK_BTN,
CONFIRM_DUPLICATE_RULE,
RULES_ROW,
SELECT_ALL_RULES_BTN,
MODAL_CONFIRMATION_BTN,
@ -80,6 +81,7 @@ export const duplicateFirstRule = () => {
cy.get(COLLAPSED_ACTION_BTN).first().click({ force: true });
cy.get(DUPLICATE_RULE_ACTION_BTN).should('be.visible');
cy.get(DUPLICATE_RULE_ACTION_BTN).click();
cy.get(CONFIRM_DUPLICATE_RULE).click();
};
/**
@ -96,6 +98,7 @@ export const duplicateRuleFromMenu = () => {
// Because of a fade effect and fast clicking this can produce more than one click
cy.get(DUPLICATE_RULE_MENU_PANEL_BTN).pipe(click);
cy.get(CONFIRM_DUPLICATE_RULE).click();
};
/**
@ -138,6 +141,7 @@ export const duplicateSelectedRules = () => {
cy.log('Duplicate selected rules');
cy.get(BULK_ACTIONS_BTN).click({ force: true });
cy.get(DUPLICATE_RULE_BULK_BTN).click();
cy.get(CONFIRM_DUPLICATE_RULE).click();
};
export const enableSelectedRules = () => {

View file

@ -133,6 +133,8 @@ import { ExceptionsViewer } from '../../../rule_exceptions/components/all_except
import type { NavTab } from '../../../../common/components/navigation/types';
import { EditRuleSettingButtonLink } from '../../../../detections/pages/detection_engine/rules/details/components/edit_rule_settings_button_link';
import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs';
import { useBulkDuplicateExceptionsConfirmation } from '../../../rule_management_ui/components/rules_table/bulk_actions/use_bulk_duplicate_confirmation';
import { BulkActionDuplicateExceptionsConfirmation } from '../../../rule_management_ui/components/rules_table/bulk_actions/bulk_duplicate_exceptions_confirmation';
/**
* Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space.
@ -625,6 +627,13 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
[containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable]
);
const {
isBulkDuplicateConfirmationVisible,
showBulkDuplicateConfirmation,
cancelRuleDuplication,
confirmRuleDuplication,
} = useBulkDuplicateExceptionsConfirmation();
if (
redirectToDetections(
isSignalIndexExists,
@ -646,6 +655,13 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
<>
<NeedAdminForUpdateRulesCallOut />
<MissingPrivilegesCallOut />
{isBulkDuplicateConfirmationVisible && (
<BulkActionDuplicateExceptionsConfirmation
onCancel={cancelRuleDuplication}
onConfirm={confirmRuleDuplication}
rulesCount={1}
/>
)}
<StyledFullHeightContainer onKeyDown={onKeyDown} ref={containerElement}>
<EuiWindowEvent event="resize" handler={noop} />
<FiltersGlobal show={showGlobalFilters({ globalFullScreen, graphEventId })}>
@ -736,6 +752,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
rule,
hasActionsPrivileges
)}
showBulkDuplicateExceptionsConfirmation={showBulkDuplicateConfirmation}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -28,7 +28,10 @@ import {
import type { RulesReferencedByExceptionListsSchema } from '../../../../common/detection_engine/rule_exceptions';
import { DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL } from '../../../../common/detection_engine/rule_exceptions';
import type { BulkActionEditPayload } from '../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema';
import type {
BulkActionEditPayload,
BulkActionDuplicatePayload,
} from '../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema';
import { BulkActionType } from '../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema';
import type {
@ -215,13 +218,23 @@ export interface BulkActionResponse {
export type QueryOrIds = { query: string; ids?: undefined } | { query?: undefined; ids: string[] };
type PlainBulkAction = {
type: Exclude<BulkActionType, BulkActionType.edit | BulkActionType.export>;
type: Exclude<
BulkActionType,
BulkActionType.edit | BulkActionType.export | BulkActionType.duplicate
>;
} & QueryOrIds;
type EditBulkAction = {
type: BulkActionType.edit;
editPayload: BulkActionEditPayload[];
} & QueryOrIds;
export type BulkAction = PlainBulkAction | EditBulkAction;
type DuplicateBulkAction = {
type: BulkActionType.duplicate;
duplicatePayload?: BulkActionDuplicatePayload;
} & QueryOrIds;
export type BulkAction = PlainBulkAction | EditBulkAction | DuplicateBulkAction;
export interface PerformBulkActionProps {
bulkAction: BulkAction;
@ -245,6 +258,8 @@ export async function performBulkAction({
query: bulkAction.query,
ids: bulkAction.ids,
edit: bulkAction.type === BulkActionType.edit ? bulkAction.editPayload : undefined,
duplicate:
bulkAction.type === BulkActionType.duplicate ? bulkAction.duplicatePayload : undefined,
};
return KibanaServices.get().http.fetch<BulkActionResponse>(DETECTION_ENGINE_RULES_BULK_ACTION, {

View file

@ -0,0 +1,77 @@
/*
* 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, { useCallback, useState } from 'react';
import { EuiRadioGroup, EuiText, EuiConfirmModal, EuiSpacer, EuiIconTip } from '@elastic/eui';
import { DuplicateOptions } from '../../../../../../common/detection_engine/rule_management/constants';
import { bulkDuplicateRuleActions as i18n } from './translations';
interface BulkDuplicateExceptionsConfirmationProps {
onCancel: () => void;
onConfirm: (s: string) => void;
rulesCount: number;
}
const BulkActionDuplicateExceptionsConfirmationComponent = ({
onCancel,
onConfirm,
rulesCount,
}: BulkDuplicateExceptionsConfirmationProps) => {
const [selectedDuplicateOption, setSelectedDuplicateOption] = useState(
DuplicateOptions.withExceptions
);
const handleRadioChange = useCallback(
(optionId) => {
setSelectedDuplicateOption(optionId);
},
[setSelectedDuplicateOption]
);
const handleConfirm = useCallback(() => {
onConfirm(selectedDuplicateOption);
}, [onConfirm, selectedDuplicateOption]);
return (
<EuiConfirmModal
title={i18n.MODAL_TITLE(rulesCount)}
onConfirm={handleConfirm}
cancelButtonText={i18n.CANCEL_BUTTON}
confirmButtonText={i18n.CONTINUE_BUTTON}
defaultFocusedButton="confirm"
onCancel={onCancel}
>
<EuiText>
{i18n.MODAL_TEXT(rulesCount)}{' '}
<EuiIconTip content={i18n.DUPLICATE_TOOLTIP} position="bottom" />
</EuiText>
<EuiSpacer />
<EuiRadioGroup
options={[
{
id: DuplicateOptions.withExceptions,
label: i18n.DUPLICATE_EXCEPTIONS_TEXT(rulesCount),
},
{
id: DuplicateOptions.withoutExceptions,
label: i18n.DUPLICATE_WITHOUT_EXCEPTIONS_TEXT(rulesCount),
},
]}
idSelected={selectedDuplicateOption}
onChange={handleRadioChange}
/>
</EuiConfirmModal>
);
};
export const BulkActionDuplicateExceptionsConfirmation = React.memo(
BulkActionDuplicateExceptionsConfirmationComponent
);
BulkActionDuplicateExceptionsConfirmation.displayName = 'BulkActionDuplicateExceptionsConfirmation';

View file

@ -134,3 +134,59 @@ 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?"
values={{ rulesCount }}
/>
),
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"
values={{ rulesCount }}
/>
),
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"
values={{ rulesCount }}
/>
),
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}}"
values={{ rulesCount }}
/>
),
CONTINUE_BUTTON: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.continueButton',
{
defaultMessage: 'Duplicate',
}
),
CANCEL_BUTTON: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.cancelButton',
{
defaultMessage: 'Cancel',
}
),
DUPLICATE_TOOLTIP: i18n.translate(
'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',
}
),
};

View file

@ -12,6 +12,7 @@ import type { Toast } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { euiThemeVars } from '@kbn/ui-theme';
import React, { useCallback } from 'react';
import { DuplicateOptions } from '../../../../../../common/detection_engine/rule_management/constants';
import type { BulkActionEditPayload } from '../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema';
import {
BulkActionType,
@ -46,6 +47,7 @@ interface UseBulkActionsArgs {
result: DryRunResult | undefined,
action: BulkActionForConfirmation
) => Promise<boolean>;
showBulkDuplicateConfirmation: () => Promise<string | null>;
completeBulkEditForm: (
bulkActionEditType: BulkActionEditType
) => Promise<BulkActionEditPayload | null>;
@ -56,6 +58,7 @@ export const useBulkActions = ({
filterOptions,
confirmDeletion,
showBulkActionConfirmation,
showBulkDuplicateConfirmation,
completeBulkEditForm,
executeBulkActionsDryRun,
}: UseBulkActionsArgs) => {
@ -125,8 +128,16 @@ export const useBulkActions = ({
startTransaction({ name: BULK_RULE_ACTIONS.DUPLICATE });
closePopover();
const modalDuplicationConfirmationResult = await showBulkDuplicateConfirmation();
if (modalDuplicationConfirmationResult === null) {
return;
}
await executeBulkAction({
type: BulkActionType.duplicate,
duplicatePayload: {
include_exceptions:
modalDuplicationConfirmationResult === DuplicateOptions.withExceptions,
},
...(isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }),
});
clearRulesSelection();
@ -461,6 +472,7 @@ export const useBulkActions = ({
filterOptions,
completeBulkEditForm,
downloadExportedRules,
showBulkDuplicateConfirmation,
]
);

View file

@ -0,0 +1,53 @@
/*
* 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 { useCallback, useRef } from 'react';
import { useBoolState } from '../../../../../common/hooks/use_bool_state';
/**
* hook that controls bulk duplicate actions exceptions confirmation modal window and its content
*/
export const useBulkDuplicateExceptionsConfirmation = () => {
const [isBulkDuplicateConfirmationVisible, showModal, hideModal] = useBoolState();
const confirmationPromiseRef = useRef<(result: string | null) => void>();
const onConfirm = useCallback((value: string) => {
confirmationPromiseRef.current?.(value);
}, []);
const onCancel = useCallback(() => {
confirmationPromiseRef.current?.(null);
}, []);
const initModal = useCallback(() => {
showModal();
return new Promise<string | null>((resolve) => {
confirmationPromiseRef.current = resolve;
}).finally(() => {
hideModal();
});
}, [showModal, hideModal]);
const showBulkDuplicateConfirmation = useCallback(async () => {
const confirmation = await initModal();
if (confirmation) {
onConfirm(confirmation);
} else {
onCancel();
}
return confirmation;
}, [initModal, onConfirm, onCancel]);
return {
isBulkDuplicateConfirmationVisible,
showBulkDuplicateConfirmation,
cancelRuleDuplication: onCancel,
confirmRuleDuplication: onConfirm,
};
};

View file

@ -36,6 +36,8 @@ import { RulesTableUtilityBar } from './rules_table_utility_bar';
import { useMonitoringColumns, useRulesColumns } from './use_columns';
import { useUserData } from '../../../../detections/components/user_info';
import { hasUserCRUDPermission } from '../../../../common/utils/privileges';
import { useBulkDuplicateExceptionsConfirmation } from './bulk_actions/use_bulk_duplicate_confirmation';
import { BulkActionDuplicateExceptionsConfirmation } from './bulk_actions/bulk_duplicate_exceptions_confirmation';
import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs';
const INITIAL_SORT_FIELD = 'enabled';
@ -101,6 +103,13 @@ export const RulesTables = React.memo<RulesTableProps>(({ selectedTab }) => {
approveBulkActionConfirmation,
} = useBulkActionsConfirmation();
const {
isBulkDuplicateConfirmationVisible,
showBulkDuplicateConfirmation,
cancelRuleDuplication,
confirmRuleDuplication,
} = useBulkDuplicateExceptionsConfirmation();
const {
bulkEditActionType,
isBulkEditFlyoutVisible,
@ -115,6 +124,7 @@ export const RulesTables = React.memo<RulesTableProps>(({ selectedTab }) => {
filterOptions,
confirmDeletion,
showBulkActionConfirmation,
showBulkDuplicateConfirmation,
completeBulkEditForm,
executeBulkActionsDryRun,
});
@ -147,12 +157,14 @@ export const RulesTables = React.memo<RulesTableProps>(({ selectedTab }) => {
isLoadingJobs,
mlJobs,
startMlJobs,
showExceptionsDuplicateConfirmation: showBulkDuplicateConfirmation,
});
const monitoringColumns = useMonitoringColumns({
hasCRUDPermissions: hasPermissions,
isLoadingJobs,
mlJobs,
startMlJobs,
showExceptionsDuplicateConfirmation: showBulkDuplicateConfirmation,
});
const isSelectAllCalled = useRef(false);
@ -259,6 +271,13 @@ export const RulesTables = React.memo<RulesTableProps>(({ selectedTab }) => {
onConfirm={approveBulkActionConfirmation}
/>
)}
{isBulkDuplicateConfirmationVisible && (
<BulkActionDuplicateExceptionsConfirmation
onCancel={cancelRuleDuplication}
onConfirm={confirmRuleDuplication}
rulesCount={selectedRuleIds?.length ? selectedRuleIds?.length : 1}
/>
)}
{isBulkEditFlyoutVisible && bulkEditActionType !== undefined && (
<BulkEditFlyout
rulesCount={bulkActionsDryRunResult?.succeededRulesCount ?? 0}

View file

@ -55,6 +55,10 @@ interface ColumnsProps {
startMlJobs: (jobIds: string[] | undefined) => Promise<void>;
}
interface ActionColumnsProps {
showExceptionsDuplicateConfirmation: () => Promise<string | null>;
}
const useEnabledColumn = ({ hasCRUDPermissions, startMlJobs }: ColumnsProps): TableColumn => {
const hasMlPermissions = useHasMlPermissions();
const hasActionsPrivileges = useHasActionsPrivileges();
@ -209,19 +213,24 @@ const INTEGRATIONS_COLUMN: TableColumn = {
truncateText: true,
};
const useActionsColumn = (): EuiTableActionsColumnType<Rule> => {
const actions = useRulesTableActions();
const useActionsColumn = ({
showExceptionsDuplicateConfirmation,
}: ActionColumnsProps): EuiTableActionsColumnType<Rule> => {
const actions = useRulesTableActions({ showExceptionsDuplicateConfirmation });
return useMemo(() => ({ actions, width: '40px' }), [actions]);
};
export interface UseColumnsProps extends ColumnsProps, ActionColumnsProps {}
export const useRulesColumns = ({
hasCRUDPermissions,
isLoadingJobs,
mlJobs,
startMlJobs,
}: ColumnsProps): TableColumn[] => {
const actionsColumn = useActionsColumn();
showExceptionsDuplicateConfirmation,
}: UseColumnsProps): TableColumn[] => {
const actionsColumn = useActionsColumn({ showExceptionsDuplicateConfirmation });
const ruleNameColumn = useRuleNameColumn();
const { isInMemorySorting } = useRulesTableContext().state;
const [showRelatedIntegrations] = useUiSetting$<boolean>(SHOW_RELATED_INTEGRATIONS_SETTING);
@ -338,9 +347,10 @@ export const useMonitoringColumns = ({
isLoadingJobs,
mlJobs,
startMlJobs,
}: ColumnsProps): TableColumn[] => {
showExceptionsDuplicateConfirmation,
}: UseColumnsProps): TableColumn[] => {
const docLinks = useKibana().services.docLinks;
const actionsColumn = useActionsColumn();
const actionsColumn = useActionsColumn({ showExceptionsDuplicateConfirmation });
const ruleNameColumn = useRuleNameColumn();
const { isInMemorySorting } = useRulesTableContext().state;
const [showRelatedIntegrations] = useUiSetting$<boolean>(SHOW_RELATED_INTEGRATIONS_SETTING);

View file

@ -8,6 +8,7 @@
import type { DefaultItemAction } from '@elastic/eui';
import { EuiToolTip } from '@elastic/eui';
import React from 'react';
import { DuplicateOptions } from '../../../../../common/detection_engine/rule_management/constants';
import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema';
import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions';
import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction';
@ -23,7 +24,11 @@ import {
import { useDownloadExportedRules } from '../../../rule_management/logic/bulk_actions/use_download_exported_rules';
import { useHasActionsPrivileges } from './use_has_actions_privileges';
export const useRulesTableActions = (): Array<DefaultItemAction<Rule>> => {
export const useRulesTableActions = ({
showExceptionsDuplicateConfirmation,
}: {
showExceptionsDuplicateConfirmation: () => Promise<string | null>;
}): Array<DefaultItemAction<Rule>> => {
const { navigateToApp } = useKibana().services.application;
const hasActionsPrivileges = useHasActionsPrivileges();
const { startTransaction } = useStartTransaction();
@ -63,9 +68,17 @@ export const useRulesTableActions = (): Array<DefaultItemAction<Rule>> => {
// TODO extract those handlers to hooks, like useDuplicateRule
onClick: async (rule: Rule) => {
startTransaction({ name: SINGLE_RULE_ACTIONS.DUPLICATE });
const modalDuplicationConfirmationResult = await showExceptionsDuplicateConfirmation();
if (modalDuplicationConfirmationResult === null) {
return;
}
const result = await executeBulkAction({
type: BulkActionType.duplicate,
ids: [rule.id],
duplicatePayload: {
include_exceptions:
modalDuplicationConfirmationResult === DuplicateOptions.withExceptions,
},
});
const createdRules = result?.attributes.results.created;
if (createdRules?.length) {

View file

@ -15,6 +15,8 @@ import { RuleActionsOverflow } from '.';
import { mockRule } from '../../../../detection_engine/rule_management_ui/components/rules_table/__mocks__/mock';
import { TestProviders } from '../../../../common/mock';
const showBulkDuplicateExceptionsConfirmation = () => Promise.resolve(null);
jest.mock(
'../../../../detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action'
);
@ -50,6 +52,7 @@ describe('RuleActionsOverflow', () => {
test('menu items rendered when a rule is passed to the component', () => {
const { getByTestId } = render(
<RuleActionsOverflow
showBulkDuplicateExceptionsConfirmation={showBulkDuplicateExceptionsConfirmation}
rule={mockRule('id')}
userHasPermissions
canDuplicateRuleWithActions={true}
@ -64,7 +67,12 @@ describe('RuleActionsOverflow', () => {
test('menu is empty when no rule is passed to the component', () => {
const { getByTestId } = render(
<RuleActionsOverflow rule={null} userHasPermissions canDuplicateRuleWithActions={true} />,
<RuleActionsOverflow
showBulkDuplicateExceptionsConfirmation={showBulkDuplicateExceptionsConfirmation}
rule={null}
userHasPermissions
canDuplicateRuleWithActions={true}
/>,
{ wrapper: TestProviders }
);
fireEvent.click(getByTestId('rules-details-popover-button-icon'));
@ -76,6 +84,7 @@ describe('RuleActionsOverflow', () => {
test('it does not open the popover when rules-details-popover-button-icon is clicked when the user does not have permission', () => {
const { getByTestId } = render(
<RuleActionsOverflow
showBulkDuplicateExceptionsConfirmation={showBulkDuplicateExceptionsConfirmation}
rule={mockRule('id')}
userHasPermissions={false}
canDuplicateRuleWithActions={true}
@ -92,6 +101,7 @@ describe('RuleActionsOverflow', () => {
test('it closes the popover when rules-details-duplicate-rule is clicked', () => {
const { getByTestId } = render(
<RuleActionsOverflow
showBulkDuplicateExceptionsConfirmation={showBulkDuplicateExceptionsConfirmation}
rule={mockRule('id')}
userHasPermissions
canDuplicateRuleWithActions={true}
@ -103,44 +113,6 @@ describe('RuleActionsOverflow', () => {
expect(getByTestId('rules-details-popover')).not.toHaveTextContent(/.+/);
});
test('it calls duplicate action when rules-details-duplicate-rule is clicked', () => {
const executeBulkAction = jest.fn();
useExecuteBulkActionMock.mockReturnValue({ executeBulkAction });
const { getByTestId } = render(
<RuleActionsOverflow
rule={mockRule('id')}
userHasPermissions
canDuplicateRuleWithActions={true}
/>,
{ wrapper: TestProviders }
);
fireEvent.click(getByTestId('rules-details-popover-button-icon'));
fireEvent.click(getByTestId('rules-details-duplicate-rule'));
expect(executeBulkAction).toHaveBeenCalledWith(
expect.objectContaining({ type: 'duplicate' })
);
});
test('it calls duplicate action with the rule and rule.id when rules-details-duplicate-rule is clicked', () => {
const executeBulkAction = jest.fn();
useExecuteBulkActionMock.mockReturnValue({ executeBulkAction });
const { getByTestId } = render(
<RuleActionsOverflow
rule={mockRule('id')}
userHasPermissions
canDuplicateRuleWithActions={true}
/>,
{ wrapper: TestProviders }
);
fireEvent.click(getByTestId('rules-details-popover-button-icon'));
fireEvent.click(getByTestId('rules-details-duplicate-rule'));
expect(executeBulkAction).toHaveBeenCalledWith({ type: 'duplicate', ids: ['id'] });
});
});
describe('rules details export rule', () => {
@ -150,6 +122,7 @@ describe('RuleActionsOverflow', () => {
const { getByTestId } = render(
<RuleActionsOverflow
showBulkDuplicateExceptionsConfirmation={showBulkDuplicateExceptionsConfirmation}
rule={mockRule('id')}
userHasPermissions
canDuplicateRuleWithActions={true}
@ -165,6 +138,7 @@ describe('RuleActionsOverflow', () => {
test('it closes the popover when rules-details-export-rule is clicked', () => {
const { getByTestId } = render(
<RuleActionsOverflow
showBulkDuplicateExceptionsConfirmation={showBulkDuplicateExceptionsConfirmation}
rule={mockRule('id')}
userHasPermissions
canDuplicateRuleWithActions={true}
@ -183,6 +157,7 @@ describe('RuleActionsOverflow', () => {
test('it closes the popover when rules-details-delete-rule is clicked', () => {
const { getByTestId } = render(
<RuleActionsOverflow
showBulkDuplicateExceptionsConfirmation={showBulkDuplicateExceptionsConfirmation}
rule={mockRule('id')}
userHasPermissions
canDuplicateRuleWithActions={true}
@ -202,6 +177,7 @@ describe('RuleActionsOverflow', () => {
const { getByTestId } = render(
<RuleActionsOverflow
showBulkDuplicateExceptionsConfirmation={showBulkDuplicateExceptionsConfirmation}
rule={mockRule('id')}
userHasPermissions
canDuplicateRuleWithActions={true}
@ -220,7 +196,12 @@ describe('RuleActionsOverflow', () => {
const rule = mockRule('id');
const { getByTestId } = render(
<RuleActionsOverflow rule={rule} userHasPermissions canDuplicateRuleWithActions={true} />,
<RuleActionsOverflow
showBulkDuplicateExceptionsConfirmation={showBulkDuplicateExceptionsConfirmation}
rule={rule}
userHasPermissions
canDuplicateRuleWithActions={true}
/>,
{ wrapper: TestProviders }
);
fireEvent.click(getByTestId('rules-details-popover-button-icon'));

View file

@ -15,6 +15,7 @@ import {
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { APP_UI_ID, SecurityPageName } from '../../../../../common/constants';
import { DuplicateOptions } from '../../../../../common/detection_engine/rule_management/constants';
import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema';
import { getRulesUrl } from '../../../../common/components/link_to/redirect_to_detection_engine';
import { useBoolState } from '../../../../common/hooks/use_bool_state';
@ -47,6 +48,7 @@ interface RuleActionsOverflowComponentProps {
rule: Rule | null;
userHasPermissions: boolean;
canDuplicateRuleWithActions: boolean;
showBulkDuplicateExceptionsConfirmation: () => Promise<string | null>;
}
/**
@ -56,6 +58,7 @@ const RuleActionsOverflowComponent = ({
rule,
userHasPermissions,
canDuplicateRuleWithActions,
showBulkDuplicateExceptionsConfirmation,
}: RuleActionsOverflowComponentProps) => {
const [isPopoverOpen, , closePopover, togglePopover] = useBoolState();
const { navigateToApp } = useKibana().services.application;
@ -83,10 +86,20 @@ const RuleActionsOverflowComponent = ({
onClick={async () => {
startTransaction({ name: SINGLE_RULE_ACTIONS.DUPLICATE });
closePopover();
const modalDuplicationConfirmationResult =
await showBulkDuplicateExceptionsConfirmation();
if (modalDuplicationConfirmationResult === null) {
return;
}
const result = await executeBulkAction({
type: BulkActionType.duplicate,
ids: [rule.id],
duplicatePayload: {
include_exceptions:
modalDuplicationConfirmationResult === DuplicateOptions.withExceptions,
},
});
const createdRules = result?.attributes.results.created;
if (createdRules?.length) {
goToRuleEditPage(createdRules[0].id, navigateToApp);
@ -148,6 +161,7 @@ const RuleActionsOverflowComponent = ({
navigateToApp,
onRuleDeletedCallback,
rule,
showBulkDuplicateExceptionsConfirmation,
startTransaction,
userHasPermissions,
downloadExportedRules,

View file

@ -95,56 +95,7 @@ export const createRuleExceptionsRoute = (router: SecuritySolutionPluginRouter)
});
}
let createdItems;
const ruleDefaultLists = rule.params.exceptionsList.filter(
(list) => list.type === ExceptionListTypeEnum.RULE_DEFAULT
);
// This should hopefully never happen, but could if we forget to add such a check to one
// of our routes allowing the user to update the rule to have more than one default list added
checkDefaultRuleExceptionListReferences({ exceptionLists: rule.params.exceptionsList });
const [ruleDefaultList] = ruleDefaultLists;
if (ruleDefaultList != null) {
// check that list does indeed exist
const exceptionListAssociatedToRule = await listsClient?.getExceptionList({
id: ruleDefaultList.id,
listId: ruleDefaultList.list_id,
namespaceType: ruleDefaultList.namespace_type,
});
// if list does exist, just need to create the items
if (exceptionListAssociatedToRule != null) {
createdItems = await createExceptionListItems({
items,
defaultList: exceptionListAssociatedToRule,
listsClient,
});
} else {
// This means that there was missed cleanup when this rule exception list was
// deleted and it remained referenced on the rule. Let's remove it from the rule,
// and update the rule's exceptions lists to include newly created default list.
const defaultList = await createAndAssociateDefaultExceptionList({
rule,
rulesClient,
listsClient,
removeOldAssociation: true,
});
createdItems = await createExceptionListItems({ items, defaultList, listsClient });
}
} else {
const defaultList = await createAndAssociateDefaultExceptionList({
rule,
rulesClient,
listsClient,
removeOldAssociation: false,
});
createdItems = await createExceptionListItems({ items, defaultList, listsClient });
}
const createdItems = await createRuleExceptions({ items, rule, listsClient, rulesClient });
const [validated, errors] = validate(createdItems, t.array(exceptionListItemSchema));
if (errors != null) {
@ -163,6 +114,67 @@ export const createRuleExceptionsRoute = (router: SecuritySolutionPluginRouter)
);
};
export const createRuleExceptions = async ({
items,
rule,
listsClient,
rulesClient,
}: {
items: CreateRuleExceptionListItemSchemaDecoded[];
listsClient: ExceptionListClient | null;
rulesClient: RulesClient;
rule: SanitizedRule<RuleParams>;
}) => {
const ruleDefaultLists = rule.params.exceptionsList.filter(
(list) => list.type === ExceptionListTypeEnum.RULE_DEFAULT
);
// This should hopefully never happen, but could if we forget to add such a check to one
// of our routes allowing the user to update the rule to have more than one default list added
checkDefaultRuleExceptionListReferences({ exceptionLists: rule.params.exceptionsList });
const [ruleDefaultList] = ruleDefaultLists;
if (ruleDefaultList != null) {
// check that list does indeed exist
const exceptionListAssociatedToRule = await listsClient?.getExceptionList({
id: ruleDefaultList.id,
listId: ruleDefaultList.list_id,
namespaceType: ruleDefaultList.namespace_type,
});
// if list does exist, just need to create the items
if (exceptionListAssociatedToRule != null) {
return createExceptionListItems({
items,
defaultList: exceptionListAssociatedToRule,
listsClient,
});
} else {
// This means that there was missed cleanup when this rule exception list was
// deleted and it remained referenced on the rule. Let's remove it from the rule,
// and update the rule's exceptions lists to include newly created default list.
const defaultList = await createAndAssociateDefaultExceptionList({
rule,
rulesClient,
listsClient,
removeOldAssociation: true,
});
return createExceptionListItems({ items, defaultList, listsClient });
}
} else {
const defaultList = await createAndAssociateDefaultExceptionList({
rule,
rulesClient,
listsClient,
removeOldAssociation: false,
});
return createExceptionListItems({ items, defaultList, listsClient });
}
};
export const createExceptionListItems = async ({
items,
defaultList,
@ -191,17 +203,15 @@ export const createExceptionListItems = async ({
);
};
export const createAndAssociateDefaultExceptionList = async ({
export const createExceptionList = async ({
rule,
listsClient,
rulesClient,
removeOldAssociation,
}: {
rule: SanitizedRule<RuleParams>;
listsClient: ExceptionListClient | null;
rulesClient: RulesClient;
removeOldAssociation: boolean;
}): Promise<ExceptionListSchema> => {
}): Promise<ExceptionListSchema | null> => {
if (!listsClient) return null;
const exceptionList: CreateExceptionListSchema = {
description: `Exception list containing exceptions for rule with id: ${rule.id}`,
meta: undefined,
@ -233,7 +243,7 @@ export const createAndAssociateDefaultExceptionList = async ({
} = validated;
// create the default rule list
const exceptionListAssociatedToRule = await listsClient?.createExceptionList({
return listsClient.createExceptionList({
description,
immutable: false,
listId,
@ -244,8 +254,22 @@ export const createAndAssociateDefaultExceptionList = async ({
type,
version,
});
};
if (exceptionListAssociatedToRule == null) {
export const createAndAssociateDefaultExceptionList = async ({
rule,
listsClient,
rulesClient,
removeOldAssociation,
}: {
rule: SanitizedRule<RuleParams>;
listsClient: ExceptionListClient | null;
rulesClient: RulesClient;
removeOldAssociation: boolean;
}): Promise<ExceptionListSchema> => {
const exceptionListToAssociate = await createExceptionList({ rule, listsClient });
if (exceptionListToAssociate == null) {
throw Error(`An error occurred creating rule default exception list`);
}
@ -265,14 +289,14 @@ export const createAndAssociateDefaultExceptionList = async ({
exceptions_list: [
...ruleExceptionLists,
{
id: exceptionListAssociatedToRule.id,
list_id: exceptionListAssociatedToRule.list_id,
type: exceptionListAssociatedToRule.type,
namespace_type: exceptionListAssociatedToRule.namespace_type,
id: exceptionListToAssociate.id,
list_id: exceptionListToAssociate.list_id,
type: exceptionListToAssociate.type,
namespace_type: exceptionListToAssociate.namespace_type,
},
],
},
});
return exceptionListAssociatedToRule;
return exceptionListToAssociate;
};

View file

@ -35,6 +35,7 @@ import { initPromisePool } from '../../../../../../utils/promise_pool';
import { buildMlAuthz } from '../../../../../machine_learning/authz';
import { deleteRules } from '../../../logic/crud/delete_rules';
import { duplicateRule } from '../../../logic/actions/duplicate_rule';
import { duplicateExceptions } from '../../../logic/actions/duplicate_exceptions';
import { findRules } from '../../../logic/search/find_rules';
import { readRules } from '../../../logic/crud/read_rules';
import { getExportByObjectIds } from '../../../logic/export/get_export_by_object_ids';
@ -497,18 +498,46 @@ export const performBulkActionRoute = (
if (isDryRun) {
return rule;
}
const migratedRule = await migrateRuleActions({
rulesClient,
savedObjectsClient,
rule,
});
let shouldDuplicateExceptions = true;
if (body.duplicate !== undefined) {
shouldDuplicateExceptions = body.duplicate.include_exceptions;
}
const createdRule = await rulesClient.create({
data: duplicateRule(migratedRule),
const duplicateRuleToCreate = await duplicateRule({
rule: migratedRule,
});
return createdRule;
const createdRule = await rulesClient.create({
data: duplicateRuleToCreate,
});
// we try to create exceptions after rule created, and then update rule
const exceptions = shouldDuplicateExceptions
? await duplicateExceptions({
ruleId: rule.params.ruleId,
exceptionLists: rule.params.exceptionsList,
exceptionsClient,
})
: [];
const updatedRule = await rulesClient.update({
id: createdRule.id,
data: {
...duplicateRuleToCreate,
params: {
...duplicateRuleToCreate.params,
exceptionsList: exceptions,
},
},
});
// TODO: figureout why types can't return just updatedRule
return { ...createdRule, ...updatedRule };
},
abortSignal: abortController.signal,
});

View file

@ -0,0 +1,62 @@
/*
* 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 type { ExceptionListClient } from '@kbn/lists-plugin/server';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type { RuleParams } from '../../../rule_schema';
interface DuplicateExceptionsParams {
ruleId: RuleParams['ruleId'];
exceptionLists: RuleParams['exceptionsList'];
exceptionsClient: ExceptionListClient | undefined;
}
export const duplicateExceptions = async ({
ruleId,
exceptionLists,
exceptionsClient,
}: DuplicateExceptionsParams): Promise<RuleParams['exceptionsList']> => {
if (exceptionLists == null) {
return [];
}
// Sort the shared lists and the rule_default lists.
// Only a single rule_default list should exist per rule.
const ruleDefaultList = exceptionLists.find(
(list) => list.type === ExceptionListTypeEnum.RULE_DEFAULT
);
const sharedLists = exceptionLists.filter(
(list) => list.type !== ExceptionListTypeEnum.RULE_DEFAULT
);
// 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({
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}`);
}
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
return [...sharedLists];
};

View file

@ -90,9 +90,11 @@ describe('duplicateRule', () => {
jest.clearAllMocks();
});
it('returns an object with fields copied from a given rule', () => {
it('returns an object with fields copied from a given rule', async () => {
const rule = createTestRule();
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual({
name: expect.anything(), // covered in a separate test
@ -111,10 +113,12 @@ describe('duplicateRule', () => {
});
});
it('appends [Duplicate] to the name', () => {
it('appends [Duplicate] to the name', async () => {
const rule = createTestRule();
rule.name = 'PowerShell Keylogging Script';
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({
@ -123,9 +127,11 @@ describe('duplicateRule', () => {
);
});
it('generates a new ruleId', () => {
it('generates a new ruleId', async () => {
const rule = createTestRule();
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({
@ -136,10 +142,12 @@ describe('duplicateRule', () => {
);
});
it('makes sure the duplicated rule is disabled', () => {
it('makes sure the duplicated rule is disabled', async () => {
const rule = createTestRule();
rule.enabled = true;
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({
@ -155,9 +163,11 @@ describe('duplicateRule', () => {
return rule;
};
it('transforms it to a custom (mutable) rule', () => {
it('transforms it to a custom (mutable) rule', async () => {
const rule = createPrebuiltRule();
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({
@ -168,7 +178,7 @@ describe('duplicateRule', () => {
);
});
it('resets related integrations to an empty array', () => {
it('resets related integrations to an empty array', async () => {
const rule = createPrebuiltRule();
rule.params.relatedIntegrations = [
{
@ -178,7 +188,9 @@ describe('duplicateRule', () => {
},
];
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({
@ -189,7 +201,7 @@ describe('duplicateRule', () => {
);
});
it('resets required fields to an empty array', () => {
it('resets required fields to an empty array', async () => {
const rule = createPrebuiltRule();
rule.params.requiredFields = [
{
@ -199,7 +211,9 @@ describe('duplicateRule', () => {
},
];
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({
@ -210,10 +224,12 @@ describe('duplicateRule', () => {
);
});
it('resets setup guide to an empty string', () => {
it('resets setup guide to an empty string', async () => {
const rule = createPrebuiltRule();
rule.params.setup = `## Config\n\nThe 'Audit Detailed File Share' audit policy must be configured...`;
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({
@ -232,9 +248,11 @@ describe('duplicateRule', () => {
return rule;
};
it('keeps it custom', () => {
it('keeps it custom', async () => {
const rule = createCustomRule();
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({
@ -245,7 +263,7 @@ describe('duplicateRule', () => {
);
});
it('copies related integrations as is', () => {
it('copies related integrations as is', async () => {
const rule = createCustomRule();
rule.params.relatedIntegrations = [
{
@ -255,7 +273,9 @@ describe('duplicateRule', () => {
},
];
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({
@ -266,7 +286,7 @@ describe('duplicateRule', () => {
);
});
it('copies required fields as is', () => {
it('copies required fields as is', async () => {
const rule = createCustomRule();
rule.params.requiredFields = [
{
@ -276,7 +296,9 @@ describe('duplicateRule', () => {
},
];
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({
@ -287,10 +309,12 @@ describe('duplicateRule', () => {
);
});
it('copies setup guide as is', () => {
it('copies setup guide as is', async () => {
const rule = createCustomRule();
rule.params.setup = `## Config\n\nThe 'Audit Detailed File Share' audit policy must be configured...`;
const result = duplicateRule(rule);
const result = await duplicateRule({
rule,
});
expect(result).toEqual(
expect.objectContaining({

View file

@ -9,7 +9,6 @@ import uuid from 'uuid';
import { i18n } from '@kbn/i18n';
import { ruleTypeMappings } from '@kbn/securitysolution-rules';
import type { SanitizedRule } from '@kbn/alerting-plugin/common';
import { SERVER_APP_ID } from '../../../../../../common/constants';
import type { InternalRuleCreate, RuleParams } from '../../../rule_schema';
@ -20,7 +19,11 @@ const DUPLICATE_TITLE = i18n.translate(
}
);
export const duplicateRule = (rule: SanitizedRule<RuleParams>): InternalRuleCreate => {
interface DuplicateRuleParams {
rule: SanitizedRule<RuleParams>;
}
export const duplicateRule = async ({ rule }: DuplicateRuleParams): Promise<InternalRuleCreate> => {
// Generate a new static ruleId
const ruleId = uuid.v4();
@ -43,6 +46,7 @@ export const duplicateRule = (rule: SanitizedRule<RuleParams>): InternalRuleCrea
relatedIntegrations,
requiredFields,
setup,
exceptionsList: [],
},
schedule: rule.schedule,
enabled: false,

View file

@ -310,7 +310,11 @@ export default ({ getService }: FtrProviderContext): void => {
await createRule(supertest, log, ruleToDuplicate);
const { body } = await postBulkAction()
.send({ query: '', action: BulkActionType.duplicate })
.send({
query: '',
action: BulkActionType.duplicate,
duplicate: { include_exceptions: false },
})
.expect(200);
expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 });
@ -352,7 +356,11 @@ export default ({ getService }: FtrProviderContext): void => {
);
const { body } = await postBulkAction()
.send({ query: '', action: BulkActionType.duplicate })
.send({
query: '',
action: BulkActionType.duplicate,
duplicate: { include_exceptions: false },
})
.expect(200);
expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 });