diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 004130ea49e26..8542aa70ac115 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -61483,6 +61483,12 @@ components: enum: - delete type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61501,6 +61507,12 @@ components: enum: - disable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61532,6 +61544,12 @@ components: required: - include_exceptions - include_expired_exceptions + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61624,6 +61642,12 @@ components: $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayload' minItems: 1 type: array + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61647,6 +61671,12 @@ components: enum: - enable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61667,6 +61697,12 @@ components: enum: - export type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61685,6 +61721,12 @@ components: enum: - run type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index a0d5276d63f95..1572228a4217f 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -70856,6 +70856,12 @@ components: enum: - delete type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -70874,6 +70880,12 @@ components: enum: - disable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -70905,6 +70917,12 @@ components: required: - include_exceptions - include_expired_exceptions + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -70997,6 +71015,12 @@ components: $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayload' minItems: 1 type: array + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -71020,6 +71044,12 @@ components: enum: - enable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -71040,6 +71070,12 @@ components: enum: - export type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -71058,6 +71094,12 @@ components: enum: - run type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts index 267b97fda8872..7e38aa6036f53 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts @@ -113,6 +113,14 @@ export const BulkActionBase = z.object({ * Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. */ ids: z.array(z.string()).min(1).optional(), + /** + * Gaps range start, valid only when query is provided + */ + gaps_range_start: z.string().optional(), + /** + * Gaps range end, valid only when query is provided + */ + gaps_range_end: z.string().optional(), }); export type BulkDeleteRules = z.infer; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml index 2525b13d754af..eb760bdc38b11 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml @@ -1146,6 +1146,12 @@ components: minItems: 1 items: type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string BulkDeleteRules: allOf: diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml index aafb2965b981f..b2111722f1034 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml @@ -4696,6 +4696,12 @@ components: enum: - delete type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4716,6 +4722,12 @@ components: enum: - disable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4749,6 +4761,12 @@ components: required: - include_exceptions - include_expired_exceptions + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4849,6 +4867,12 @@ components: $ref: '#/components/schemas/BulkActionEditPayload' minItems: 1 type: array + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4874,6 +4898,12 @@ components: enum: - enable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4896,6 +4926,12 @@ components: enum: - export type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4916,6 +4952,12 @@ components: enum: - run type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml index 8b6133337a97c..f3937a3bdb954 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml @@ -4026,6 +4026,12 @@ components: enum: - delete type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4046,6 +4052,12 @@ components: enum: - disable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4079,6 +4091,12 @@ components: required: - include_exceptions - include_expired_exceptions + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4179,6 +4197,12 @@ components: $ref: '#/components/schemas/BulkActionEditPayload' minItems: 1 type: array + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4204,6 +4228,12 @@ components: enum: - enable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4226,6 +4256,12 @@ components: enum: - export type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4246,6 +4282,12 @@ components: enum: - run type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index d3de6cf7aaa0f..37ea2769a9c1f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -809,6 +809,33 @@ describe('Detections Rules API', () => { ); }); + test('passes gap range with query', async () => { + const gapRange = { start: '2025-01-01T00:00:00.000Z', end: '2025-01-02T00:00:00.000Z' }; + await performBulkAction({ + bulkAction: { + type: BulkActionTypeEnum.enable, + query: '', + gapRange, + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_bulk_action', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + action: 'enable', + query: '', + gaps_range_start: '2025-01-01T00:00:00.000Z', + gaps_range_end: '2025-01-02T00:00:00.000Z', + }), + query: { + dry_run: false, + }, + }) + ); + }); + test('executes dry run', async () => { await performBulkAction({ bulkAction: { type: BulkActionTypeEnum.disable, query: 'some query' }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 1af72cf2227a6..64e96789c656a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -341,7 +341,10 @@ export interface BulkActionErrorResponse { attributes?: BulkActionAttributes; } -export type QueryOrIds = { query: string; ids?: undefined } | { query?: undefined; ids: string[] }; +export type QueryOrIds = + | { query: string; ids?: undefined; gapRange?: { start: string; end: string } } + | { query?: undefined; ids: string[] }; + type PlainBulkAction = { type: Exclude< BulkActionType, @@ -398,6 +401,8 @@ export async function performBulkAction({ duplicate: bulkAction.type === BulkActionTypeEnum.duplicate ? bulkAction.duplicatePayload : undefined, run: bulkAction.type === BulkActionTypeEnum.run ? bulkAction.runPayload : undefined, + gaps_range_start: 'gapRange' in bulkAction ? bulkAction.gapRange?.start : undefined, + gaps_range_end: 'gapRange' in bulkAction ? bulkAction.gapRange?.end : undefined, }; return KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_BULK_ACTION, { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx index 53424e500a709..2d89937064f9f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx @@ -10,7 +10,7 @@ import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiTextColor } from '@elastic/eui'; import type { Toast } from '@kbn/core/public'; import { toMountPoint } from '@kbn/react-kibana-mount'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { ML_RULES_UNAVAILABLE } from './translations'; import { MAX_MANUAL_RULE_RUN_BULK_SIZE } from '../../../../../../common/constants'; import type { TimeRange } from '../../../../rule_gaps/types'; @@ -18,6 +18,8 @@ import { useKibana } from '../../../../../common/lib/kibana'; import { useUserPrivileges } from '../../../../../common/components/user_privileges'; import { convertRulesFilterToKQL } from '../../../../../../common/detection_engine/rule_management/rule_filtering'; import { DuplicateOptions } from '../../../../../../common/detection_engine/rule_management/constants'; +import { getGapRange } from '../../../../rule_gaps/api/hooks/utils'; +import { defaultRangeValue } from '../../../../rule_gaps/constants'; import type { BulkActionEditPayload, BulkActionEditType, @@ -91,6 +93,16 @@ export const useBulkActions = ({ state: { isAllSelected, rules, loadingRuleIds, selectedRuleIds }, actions: { clearRulesSelection, setIsPreflightInProgress }, } = rulesTableContext; + const globalQuery = useMemo(() => { + const gapRange = filterOptions?.showRulesWithGaps + ? getGapRange(filterOptions.gapSearchRange ?? defaultRangeValue) + : undefined; + + return { + query: kql, + ...(gapRange && { gapRange }), + }; + }, [kql, filterOptions]); const getBulkItemsPopoverContent = useCallback( (closePopover: () => void): EuiContextMenuPanelDescriptor[] => { @@ -122,7 +134,7 @@ export const useBulkActions = ({ await executeBulkAction({ type: BulkActionTypeEnum.enable, - ...(isAllSelected ? { query: kql } : { ids: ruleIds }), + ...(isAllSelected ? globalQuery : { ids: ruleIds }), }); }; @@ -134,7 +146,7 @@ export const useBulkActions = ({ await executeBulkAction({ type: BulkActionTypeEnum.disable, - ...(isAllSelected ? { query: kql } : { ids: enabledIds }), + ...(isAllSelected ? globalQuery : { ids: enabledIds }), }); }; @@ -158,7 +170,7 @@ export const useBulkActions = ({ DuplicateOptions.withExceptionsExcludeExpiredExceptions ), }, - ...(isAllSelected ? { query: kql } : { ids: selectedRuleIds }), + ...(isAllSelected ? globalQuery : { ids: selectedRuleIds }), }); clearRulesSelection(); }; @@ -175,7 +187,7 @@ export const useBulkActions = ({ await executeBulkAction({ type: BulkActionTypeEnum.delete, - ...(isAllSelected ? { query: kql } : { ids: selectedRuleIds }), + ...(isAllSelected ? globalQuery : { ids: selectedRuleIds }), }); }; @@ -183,9 +195,7 @@ export const useBulkActions = ({ closePopover(); startTransaction({ name: BULK_RULE_ACTIONS.EXPORT }); - const response = await bulkExport( - isAllSelected ? { query: kql } : { ids: selectedRuleIds } - ); + const response = await bulkExport(isAllSelected ? globalQuery : { ids: selectedRuleIds }); // if response null, likely network error happened and export rules haven't been received if (!response) { @@ -215,9 +225,7 @@ export const useBulkActions = ({ const dryRunResult = await executeBulkActionsDryRun({ type: BulkActionTypeEnum.run, - ...(isAllSelected - ? { query: convertRulesFilterToKQL(filterOptions) } - : { ids: selectedRuleIds }), + ...(isAllSelected ? globalQuery : { ids: selectedRuleIds }), runPayload: { start_date: new Date(Date.now() - 1000).toISOString(), end_date: new Date().toISOString(), @@ -252,7 +260,7 @@ export const useBulkActions = ({ await executeBulkAction({ type: BulkActionTypeEnum.run, - ...(isAllSelected ? { query: kql } : { ids: enabledIds }), + ...(isAllSelected ? globalQuery : { ids: enabledIds }), runPayload: { start_date: modalManualRuleRunConfirmationResult.startDate.toISOString(), end_date: modalManualRuleRunConfirmationResult.endDate.toISOString(), @@ -278,9 +286,7 @@ export const useBulkActions = ({ const dryRunResult = await executeBulkActionsDryRun({ type: BulkActionTypeEnum.edit, - ...(isAllSelected - ? { query: convertRulesFilterToKQL(filterOptions) } - : { ids: selectedRuleIds }), + ...(isAllSelected ? globalQuery : { ids: selectedRuleIds }), editPayload: computeDryRunEditPayload(bulkEditActionType), }); @@ -343,7 +349,9 @@ export const useBulkActions = ({ await executeBulkAction({ type: BulkActionTypeEnum.edit, ...prepareSearchParams({ - ...(isAllSelected ? { filterOptions } : { selectedRuleIds }), + ...(isAllSelected + ? { filterOptions, gapRange: globalQuery.gapRange } + : { selectedRuleIds }), dryRunResult, }), editPayload: [editPayload], @@ -584,7 +592,6 @@ export const useBulkActions = ({ startTransaction, hasMlPermissions, executeBulkAction, - kql, toasts, showBulkDuplicateConfirmation, showManualRuleRunConfirmation, @@ -600,6 +607,7 @@ export const useBulkActions = ({ completeBulkEditForm, startServices, canCreateTimelines, + globalQuery, ] ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts index 778d0a3f3225f..ac074cd162893 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts @@ -118,4 +118,54 @@ describe('prepareSearchParams', () => { expect(result).toEqual({ query: expect.any(String) }); } ); + + test('should not include gapRange in the output when provided with ids', () => { + const selectedRuleIds = ['rule:1', 'rule:2']; + const dryRunResult: DryRunResult = { + ruleErrors: [], + }; + const gapRange = { start: '2025-01-01T00:00:00.000Z', end: '2025-01-02T00:00:00.000Z' }; + const result = prepareSearchParams({ + selectedRuleIds, + dryRunResult, + gapRange, + }); + + expect(result).toEqual({ ids: ['rule:1', 'rule:2'] }); + }); + + test('should include gapRange in the query when provided with query', () => { + const filterOptions: FilterOptions = { + filter: '', + tags: [], + showCustomRules: false, + showElasticRules: false, + }; + const dryRunResult: DryRunResult = { + ruleErrors: [], + }; + const gapRange = { start: '2025-01-01T00:00:00.000Z', end: '2025-01-02T00:00:00.000Z' }; + const result = prepareSearchParams({ + filterOptions, + dryRunResult, + gapRange, + }); + + expect(result).toEqual({ query: expect.any(String), gapRange }); + }); + + test('should return only query when neither selectedRuleIds nor gapRange are provided', () => { + const dryRunResult: DryRunResult = { ruleErrors: [] }; + + const filterOptions: FilterOptions = { + filter: '', + tags: [], + showCustomRules: false, + showElasticRules: false, + }; + + const result = prepareSearchParams({ dryRunResult, filterOptions }); + + expect(result).toEqual({ query: expect.any(String) }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts index 646b289dbf336..658d020261962 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts @@ -14,7 +14,11 @@ import { BulkActionsDryRunErrCodeEnum } from '../../../../../../../common/api/de type PrepareSearchFilterProps = | { selectedRuleIds: string[]; dryRunResult?: DryRunResult } - | { filterOptions: FilterOptions; dryRunResult?: DryRunResult }; + | { + filterOptions: FilterOptions; + gapRange?: { start: string; end: string }; + dryRunResult?: DryRunResult; + }; /** * helper methods to prepare search params for bulk actions based on results of previous dry run @@ -61,5 +65,6 @@ export const prepareSearchParams = ({ return { query: convertRulesFilterToKQL(modifiedFilterOptions), + ...(props.gapRange ? { gapRange: props.gapRange } : {}), }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts index d74bcc5e7f450..c20ee9cc2132d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts @@ -7,6 +7,7 @@ import type { RulesClient } from '@kbn/alerting-plugin/server'; import { BadRequestError } from '@kbn/securitysolution-es-utils'; +import { gapStatus } from '@kbn/alerting-plugin/common'; import { MAX_RULES_TO_UPDATE_IN_PARALLEL } from '../../../../../../../common/constants'; import type { PromisePoolOutcome } from '../../../../../../utils/promise_pool'; import { initPromisePool } from '../../../../../../utils/promise_pool'; @@ -20,12 +21,14 @@ export const fetchRulesByQueryOrIds = async ({ rulesClient, abortSignal, maxRules, + gapRange, }: { query: string | undefined; ids: string[] | undefined; rulesClient: RulesClient; abortSignal: AbortSignal; maxRules: number; + gapRange?: { start: string; end: string }; }): Promise> => { if (ids) { return initPromisePool({ @@ -42,6 +45,23 @@ export const fetchRulesByQueryOrIds = async ({ }); } + let ruleIdsWithGaps: string[] | undefined; + // If there is a gap range, we need to find the rules that have gaps in that range + if (gapRange) { + const ruleIdsWithGapsResponse = await rulesClient.getRuleIdsWithGaps({ + start: gapRange.start, + end: gapRange.end, + statuses: [gapStatus.UNFILLED, gapStatus.PARTIALLY_FILLED], + }); + ruleIdsWithGaps = ruleIdsWithGapsResponse.ruleIds; + if (ruleIdsWithGaps.length === 0) { + return { + results: [], + errors: [], + }; + } + } + const { data, total } = await findRules({ rulesClient, perPage: maxRules, @@ -50,6 +70,7 @@ export const fetchRulesByQueryOrIds = async ({ sortField: undefined, sortOrder: undefined, fields: undefined, + ruleIds: ruleIdsWithGaps, }); if (total > maxRules) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts index 476c3b47514ca..2795956fc791b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts @@ -630,6 +630,93 @@ describe('Perform bulk action route', () => { ) ); }); + + it('rejects payload if both ids and gap range are defined', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getBulkDisableRuleActionSchemaMock(), + query: undefined, + ids: ['id'], + gaps_range_start: '2025-01-01T00:00:00.000Z', + gaps_range_end: '2025-01-02T00:00:00.000Z', + }, + }); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(400); + expect(response.body.message).toEqual( + 'Cannot use both ids and gaps_range_start/gaps_range_end in request payload.' + ); + }); + + it('rejects payload if only gaps_range_start is defined without gaps_range_end', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getBulkDisableRuleActionSchemaMock(), + query: '', + gaps_range_start: '2025-01-01T00:00:00.000Z', + }, + }); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(400); + expect(response.body.message).toEqual( + 'Both gaps_range_start and gaps_range_end must be provided together.' + ); + }); + + it('rejects payload if only gaps_range_end is defined without gaps_range_start', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getBulkDisableRuleActionSchemaMock(), + query: '', + gaps_range_end: '2025-01-02T00:00:00.000Z', + }, + }); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(400); + expect(response.body.message).toEqual( + 'Both gaps_range_start and gaps_range_end must be provided together.' + ); + }); + }); + + describe('gap range functionality', () => { + it('passes gap range to rules find when provided with query', async () => { + const gapStartDate = '2025-01-01T00:00:00.000Z'; + const gapEndDate = '2025-01-02T00:00:00.000Z'; + + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getBulkDisableRuleActionSchemaMock(), + query: '', + gaps_range_start: gapStartDate, + gaps_range_end: gapEndDate, + }, + }); + + await server.inject(request, requestContextMock.convertContext(context)); + + expect(clients.rulesClient.getRuleIdsWithGaps).toHaveBeenCalledWith( + expect.objectContaining({ + start: gapStartDate, + end: gapEndDate, + statuses: ['unfilled', 'partially_filled'], + }) + ); + }); }); it('should process large number of rules, larger than configured concurrency', async () => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index 54940c6ab7ed9..6a685c0f7874a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -50,6 +50,50 @@ const MAX_RULES_TO_PROCESS_TOTAL = 10000; const MAX_RULES_TO_BULK_EDIT = 2000; const MAX_ROUTE_CONCURRENCY = 5; +interface ValidationError { + body: string; + statusCode: number; +} + +const validateBulkAction = ( + body: PerformRulesBulkActionRequestBody +): ValidationError | undefined => { + if (body?.ids && body.ids.length > RULES_TABLE_MAX_PAGE_SIZE) { + return { + body: `More than ${RULES_TABLE_MAX_PAGE_SIZE} ids sent for bulk edit action.`, + statusCode: 400, + }; + } + + if (body?.ids && body.query !== undefined) { + return { + body: `Both query and ids are sent. Define either ids or query in request payload.`, + statusCode: 400, + }; + } + + // Validate that ids and gap range params are not used together + if (body?.ids && (body.gaps_range_start || body.gaps_range_end)) { + return { + body: `Cannot use both ids and gaps_range_start/gaps_range_end in request payload.`, + statusCode: 400, + }; + } + + // Validate that both gap range params are provided if any is used + if ( + (body.gaps_range_start && !body.gaps_range_end) || + (!body.gaps_range_start && body.gaps_range_end) + ) { + return { + body: `Both gaps_range_start and gaps_range_end must be provided together.`, + statusCode: 400, + }; + } + + return undefined; +}; + export const performBulkActionRoute = ( router: SecuritySolutionPluginRouter, ml: SetupPlugins['ml'] @@ -89,18 +133,9 @@ export const performBulkActionRoute = ( const { body } = request; const siemResponse = buildSiemResponse(response); - if (body?.ids && body.ids.length > RULES_TABLE_MAX_PAGE_SIZE) { - return siemResponse.error({ - body: `More than ${RULES_TABLE_MAX_PAGE_SIZE} ids sent for bulk edit action.`, - statusCode: 400, - }); - } - - if (body?.ids && body.query !== undefined) { - return siemResponse.error({ - body: `Both query and ids are sent. Define either ids or query in request payload.`, - statusCode: 400, - }); + const validationError = validateBulkAction(body); + if (validationError) { + return siemResponse.error(validationError); } const isDryRun = request.query.dry_run; @@ -148,6 +183,15 @@ export const performBulkActionRoute = ( }); const query = body.query !== '' ? body.query : undefined; + let gapRange; + + // If gap range params are present, set up the gap range parameter + if (body.gaps_range_start && body.gaps_range_end) { + gapRange = { + start: body.gaps_range_start, + end: body.gaps_range_end, + }; + } const fetchRulesOutcome = await fetchRulesByQueryOrIds({ rulesClient, @@ -158,6 +202,7 @@ export const performBulkActionRoute = ( body.action === BulkActionTypeEnum.edit ? MAX_RULES_TO_BULK_EDIT : MAX_RULES_TO_PROCESS_TOTAL, + gapRange, }); const rules = fetchRulesOutcome.results.map(({ result }) => result); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts index 29cb6884268bc..ed5d5ab66feb0 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts @@ -3054,6 +3054,35 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + describe('gaps_range filtering', () => { + it('should not affect rules without gaps when using gaps_range filters', async () => { + // Create two rules without gaps + await createRule(supertest, log, { + ...getSimpleRule('rule-without-gaps-1'), + }); + await createRule(supertest, log, { + ...getSimpleRule('rule-without-gaps-2'), + }); + + // Execute bulk action with gaps range filter + const { body } = await postBulkAction().send({ + query: '', + action: BulkActionTypeEnum.duplicate, + gaps_range_start: '2025-01-01T00:00:00.000Z', + gaps_range_end: '2025-01-02T00:00:00.000Z', + duplicate: { include_exceptions: false, include_expired_exceptions: false }, + }); + + // Verify the summary shows no rules were processed + expect(body.attributes.summary).toEqual({ + failed: 0, + skipped: 0, + succeeded: 0, + total: 0, + }); + }); + }); + it('should limit concurrent requests to 5', async () => { const ruleId = 'ruleId'; const timelineId = '91832785-286d-4ebe-b884-1a208d111a70';