diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/utils.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/utils.ts index d7e51d5b7d091..058bf3e959bc6 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/utils.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/utils.ts @@ -10,3 +10,7 @@ import type { RuleResponse } from './rule_schemas.gen'; export function isCustomizedPrebuiltRule(rule: RuleResponse): boolean { return rule.rule_source?.type === 'external' && rule.rule_source.is_customized; } + +export function isNonCustomizedPrebuiltRule(rule: RuleResponse): boolean { + return rule.rule_source?.type === 'external' && rule.rule_source.is_customized === false; +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_route.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_route.ts new file mode 100644 index 0000000000000..164ab43db9648 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_route.ts @@ -0,0 +1,26 @@ +/* + * 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 { z } from '@kbn/zod'; +import type { RuleResponse } from '../../model/rule_schema/rule_schemas.gen'; +import type { PartialRuleDiff } from '../model'; + +export type GetPrebuiltRuleBaseVersionRequest = z.infer; +export const GetPrebuiltRuleBaseVersionRequest = z.object({ + id: z.string(), +}); + +export interface GetPrebuiltRuleBaseVersionResponseBody { + /** The base version of the rule */ + base_version: RuleResponse; + + /** The current version of the rule */ + current_version: RuleResponse; + + /** The resulting diff between the base and current versions of the rule */ + diff: PartialRuleDiff; +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/index.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/index.ts index df1b5851f5474..10ee54a38cc7c 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/index.ts @@ -23,3 +23,5 @@ export * from './model/diff/three_way_diff/three_way_diff'; export * from './model/diff/three_way_diff/three_way_diff_conflict'; export * from './model/diff/three_way_diff/three_way_merge_outcome'; export * from './common/prebuilt_rules_filter'; +export * from './revert_prebuilt_rule/revert_prebuilt_rule_route'; +export * from './get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_route'; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/revert_prebuilt_rule/revert_prebuilt_rule_route.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/revert_prebuilt_rule/revert_prebuilt_rule_route.ts new file mode 100644 index 0000000000000..1284c769a4c27 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/revert_prebuilt_rule/revert_prebuilt_rule_route.ts @@ -0,0 +1,56 @@ +/* + * 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 { z } from '@kbn/zod'; +import { RuleResponse } from '../../model/rule_schema/rule_schemas.gen'; +import { NormalizedRuleError } from '../../rule_management'; + +export type RevertPrebuiltRulesRequest = z.infer; +export const RevertPrebuiltRulesRequest = z.object({ + /** ID of rule to revert */ + id: z.string(), + + /** Revision of rule to guard against concurrence */ + revision: z.number(), + + /** Version of rule to guard against concurrence */ + version: z.number(), +}); + +export type BulkRevertSkipReason = z.infer; +export const BulkRevertSkipReason = z.enum(['RULE_NOT_PREBUILT', 'RULE_NOT_CUSTOMIZED']); +export type BulkRevertSkipReasonEnum = typeof BulkRevertSkipReason.enum; +export const BulkRevertSkipReasonEnum = BulkRevertSkipReason.enum; + +export type BulkActionReversionSkipResult = z.infer; +export const BulkActionReversionSkipResult = z.object({ + id: z.string(), + skip_reason: BulkRevertSkipReason, +}); + +export type RevertPrebuiltRulesResponseBody = z.infer; +export const RevertPrebuiltRulesResponseBody = z.object({ + success: z.boolean().optional(), + status_code: z.number().int().optional(), + message: z.string().optional(), + rules_count: z.number().int().optional(), + attributes: z.object({ + results: z.object({ + updated: z.array(RuleResponse), // An array of the rule objects reverted to their base version + created: z.array(RuleResponse), + deleted: z.array(RuleResponse), + skipped: z.array(BulkActionReversionSkipResult), // An array of the rule ids and reasons that were skipped during reversion (due to being already non-customized) + }), + summary: z.object({ + failed: z.number().int(), + skipped: z.number().int(), + succeeded: z.number().int(), + total: z.number().int(), + }), + errors: z.array(NormalizedRuleError).optional(), // An array of error objects, something containing the id of the rule causing the error and the reason behind it (e.g. no base version, rule is not a prebuilt Elastic rule) + }), +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/urls.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/urls.ts index 3f744dffc9447..4a875d775c613 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/urls.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/urls.ts @@ -17,8 +17,10 @@ export const PREBUILT_RULES_URL = LEGACY_BASE_URL; export const PREBUILT_RULES_STATUS_URL = `${LEGACY_BASE_URL}/_status` as const; export const GET_PREBUILT_RULES_STATUS_URL = `${BASE_URL}/status` as const; +export const GET_PREBUILT_RULES_BASE_VERSION_URL = `${BASE_URL}/base_version` as const; export const BOOTSTRAP_PREBUILT_RULES_URL = `${BASE_URL}/_bootstrap` as const; export const REVIEW_RULE_UPGRADE_URL = `${BASE_URL}/upgrade/_review` as const; export const PERFORM_RULE_UPGRADE_URL = `${BASE_URL}/upgrade/_perform` as const; export const REVIEW_RULE_INSTALLATION_URL = `${BASE_URL}/installation/_review` as const; export const PERFORM_RULE_INSTALLATION_URL = `${BASE_URL}/installation/_perform` as const; +export const REVERT_PREBUILT_RULES_URL = `${BASE_URL}/revert` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts index b2a1e24108c61..bba1908994748 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts @@ -54,6 +54,7 @@ import { extractRuleSchedule } from './extract_rule_schedule'; import { extractTimelineTemplateReference } from './extract_timeline_template_reference'; import { extractTimestampOverrideObject } from './extract_timestamp_override_object'; import { extractThreatArray } from './extract_threat_array'; +import { normalizeRuleThreshold } from './normalize_rule_threshold'; /** * Normalizes a given rule to the form which is suitable for passing to the diff algorithm. @@ -224,7 +225,7 @@ const extractDiffableThresholdFieldsFromRuleObject = ( type: rule.type, kql_query: extractRuleKqlQuery(rule.query, rule.language, rule.filters, rule.saved_id), data_source: extractRuleDataSource(rule.index, rule.data_view_id), - threshold: rule.threshold, + threshold: normalizeRuleThreshold(rule.threshold), alert_suppression: rule.alert_suppression, }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/normalize_rule_threshold.test.ts b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/normalize_rule_threshold.test.ts new file mode 100644 index 0000000000000..49267f0f57bb8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/normalize_rule_threshold.test.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { normalizeRuleThreshold } from './normalize_rule_threshold'; + +describe('normalizeRuleThreshold', () => { + it('returns threshold without cardinality field when array is empty', () => { + const normalizedField = normalizeRuleThreshold({ + value: 30, + field: ['field'], + cardinality: [], + }); + + expect(normalizedField).toEqual({ value: 30, field: ['field'] }); + }); + + it('returns cardinality field when it is populated', () => { + const normalizedField = normalizeRuleThreshold({ + value: 30, + field: ['field'], + cardinality: [{ value: 20, field: 'field-cardinality' }], + }); + + expect(normalizedField).toEqual({ + value: 30, + field: ['field'], + cardinality: [{ value: 20, field: 'field-cardinality' }], + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/normalize_rule_threshold.ts b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/normalize_rule_threshold.ts new file mode 100644 index 0000000000000..9592ea141f46c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/normalize_rule_threshold.ts @@ -0,0 +1,18 @@ +/* + * 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 { Threshold } from '../../../api/detection_engine/model/rule_schema'; + +export const normalizeRuleThreshold = (threshold: Threshold): Threshold => { + const cardinality = + threshold.cardinality && threshold.cardinality.length ? threshold.cardinality : undefined; + return { + value: threshold.value, + field: threshold.field, + cardinality, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts index d21ef8676c345..5c9ecff51479c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts @@ -710,6 +710,28 @@ export const EXPORT_RULE = i18n.translate( } ); +export const REVERT_RULE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.actions.revertRuleDescription', + { + defaultMessage: 'Revert to Elastic version', + } +); + +export const REVERT_RULE_TOOLTIP_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.actions.revertRuleTooltipTitle', + { + defaultMessage: 'Unable to revert rule', + } +); + +export const REVERT_RULE_TOOLTIP_CONTENT = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.actions.revertRuleTooltipContent', + { + defaultMessage: + "This rule hasn't been updated in a while and there's no available version to revert to. We recommend updating this rule instead.", + } +); + export const DELETE_RULE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteRuleDescription', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index 4cee01462e4cc..fa2d81142c749 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -155,6 +155,7 @@ import { useLegacyUrlRedirect } from './use_redirect_legacy_url'; import { RuleDetailTabs, useRuleDetailsTabs } from './use_rule_details_tabs'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useRuleUpdateCallout } from '../../../rule_management/hooks/use_rule_update_callout'; +import { usePrebuiltRulesViewBaseDiff } from '../../../rule_management/hooks/use_prebuilt_rules_view_base_diff'; const RULE_EXCEPTION_LIST_TYPES = [ ExceptionListTypeEnum.DETECTION, @@ -432,6 +433,18 @@ const RuleDetailsPageComponent: React.FC = ({ onUpgrade: refreshRule, }); + const { + baseVersionFlyout, + openFlyout, + doesBaseVersionExist, + isLoading: isBaseVersionLoading, + } = usePrebuiltRulesViewBaseDiff({ rule, onRevert: refreshRule }); + + const isRevertBaseVersionDisabled = useMemo( + () => !doesBaseVersionExist || isBaseVersionLoading, + [doesBaseVersionExist, isBaseVersionLoading] + ); + const ruleStatusInfo = useMemo(() => { return ( <> @@ -608,6 +621,7 @@ const RuleDetailsPageComponent: React.FC = ({ {upgradeCallout} + {baseVersionFlyout} {isBulkDuplicateConfirmationVisible && ( = ({ showBulkDuplicateExceptionsConfirmation={showBulkDuplicateConfirmation} showManualRuleRunConfirmation={showManualRuleRunConfirmation} confirmDeletion={confirmDeletion} + isRevertBaseVersionDisabled={isRevertBaseVersionDisabled} + openRuleDiffFlyout={openFlyout} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/rule_actions_overflow/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/rule_actions_overflow/index.test.tsx index 08641b789a985..b7a215feb83e3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/rule_actions_overflow/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/rule_actions_overflow/index.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor, within } from '@testing-library/react'; import React from 'react'; import { RuleActionsOverflow } from '.'; import { ManualRuleRunEventTypes } from '../../../../../common/lib/telemetry'; @@ -13,6 +13,7 @@ import { TestProviders } from '../../../../../common/mock'; import { useBulkExport } from '../../../../rule_management/logic/bulk_actions/use_bulk_export'; import { useExecuteBulkAction } from '../../../../rule_management/logic/bulk_actions/use_execute_bulk_action'; import { mockRule } from '../../../../rule_management_ui/components/rules_table/__mocks__/mock'; +import type { ExternalRuleSource } from '../../../../../../common/api/detection_engine'; const showBulkDuplicateExceptionsConfirmation = () => Promise.resolve(null); const showManualRuleRunConfirmation = () => Promise.resolve(null); @@ -46,6 +47,7 @@ jest.mock('../../../../../common/lib/kibana', () => { const useExecuteBulkActionMock = useExecuteBulkAction as jest.Mock; const useBulkExportMock = useBulkExport as jest.Mock; +const openRuleDiffFlyoutMock = jest.fn(); describe('RuleActionsOverflow', () => { describe('rules details menu panel', () => { @@ -58,6 +60,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -66,6 +70,7 @@ describe('RuleActionsOverflow', () => { expect(getByTestId('rules-details-menu-panel')).toHaveTextContent('Export rule'); expect(getByTestId('rules-details-menu-panel')).toHaveTextContent('Delete rule'); expect(getByTestId('rules-details-menu-panel')).toHaveTextContent('Manual run'); + expect(getByTestId('rules-details-menu-panel')).not.toHaveTextContent('Revert rule'); // Don't show revert rule action when rule is custom }); test('menu is empty when no rule is passed to the component', () => { @@ -77,6 +82,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -95,6 +102,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions={false} canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -114,6 +123,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -137,6 +148,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -155,6 +168,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -176,6 +191,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -198,6 +215,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -222,6 +241,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -245,6 +266,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -264,6 +287,8 @@ describe('RuleActionsOverflow', () => { userHasPermissions canDuplicateRuleWithActions={true} confirmDeletion={() => Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} />, { wrapper: TestProviders } ); @@ -280,4 +305,52 @@ describe('RuleActionsOverflow', () => { }); }); }); + + describe('rule revert to base version flyout', () => { + const customizedMockRule = { + ...mockRule('id'), + rule_source: { type: 'external', is_customized: true } as ExternalRuleSource, + }; + test('it shows the revert action when rule is prebuilt and customized', () => { + const { getByTestId } = render( + Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={false} + />, + { wrapper: TestProviders } + ); + fireEvent.click(getByTestId('rules-details-popover-button-icon')); + const revertRuleButton = within(getByTestId('rules-details-menu-panel')).getByText( + 'Revert to Elastic version' + ); + + fireEvent.click(revertRuleButton); + // Popover is not shown + expect(getByTestId('rules-details-popover')).not.toHaveTextContent(/.+/); + }); + + test('it disabled the revert action when isRevertBaseVersionDisabled is true', async () => { + const { getByTestId } = render( + Promise.resolve(true)} + openRuleDiffFlyout={openRuleDiffFlyoutMock} + isRevertBaseVersionDisabled={true} + />, + { wrapper: TestProviders } + ); + fireEvent.click(getByTestId('rules-details-popover-button-icon')); + expect(getByTestId('rules-details-revert-rule')).toBeDisabled(); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/rule_actions_overflow/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/rule_actions_overflow/index.tsx index 6b64a2341dd44..2a87a592fd9f0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/rule_actions_overflow/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/rule_actions_overflow/index.tsx @@ -14,6 +14,8 @@ import { } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import type { OpenRuleDiffFlyoutParams } from '../../../../rule_management/hooks/use_prebuilt_rules_view_base_diff'; +import { isCustomizedPrebuiltRule } from '../../../../../../common/api/detection_engine'; import { useScheduleRuleRun } from '../../../../rule_gaps/logic/use_schedule_rule_run'; import type { TimeRange } from '../../../../rule_gaps/types'; import { APP_UI_ID, SecurityPageName } from '../../../../../../common'; @@ -55,6 +57,8 @@ interface RuleActionsOverflowComponentProps { showBulkDuplicateExceptionsConfirmation: () => Promise; showManualRuleRunConfirmation: () => Promise; confirmDeletion: () => Promise; + openRuleDiffFlyout: (params: OpenRuleDiffFlyoutParams) => void; + isRevertBaseVersionDisabled: boolean; } /** @@ -67,6 +71,8 @@ const RuleActionsOverflowComponent = ({ showBulkDuplicateExceptionsConfirmation, showManualRuleRunConfirmation, confirmDeletion, + openRuleDiffFlyout, + isRevertBaseVersionDisabled, }: RuleActionsOverflowComponentProps) => { const [isPopoverOpen, , closePopover, togglePopover] = useBoolState(); const { @@ -177,6 +183,33 @@ const RuleActionsOverflowComponent = ({ > {i18nActions.MANUAL_RULE_RUN} , + ...(isCustomizedPrebuiltRule(rule) // Don't display action if rule isn't a customized prebuilt rule + ? [ + { + closePopover(); + openRuleDiffFlyout({ isReverting: true }); + }} + > + {i18nActions.REVERT_RULE} + , + ] + : []), => + KibanaServices.get().http.fetch(GET_PREBUILT_RULES_BASE_VERSION_URL, { + method: 'GET', + version: '1', + signal, + query: request, + }); + +export const revertPrebuiltRule = async ( + body: RevertPrebuiltRulesRequest +): Promise => + KibanaServices.get().http.fetch(REVERT_PREBUILT_RULES_URL, { + method: 'POST', + version: '1', + body: JSON.stringify(body), + }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_bootstrap_prebuilt_rules.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_bootstrap_prebuilt_rules.ts index 82c9159f62c71..36fd1ba13b837 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_bootstrap_prebuilt_rules.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_bootstrap_prebuilt_rules.ts @@ -14,6 +14,7 @@ import { useInvalidateFetchPrebuiltRulesInstallReviewQuery } from './use_fetch_p import { useInvalidateFetchPrebuiltRulesStatusQuery } from './use_fetch_prebuilt_rules_status_query'; import { useInvalidateFetchPrebuiltRulesUpgradeReviewQuery } from './use_fetch_prebuilt_rules_upgrade_review_query'; import { useInvalidateFindRulesQuery } from '../use_find_rules_query'; +import { useInvalidateFetchPrebuiltRuleBaseVersionQuery } from './use_fetch_prebuilt_rule_base_version_query'; export const BOOTSTRAP_PREBUILT_RULES_KEY = ['POST', BOOTSTRAP_PREBUILT_RULES_URL]; @@ -24,6 +25,7 @@ export const useBootstrapPrebuiltRulesMutation = ( const invalidatePrebuiltRulesInstallReview = useInvalidateFetchPrebuiltRulesInstallReviewQuery(); const invalidatePrebuiltRulesUpdateReview = useInvalidateFetchPrebuiltRulesUpgradeReviewQuery(); const invalidateFindRulesQuery = useInvalidateFindRulesQuery(); + const invalidateFetchPrebuiltRuleBaseVerison = useInvalidateFetchPrebuiltRuleBaseVersionQuery(); return useMutation(() => bootstrapPrebuiltRules(), { ...options, @@ -42,6 +44,7 @@ export const useBootstrapPrebuiltRulesMutation = ( invalidatePrePackagedRulesStatus(); invalidatePrebuiltRulesInstallReview(); invalidatePrebuiltRulesUpdateReview(); + invalidateFetchPrebuiltRuleBaseVerison(); } const hasRuleUpdates = diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_fetch_prebuilt_rule_base_version_query.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_fetch_prebuilt_rule_base_version_query.ts new file mode 100644 index 0000000000000..ab30f25af9d52 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_fetch_prebuilt_rule_base_version_query.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useCallback } from 'react'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { get } from 'lodash'; +import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; +import type { GetPrebuiltRuleBaseVersionResponseBody } from '../../../../../../common/api/detection_engine'; +import { getPrebuiltRuleBaseVersion } from '../../api'; +import { GET_PREBUILT_RULES_BASE_VERSION_URL } from '../../../../../../common/api/detection_engine/prebuilt_rules/urls'; +import { DEFAULT_QUERY_OPTIONS } from '../constants'; +import { retryOnRateLimitedError } from './retry_on_rate_limited_error'; +import { cappedExponentialBackoff } from './capped_exponential_backoff'; +import * as i18n from '../translations'; + +export const GET_RULE_BASE_VERSION_QUERY_KEY = ['POST', GET_PREBUILT_RULES_BASE_VERSION_URL]; + +export interface UseFetchPrebuiltRuleBaseVersionQueryProps { + id: string | undefined; + enabled: boolean; +} + +export const useFetchPrebuiltRuleBaseVersionQuery = ( + { id, enabled }: UseFetchPrebuiltRuleBaseVersionQueryProps, + options?: UseQueryOptions +) => { + const { addError } = useAppToasts(); + return useQuery( + [...GET_RULE_BASE_VERSION_QUERY_KEY, id], + async ({ signal }) => { + if (id) { + return getPrebuiltRuleBaseVersion({ signal, request: { id } }); + } + return null; + }, + { + ...DEFAULT_QUERY_OPTIONS, + ...options, + enabled, + onError: (error) => { + const statusCode = get(error, 'response.status'); + // If we cannot find the rule base version, we suppress the error and handle it internally + if (statusCode === 404) { + return; + } + addError(error, { + title: i18n.FETCH_PREBUILT_RULE_BASE_VERSION_ERROR, + }); + }, + retry: retryOnRateLimitedError, + retryDelay: cappedExponentialBackoff, + } + ); +}; + +export const useInvalidateFetchPrebuiltRuleBaseVersionQuery = () => { + const queryClient = useQueryClient(); + + return useCallback(() => { + queryClient.invalidateQueries(GET_RULE_BASE_VERSION_QUERY_KEY, { + refetchType: 'active', + }); + }, [queryClient]); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_rules_upgrade_mutation.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_rules_upgrade_mutation.ts index ecb604d63d4e0..b2b2c5f4fb7ee 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_rules_upgrade_mutation.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_rules_upgrade_mutation.ts @@ -20,6 +20,7 @@ import { useInvalidateFetchPrebuiltRulesStatusQuery } from './use_fetch_prebuilt import { useInvalidateFetchPrebuiltRulesUpgradeReviewQuery } from './use_fetch_prebuilt_rules_upgrade_review_query'; import { retryOnRateLimitedError } from './retry_on_rate_limited_error'; import { cappedExponentialBackoff } from './capped_exponential_backoff'; +import { useInvalidateFetchPrebuiltRuleBaseVersionQuery } from './use_fetch_prebuilt_rule_base_version_query'; export const PERFORM_RULES_UPGRADE_KEY = ['POST', PERFORM_RULE_UPGRADE_URL]; @@ -38,6 +39,7 @@ export const usePerformRulesUpgradeMutation = ( useInvalidateFetchPrebuiltRulesUpgradeReviewQuery(); const invalidateRuleStatus = useInvalidateFetchPrebuiltRulesStatusQuery(); const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); + const invalidateFetchPrebuiltRuleBaseVerison = useInvalidateFetchPrebuiltRuleBaseVersionQuery(); return useMutation( (args: PerformRuleUpgradeRequestBody) => { @@ -55,6 +57,7 @@ export const usePerformRulesUpgradeMutation = ( invalidateFetchPrebuiltRulesUpgradeReview(); invalidateRuleStatus(); invalidateFetchCoverageOverviewQuery(); + invalidateFetchPrebuiltRuleBaseVerison(); if (options?.onSettled) { options.onSettled(...args); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_revert_prebuilt_rule_mutation.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_revert_prebuilt_rule_mutation.ts new file mode 100644 index 0000000000000..c7cc224c2b41c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_revert_prebuilt_rule_mutation.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; +import type { HTTPError } from '../../../../../../common/detection_engine/types'; +import type { + RevertPrebuiltRulesResponseBody, + RevertPrebuiltRulesRequest, +} from '../../../../../../common/api/detection_engine'; +import { REVERT_PREBUILT_RULES_URL } from '../../../../../../common/api/detection_engine/prebuilt_rules/urls'; +import { revertPrebuiltRule } from '../../api'; +import { useInvalidateFetchCoverageOverviewQuery } from '../use_fetch_coverage_overview_query'; +import { useInvalidateFetchRuleManagementFiltersQuery } from '../use_fetch_rule_management_filters_query'; +import { useInvalidateFindRulesQuery } from '../use_find_rules_query'; +import { useInvalidateFetchPrebuiltRulesStatusQuery } from './use_fetch_prebuilt_rules_status_query'; +import { useInvalidateFetchPrebuiltRulesUpgradeReviewQuery } from './use_fetch_prebuilt_rules_upgrade_review_query'; +import { retryOnRateLimitedError } from './retry_on_rate_limited_error'; +import { cappedExponentialBackoff } from './capped_exponential_backoff'; +import { useInvalidateFetchPrebuiltRuleBaseVersionQuery } from './use_fetch_prebuilt_rule_base_version_query'; + +export const REVERT_PREBUILT_RULE_KEY = ['POST', REVERT_PREBUILT_RULES_URL]; + +export const useRevertPrebuiltRuleMutation = ( + options?: UseMutationOptions< + RevertPrebuiltRulesResponseBody, + HTTPError, + RevertPrebuiltRulesRequest + > +) => { + const invalidateFindRulesQuery = useInvalidateFindRulesQuery(); + const invalidatePrePackagedRulesStatus = useInvalidateFetchPrebuiltRulesStatusQuery(); + const invalidateFetchRuleManagementFilters = useInvalidateFetchRuleManagementFiltersQuery(); + const invalidateFetchPrebuiltRulesUpgradeReview = + useInvalidateFetchPrebuiltRulesUpgradeReviewQuery(); + const invalidateRuleStatus = useInvalidateFetchPrebuiltRulesStatusQuery(); + const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); + const invalidateFetchPrebuiltRuleBaseVerison = useInvalidateFetchPrebuiltRuleBaseVersionQuery(); + + return useMutation( + (args: RevertPrebuiltRulesRequest) => { + return revertPrebuiltRule(args); + }, + { + ...options, + mutationKey: REVERT_PREBUILT_RULE_KEY, + onSettled: (...args) => { + invalidatePrePackagedRulesStatus(); + invalidateFindRulesQuery(); + invalidateFetchRuleManagementFilters(); + + invalidateFetchPrebuiltRulesUpgradeReview(); + invalidateRuleStatus(); + invalidateFetchCoverageOverviewQuery(); + invalidateFetchPrebuiltRuleBaseVerison(); + + if (options?.onSettled) { + options.onSettled(...args); + } + }, + retry: retryOnRateLimitedError, + retryDelay: cappedExponentialBackoff, + } + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/translations.ts index 96afe91611c69..7a47a93f84eb2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/translations.ts @@ -27,3 +27,10 @@ export const ACTIONS_FETCH_ERROR_DESCRIPTION = i18n.translate( defaultMessage: 'Viewing actions is not available', } ); + +export const FETCH_PREBUILT_RULE_BASE_VERSION_ERROR = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDetails.fetchPrebuiltRuleBaseVersion', + { + defaultMessage: 'Failed to fetch Elastic rule version', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts index 376b26684d4a5..0daf08d931078 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts @@ -22,6 +22,7 @@ import { useInvalidateFetchPrebuiltRulesStatusQuery } from './prebuilt_rules/use import { useInvalidateFetchPrebuiltRulesUpgradeReviewQuery } from './prebuilt_rules/use_fetch_prebuilt_rules_upgrade_review_query'; import { useInvalidateFetchPrebuiltRulesInstallReviewQuery } from './prebuilt_rules/use_fetch_prebuilt_rules_install_review_query'; import { useInvalidateFetchCoverageOverviewQuery } from './use_fetch_coverage_overview_query'; +import { useInvalidateFetchPrebuiltRuleBaseVersionQuery } from './prebuilt_rules/use_fetch_prebuilt_rule_base_version_query'; export const BULK_ACTION_MUTATION_KEY = ['POST', DETECTION_ENGINE_RULES_BULK_ACTION]; @@ -41,6 +42,7 @@ export const useBulkActionMutation = ( const invalidateFetchPrebuiltRulesUpgradeReviewQuery = useInvalidateFetchPrebuiltRulesUpgradeReviewQuery(); const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); + const invalidateFetchPrebuiltRuleBaseVerison = useInvalidateFetchPrebuiltRuleBaseVersionQuery(); const updateRulesCache = useUpdateRulesCache(); return useMutation< @@ -84,6 +86,7 @@ export const useBulkActionMutation = ( invalidateFetchPrebuiltRulesInstallReviewQuery(); invalidateFetchPrebuiltRulesUpgradeReviewQuery(); invalidateFetchCoverageOverviewQuery(); + invalidateFetchPrebuiltRuleBaseVerison(); break; case BulkActionTypeEnum.duplicate: invalidateFindRulesQuery(); @@ -102,6 +105,7 @@ export const useBulkActionMutation = ( invalidateFetchRuleManagementFilters(); invalidateFetchCoverageOverviewQuery(); invalidateFetchPrebuiltRulesUpgradeReviewQuery(); + invalidateFetchPrebuiltRuleBaseVerison(); break; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts index 812b97b669378..938980d2a23a0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts @@ -18,6 +18,7 @@ import { useUpdateRuleByIdCache } from './use_fetch_rule_by_id_query'; import { useInvalidateFetchRuleManagementFiltersQuery } from './use_fetch_rule_management_filters_query'; import { useInvalidateFetchCoverageOverviewQuery } from './use_fetch_coverage_overview_query'; import { useInvalidateFetchPrebuiltRulesUpgradeReviewQuery } from './prebuilt_rules/use_fetch_prebuilt_rules_upgrade_review_query'; +import { useInvalidateFetchPrebuiltRuleBaseVersionQuery } from './prebuilt_rules/use_fetch_prebuilt_rule_base_version_query'; export const UPDATE_RULE_MUTATION_KEY = ['PUT', DETECTION_ENGINE_RULES_URL]; @@ -28,6 +29,7 @@ export const useUpdateRuleMutation = ( const invalidateFetchRuleManagementFilters = useInvalidateFetchRuleManagementFiltersQuery(); const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); const invalidatePrebuiltRulesUpdateReview = useInvalidateFetchPrebuiltRulesUpgradeReviewQuery(); + const invalidateFetchPrebuiltRuleBaseVerison = useInvalidateFetchPrebuiltRuleBaseVersionQuery(); const updateRuleCache = useUpdateRuleByIdCache(); return useMutation( @@ -40,6 +42,7 @@ export const useUpdateRuleMutation = ( invalidateFetchRuleManagementFilters(); invalidateFetchCoverageOverviewQuery(); invalidatePrebuiltRulesUpdateReview(); + invalidateFetchPrebuiltRuleBaseVerison(); const [response] = args; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/base_version_diff/base_version_flyout.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/base_version_diff/base_version_flyout.tsx new file mode 100644 index 0000000000000..466e62ec4734f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/base_version_diff/base_version_flyout.tsx @@ -0,0 +1,185 @@ +/* + * 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, useMemo, useRef, useEffect } from 'react'; +import { EuiButton, EuiCallOut, EuiSpacer, EuiToolTip } from '@elastic/eui'; +import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; +import type { PartialRuleDiff, RuleResponse } from '../../../../../../common/api/detection_engine'; +import { PerFieldRuleDiffTab } from '../per_field_rule_diff_tab'; +import { RuleDetailsFlyout, TabContentPadding } from '../rule_details_flyout'; +import * as ruleDetailsI18n from '../translations'; +import * as i18n from './translations'; +import { RuleDiffTab } from '../rule_diff_tab'; +import { BaseVersionDiffFlyoutSubheader } from './base_version_flyout_subheader'; +import { + getRevertRuleErrorStatusCode, + useRevertPrebuiltRule, +} from '../../../logic/prebuilt_rules/use_revert_prebuilt_rule'; + +export const PREBUILT_RULE_BASE_VERSION_FLYOUT_ANCHOR = 'baseVersionPrebuiltRulePreview'; + +interface PrebuiltRuleConcurrencyControl { + revision: number; +} + +interface PrebuiltRulesBaseVersionFlyoutComponentProps { + currentRule: RuleResponse; + baseRule: RuleResponse; + diff: PartialRuleDiff; + closeFlyout: () => void; + isReverting: boolean; + onRevert?: () => void; +} + +export const PrebuiltRulesBaseVersionFlyout = memo(function PrebuiltRulesBaseVersionFlyout({ + currentRule, + baseRule, + diff, + closeFlyout, + isReverting, + onRevert, +}: PrebuiltRulesBaseVersionFlyoutComponentProps): JSX.Element { + useConcurrencyControl(currentRule); + + const { mutateAsync: revertPrebuiltRule, isLoading } = useRevertPrebuiltRule(); + const subHeader = useMemo( + () => , + [currentRule, diff] + ); + + const revertRule = useCallback(async () => { + try { + await revertPrebuiltRule({ + id: currentRule.id, + version: currentRule.version, + revision: currentRule.revision, + }); + closeFlyout(); + } catch (error) { + const statusCode = getRevertRuleErrorStatusCode(error); + // Don't close flyout on concurrency errors + if (statusCode !== 409) { + closeFlyout(); + } + } finally { + if (onRevert) { + onRevert(); + } + } + }, [ + closeFlyout, + currentRule.id, + currentRule.revision, + currentRule.version, + onRevert, + revertPrebuiltRule, + ]); + + const ruleActions = useMemo(() => { + return isReverting ? ( + + {i18n.REVERT_BUTTON_LABEL} + + ) : null; + }, [isLoading, isReverting, revertRule]); + + const extraTabs = useMemo(() => { + const headerCallout = isReverting ? ( + <> + +

{i18n.REVERTING_RULE_CALLOUT_MESSAGE}

+
+ + + ) : null; + + const updatesTab = { + id: 'updates', + name: ( + + <>{i18n.BASE_VERSION_FLYOUT_UPDATES_TAB_TITLE} + + ), + content: ( + + + + ), + }; + + const jsonViewTab = { + id: 'jsonViewUpdates', + name: ( + + <>{ruleDetailsI18n.JSON_VIEW_UPDATES_TAB_LABEL} + + ), + content: ( +
+ +
+ ), + }; + + return [updatesTab, jsonViewTab]; + }, [baseRule, currentRule, diff, isReverting]); + + return ( + + ); +}); + +/** + * We should detect situations when the rule is edited or upgraded concurrently. + * + * `revision` is the indication for any changes. + * If `rule.revision` has suddenly increased then it means we hit a concurrency issue. + * + * `rule.revision` gets bumped upon rule upgrade as well. + */ +function useConcurrencyControl(rule: RuleResponse): void { + const concurrencyControl = useRef(); + const { addWarning } = useAppToasts(); + + useEffect(() => { + const concurrency = concurrencyControl.current; + + if (concurrency != null && concurrency.revision !== rule.revision) { + addWarning({ + title: i18n.NEW_REVISION_DETECTED_WARNING, + text: i18n.NEW_REVISION_DETECTED_WARNING_MESSAGE, + }); + } + + concurrencyControl.current = { + revision: rule.revision, + }; + }, [addWarning, rule.revision]); +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/base_version_diff/base_version_flyout_subheader.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/base_version_diff/base_version_flyout_subheader.tsx new file mode 100644 index 0000000000000..a38fd0517032f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/base_version_diff/base_version_flyout_subheader.tsx @@ -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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import { startCase, camelCase } from 'lodash'; +import { FormattedDate } from '../../../../../common/components/formatted_date'; +import type { PartialRuleDiff, RuleResponse } from '../../../../../../common/api/detection_engine'; +import * as i18n from './translations'; +import { fieldToDisplayNameMap } from '../diff_components/translations'; + +interface BaseVersionDiffFlyoutSubheaderProps { + currentRule: RuleResponse; + diff: PartialRuleDiff; +} + +export const BaseVersionDiffFlyoutSubheader = ({ + currentRule, + diff, +}: BaseVersionDiffFlyoutSubheaderProps) => { + const lastUpdate = ( + + + {i18n.LAST_UPDATE} + {':'} + {' '} + {i18n.UPDATED_BY_AND_WHEN( + currentRule.updated_by, + + )} + + ); + + const fieldsDiff = Object.keys(diff.fields); + const fieldUpdates = fieldsDiff.length > 0 && ( + + + {i18n.FIELD_UPDATES} + {':'} + {' '} + {fieldsDiff + .map((fieldName) => fieldToDisplayNameMap[fieldName] ?? startCase(camelCase(fieldName))) + .join(', ')} + + ); + + return ( + <> + + {lastUpdate} + + + + {fieldUpdates} + + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/base_version_diff/translations.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/base_version_diff/translations.tsx new file mode 100644 index 0000000000000..8501333040612 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/base_version_diff/translations.tsx @@ -0,0 +1,104 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { ReactNode } from 'react'; + +export const BASE_VERSION_FLYOUT_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.header.title', + { + defaultMessage: 'Rule modifications', + } +); + +export const LAST_UPDATE = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.header.lastUpdate', + { + defaultMessage: 'Last updated', + } +); + +export const UPDATED_BY_AND_WHEN = (updatedBy: ReactNode, updatedAt: ReactNode) => ( + +); + +export const FIELD_UPDATES = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.header.fieldUpdates', + { + defaultMessage: 'Field updates', + } +); + +export const REVERT_BUTTON_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.revertButtonLabel', + { + defaultMessage: 'Revert', + } +); + +export const REVERTING_RULE_CALLOUT_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.revertCalloutTitle', + { + defaultMessage: 'Are you sure you want to revert these changes?', + } +); + +export const REVERTING_RULE_CALLOUT_MESSAGE = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.revertCalloutMessage', + { + defaultMessage: "Reverted changes can't be recovered.", + } +); + +export const BASE_VERSION_FLYOUT_UPDATES_TAB_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.updatesTabTitle', + { + defaultMessage: 'Elastic rule diff overview', + } +); + +export const BASE_VERSION_FLYOUT_UPDATES_TAB_TOOLTIP = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.updatesTabTooltip', + { + defaultMessage: 'See all changes made to the rule', + } +); + +export const BASE_VERSION_FLYOUT_JSON_TAB_TOOLTIP = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.jsonTabTooltip', + { + defaultMessage: 'See all changes made to the rule in JSON format', + } +); + +export const BASE_VERSION_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.baseVersionLabel', + { + defaultMessage: 'Original Elastic rule', + } +); + +export const NEW_REVISION_DETECTED_WARNING = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.ruleNewRevisionDetectedWarning', + { + defaultMessage: 'Installed rule changed', + } +); + +export const NEW_REVISION_DETECTED_WARNING_MESSAGE = i18n.translate( + 'xpack.securitySolution.detectionEngine.baseVersionFlyout.ruleNewRevisionDetectedWarningMessage', + { + defaultMessage: + 'The installed rule was changed, the rule modifications diff flyout has been updated.', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/header_bar.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/header_bar.tsx index f78c300d56598..c56dbdecd0712 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/header_bar.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/header_bar.tsx @@ -17,7 +17,11 @@ import React from 'react'; import { css } from '@emotion/css'; import * as i18n from '../json_diff/translations'; -export const RuleDiffHeaderBar = () => { +interface RuleDiffHeaderBarProps { + diffRightSideTitle?: string; +} + +export const RuleDiffHeaderBar = ({ diffRightSideTitle }: RuleDiffHeaderBarProps) => { const { euiTheme } = useEuiTheme(); return (
{ size="m" /> -
{i18n.ELASTIC_UPDATE_VERSION}
+
{diffRightSideTitle ?? i18n.ELASTIC_UPDATE_VERSION}
diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts index 08e4c9535ae91..3850ee98a84f6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts @@ -48,8 +48,8 @@ export const getSectionedFieldDiffs = (fields: FieldsGroupDiff[]) => { }; /** - * Filters out any fields that have a `diff_outcome` of `CustomizedValueNoUpdate` - * or `CustomizedValueSameUpdate` as they are not supported for display in the + * Filters out any fields that have a `diff_outcome` of + * `CustomizedValueSameUpdate` as it is not supported for display in the * current per-field rule diff flyout */ export const filterUnsupportedDiffOutcomes = ( @@ -59,7 +59,6 @@ export const filterUnsupportedDiffOutcomes = ( Object.entries(fields).filter(([key, value]) => { const diff = value as ThreeWayDiff; return ( - diff.diff_outcome !== ThreeWayDiffOutcome.CustomizedValueNoUpdate && diff.diff_outcome !== ThreeWayDiffOutcome.CustomizedValueSameUpdate && diff.diff_outcome !== ThreeWayDiffOutcome.MissingBaseNoUpdate ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.tsx index 32dc508f41bb4..32128cad6b846 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.tsx @@ -17,9 +17,14 @@ import * as i18n from './translations'; interface PerFieldRuleDiffTabProps { ruleDiff: PartialRuleDiff; header?: React.ReactNode; + diffRightSideTitle?: string; } -export const PerFieldRuleDiffTab = ({ ruleDiff, header }: PerFieldRuleDiffTabProps) => { +export const PerFieldRuleDiffTab = ({ + ruleDiff, + header, + diffRightSideTitle, +}: PerFieldRuleDiffTabProps) => { const fieldsToRender = useMemo(() => { const fields: FieldsGroupDiff[] = []; // Filter out diff outcomes that we don't support displaying in the per-field diff flyout @@ -44,7 +49,7 @@ export const PerFieldRuleDiffTab = ({ ruleDiff, header }: PerFieldRuleDiffTabPro return ( <> - + {header} {aboutFields.length !== 0 && ( void; + title?: string; } export function RuleDetailsFlyout({ @@ -171,6 +172,7 @@ function RuleDetailsFlyoutContent({ extraTabs = DEFAULT_EXTRA_TABS, titleId, closeFlyout, + title, }: RuleDetailsFlyoutContentProps): JSX.Element { const { expandedOverviewSections, toggleOverviewSection } = useOverviewTabSections(); @@ -235,7 +237,7 @@ function RuleDetailsFlyoutContent({ <> -

{rule.name}

+

{title ?? rule.name}

{subHeader && ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx index a24e1bb8c042d..b54dffd4070fd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx @@ -121,9 +121,10 @@ const normalizeRule = (originalRule: RuleResponse): RuleResponse => { interface RuleDiffTabProps { oldRule: RuleResponse; newRule: RuleResponse; + newRuleLabel?: string; } -export const RuleDiffTab = ({ oldRule, newRule }: RuleDiffTabProps) => { +export const RuleDiffTab = ({ oldRule, newRule, newRuleLabel }: RuleDiffTabProps) => { const [oldSource, newSource] = useMemo(() => { const visibleNewRuleProperties = omit(normalizeRule(newRule), ...HIDDEN_PROPERTIES); const visibleOldRuleProperties = omit( @@ -162,7 +163,7 @@ export const RuleDiffTab = ({ oldRule, newRule }: RuleDiffTabProps) => { size="m" /> -
{i18n.ELASTIC_UPDATE_VERSION}
+
{newRuleLabel ?? i18n.ELASTIC_UPDATE_VERSION}
diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/hooks/use_prebuilt_rules_view_base_diff.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/hooks/use_prebuilt_rules_view_base_diff.tsx new file mode 100644 index 0000000000000..121740a07febe --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/hooks/use_prebuilt_rules_view_base_diff.tsx @@ -0,0 +1,67 @@ +/* + * 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, { useState, useCallback, useMemo } from 'react'; + +import { isCustomizedPrebuiltRule } from '../../../../common/api/detection_engine/model/rule_schema/utils'; +import type { RuleResponse } from '../../../../common/api/detection_engine'; +import { PrebuiltRulesBaseVersionFlyout } from '../components/rule_details/base_version_diff/base_version_flyout'; +import { useFetchPrebuiltRuleBaseVersionQuery } from '../api/hooks/prebuilt_rules/use_fetch_prebuilt_rule_base_version_query'; + +export const PREBUILT_RULE_BASE_VERSION_FLYOUT_ANCHOR = 'baseVersionPrebuiltRulePreview'; + +export interface OpenRuleDiffFlyoutParams { + isReverting?: boolean; +} + +interface UsePrebuiltRulesViewBaseDiffProps { + rule: RuleResponse | null; + onRevert?: () => void; +} + +export const usePrebuiltRulesViewBaseDiff = ({ + rule, + onRevert, +}: UsePrebuiltRulesViewBaseDiffProps) => { + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + const [isReverting, setIsReverting] = useState(false); + const enabled = useMemo(() => rule != null && isCustomizedPrebuiltRule(rule), [rule]); + const { data, isLoading, error } = useFetchPrebuiltRuleBaseVersionQuery({ + id: rule?.id, + enabled, + }); + + // Handle when we receive an error when the base_version doesn't exist + const doesBaseVersionExist: boolean = useMemo(() => !error && data != null, [data, error]); + + const openFlyout = useCallback( + ({ isReverting: renderRevertFeatures = false }: OpenRuleDiffFlyoutParams) => { + setIsReverting(renderRevertFeatures); + setIsFlyoutOpen(true); + }, + [] + ); + + const closeFlyout = useCallback(() => setIsFlyoutOpen(false), []); + + return { + baseVersionFlyout: + isFlyoutOpen && !isLoading && data != null && doesBaseVersionExist ? ( + + ) : null, + openFlyout, + doesBaseVersionExist, + isLoading, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/translations.ts index 87580af582a16..7c554224c9517 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/translations.ts @@ -58,3 +58,41 @@ export const UPGRADE_RULE_FAILED = (failed: number) => defaultMessage: '{failed, plural, one {# rule} other {# rules}} failed to update.', values: { failed }, }); + +export const RULE_REVERT_FAILED = i18n.translate( + 'xpack.securitySolution.detectionEngine.prebuiltRules.toast.ruleRevertFailed', + { + defaultMessage: 'Rule reversion failed', + } +); + +export const RULE_REVERT_FAILED_CONCURRENCY_MESSAGE = i18n.translate( + 'xpack.securitySolution.detectionEngine.prebuiltRules.toast.ruleRevertFailedConcurrencyMessage', + { + defaultMessage: + 'Something in the rule object has changed before reversion was completed. Please review the updated diff and try again.', + } +); + +export const REVERT_RULE_SUCCESS = (succeeded: number) => + i18n.translate('xpack.securitySolution.detectionEngine.prebuiltRules.toast.revertRuleSuccess', { + defaultMessage: '{succeeded, plural, one {# rule} other {# rules}} reverted successfully.', + values: { succeeded }, + }); + +export const REVERT_RULE_SKIPPED = (skipped: number) => + i18n.translate('xpack.securitySolution.detectionEngine.prebuiltRules.toast.revertRuleSkipped', { + defaultMessage: + '{skipped, plural, one {# rule was} other {# rules were}} skipped during reversion.', + values: { skipped }, + }); + +export const ALL_REVERT_RULES_SKIPPED = (skipped: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.prebuiltRules.toast.allRevertRuleSkipped', + { + defaultMessage: + '{skipped, plural, one {# rule was} other {All # rules were}} skipped during reversion.', + values: { skipped }, + } + ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/use_revert_prebuilt_rule.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/use_revert_prebuilt_rule.ts new file mode 100644 index 0000000000000..07496f1c886f9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/prebuilt_rules/use_revert_prebuilt_rule.ts @@ -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 type { RevertPrebuiltRulesResponseBody } from '../../../../../common/api/detection_engine'; +import type { HTTPError } from '../../../../../common/detection_engine/types'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useRevertPrebuiltRuleMutation } from '../../api/hooks/prebuilt_rules/use_revert_prebuilt_rule_mutation'; + +import * as i18n from './translations'; + +export const useRevertPrebuiltRule = () => { + const { addError, addSuccess, addWarning } = useAppToasts(); + + return useRevertPrebuiltRuleMutation({ + onError: (error) => { + addError(populateErrorStack(error), { + title: i18n.RULE_REVERT_FAILED, + toastMessage: getErrorToastMessage(error), + }); + }, + onSuccess: (result) => { + if (result.attributes.summary.total === result.attributes.summary.skipped) { + addWarning(i18n.ALL_REVERT_RULES_SKIPPED(result.attributes.summary.skipped)); + } + addSuccess(getSuccessToastMessage(result.attributes)); + }, + }); +}; + +const populateErrorStack = (error: HTTPError): HTTPError => { + error.stack = JSON.stringify(error.body, null, 2); + + return error; +}; + +const getErrorToastMessage = (error: HTTPError): string => { + const statusCode = getRevertRuleErrorStatusCode(error); + if (statusCode === 409) { + return i18n.RULE_REVERT_FAILED_CONCURRENCY_MESSAGE; + } + return (error.body as RevertPrebuiltRulesResponseBody)?.attributes.errors?.at(0)?.message ?? ''; +}; + +export const getRevertRuleErrorStatusCode = (error: HTTPError) => + (error.body as RevertPrebuiltRulesResponseBody)?.attributes.errors?.at(0)?.status_code; + +const getSuccessToastMessage = (result: { + summary: { + total: number; + succeeded: number; + skipped: number; + failed: number; + }; +}) => { + const toastMessage: string[] = []; + const { + summary: { succeeded, skipped }, + } = result; + if (succeeded > 0) { + toastMessage.push(i18n.REVERT_RULE_SUCCESS(succeeded)); + } + if (skipped > 0) { + toastMessage.push(i18n.REVERT_RULE_SKIPPED(skipped)); + } + return toastMessage.join(' '); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/__integration_tests__/rules_upgrade/test_utils/rule_upgrade_flyout.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/__integration_tests__/rules_upgrade/test_utils/rule_upgrade_flyout.tsx index 0d6900afc47e6..06ac7f40ed37f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/__integration_tests__/rules_upgrade/test_utils/rule_upgrade_flyout.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/__integration_tests__/rules_upgrade/test_utils/rule_upgrade_flyout.tsx @@ -111,6 +111,7 @@ export function mockRuleUpgradeReviewData({ target_rule: { rule_id: 'test-rule', type: ruleType, + threshold: { value: 30, field: ['fieldC'] }, // We use the `convertRuleToDiffable` util in the FieldUpgradeContext which needs relevant fields to convert }, diff: { num_fields_with_updates: 2, // tested field + version field diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_handler.ts new file mode 100644 index 0000000000000..9c04848c1b28c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_handler.ts @@ -0,0 +1,116 @@ +/* + * 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 { KibanaRequest, KibanaResponseFactory } from '@kbn/core/server'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { pickBy } from 'lodash'; +import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema'; +import type { FullRuleDiff } from '../../../../../../common/api/detection_engine/prebuilt_rules'; +import { + ThreeWayDiffOutcome, + type GetPrebuiltRuleBaseVersionRequest, + type GetPrebuiltRuleBaseVersionResponseBody, + type PartialRuleDiff, + type ThreeWayDiff, +} from '../../../../../../common/api/detection_engine/prebuilt_rules'; +import type { SecuritySolutionRequestHandlerContext } from '../../../../../types'; +import { buildSiemResponse } from '../../../routes/utils'; +import { calculateRuleDiff } from '../../logic/diff/calculate_rule_diff'; +import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt_rule_assets_client'; +import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response'; +import { getRuleById } from '../../../rule_management/logic/detection_rules_client/methods/get_rule_by_id'; +import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; + +export const getPrebuiltRuleBaseVersionHandler = async ( + context: SecuritySolutionRequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory +) => { + const siemResponse = buildSiemResponse(response); + const { id } = request.query; + + try { + const ctx = await context.resolve(['core', 'alerting']); + const soClient = ctx.core.savedObjects.client; + const rulesClient = await ctx.alerting.getRulesClient(); + const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient); + + const currentRule = await getRuleById({ rulesClient, id }); + + if (!currentRule) { + throw new Error(`Cannot find rule with id: ${id}`); + } + + const [baseRule] = await ruleAssetsClient.fetchAssetsByVersion([currentRule]); + + if (!baseRule) { + return siemResponse.error({ + body: 'Cannot find rule base_version', + statusCode: 404, + }); + } + + const { ruleDiff } = calculateRuleDiff({ + current: currentRule, + base: baseRule, + target: baseRule, // We're using the base version as the target version as we want to revert the rule + }); + + const { diff, baseVersion, currentVersion } = formatDiffResponse({ + ruleDiff, + baseRule, + currentRule, + }); + + const body: GetPrebuiltRuleBaseVersionResponseBody = { + diff, + current_version: currentVersion, + base_version: baseVersion, + }; + + return response.ok({ body }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } +}; + +const formatDiffResponse = ({ + ruleDiff, + baseRule, + currentRule, +}: { + ruleDiff: FullRuleDiff; + baseRule: PrebuiltRuleAsset; + currentRule: RuleResponse; +}): { diff: PartialRuleDiff; baseVersion: RuleResponse; currentVersion: RuleResponse } => { + const baseVersion: RuleResponse = { + ...convertPrebuiltRuleAssetToRuleResponse(baseRule), + id: currentRule.id, + // Set all fields to the original Elastic version values + created_at: currentRule.created_at, + created_by: currentRule.created_by, + updated_at: currentRule.created_at, + updated_by: currentRule.created_by, + revision: 0, + }; + + return { + baseVersion, + currentVersion: currentRule, + diff: { + ...ruleDiff, + fields: pickBy>( + ruleDiff.fields, + (fieldDiff) => fieldDiff.diff_outcome === ThreeWayDiffOutcome.CustomizedValueNoUpdate + ), + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_route.ts new file mode 100644 index 0000000000000..8e4812e8619d2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_route.ts @@ -0,0 +1,38 @@ +/* + * 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 { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import { + GET_PREBUILT_RULES_BASE_VERSION_URL, + GetPrebuiltRuleBaseVersionRequest, +} from '../../../../../../common/api/detection_engine/prebuilt_rules'; +import type { SecuritySolutionPluginRouter } from '../../../../../types'; +import { getPrebuiltRuleBaseVersionHandler } from './get_prebuilt_rule_base_version_handler'; + +export const getPrebuiltRuleBaseVersion = (router: SecuritySolutionPluginRouter) => { + router.versioned + .get({ + access: 'internal', + path: GET_PREBUILT_RULES_BASE_VERSION_URL, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + query: buildRouteValidationWithZod(GetPrebuiltRuleBaseVersionRequest), + }, + }, + }, + getPrebuiltRuleBaseVersionHandler + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/register_routes.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/register_routes.ts index 3fab93cf7a573..fa39d2aa5e4c6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/register_routes.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/register_routes.ts @@ -14,6 +14,8 @@ import { reviewRuleUpgradeRoute } from './review_rule_upgrade/review_rule_upgrad import { performRuleInstallationRoute } from './perform_rule_installation/perform_rule_installation_route'; import { performRuleUpgradeRoute } from './perform_rule_upgrade/perform_rule_upgrade_route'; import { bootstrapPrebuiltRulesRoute } from './bootstrap_prebuilt_rules/bootstrap_prebuilt_rules'; +import { getPrebuiltRuleBaseVersion } from './get_prebuilt_rule_base_version/get_prebuilt_rule_base_version_route'; +import { revertPrebuiltRule } from './revert_prebuilt_rule/revert_prebuilt_rule_route'; export const registerPrebuiltRulesRoutes = (router: SecuritySolutionPluginRouter) => { // Legacy endpoints that we're going to deprecate @@ -27,4 +29,6 @@ export const registerPrebuiltRulesRoutes = (router: SecuritySolutionPluginRouter reviewRuleInstallationRoute(router); reviewRuleUpgradeRoute(router); bootstrapPrebuiltRulesRoute(router); + getPrebuiltRuleBaseVersion(router); + revertPrebuiltRule(router); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/filter_out_non_revertable_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/filter_out_non_revertable_rules.ts new file mode 100644 index 0000000000000..7ed24e72f26a8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/filter_out_non_revertable_rules.ts @@ -0,0 +1,41 @@ +/* + * 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 { + isCustomizedPrebuiltRule, + isNonCustomizedPrebuiltRule, +} from '../../../../../../common/api/detection_engine/model/rule_schema/utils'; +import { + BulkRevertSkipReasonEnum, + type BulkActionReversionSkipResult, + type RuleResponse, +} from '../../../../../../common/api/detection_engine'; + +export const filterOutNonRevertableRules = (rules: RuleResponse[]) => { + const skipped: BulkActionReversionSkipResult[] = []; + const rulesToRevert = rules.filter((rule) => { + if (isCustomizedPrebuiltRule(rule)) { + return true; + } + + if (isNonCustomizedPrebuiltRule(rule)) { + skipped.push({ + id: rule.id, + skip_reason: BulkRevertSkipReasonEnum.RULE_NOT_CUSTOMIZED, + }); + return false; + } + + skipped.push({ + id: rule.id, + skip_reason: BulkRevertSkipReasonEnum.RULE_NOT_PREBUILT, + }); + return false; + }); + + return { rulesToRevert, skipped }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/get_concurrrency_errors.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/get_concurrrency_errors.ts new file mode 100644 index 0000000000000..b0ec266430bc5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/get_concurrrency_errors.ts @@ -0,0 +1,32 @@ +/* + * 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 { RuleResponse } from '../../../../../../common/api/detection_engine'; +import type { BulkActionError } from '../../../rule_management/api/rules/bulk_actions/bulk_actions_response'; +import { createBulkActionError } from '../../../rule_management/utils/utils'; + +export const getConcurrencyErrors = ( + revision: number, + version: number, + rule: RuleResponse +): BulkActionError | undefined => { + if (rule.version !== version) { + return createBulkActionError({ + id: rule.id, + message: `Version mismatch for rule with id: ${rule.id}. Expected ${version}, got ${rule.version}`, + statusCode: 409, + }); + } + + if (rule.revision !== revision) { + return createBulkActionError({ + id: rule.id, + message: `Revision mismatch for rule with id: ${rule.id}. Expected ${revision}, got ${rule.revision}`, + statusCode: 409, + }); + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/revert_prebuilt_rule_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/revert_prebuilt_rule_handler.ts new file mode 100644 index 0000000000000..446691755a8e8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/revert_prebuilt_rule_handler.ts @@ -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 type { IKibanaResponse, KibanaRequest, KibanaResponseFactory } from '@kbn/core/server'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { getErrorMessage, getErrorStatusCode } from '../../../../../utils/error_helpers'; +import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema'; +import type { BulkActionReversionSkipResult } from '../../../../../../common/api/detection_engine/prebuilt_rules'; +import { + type RevertPrebuiltRulesRequest, + type RevertPrebuiltRulesResponseBody, +} from '../../../../../../common/api/detection_engine/prebuilt_rules'; +import type { SecuritySolutionRequestHandlerContext } from '../../../../../types'; +import { buildSiemResponse } from '../../../routes/utils'; +import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt_rule_assets_client'; +import { + normalizeErrorResponse, + type BulkActionError, +} from '../../../rule_management/api/rules/bulk_actions/bulk_actions_response'; +import type { RuleTriad } from '../../model/rule_groups/get_rule_groups'; +import { zipRuleVersions } from '../../logic/rule_versions/zip_rule_versions'; +import { revertPrebuiltRules } from '../../logic/rule_objects/revert_prebuilt_rules'; +import { getConcurrencyErrors } from './get_concurrrency_errors'; +import { filterOutNonRevertableRules } from './filter_out_non_revertable_rules'; +import { getRuleById } from '../../../rule_management/logic/detection_rules_client/methods/get_rule_by_id'; +import { createBulkActionError } from '../../../rule_management/utils/utils'; + +export const revertPrebuiltRuleHandler = async ( + context: SecuritySolutionRequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory +) => { + const siemResponse = buildSiemResponse(response); + const { id, revision, version } = request.body; + + try { + const ctx = await context.resolve(['core', 'alerting', 'securitySolution']); + const soClient = ctx.core.savedObjects.client; + const rulesClient = await ctx.alerting.getRulesClient(); + const detectionRulesClient = ctx.securitySolution.getDetectionRulesClient(); + const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient); + + const concurrencySet = { [id]: { revision, version } }; + + const updated: RuleResponse[] = []; + const errors: BulkActionError[] = []; + + const ruleResponse = await getRuleById({ rulesClient, id }); + + if (!ruleResponse) { + errors.push( + createBulkActionError({ + id, + message: `Cannot find rule with id: ${id}`, + statusCode: 404, + }) + ); + + // Return early as there's no reason to continue if we can't find the rule + return buildRuleReversionResponse(response, { + updated, + skipped: [], + errors, + }); + } + + const { rulesToRevert, skipped } = filterOutNonRevertableRules([ruleResponse]); + + const prebuiltRuleAssets = await ruleAssetsClient.fetchAssetsByVersion(rulesToRevert); + const ruleVersionsMap = zipRuleVersions(rulesToRevert, [], prebuiltRuleAssets); // We use base versions as target param as we are reverting rules + const revertableRules: RuleTriad[] = []; + + rulesToRevert.forEach((rule) => { + const ruleVersions = ruleVersionsMap.get(rule.rule_id); + const currentVersion = ruleVersions?.current; + const baseVersion = ruleVersions?.target; + + if (!currentVersion) { + errors.push( + createBulkActionError({ + id: rule.id, + message: `Cannot find rule with id: ${rule.id}`, + statusCode: 404, + }) + ); + return; + } + + if (!baseVersion) { + errors.push( + createBulkActionError({ + id: rule.id, + message: `Cannot find base_version for rule id: ${rule.id}`, + statusCode: 404, + }) + ); + return; + } + + const concurrencyError = getConcurrencyErrors( + concurrencySet[rule.id].revision, + concurrencySet[rule.id].version, + rule + ); + if (concurrencyError) { + errors.push(concurrencyError); + return; + } + + revertableRules.push({ + current: currentVersion, + target: baseVersion, // Use base version as target to revert rule + }); + }); + + const { results: revertResults, errors: revertErrors } = await revertPrebuiltRules( + detectionRulesClient, + revertableRules + ); + + const formattedUpdateErrors = revertErrors.map(({ error, item }) => { + return { + message: getErrorMessage(error), + status: getErrorStatusCode(error), + rule: item.current, + }; + }); + + errors.push(...formattedUpdateErrors); + updated.push(...revertResults.map(({ result }) => result)); + + return buildRuleReversionResponse(response, { + updated, + skipped, + errors, + }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } +}; + +// Similar to `buildBulkResponse` in /bulk_actions_response.ts but the `RevertPrebuiltRulesResponseBody` type has a slightly different return body +// If we extend the revert route this can be folded into the existing buildBuleResponse function +const buildRuleReversionResponse = ( + response: KibanaResponseFactory, + { + updated, + skipped, + errors, + }: { + updated: RuleResponse[]; + skipped: BulkActionReversionSkipResult[]; + errors: BulkActionError[]; + } +): IKibanaResponse => { + const numSucceeded = updated.length; + const numSkipped = skipped.length; + const numFailed = errors.length; + + const summary = { + failed: numFailed, + succeeded: numSucceeded, + skipped: numSkipped, + total: numSucceeded + numFailed + numSkipped, + }; + + const results = { + updated, + skipped, + created: [], + deleted: [], + }; + + if (numFailed > 0) { + const message = + summary.succeeded > 0 ? 'Rule reversion partially failed' : 'Rule reversion failed'; + return response.custom({ + headers: { 'content-type': 'application/json' }, + body: { + message, + status_code: 500, + attributes: { + errors: normalizeErrorResponse(errors), + results, + summary, + }, + }, + statusCode: 500, + }); + } + + const responseBody: RevertPrebuiltRulesResponseBody = { + success: true, + rules_count: summary.total, + attributes: { results, summary }, + }; + + return response.ok({ body: responseBody }); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/revert_prebuilt_rule_route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/revert_prebuilt_rule_route.ts new file mode 100644 index 0000000000000..b57041074e7b1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/revert_prebuilt_rule/revert_prebuilt_rule_route.ts @@ -0,0 +1,44 @@ +/* + * 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 { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import { + REVERT_PREBUILT_RULES_URL, + RevertPrebuiltRulesRequest, +} from '../../../../../../common/api/detection_engine/prebuilt_rules'; +import type { SecuritySolutionPluginRouter } from '../../../../../types'; +import { PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS } from '../../constants'; +import { revertPrebuiltRuleHandler } from './revert_prebuilt_rule_handler'; + +export const revertPrebuiltRule = (router: SecuritySolutionPluginRouter) => { + router.versioned + .post({ + access: 'internal', + path: REVERT_PREBUILT_RULES_URL, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, + options: { + timeout: { + idleSocket: PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS, + }, + }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + body: buildRouteValidationWithZod(RevertPrebuiltRulesRequest), + }, + }, + }, + revertPrebuiltRuleHandler + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/revert_prebuilt_rules.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/revert_prebuilt_rules.ts new file mode 100644 index 0000000000000..34d15964420f8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_objects/revert_prebuilt_rules.ts @@ -0,0 +1,38 @@ +/* + * 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 { MAX_RULES_TO_UPDATE_IN_PARALLEL } from '../../../../../../common/constants'; +import { initPromisePool } from '../../../../../utils/promise_pool'; +import { withSecuritySpan } from '../../../../../utils/with_security_span'; +import type { IDetectionRulesClient } from '../../../rule_management/logic/detection_rules_client/detection_rules_client_interface'; +import type { RuleTriad } from '../../model/rule_groups/get_rule_groups'; + +/** + * Merges customization adjacent fields (actions, exception_list, etc.) and updates rule to provided rule asset. + * This implements a chunked approach to not saturate network connections and + * avoid being a "noisy neighbor". + * @param detectionRulesClient IDetectionRulesClient + * @param ruleVersions The rules versions to update + */ +export const revertPrebuiltRules = async ( + detectionRulesClient: IDetectionRulesClient, + ruleVersions: RuleTriad[] +) => + withSecuritySpan('revertPrebuiltRule', async () => { + const result = await initPromisePool({ + concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, + items: ruleVersions, + executor: async ({ current, target }) => { + return detectionRulesClient.revertPrebuiltRule({ + ruleAsset: target, + existingRule: current, + }); + }, + }); + + return result; + }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/__mocks__/detection_rules_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/__mocks__/detection_rules_client.ts index 5f1dc3814acb6..d6753da3b9acd 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/__mocks__/detection_rules_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/__mocks__/detection_rules_client.ts @@ -17,6 +17,7 @@ const createDetectionRulesClientMock = () => { patchRule: jest.fn(), deleteRule: jest.fn(), upgradePrebuiltRule: jest.fn(), + revertPrebuiltRule: jest.fn(), importRule: jest.fn(), importRules: jest.fn(), getRuleCustomizationStatus: jest.fn(), diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.revert_prebuilt_rule.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.revert_prebuilt_rule.test.ts new file mode 100644 index 0000000000000..49748d5cd18dd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.revert_prebuilt_rule.test.ts @@ -0,0 +1,180 @@ +/* + * 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 { rulesClientMock } from '@kbn/alerting-plugin/server/mocks'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; + +import { + getCreateEqlRuleSchemaMock, + getCreateRulesSchemaMock, + getRulesEqlSchemaMock, +} from '../../../../../../common/api/detection_engine/model/rule_schema/mocks'; +import type { PrebuiltRuleAsset } from '../../../prebuilt_rules'; +import { getRuleMock } from '../../../routes/__mocks__/request_responses'; +import { getEqlRuleParams } from '../../../rule_schema/mocks'; +import { buildMlAuthz } from '../../../../machine_learning/authz'; +import { throwAuthzError } from '../../../../machine_learning/validation'; +import { createDetectionRulesClient } from './detection_rules_client'; +import type { IDetectionRulesClient } from './detection_rules_client_interface'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { licenseMock } from '@kbn/licensing-plugin/common/licensing.mock'; +import { createProductFeaturesServiceMock } from '../../../../product_features_service/mocks'; + +jest.mock('../../../../machine_learning/authz'); +jest.mock('../../../../machine_learning/validation'); + +describe('DetectionRulesClient.revertPrebuiltRule', () => { + let rulesClient: ReturnType; + let detectionRulesClient: IDetectionRulesClient; + + const mlAuthz = (buildMlAuthz as jest.Mock)(); + + const ruleAsset: PrebuiltRuleAsset = { + ...getCreateEqlRuleSchemaMock(), + tags: ['test'], + type: 'eql', + version: 1, + rule_id: 'rule-id', + }; + const existingRule = getRulesEqlSchemaMock(); + existingRule.actions = [ + { + group: 'default', + id: 'test_id', + action_type_id: '.index', + params: {}, + }, + ]; + existingRule.exceptions_list = [ + { + id: 'exception_list', + list_id: 'some-id', + namespace_type: 'single', + type: 'detection', + }, + ]; + + beforeEach(() => { + rulesClient = rulesClientMock.create(); + + detectionRulesClient = createDetectionRulesClient({ + actionsClient: { + isSystemAction: jest.fn((id: string) => id === 'system-connector-.cases'), + } as unknown as jest.Mocked, + rulesClient, + mlAuthz, + savedObjectsClient: savedObjectsClientMock.create(), + license: licenseMock.createLicenseMock(), + productFeaturesService: createProductFeaturesServiceMock(), + }); + }); + + it('throws if mlAuth fails', async () => { + (throwAuthzError as jest.Mock).mockImplementationOnce(() => { + throw new Error('mocked MLAuth error'); + }); + + const mlRuleAsset: PrebuiltRuleAsset = { + ...getCreateRulesSchemaMock(), + version: 1, + rule_id: 'rule-id', + }; + const mlExistingRule = getRulesEqlSchemaMock(); + + await expect( + detectionRulesClient.revertPrebuiltRule({ + ruleAsset: mlRuleAsset, + existingRule: mlExistingRule, + }) + ).rejects.toThrow('mocked MLAuth error'); + + expect(rulesClient.update).not.toHaveBeenCalled(); + }); + + it('patches the existing rule with the new params from the rule asset', async () => { + rulesClient.update.mockResolvedValue(getRuleMock(getEqlRuleParams())); + + await detectionRulesClient.revertPrebuiltRule({ ruleAsset, existingRule }); + expect(rulesClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: ruleAsset.name, + tags: ruleAsset.tags, + // actions are kept from original rule + actions: [ + expect.objectContaining({ + actionTypeId: '.index', + group: 'default', + id: 'test_id', + params: {}, + }), + ], + params: expect.objectContaining({ + index: ruleAsset.index, + description: ruleAsset.description, + exceptionsList: [ + { + id: 'exception_list', + list_id: 'some-id', + namespace_type: 'single', + type: 'detection', + }, + ], + }), + }), + id: existingRule.id, + }) + ); + }); + + it('merges exceptions lists for existing rule and stock rule asset', async () => { + rulesClient.update.mockResolvedValue(getRuleMock(getEqlRuleParams())); + ruleAsset.exceptions_list = [ + { + id: 'exception_list', + list_id: 'some-id', + namespace_type: 'single', + type: 'detection', + }, + { + id: 'second_exception_list', + list_id: 'some-other-id', + namespace_type: 'single', + type: 'detection', + }, + ]; + + await detectionRulesClient.revertPrebuiltRule({ ruleAsset, existingRule }); + expect(rulesClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: ruleAsset.name, + tags: ruleAsset.tags, + params: expect.objectContaining({ + index: ruleAsset.index, + description: ruleAsset.description, + exceptionsList: [ + { + id: 'second_exception_list', + list_id: 'some-other-id', + namespace_type: 'single', + type: 'detection', + }, + { + id: 'exception_list', + list_id: 'some-id', + namespace_type: 'single', + type: 'detection', + }, + ], + }), + }), + id: existingRule.id, + }) + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts index 22d1c138ac86b..903dfcc42d9d7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.ts @@ -25,6 +25,7 @@ import type { ImportRuleArgs, ImportRulesArgs, PatchRuleArgs, + RevertPrebuiltRuleArgs, UpdateRuleArgs, UpgradePrebuiltRuleArgs, } from './detection_rules_client_interface'; @@ -35,6 +36,7 @@ import { importRules } from './methods/import_rules'; import { patchRule } from './methods/patch_rule'; import { updateRule } from './methods/update_rule'; import { upgradePrebuiltRule } from './methods/upgrade_prebuilt_rule'; +import { revertPrebuiltRule } from './methods/revert_prebuilt_rule'; import { MINIMUM_RULE_CUSTOMIZATION_LICENSE } from '../../../../../../common/constants'; interface DetectionRulesClientParams { @@ -149,6 +151,22 @@ export const createDetectionRulesClient = ({ }); }, + async revertPrebuiltRule({ + ruleAsset, + existingRule, + }: RevertPrebuiltRuleArgs): Promise { + return withSecuritySpan('DetectionRulesClient.revertPrebuiltRule', async () => { + return revertPrebuiltRule({ + actionsClient, + rulesClient, + ruleAsset, + mlAuthz, + prebuiltRuleAssetClient, + existingRule, + }); + }); + }, + async importRule(args: ImportRuleArgs): Promise { return withSecuritySpan('DetectionRulesClient.importRule', async () => { return importRule({ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts index 9539590b3770f..1a112fc9c3d34 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface.ts @@ -27,6 +27,7 @@ export interface IDetectionRulesClient { patchRule: (args: PatchRuleArgs) => Promise; deleteRule: (args: DeleteRuleArgs) => Promise; upgradePrebuiltRule: (args: UpgradePrebuiltRuleArgs) => Promise; + revertPrebuiltRule: (args: RevertPrebuiltRuleArgs) => Promise; importRule: (args: ImportRuleArgs) => Promise; importRules: (args: ImportRulesArgs) => Promise>; } @@ -55,6 +56,11 @@ export interface UpgradePrebuiltRuleArgs { ruleAsset: PrebuiltRuleAsset; } +export interface RevertPrebuiltRuleArgs { + ruleAsset: PrebuiltRuleAsset; + existingRule: RuleResponse; +} + export interface ImportRuleArgs { ruleToImport: RuleToImport; overrideFields?: { rule_source: RuleSource; immutable: boolean }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/revert_prebuilt_rule.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/revert_prebuilt_rule.ts new file mode 100644 index 0000000000000..b9390ae8ac64f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/revert_prebuilt_rule.ts @@ -0,0 +1,55 @@ +/* + * 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 { RulesClient } from '@kbn/alerting-plugin/server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; + +import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema'; +import type { MlAuthz } from '../../../../../machine_learning/authz'; +import type { PrebuiltRuleAsset } from '../../../../prebuilt_rules'; +import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; +import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; +import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; +import { applyRuleUpdate } from '../mergers/apply_rule_update'; +import { validateMlAuth, mergeExceptionLists } from '../utils'; + +export const revertPrebuiltRule = async ({ + actionsClient, + rulesClient, + ruleAsset, + mlAuthz, + existingRule, + prebuiltRuleAssetClient, +}: { + actionsClient: ActionsClient; + rulesClient: RulesClient; + ruleAsset: PrebuiltRuleAsset; + mlAuthz: MlAuthz; + existingRule: RuleResponse; + prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient; +}): Promise => { + await validateMlAuth(mlAuthz, ruleAsset.type); + const updatedRule = await applyRuleUpdate({ + prebuiltRuleAssetClient, + existingRule, + ruleUpdate: ruleAsset, + }); + + // We want to preserve existing actions from existing rule on upgrade + if (existingRule.actions.length) { + updatedRule.actions = existingRule.actions; + } + + const updatedRuleWithMergedExceptions = mergeExceptionLists(updatedRule, existingRule); + + const updatedInternalRule = await rulesClient.update({ + id: existingRule.id, + data: convertRuleResponseToAlertingRule(updatedRuleWithMergedExceptions, actionsClient), + }); + + return convertAlertingRuleToRuleResponse(updatedInternalRule); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts index 353e15daf9326..414406d311146 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts @@ -29,6 +29,7 @@ import { createBulkErrorObject } from '../../routes/utils'; import type { InvestigationFieldsCombined, RuleAlertType, RuleParams } from '../../rule_schema'; import { hasValidRuleType } from '../../rule_schema'; import { internalRuleToAPIResponse } from '../logic/detection_rules_client/converters/internal_rule_to_api_response'; +import type { BulkActionError } from '../api/rules/bulk_actions/bulk_actions_response'; type PromiseFromStreams = RuleToImport | Error; const MAX_CONCURRENT_SEARCHES = 10; @@ -266,3 +267,20 @@ export const separateActionsAndSystemAction = ( !isEmpty(actions) ? partition((action: RuleActionSchema) => actionsClient.isSystemAction(action.id))(actions) : [[], actions]; + +export const createBulkActionError = ({ + message, + statusCode, + id, +}: { + message: string; + statusCode: number; + id: string; +}): BulkActionError => { + const error: Error & { statusCode?: number } = new Error(message); + error.statusCode = statusCode; + return { + item: id, + error, + }; +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/index.ts index c5195285f3275..10ca4133b7e73 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/index.ts @@ -14,5 +14,6 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./install_prebuilt_rules')); loadTestFile(require.resolve('./status')); loadTestFile(require.resolve('./upgrade_prebuilt_rules')); + loadTestFile(require.resolve('./revert_prebuilt_rules')); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/revert_prebuilt_rules/get_prebuilt_rule_base_version.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/revert_prebuilt_rules/get_prebuilt_rule_base_version.ts new file mode 100644 index 0000000000000..359d3cc488353 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/revert_prebuilt_rules/get_prebuilt_rule_base_version.ts @@ -0,0 +1,172 @@ +/* + * 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 'expect'; +import { + GET_PREBUILT_RULES_BASE_VERSION_URL, + ThreeWayDiffConflict, + ThreeWayDiffOutcome, + ThreeWayMergeOutcome, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { deleteAllRules } from '../../../../../../../common/utils/security_solution'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { + createPrebuiltRuleAssetSavedObjects, + createRuleAssetSavedObject, + deleteAllPrebuiltRuleAssets, + installPrebuiltRules, + getCustomQueryRuleParams, +} from '../../../../utils'; +import { getPrebuiltRuleBaseVersion } from '../../../../utils/rules/prebuilt_rules/get_prebuilt_rule_base_version'; + +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const securitySolutionApi = getService('securitySolutionApi'); + const log = getService('log'); + + const ruleAsset = createRuleAssetSavedObject({ + rule_id: 'rule_1', + }); + + describe('@ess @serverless @skipInServerlessMKI Get prebuilt rule base version', () => { + after(async () => { + await deleteAllRules(supertest, log); + await deleteAllPrebuiltRuleAssets(es, log); + }); + + describe('fetching prebuilt rule base versions', () => { + beforeEach(async () => { + await deleteAllRules(supertest, log); + await deleteAllPrebuiltRuleAssets(es, log); + await createPrebuiltRuleAssetSavedObjects(es, [ruleAsset]); + await installPrebuiltRules(es, supertest); + }); + + it('returns rule versions and diff field if rule is customized', async () => { + const { + body: { + data: [baseVersion], + }, + } = await securitySolutionApi.findRules({ + query: { + filter: 'alert.attributes.params.immutable: true', + per_page: 1, + }, + }); + + const { body: modifiedCurrentVersion } = await securitySolutionApi.patchRule({ + body: { rule_id: 'rule_1', description: 'new description' }, + }); + + const response = await getPrebuiltRuleBaseVersion(supertest, { id: baseVersion.id }); + + expect(response.base_version).toEqual(baseVersion); + expect(response.current_version).toEqual({ + ...baseVersion, + description: 'new description', + revision: 1, // Rule has been modified once + rule_source: { + is_customized: true, + type: 'external', + }, + updated_at: modifiedCurrentVersion.updated_at, + }); + expect(response.diff).toMatchObject({ + num_fields_with_updates: 0, + num_fields_with_conflicts: 0, + num_fields_with_non_solvable_conflicts: 0, + }); + expect(response.diff.fields).toEqual({ + description: { + conflict: ThreeWayDiffConflict.NONE, + diff_outcome: ThreeWayDiffOutcome.CustomizedValueNoUpdate, + merge_outcome: ThreeWayMergeOutcome.Current, + base_version: 'some description', + current_version: 'new description', + merged_version: 'new description', + target_version: 'some description', + has_update: false, + has_base_version: true, + }, + }); + }); + + it('returns rule versions and empty diff field if rule is not customized', async () => { + const { + body: { + data: [baseVersion], + }, + } = await securitySolutionApi.findRules({ + query: { + filter: 'alert.attributes.params.immutable: true', + per_page: 1, + }, + }); + + const response = await getPrebuiltRuleBaseVersion(supertest, { id: baseVersion.id }); + + expect(response.base_version).toEqual(baseVersion); + expect(response.current_version).toEqual(baseVersion); + expect(response.diff).toMatchObject({ + num_fields_with_updates: 0, + num_fields_with_conflicts: 0, + num_fields_with_non_solvable_conflicts: 0, + }); + expect(response.diff.fields).toEqual({}); + }); + + describe('error states', () => { + it('returns a 404 error if rule base version cannot be found', async () => { + await deleteAllPrebuiltRuleAssets(es, log); // Delete rule base versions + + const { + body: { + data: [prebuiltRule], + }, + } = await securitySolutionApi.findRules({ + query: { + filter: 'alert.attributes.params.immutable: true', + per_page: 1, + }, + }); + + const { body } = await supertest + .get(GET_PREBUILT_RULES_BASE_VERSION_URL) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'securitySolution') + .query({ id: prebuiltRule.id }) + .expect(404); + + expect(body).toEqual({ + status_code: 404, + message: 'Cannot find rule base_version', + }); + }); + + it('returns a 404 error if rule is custom', async () => { + const { body: customRule } = await securitySolutionApi.createRule({ + body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), + }); + + const { body } = await supertest + .get(GET_PREBUILT_RULES_BASE_VERSION_URL) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'securitySolution') + .query({ id: customRule.id }) + .expect(404); + + expect(body).toEqual({ + status_code: 404, + message: 'Cannot find rule base_version', + }); + }); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/revert_prebuilt_rules/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/revert_prebuilt_rules/index.ts new file mode 100644 index 0000000000000..0cb7d1831cb1e --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/revert_prebuilt_rules/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { FtrProviderContext } from '../../../../../../ftr_provider_context'; + +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('Revert prebuilt rules', function () { + loadTestFile(require.resolve('./get_prebuilt_rule_base_version')); + loadTestFile(require.resolve('./revert_prebuilt_rules')); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/revert_prebuilt_rules/revert_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/revert_prebuilt_rules/revert_prebuilt_rules.ts new file mode 100644 index 0000000000000..0b08eddcb03a5 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/customization_enabled/revert_prebuilt_rules/revert_prebuilt_rules.ts @@ -0,0 +1,340 @@ +/* + * 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 'expect'; +import { BulkRevertSkipReasonEnum } from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { deleteAllRules } from '../../../../../../../common/utils/security_solution'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { + createPrebuiltRuleAssetSavedObjects, + createRuleAssetSavedObject, + deleteAllPrebuiltRuleAssets, + installPrebuiltRules, + getCustomQueryRuleParams, + getWebHookAction, +} from '../../../../utils'; +import { revertPrebuiltRule } from '../../../../utils/rules/prebuilt_rules/revert_prebuilt_rule'; + +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const securitySolutionApi = getService('securitySolutionApi'); + const log = getService('log'); + + const ruleAsset = createRuleAssetSavedObject({ + rule_id: 'rule_1', + }); + + describe('@ess @serverless @skipInServerlessMKI Revert prebuilt rule', () => { + before(async () => { + await deleteAllRules(supertest, log); + await deleteAllPrebuiltRuleAssets(es, log); + }); + + describe('reverting prebuilt rules', () => { + beforeEach(async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ruleAsset]); + await installPrebuiltRules(es, supertest); + }); + + afterEach(async () => { + await deleteAllRules(supertest, log); + await deleteAllPrebuiltRuleAssets(es, log); + }); + + it('reverts a customized prebuilt rule to original Elastic version', async () => { + const { body: nonCustomizedPrebuiltRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule_1' }, + }) + .expect(200); + + const { body: customizedPrebuiltRule } = await securitySolutionApi.patchRule({ + body: { rule_id: 'rule_1', description: 'new description' }, + }); + + const response = await revertPrebuiltRule(supertest, { + id: customizedPrebuiltRule.id, + version: customizedPrebuiltRule.version, + revision: customizedPrebuiltRule.revision, + }); + + expect(response).toEqual({ + success: true, + rules_count: 1, + attributes: { + results: { + updated: [ + expect.objectContaining({ + rule_source: { + is_customized: false, + type: 'external', + }, + description: nonCustomizedPrebuiltRule.description, // Modified field should be set to its original asset value + revision: ++customizedPrebuiltRule.revision, // We increment the revision number during reversion + }), + ], + skipped: [], + created: [], + deleted: [], + }, + summary: { + failed: 0, + succeeded: 1, + skipped: 0, + total: 1, + }, + }, + }); + }); + + it('does not modify `exception_list` field', async () => { + const { body: customizedPrebuiltRule } = await securitySolutionApi.patchRule({ + body: { + rule_id: 'rule_1', + description: 'new description', + exceptions_list: [ + { + id: 'some_uuid', + list_id: 'list_id_single', + namespace_type: 'single', + type: 'detection', + }, + ], + }, + }); + + const response = await revertPrebuiltRule(supertest, { + id: customizedPrebuiltRule.id, + version: customizedPrebuiltRule.version, + revision: customizedPrebuiltRule.revision, + }); + + expect(response.attributes.results.updated).toEqual([ + expect.objectContaining({ + rule_source: { + is_customized: false, + type: 'external', + }, + exceptions_list: [ + expect.objectContaining({ + id: 'some_uuid', + list_id: 'list_id_single', + namespace_type: 'single', + type: 'detection', + }), + ], + }), + ]); + }); + + it('does not modify `actions` field', async () => { + const { body: hookAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + + const { body: customizedPrebuiltRule } = await securitySolutionApi.patchRule({ + body: { + rule_id: 'rule_1', + description: 'new description', + actions: [ + { + group: 'default', + id: hookAction.id, + action_type_id: hookAction.connector_type_id, + params: {}, + }, + ], + }, + }); + + const response = await revertPrebuiltRule(supertest, { + id: customizedPrebuiltRule.id, + version: customizedPrebuiltRule.version, + revision: customizedPrebuiltRule.revision, + }); + + expect(response.attributes.results.updated).toEqual([ + expect.objectContaining({ + rule_source: { + is_customized: false, + type: 'external', + }, + actions: [ + expect.objectContaining({ + action_type_id: hookAction.connector_type_id, + frequency: { notifyWhen: 'onActiveAlert', summary: true, throttle: null }, + group: 'default', + id: hookAction.id, + params: {}, + }), + ], + }), + ]); + }); + + it("skips a prebuilt rule if it's not customized", async () => { + const { body: nonCustomizedPrebuiltRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule_1' }, + }) + .expect(200); + + const response = await revertPrebuiltRule(supertest, { + id: nonCustomizedPrebuiltRule.id, + version: nonCustomizedPrebuiltRule.version, + revision: nonCustomizedPrebuiltRule.revision, + }); + + expect(response).toEqual({ + success: true, + rules_count: 1, + attributes: { + results: { + updated: [], + skipped: [ + { + id: nonCustomizedPrebuiltRule.id, + skip_reason: BulkRevertSkipReasonEnum.RULE_NOT_CUSTOMIZED, + }, + ], + created: [], + deleted: [], + }, + summary: { + failed: 0, + succeeded: 0, + skipped: 1, + total: 1, + }, + }, + }); + }); + + it("skips a rule if it's not prebuilt", async () => { + const { body: customRule } = await securitySolutionApi.createRule({ + body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), + }); + + const response = await revertPrebuiltRule(supertest, { + id: customRule.id, + version: customRule.version, + revision: customRule.revision, + }); + + expect(response).toEqual({ + success: true, + rules_count: 1, + attributes: { + results: { + updated: [], + skipped: [ + { + id: customRule.id, + skip_reason: BulkRevertSkipReasonEnum.RULE_NOT_PREBUILT, + }, + ], + created: [], + deleted: [], + }, + summary: { + failed: 0, + succeeded: 0, + skipped: 1, + total: 1, + }, + }, + }); + }); + + it('throws an error if rule base version cannot be found', async () => { + const { body: customizedPrebuiltRule } = await securitySolutionApi.patchRule({ + body: { rule_id: 'rule_1', description: 'new description' }, + }); + + await deleteAllPrebuiltRuleAssets(es, log); // Delete rule base versions + + const response = await revertPrebuiltRule(supertest, { + id: customizedPrebuiltRule.id, + version: customizedPrebuiltRule.version, + revision: customizedPrebuiltRule.revision, + }); + + expect(response.message).toEqual('Rule reversion failed'); + expect(response.attributes.summary).toEqual({ + failed: 1, + succeeded: 0, + skipped: 0, + total: 1, + }); + expect(response.attributes.errors).toEqual([ + expect.objectContaining({ + message: `Cannot find base_version for rule id: ${customizedPrebuiltRule.id}`, + status_code: 404, + }), + ]); + }); + + it("throws an error if version param doesn't equal the fetched rule version", async () => { + const { body: customizedPrebuiltRule } = await securitySolutionApi.patchRule({ + body: { rule_id: 'rule_1', description: 'new description' }, + }); + + const response = await revertPrebuiltRule(supertest, { + id: customizedPrebuiltRule.id, + version: customizedPrebuiltRule.version + 1, + revision: customizedPrebuiltRule.revision, + }); + + expect(response.message).toEqual('Rule reversion failed'); + expect(response.attributes.summary).toEqual({ + failed: 1, + succeeded: 0, + skipped: 0, + total: 1, + }); + expect(response.attributes.errors).toEqual([ + expect.objectContaining({ + message: `Version mismatch for rule with id: ${customizedPrebuiltRule.id}. Expected ${ + customizedPrebuiltRule.version + 1 + }, got ${customizedPrebuiltRule.version}`, + status_code: 409, + }), + ]); + }); + + it("throws an error if revision param doesn't equal the fetched rule revision", async () => { + const { body: customizedPrebuiltRule } = await securitySolutionApi.patchRule({ + body: { rule_id: 'rule_1', description: 'new description' }, + }); + + const response = await revertPrebuiltRule(supertest, { + id: customizedPrebuiltRule.id, + version: customizedPrebuiltRule.version, + revision: customizedPrebuiltRule.revision + 1, + }); + + expect(response.message).toEqual('Rule reversion failed'); + expect(response.attributes.summary).toEqual({ + failed: 1, + succeeded: 0, + skipped: 0, + total: 1, + }); + expect(response.attributes.errors).toEqual([ + expect.objectContaining({ + message: `Revision mismatch for rule with id: ${customizedPrebuiltRule.id}. Expected ${ + customizedPrebuiltRule.revision + 1 + }, got ${customizedPrebuiltRule.revision}`, + status_code: 409, + }), + ]); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rule_base_version.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rule_base_version.ts new file mode 100644 index 0000000000000..b3feee14405b8 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rule_base_version.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + GetPrebuiltRuleBaseVersionRequest, + GetPrebuiltRuleBaseVersionResponseBody, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { GET_PREBUILT_RULES_BASE_VERSION_URL } from '@kbn/security-solution-plugin/common/api/detection_engine/prebuilt_rules/urls'; +import type SuperTest from 'supertest'; + +/** + * Returns prebuilt rule base version, current version, and field diff + * + * @param supertest SuperTest instance + * @returns `GetPrebuiltRuleBaseVersionResponseBody` rules response + */ +export const getPrebuiltRuleBaseVersion = async ( + supertest: SuperTest.Agent, + query: GetPrebuiltRuleBaseVersionRequest +): Promise => { + const response = await supertest + .get(GET_PREBUILT_RULES_BASE_VERSION_URL) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'securitySolution') + .query(query) + .expect(200); + + return response.body; +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/revert_prebuilt_rule.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/revert_prebuilt_rule.ts new file mode 100644 index 0000000000000..4af075a6b8c7e --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/revert_prebuilt_rule.ts @@ -0,0 +1,33 @@ +/* + * 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 { + RevertPrebuiltRulesRequest, + RevertPrebuiltRulesResponseBody, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { REVERT_PREBUILT_RULES_URL } from '@kbn/security-solution-plugin/common/api/detection_engine/prebuilt_rules/urls'; +import type SuperTest from 'supertest'; + +/** + * Reverts customized prebuilt rules to original base version + * + * @param supertest SuperTest instance + * @returns `RevertPrebuiltRulesResponseBody` rules response + */ +export const revertPrebuiltRule = async ( + supertest: SuperTest.Agent, + body: RevertPrebuiltRulesRequest +): Promise => { + const response = await supertest + .post(REVERT_PREBUILT_RULES_URL) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'securitySolution') + .send(body); + + return response.body; +}; diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/revert_prebuilt_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/revert_prebuilt_rule.cy.ts new file mode 100644 index 0000000000000..4b6c0bae6e5d7 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/prebuilt_rules/revert_prebuilt_rule.cy.ts @@ -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 { BASE_VERSION_FLYOUT } from '../../../../screens/rule_details_flyout'; +import { RULE_UPGRADE_PER_FIELD_DIFF_LABEL } from '../../../../screens/rule_updates'; +import { POPOVER_ACTIONS_TRIGGER_BUTTON, RULE_NAME_HEADER } from '../../../../screens/rule_details'; +import { getIndexPatterns, getNewRule } from '../../../../objects/rule'; +import { + expectModifiedBadgeToNotBeDisplayed, + revertRuleFromDetailsPage, +} from '../../../../tasks/alerts_detection_rules'; +import { + RULE_DETAILS_REVERT_RULE_BTN, + RULE_DETAILS_REVERT_RULE_TOOLTIP, + RULE_NAME, + TOASTER_MESSAGE, +} from '../../../../screens/alerts_detection_rules'; +import { createRuleAssetSavedObject } from '../../../../helpers/rules'; +import { + deleteAlertsAndRules, + deletePrebuiltRulesAssets, +} from '../../../../tasks/api_calls/common'; +import { + createAndInstallMockedPrebuiltRules, + preventPrebuiltRulesPackageInstallation, +} from '../../../../tasks/api_calls/prebuilt_rules'; +import { createRule, patchRule } from '../../../../tasks/api_calls/rules'; + +import { login } from '../../../../tasks/login'; + +import { visitRulesManagementTable } from '../../../../tasks/rules_management'; +describe( + 'Detection rules, Prebuilt Rules reversion workflow', + { + tags: ['@ess', '@serverless', '@skipInServerlessMKI'], + }, + + () => { + describe('Reverting prebuilt rules', () => { + const PREBUILT_RULE = createRuleAssetSavedObject({ + name: 'Non-customized prebuilt rule', + rule_id: 'rule_1', + version: 1, + index: getIndexPatterns(), + }); + + beforeEach(() => { + login(); + deleteAlertsAndRules(); + deletePrebuiltRulesAssets(); + preventPrebuiltRulesPackageInstallation(); + /* Create a new rule and install it */ + createAndInstallMockedPrebuiltRules([PREBUILT_RULE]); + visitRulesManagementTable(); + }); + + it('reverts customized prebuilt rule to current Elastic version', function () { + patchRule('rule_1', { name: 'Customized prebuilt rule' }); // We want to make this a customized prebuilt rule + cy.get(RULE_NAME).contains('Customized prebuilt rule').click(); + + revertRuleFromDetailsPage(); + expectModifiedBadgeToNotBeDisplayed(); + + cy.get(RULE_NAME_HEADER).should('contain', 'Non-customized prebuilt rule'); // Correctly displays reverted title + }); + + it('shows diff between current and original Elastic rule versions in flyout', function () { + patchRule('rule_1', { name: 'Customized prebuilt rule' }); // We want to make this a customized prebuilt rule + cy.get(RULE_NAME).contains('Customized prebuilt rule').click(); + + cy.get(POPOVER_ACTIONS_TRIGGER_BUTTON).click(); + cy.get(RULE_DETAILS_REVERT_RULE_BTN).click(); + + cy.get(RULE_UPGRADE_PER_FIELD_DIFF_LABEL).should('have.text', 'Name'); + }); + + it("doesn't close diff flyout on concurrency errors", function () { + patchRule('rule_1', { name: 'Customized prebuilt rule' }); // We want to make this a customized prebuilt rule + cy.get(RULE_NAME).contains('Customized prebuilt rule').click(); + + cy.get(POPOVER_ACTIONS_TRIGGER_BUTTON).click(); + cy.get(RULE_DETAILS_REVERT_RULE_BTN).click(); + + patchRule('rule_1', { description: 'customized description' }); // Customize another field to iterate revision field + revertRuleFromDetailsPage(); + + cy.get(BASE_VERSION_FLYOUT).should('exist'); // Flyout shouldn't be closed + cy.get(TOASTER_MESSAGE).should( + 'have.text', + 'Something in the rule object has changed before reversion was completed. Please review the updated diff and try again.' + ); + }); + + it(`disables "Revert prebuilt rule" button when rule's base version is missing`, function () { + patchRule('rule_1', { name: 'Customized prebuilt rule' }); // We want to make this a customized prebuilt rule + deletePrebuiltRulesAssets(); // Delete the base version of the prebuilt rule + + cy.get(RULE_NAME).contains('Customized prebuilt rule').click(); + + cy.get(POPOVER_ACTIONS_TRIGGER_BUTTON).click(); + cy.get(RULE_DETAILS_REVERT_RULE_BTN).should('be.disabled'); + cy.get(RULE_DETAILS_REVERT_RULE_BTN).trigger('mouseover', { force: true }); // Have to force because button element is disabled + cy.get(RULE_DETAILS_REVERT_RULE_TOOLTIP).should('exist'); + }); + + it(`hides "Revert prebuilt rule" button appear when rule is non-customzied`, function () { + cy.get(RULE_NAME).contains('Non-customized prebuilt rule').click(); + + cy.get(POPOVER_ACTIONS_TRIGGER_BUTTON).click(); + cy.get(RULE_DETAILS_REVERT_RULE_BTN).should('not.exist'); + }); + + it(`hides "Revert prebuilt rule" button appear when rule is not prebuilt`, function () { + createRule( + getNewRule({ + name: 'Custom rule', + index: getIndexPatterns(), + enabled: false, + }) + ); + cy.get(RULE_NAME).contains('Custom rule').click(); + + cy.get(POPOVER_ACTIONS_TRIGGER_BUTTON).click(); + cy.get(RULE_DETAILS_REVERT_RULE_BTN).should('not.exist'); + }); + }); + } +); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts b/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts index eca59d726a483..264a953a97aad 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts @@ -137,6 +137,11 @@ export const RULE_DETAILS_DELETE_BTN = '[data-test-subj="rules-details-delete-ru export const RULE_DETAILS_MANUAL_RULE_RUN_BTN = '[data-test-subj="rules-details-manual-rule-run"]'; +export const RULE_DETAILS_REVERT_RULE_BTN = '[data-test-subj="rules-details-revert-rule"]'; + +export const RULE_DETAILS_REVERT_RULE_TOOLTIP = + '[data-test-subj="rules-details-revert-rule-tooltip"]'; + export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]'; export const SELECT_ALL_RULES_ON_PAGE_CHECKBOX = '[data-test-subj="checkboxSelectAll"]'; @@ -149,6 +154,8 @@ export const INPUT_FILE = 'input[type=file]'; export const TOASTER = '[data-test-subj="euiToastHeader"]'; +export const TOASTER_MESSAGE = '[data-test-subj="errorToastMessage"]'; + export const SUCCESS_TOASTER = '[class*="euiToast-success"] [data-test-subj="euiToastHeader"]'; export const TOASTER_BODY = '[data-test-subj="globalToastList"] [data-test-subj="euiToastBody"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/rule_details_flyout.ts b/x-pack/test/security_solution_cypress/cypress/screens/rule_details_flyout.ts index ef7eff086e829..8d83c23f2ecc2 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/rule_details_flyout.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/rule_details_flyout.ts @@ -9,3 +9,5 @@ export const TABLE_TAB = '[data-test-subj="securitySolutionDocumentDetailsFlyout export const FILTER_INPUT = '[data-test-subj="securitySolutionDocumentDetailsFlyoutBody"] [data-test-subj="search-input"]'; + +export const BASE_VERSION_FLYOUT = '[data-test-subj="baseVersionPrebuiltRulePreview"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/rule_updates.ts b/x-pack/test/security_solution_cypress/cypress/screens/rule_updates.ts index dbe907a9040f9..5ad7fc0cc7568 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/rule_updates.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/rule_updates.ts @@ -12,3 +12,8 @@ export const RULE_UPGRADE_TABLE_MODIFICATION_FILTER_PANEL = '[data-test-subj="rule-customization-filter-popover"]'; export const RULE_UPGRADE_CONFLICTS_MODAL = '[data-test-subj="upgradeConflictsModal"]'; + +export const RULE_UPGRADE_PER_FIELD_DIFF_LABEL = '[data-test-subj="ruleUpgradePerFieldDiffLabel"]'; + +export const REVERT_MODAL_CONFIRMATION_BTN = + '[data-test-subj="revertPrebuiltRuleFromFlyoutButton"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts index 1e15cf8c2f257..7afe0c49acdf0 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts @@ -57,6 +57,7 @@ import { SELECT_ALL_RULES_ON_PAGE_CHECKBOX, RULE_DETAILS_MANUAL_RULE_RUN_BTN, MANUAL_RULE_RUN_ACTION_BTN, + RULE_DETAILS_REVERT_RULE_BTN, } from '../screens/alerts_detection_rules'; import type { RULES_MONITORING_TABLE } from '../screens/alerts_detection_rules'; import { EUI_CHECKBOX } from '../screens/common/controls'; @@ -72,6 +73,7 @@ import { PAGE_CONTENT_SPINNER } from '../screens/common/page'; import { goToRuleEditSettings } from './rule_details'; import { goToActionsStepTab } from './create_new_rule'; import { setKibanaSetting } from './api_calls/kibana_advanced_settings'; +import { REVERT_MODAL_CONFIRMATION_BTN } from '../screens/rule_updates'; export const getRulesManagementTableRows = () => cy.get(RULES_MANAGEMENT_TABLE).find(RULES_ROW); @@ -149,6 +151,13 @@ export const manualRuleRunFromDetailsPage = () => { cy.get(MODAL_CONFIRMATION_BTN).click(); }; +export const revertRuleFromDetailsPage = () => { + cy.get(POPOVER_ACTIONS_TRIGGER_BUTTON).click({ force: true }); + cy.get(RULE_DETAILS_REVERT_RULE_BTN).click(); + cy.get(RULE_DETAILS_REVERT_RULE_BTN).should('not.exist'); + cy.get(REVERT_MODAL_CONFIRMATION_BTN).click(); +}; + export const exportRule = (name: string) => { cy.log(`Export rule "${name}"`);