diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 7bb433738b30a..a99a3f8ee2fe9 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -375,3 +375,30 @@ export const WARNING_TRANSFORM_STATES = new Set([ TRANSFORM_STATES.STOPPED, TRANSFORM_STATES.STOPPING, ]); + +/** + * How many rules to update at a time is set to 50 from errors coming from + * the slow environments such as cloud when the rule updates are > 100 we were + * seeing timeout issues. + * + * Since there is not timeout options at the alerting API level right now, we are + * at the mercy of the Elasticsearch server client/server default timeouts and what + * we are doing could be considered a workaround to not being able to increase the timeouts. + * + * However, other bad effects and saturation of connections beyond 50 makes this a "noisy neighbor" + * if we don't limit its number of connections as we increase the number of rules that can be + * installed at a time. + * + * Lastly, we saw weird issues where Chrome on upstream 408 timeouts will re-call the REST route + * which in turn could create additional connections we want to avoid. + * + * See file import_rules_route.ts for another area where 50 was chosen, therefore I chose + * 50 here to mimic it as well. If you see this re-opened or what similar to it, consider + * reducing the 50 above to a lower number. + * + * See the original ticket here: + * https://github.com/elastic/kibana/issues/94418 + */ +export const MAX_RULES_TO_UPDATE_IN_PARALLEL = 50; + +export const LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX = `${APP_ID}:limitedConcurrency`; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 23c45c03b62a0..7e4a4fd1295bd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -453,6 +453,53 @@ export enum BulkAction { 'export' = 'export', 'delete' = 'delete', 'duplicate' = 'duplicate', + 'edit' = 'edit', } export const bulkAction = enumeration('BulkAction', BulkAction); + +export enum BulkActionEditType { + 'add_tags' = 'add_tags', + 'delete_tags' = 'delete_tags', + 'set_tags' = 'set_tags', + 'add_index_patterns' = 'add_index_patterns', + 'delete_index_patterns' = 'delete_index_patterns', + 'set_index_patterns' = 'set_index_patterns', + 'set_timeline' = 'set_timeline', +} + +export const bulkActionEditType = enumeration('BulkActionEditType', BulkActionEditType); + +const bulkActionEditPayloadTags = t.type({ + type: t.union([ + t.literal(BulkActionEditType.add_tags), + t.literal(BulkActionEditType.delete_tags), + t.literal(BulkActionEditType.set_tags), + ]), + value: tags, +}); + +const bulkActionEditPayloadIndexPatterns = t.type({ + type: t.union([ + t.literal(BulkActionEditType.add_index_patterns), + t.literal(BulkActionEditType.delete_index_patterns), + t.literal(BulkActionEditType.set_index_patterns), + ]), + value: index, +}); + +const bulkActionEditPayloadTimeline = t.type({ + type: t.literal(BulkActionEditType.set_timeline), + value: t.type({ + timeline_id, + timeline_title, + }), +}); + +export const bulkActionEditPayload = t.union([ + bulkActionEditPayloadTags, + bulkActionEditPayloadIndexPatterns, + bulkActionEditPayloadTimeline, +]); + +export type BulkActionEditPayload = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts index cb78168fbec6e..b6c241dfd15d2 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts @@ -5,10 +5,16 @@ * 2.0. */ -import { BulkAction } from '../common/schemas'; +import { BulkAction, BulkActionEditType } from '../common/schemas'; import { PerformBulkActionSchema } from './perform_bulk_action_schema'; export const getPerformBulkActionSchemaMock = (): PerformBulkActionSchema => ({ query: '', action: BulkAction.disable, }); + +export const getPerformBulkActionEditSchemaMock = (): PerformBulkActionSchema => ({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.add_tags, value: ['tag1'] }], +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts index a9707b88f5240..855b7ea506d81 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts @@ -8,61 +8,358 @@ import { performBulkActionSchema, PerformBulkActionSchema } from './perform_bulk_action_schema'; import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; import { left } from 'fp-ts/lib/Either'; -import { BulkAction } from '../common/schemas'; +import { BulkAction, BulkActionEditType } from '../common/schemas'; + +const retrieveValidationMessage = (payload: unknown) => { + const decoded = performBulkActionSchema.decode(payload); + const checked = exactCheck(payload, decoded); + return foldLeftRight(checked); +}; describe('perform_bulk_action_schema', () => { - test('query and action is valid', () => { - const payload: PerformBulkActionSchema = { - query: 'name: test', - action: BulkAction.enable, - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + describe('cases common to every bulk action', () => { + // missing query means it will request for all rules + test('valid request: missing query', () => { + const payload: PerformBulkActionSchema = { + query: undefined, + action: BulkAction.enable, + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('invalid request: missing action', () => { + const payload: Omit = { + query: 'name: test', + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "action"', + 'Invalid value "undefined" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: unknown action', () => { + const payload: Omit & { action: 'unknown' } = { + query: 'name: test', + action: 'unknown', + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "unknown" supplied to "action"', + 'Invalid value "undefined" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: unknown property', () => { + const payload = { + query: 'name: test', + action: BulkAction.enable, + ids: ['id'], + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual(['invalid keys "ids,["id"]"']); + expect(message.schema).toEqual({}); + }); + }); + + describe('bulk enable', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.enable, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('bulk disable', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.disable, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('bulk export', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.export, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); }); - test('missing query is valid', () => { - const payload: PerformBulkActionSchema = { - query: undefined, - action: BulkAction.enable, - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + describe('bulk delete', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.delete, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); }); - test('missing action is invalid', () => { - const payload: Omit = { - query: 'name: test', - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action"', - ]); - expect(message.schema).toEqual({}); + describe('bulk duplicate', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.duplicate, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); }); - test('unknown action is invalid', () => { - const payload: Omit & { action: 'unknown' } = { - query: 'name: test', - action: 'unknown', - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "unknown" supplied to "action"', - ]); - expect(message.schema).toEqual({}); + describe('bulk edit', () => { + describe('cases common to every type of editing', () => { + test('invalid request: missing edit payload', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "undefined" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: specified edit payload for another action', () => { + const payload = { + query: 'name: test', + action: BulkAction.enable, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'invalid keys "edit,[{"type":"set_tags","value":["test-tag"]}]"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: wrong type for edit payload', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: { type: BulkActionEditType.set_tags, value: ['test-tag'] }, + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "{"type":"set_tags","value":["test-tag"]}" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('tags', () => { + test('invalid request: wrong tags type', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: 'test-tag' }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "test-tag" supplied to "edit,value"', + 'Invalid value "set_tags" supplied to "edit,type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('valid request: add_tags edit action', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.add_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: set_tags edit action', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: delete_tags edit action', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.delete_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('index_patterns', () => { + test('invalid request: wrong index_patterns type', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: 'logs-*' }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "logs-*" supplied to "edit,value"', + 'Invalid value "set_tags" supplied to "edit,type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('valid request: set_index_patterns edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_index_patterns, value: ['logs-*'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: add_index_patterns edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.add_index_patterns, value: ['logs-*'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: delete_index_patterns edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [ + { type: BulkActionEditType.delete_index_patterns, value: ['logs-*'] }, + ], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('timeline', () => { + test('invalid request: wrong timeline payload type', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_timeline, value: [] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "set_timeline" supplied to "edit,type"', + 'Invalid value "[]" supplied to "edit,value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: missing timeline_id', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_title: 'Test timeline title', + }, + }, + ], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "set_timeline" supplied to "edit,type"', + 'Invalid value "{"timeline_title":"Test timeline title"}" supplied to "edit,value"', + 'Invalid value "undefined" supplied to "edit,value,timeline_id"', + ]); + expect(message.schema).toEqual({}); + }); + + test('valid request: set_timeline edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: 'timelineid', + timeline_title: 'Test timeline title', + }, + }, + ], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts index adb26f107c8cd..02de2f845b85d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts @@ -6,13 +6,33 @@ */ import * as t from 'io-ts'; -import { bulkAction, queryOrUndefined } from '../common/schemas'; +import { BulkAction, queryOrUndefined, bulkActionEditPayload } from '../common/schemas'; -export const performBulkActionSchema = t.exact( - t.type({ - query: queryOrUndefined, - action: bulkAction, - }) -); +export const performBulkActionSchema = t.intersection([ + t.exact( + t.type({ + query: queryOrUndefined, + }) + ), + t.union([ + t.exact( + t.type({ + action: t.union([ + t.literal(BulkAction.delete), + t.literal(BulkAction.disable), + t.literal(BulkAction.duplicate), + t.literal(BulkAction.enable), + t.literal(BulkAction.export), + ]), + }) + ), + t.exact( + t.type({ + action: t.literal(BulkAction.edit), + [BulkAction.edit]: t.array(bulkActionEditPayload), + }) + ), + ]), +]); export type PerformBulkActionSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 2b5182578d4b2..c8af729ec3a68 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -38,8 +38,6 @@ export const METADATA_UNITED_INDEX = '.metrics-endpoint.metadata_united_default' export const policyIndexPattern = 'metrics-endpoint.policy-*'; export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; -export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; -export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; export const HOST_METADATA_LIST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts index 7d32785222fed..2cc022ca7412c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts @@ -14,6 +14,7 @@ export type LoadingRuleAction = | 'disable' | 'export' | 'delete' + | 'edit' | null; export interface RulesTableState { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 1411ed25b6e89..2507d5a9596b6 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -235,6 +235,7 @@ export type BulkActionResponse = { [BulkAction.enable]: BulkActionResult; [BulkAction.duplicate]: BulkActionResult; [BulkAction.export]: Blob; + [BulkAction.edit]: BulkActionResult; }[Action]; export interface BasicFetchProps { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts b/x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts deleted file mode 100644 index 916d78107e20f..0000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - CoreSetup, - KibanaRequest, - LifecycleResponseFactory, - OnPreAuthToolkit, -} from 'kibana/server'; -import { - LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG, - LIMITED_CONCURRENCY_ENDPOINT_COUNT, -} from '../../../common/endpoint/constants'; - -class MaxCounter { - constructor(private readonly max: number = 1) {} - private counter = 0; - valueOf() { - return this.counter; - } - increase() { - if (this.counter < this.max) { - this.counter += 1; - } - } - decrease() { - if (this.counter > 0) { - this.counter -= 1; - } - } - lessThanMax() { - return this.counter < this.max; - } -} - -function shouldHandleRequest(request: KibanaRequest) { - const tags = request.route.options.tags; - return tags.includes(LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG); -} - -export function registerLimitedConcurrencyRoutes(core: CoreSetup) { - const counter = new MaxCounter(LIMITED_CONCURRENCY_ENDPOINT_COUNT); - core.http.registerOnPreAuth(function preAuthHandler( - request: KibanaRequest, - response: LifecycleResponseFactory, - toolkit: OnPreAuthToolkit - ) { - if (!shouldHandleRequest(request)) { - return toolkit.next(); - } - - if (!counter.lessThanMax()) { - return response.customError({ - body: 'Too Many Requests', - statusCode: 429, - }); - } - - counter.increase(); - - // requests.events.aborted$ has a bug (but has test which explicitly verifies) where it's fired even when the request completes - // https://github.com/elastic/kibana/pull/70495#issuecomment-656288766 - request.events.aborted$.toPromise().then(() => { - counter.decrease(); - }); - - return toolkit.next(); - }); -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 547bdb9105c21..9d9df85c19cfc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -41,7 +41,10 @@ import { getSignalsMigrationStatusSchemaMock } from '../../../../../common/detec import { RuleParams } from '../../schemas/rule_schemas'; import { SanitizedAlert, ResolvedSanitizedRule } from '../../../../../../alerting/common'; import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; -import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; +import { + getPerformBulkActionSchemaMock, + getPerformBulkActionEditSchemaMock, +} from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common/schemas'; import { GetCurrentStatusBulkResult } from '../../rule_execution_log/types'; // eslint-disable-next-line no-restricted-imports @@ -132,6 +135,13 @@ export const getBulkActionRequest = () => body: getPerformBulkActionSchemaMock(), }); +export const getBulkActionEditRequest = () => + requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: getPerformBulkActionEditSchemaMock(), + }); + export const getDeleteBulkRequest = () => requestMock.create({ method: 'delete', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index 9e821c8f686f6..1a79d12ae1b18 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -144,7 +144,6 @@ export const patchRulesBulkRoute = ( const rule = await patchRules({ rule: migratedRule, rulesClient, - savedObjectsClient, author, buildingBlockType, description, @@ -157,8 +156,6 @@ export const patchRulesBulkRoute = ( license, outputIndex, savedId, - spaceId: context.securitySolution.getSpaceId(), - ruleStatusClient, timelineId, timelineTitle, meta, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts index da3e4ccc99b99..6d11fc5851625 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -144,7 +144,6 @@ export const patchRulesRoute = ( const rule = await patchRules({ rulesClient, - savedObjectsClient, author, buildingBlockType, description, @@ -157,8 +156,6 @@ export const patchRulesRoute = ( license, outputIndex, savedId, - spaceId: context.securitySolution.getSpaceId(), - ruleStatusClient, timelineId, timelineTitle, meta, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts index 3e85b4898d01c..c99760b72b56b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts @@ -11,6 +11,7 @@ import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getBulkActionRequest, + getBulkActionEditRequest, getFindResultWithSingleHit, getFindResultWithMultiHits, } from '../__mocks__/request_responses'; @@ -18,24 +19,28 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { performBulkActionRoute } from './perform_bulk_action_route'; import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; import { loggingSystemMock } from 'src/core/server/mocks'; +import { isElasticRule } from '../../../../usage/detections'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); +jest.mock('../../../../usage/detections', () => ({ isElasticRule: jest.fn() })); describe.each([ ['Legacy', false], ['RAC', true], ])('perform_bulk_action - %s', (_, isRuleRegistryEnabled) => { + const isElasticRuleMock = isElasticRule as jest.Mock; let server: ReturnType; let { clients, context } = requestContextMock.createTools(); let ml: ReturnType; let logger: ReturnType; + const mockRule = getFindResultWithSingleHit(isRuleRegistryEnabled).data[0]; beforeEach(() => { server = serverMock.create(); logger = loggingSystemMock.createLogger(); ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); - + isElasticRuleMock.mockReturnValue(false); clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); performBulkActionRoute(server.router, ml, logger, isRuleRegistryEnabled); @@ -73,20 +78,78 @@ describe.each([ expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); + }); + + describe('rules execution failures', () => { + it('returns error if rule is immutable/elastic', async () => { + isElasticRuleMock.mockReturnValue(true); + clients.rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: [mockRule], + total: 1, + }) + ); - it('catches error if disable throws error', async () => { + const response = await server.inject(getBulkActionEditRequest(), context); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Bulk edit failed', + status_code: 500, + attributes: { + errors: [ + { + message: 'Elastic rule can`t be edited', + status_code: 403, + rules: [ + { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + rules: { + failed: 1, + succeeded: 0, + total: 1, + }, + }, + }); + }); + + it('returns error if disable rule throws error', async () => { clients.rulesClient.disable.mockImplementation(async () => { throw new Error('Test error'); }); const response = await server.inject(getBulkActionRequest(), context); expect(response.status).toEqual(500); expect(response.body).toEqual({ - message: 'Test error', + message: 'Bulk edit failed', status_code: 500, + attributes: { + errors: [ + { + message: 'Test error', + status_code: 500, + rules: [ + { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + rules: { + failed: 1, + succeeded: 0, + total: 1, + }, + }, }); }); - it('rejects patching a rule if mlAuthz fails', async () => { + it('returns error if machine learning rule validation fails', async () => { (buildMlAuthz as jest.Mock).mockReturnValueOnce({ validateRuleType: jest .fn() @@ -94,12 +157,105 @@ describe.each([ }); const response = await server.inject(getBulkActionRequest(), context); - expect(response.status).toEqual(403); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + attributes: { + errors: [ + { + message: 'mocked validation message', + status_code: 403, + rules: [ + { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + rules: { + failed: 1, + succeeded: 0, + total: 1, + }, + }, + message: 'Bulk edit failed', + status_code: 500, + }); + }); + + it('returns partial failure error if couple of rule validations fail and the rest are successfull', async () => { + clients.rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: [ + { ...mockRule, id: 'failed-rule-id-1' }, + { ...mockRule, id: 'failed-rule-id-2' }, + { ...mockRule, id: 'failed-rule-id-3' }, + mockRule, + mockRule, + ], + total: 5, + }) + ); + + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockImplementationOnce(() => ({ valid: false, message: 'mocked validation message' })) + .mockImplementationOnce(() => ({ valid: false, message: 'mocked validation message' })) + .mockImplementationOnce(() => ({ valid: false, message: 'test failure' })) + .mockImplementationOnce(() => ({ valid: true })) + .mockImplementationOnce(() => ({ valid: true })), + }); + const response = await server.inject(getBulkActionEditRequest(), context); + + expect(response.status).toEqual(500); expect(response.body).toEqual({ - message: 'mocked validation message', - status_code: 403, + attributes: { + rules: { + failed: 3, + succeeded: 2, + total: 5, + }, + errors: [ + { + message: 'mocked validation message', + status_code: 403, + rules: [ + { + id: 'failed-rule-id-1', + name: 'Detect Root/Admin Users', + }, + { + id: 'failed-rule-id-2', + name: 'Detect Root/Admin Users', + }, + ], + }, + { + message: 'test failure', + status_code: 403, + rules: [ + { + id: 'failed-rule-id-3', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + }, + message: 'Bulk edit partially failed', + status_code: 500, }); }); + + it('return error message limited to length of 1000, to prevent large response size', async () => { + clients.rulesClient.disable.mockImplementation(async () => { + throw new Error('a'.repeat(1_300)); + }); + const response = await server.inject(getBulkActionRequest(), context); + expect(response.status).toEqual(500); + expect(response.body.attributes.errors[0].message.length).toEqual(1000); + }); }); describe('request validation', () => { @@ -111,7 +267,7 @@ describe.each([ }); const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - 'Invalid value "undefined" supplied to "action"' + 'Invalid value "undefined" supplied to "action",Invalid value "undefined" supplied to "edit"' ); }); @@ -123,7 +279,7 @@ describe.each([ }); const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - 'Invalid value "unknown" supplied to "action"' + 'Invalid value "unknown" supplied to "action",Invalid value "undefined" supplied to "edit"' ); }); @@ -149,4 +305,19 @@ describe.each([ expect(result.ok).toHaveBeenCalled(); }); }); + + it('should process large number of rules, larger than configured concurrency', async () => { + const rulesNumber = 6_000; + clients.rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: Array.from({ length: rulesNumber }).map(() => mockRule), + total: rulesNumber, + }) + ); + + const response = await server.inject(getBulkActionEditRequest(), context); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ success: true, rules_count: rulesNumber }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts index fb766124ea6ee..f263cd7b9cec1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts @@ -5,25 +5,129 @@ * 2.0. */ +import moment from 'moment'; import { transformError } from '@kbn/securitysolution-es-utils'; import { Logger } from 'src/core/server'; -import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants'; +import { RuleAlertType as Rule } from '../../rules/types'; + +import { + DETECTION_ENGINE_RULES_BULK_ACTION, + MAX_RULES_TO_UPDATE_IN_PARALLEL, +} from '../../../../../common/constants'; import { BulkAction } from '../../../../../common/detection_engine/schemas/common/schemas'; import { performBulkActionSchema } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema'; import { SetupPlugins } from '../../../../plugin'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; +import { routeLimitedConcurrencyTag } from '../../../../utils/route_limited_concurrency_tag'; +import { initPromisePool } from '../../../../utils/promise_pool'; +import { isElasticRule } from '../../../../usage/detections'; import { buildMlAuthz } from '../../../machine_learning/authz'; import { throwHttpError } from '../../../machine_learning/validation'; import { deleteRules } from '../../rules/delete_rules'; import { duplicateRule } from '../../rules/duplicate_rule'; import { enableRule } from '../../rules/enable_rule'; import { findRules } from '../../rules/find_rules'; +import { patchRules } from '../../rules/patch_rules'; +import { appplyBulkActionEditToRule } from '../../rules/bulk_action_edit'; import { getExportByObjectIds } from '../../rules/get_export_by_object_ids'; import { buildSiemResponse } from '../utils'; -const BULK_ACTION_RULES_LIMIT = 10000; +const MAX_RULES_TO_PROCESS_TOTAL = 10000; +const MAX_ERROR_MESSAGE_LENGTH = 1000; +const MAX_ROUTE_CONCURRENCY = 5; + +type RuleActionFn = (rule: Rule) => Promise; + +type RuleActionSuccess = undefined; + +type RuleActionResult = RuleActionSuccess | RuleActionError; + +interface RuleActionError { + error: { + message: string; + statusCode: number; + }; + rule: { + id: string; + name: string; + }; +} + +interface NormalizedRuleError { + message: string; + status_code: number; + rules: Array<{ + id: string; + name: string; + }>; +} + +const normalizeErrorResponse = (errors: RuleActionError[]): NormalizedRuleError[] => { + const errorsMap = new Map(); + + errors.forEach((ruleError) => { + const { message } = ruleError.error; + if (errorsMap.has(message)) { + errorsMap.get(message).rules.push(ruleError.rule); + } else { + const { error, rule } = ruleError; + errorsMap.set(message, { + message: error.message, + status_code: error.statusCode, + rules: [rule], + }); + } + }); + + return Array.from(errorsMap, ([_, normalizedError]) => normalizedError); +}; + +const getErrorResponseBody = (errors: RuleActionError[], rulesCount: number) => { + const errorsCount = errors.length; + return { + message: errorsCount === rulesCount ? 'Bulk edit failed' : 'Bulk edit partially failed', + status_code: 500, + attributes: { + errors: normalizeErrorResponse(errors).map(({ message, ...error }) => ({ + ...error, + message: + message.length > MAX_ERROR_MESSAGE_LENGTH + ? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 3)}...` + : message, + })), + rules: { + total: rulesCount, + failed: errorsCount, + succeeded: rulesCount - errorsCount, + }, + }, + }; +}; + +const executeActionAndHandleErrors = async ( + rule: Rule, + action: RuleActionFn +): Promise => { + try { + await action(rule); + } catch (err) { + const { message, statusCode } = transformError(err); + return { + error: { message, statusCode }, + rule: { id: rule.id, name: rule.name }, + }; + } +}; + +const executeBulkAction = async (rules: Rule[], action: RuleActionFn, abortSignal: AbortSignal) => + initPromisePool({ + concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, + items: rules, + executor: async (rule) => executeActionAndHandleErrors(rule, action), + abortSignal, + }); export const performBulkActionRoute = ( router: SecuritySolutionPluginRouter, @@ -38,12 +142,20 @@ export const performBulkActionRoute = ( body: buildRouteValidation(performBulkActionSchema), }, options: { - tags: ['access:securitySolution'], + tags: ['access:securitySolution', routeLimitedConcurrencyTag(MAX_ROUTE_CONCURRENCY)], + timeout: { + idleSocket: moment.duration(15, 'minutes').asMilliseconds(), + }, }, }, async (context, request, response) => { const { body } = request; const siemResponse = buildSiemResponse(response); + const abortController = new AbortController(); + + // subscribing to completed$, because it handles both cases when request was completed and aborted. + // when route is finished by timeout, aborted$ is not getting fired + request.events.completed$.subscribe(() => abortController.abort()); try { const rulesClient = context.alerting?.getRulesClient(); @@ -65,7 +177,7 @@ export const performBulkActionRoute = ( const rules = await findRules({ isRuleRegistryEnabled, rulesClient, - perPage: BULK_ACTION_RULES_LIMIT, + perPage: MAX_RULES_TO_PROCESS_TOTAL, filter: body.query !== '' ? body.query : undefined, page: undefined, sortField: undefined, @@ -73,17 +185,23 @@ export const performBulkActionRoute = ( fields: undefined, }); - if (rules.total > BULK_ACTION_RULES_LIMIT) { + if (rules.total > MAX_RULES_TO_PROCESS_TOTAL) { return siemResponse.error({ - body: `More than ${BULK_ACTION_RULES_LIMIT} rules matched the filter query. Try to narrow it down.`, + body: `More than ${MAX_RULES_TO_PROCESS_TOTAL} rules matched the filter query. Try to narrow it down.`, statusCode: 400, }); } + let processingResponse: { + results: RuleActionResult[]; + } = { + results: [], + }; switch (body.action) { case BulkAction.enable: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { if (!rule.enabled) { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); await enableRule({ @@ -91,39 +209,46 @@ export const performBulkActionRoute = ( rulesClient, }); } - }) + }, + abortController.signal ); break; case BulkAction.disable: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { if (rule.enabled) { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); await rulesClient.disable({ id: rule.id }); } - }) + }, + abortController.signal ); break; case BulkAction.delete: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { await deleteRules({ ruleId: rule.id, rulesClient, ruleStatusClient, }); - }) + }, + abortController.signal ); break; case BulkAction.duplicate: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); await rulesClient.create({ data: duplicateRule(rule, isRuleRegistryEnabled), }); - }) + }, + abortController.signal ); break; case BulkAction.export: @@ -145,9 +270,65 @@ export const performBulkActionRoute = ( }, body: responseBody, }); + case BulkAction.edit: + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { + throwHttpError({ + valid: !isElasticRule(rule.tags), + message: 'Elastic rule can`t be edited', + }); + + throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); + + const editedRule = body[BulkAction.edit].reduce( + (acc, action) => appplyBulkActionEditToRule(acc, action), + rule + ); + + const { tags, params: { timelineTitle, timelineId } = {} } = editedRule; + const index = 'index' in editedRule.params ? editedRule.params.index : undefined; + + await patchRules({ + rulesClient, + rule, + tags, + index, + timelineTitle, + timelineId, + }); + }, + abortController.signal + ); + } + + if (abortController.signal.aborted === true) { + throw Error('Bulk action was aborted'); + } + + const errors = processingResponse.results.filter( + (resp): resp is RuleActionError => resp?.error !== undefined + ); + const rulesCount = rules.data.length; + + if (errors.length > 0) { + const responseBody = getErrorResponseBody(errors, rulesCount); + + return response.custom({ + headers: { + 'content-type': 'application/json', + }, + body: Buffer.from(JSON.stringify(responseBody)), + statusCode: 500, + }); } - return response.ok({ body: { success: true, rules_count: rules.data.length } }); + return response.ok({ + body: { + success: true, + rules_count: rulesCount, + }, + }); } catch (err) { const error = transformError(err); return siemResponse.error({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index db4887f14108e..e3a125e50bfe9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -85,13 +85,10 @@ export const updateRulesBulkRoute = ( }); const rule = await updateRules({ - spaceId: context.securitySolution.getSpaceId(), rulesClient, - ruleStatusClient, defaultOutputIndex: siemClient.getSignalsIndex(), existingRule: migratedRule, ruleUpdate: payloadRule, - isRuleRegistryEnabled, }); if (rule != null) { const ruleStatus = await ruleStatusClient.getCurrentStatus({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts index d18171c489512..f8bb60eb5f77f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -76,12 +76,9 @@ export const updateRulesRoute = ( }); const rule = await updateRules({ defaultOutputIndex: siemClient.getSignalsIndex(), - isRuleRegistryEnabled, rulesClient, - ruleStatusClient, existingRule: migratedRule, ruleUpdate: request.body, - spaceId: context.securitySolution.getSpaceId(), }); if (rule != null) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts index 02f3ab46f7cf2..3f0adaf58a2fd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts @@ -243,11 +243,8 @@ export const importRules = async ({ }); await patchRules({ rulesClient, - savedObjectsClient, author, buildingBlockType, - spaceId, - ruleStatusClient, description, enabled, eventCategoryOverride, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts new file mode 100644 index 0000000000000..db6ef37cade36 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { + addItemsToArray, + deleteItemsFromArray, + appplyBulkActionEditToRule, +} from './bulk_action_edit'; +import { BulkActionEditType } from '../../../../common/detection_engine/schemas/common/schemas'; +import { RuleAlertType } from './types'; +describe('bulk_action_edit', () => { + describe('addItemsToArray', () => { + test('should add single item to array', () => { + expect(addItemsToArray(['a', 'b', 'c'], ['d'])).toEqual(['a', 'b', 'c', 'd']); + }); + + test('should add multiple items to array', () => { + expect(addItemsToArray(['a', 'b', 'c'], ['d', 'e'])).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + test('should not allow to add duplicated items', () => { + expect(addItemsToArray(['a', 'c'], ['b', 'c'])).toEqual(['a', 'c', 'b']); + }); + }); + + describe('deleteItemsFromArray', () => { + test('should remove single item from array', () => { + expect(deleteItemsFromArray(['a', 'b', 'c'], ['c'])).toEqual(['a', 'b']); + }); + + test('should remove multiple items from array', () => { + expect(deleteItemsFromArray(['a', 'b', 'c'], ['b', 'c'])).toEqual(['a']); + }); + + test('should return array unchanged if items to remove absent in array', () => { + expect(deleteItemsFromArray(['a', 'c'], ['x', 'z'])).toEqual(['a', 'c']); + }); + }); + + describe('appplyBulkActionEditToRule', () => { + const ruleMock = { + tags: ['tag1', 'tag2'], + params: { index: ['initial-index-*'] }, + }; + describe('tags', () => { + test('should add new tags to rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.add_tags, + value: ['new_tag'], + }); + expect(editedRule.tags).toEqual(['tag1', 'tag2', 'new_tag']); + }); + test('should remove tag from rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.delete_tags, + value: ['tag1'], + }); + expect(editedRule.tags).toEqual(['tag2']); + }); + + test('should rewrite tags in rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.set_tags, + value: ['tag_r_1', 'tag_r_2'], + }); + expect(editedRule.tags).toEqual(['tag_r_1', 'tag_r_2']); + }); + }); + + describe('index_patterns', () => { + test('should add new index pattern to rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.add_index_patterns, + value: ['my-index-*'], + }); + expect(editedRule.params).toHaveProperty('index', ['initial-index-*', 'my-index-*']); + }); + test('should remove index pattern from rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.delete_index_patterns, + value: ['initial-index-*'], + }); + expect(editedRule.params).toHaveProperty('index', []); + }); + + test('should rewrite index pattern in rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.set_index_patterns, + value: ['index'], + }); + expect(editedRule.params).toHaveProperty('index', ['index']); + }); + + test('should not add new index pattern to rule if index pattern is absent', () => { + const editedRule = appplyBulkActionEditToRule({ params: {} } as RuleAlertType, { + type: BulkActionEditType.add_index_patterns, + value: ['my-index-*'], + }); + expect(editedRule.params).not.toHaveProperty('index'); + }); + + test('should not remove index pattern to rule if index pattern is absent', () => { + const editedRule = appplyBulkActionEditToRule({ params: {} } as RuleAlertType, { + type: BulkActionEditType.delete_index_patterns, + value: ['initial-index-*'], + }); + expect(editedRule.params).not.toHaveProperty('index'); + }); + + test('should not set index pattern to rule if index pattern is absent', () => { + const editedRule = appplyBulkActionEditToRule({ params: {} } as RuleAlertType, { + type: BulkActionEditType.set_index_patterns, + value: ['index-*'], + }); + expect(editedRule.params).not.toHaveProperty('index'); + }); + }); + + describe('timeline', () => { + test('should set timeline', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: '91832785-286d-4ebe-b884-1a208d111a70', + timeline_title: 'Test timeline', + }, + }); + + expect(editedRule.params.timelineId).toBe('91832785-286d-4ebe-b884-1a208d111a70'); + expect(editedRule.params.timelineTitle).toBe('Test timeline'); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts new file mode 100644 index 0000000000000..0f56fd86be8ed --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts @@ -0,0 +1,73 @@ +/* + * 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 { RuleAlertType } from './types'; + +import { + BulkActionEditPayload, + BulkActionEditType, +} from '../../../../common/detection_engine/schemas/common/schemas'; + +export const addItemsToArray = (arr: T[], items: T[]): T[] => + Array.from(new Set([...arr, ...items])); + +export const deleteItemsFromArray = (arr: T[], items: T[]): T[] => { + const itemsSet = new Set(items); + return arr.filter((item) => !itemsSet.has(item)); +}; + +export const appplyBulkActionEditToRule = ( + existingRule: RuleAlertType, + action: BulkActionEditPayload +): RuleAlertType => { + const rule = { ...existingRule, params: { ...existingRule.params } }; + switch (action.type) { + // tags actions + case BulkActionEditType.add_tags: + rule.tags = addItemsToArray(rule.tags ?? [], action.value); + break; + + case BulkActionEditType.delete_tags: + rule.tags = deleteItemsFromArray(rule.tags ?? [], action.value); + break; + + case BulkActionEditType.set_tags: + rule.tags = action.value; + break; + + // index_patterns actions + // index is not present in all rule types(machine learning). But it's mandatory for the rest. + // So we check if index is present and only in that case apply action + case BulkActionEditType.add_index_patterns: + if (rule.params && 'index' in rule.params) { + rule.params.index = addItemsToArray(rule.params.index ?? [], action.value); + } + break; + + case BulkActionEditType.delete_index_patterns: + if (rule.params && 'index' in rule.params) { + rule.params.index = deleteItemsFromArray(rule.params.index ?? [], action.value); + } + break; + + case BulkActionEditType.set_index_patterns: + if (rule.params && 'index' in rule.params) { + rule.params.index = action.value; + } + break; + + // timeline actions + case BulkActionEditType.set_timeline: + rule.params = { + ...rule.params, + timelineId: action.value.timeline_id, + timelineTitle: action.value.timeline_title, + }; + } + + return rule; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index 3a602a54ca099..2bd59abb1ea6f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -7,18 +7,13 @@ import { PatchRulesOptions } from './types'; import { rulesClientMock } from '../../../../../alerting/server/mocks'; -import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { getAlertMock } from '../routes/__mocks__/request_responses'; import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; -import { ruleExecutionLogClientMock } from '../rule_execution_log/__mocks__/rule_execution_log_client'; export const getPatchRulesOptionsMock = (isRuleRegistryEnabled: boolean): PatchRulesOptions => ({ author: ['Elastic'], buildingBlockType: undefined, rulesClient: rulesClientMock.create(), - savedObjectsClient: savedObjectsClientMock.create(), - spaceId: 'default', - ruleStatusClient: ruleExecutionLogClientMock.create(), anomalyThreshold: undefined, description: 'some description', enabled: true, @@ -71,9 +66,6 @@ export const getPatchMlRulesOptionsMock = (isRuleRegistryEnabled: boolean): Patc author: ['Elastic'], buildingBlockType: undefined, rulesClient: rulesClientMock.create(), - savedObjectsClient: savedObjectsClientMock.create(), - spaceId: 'default', - ruleStatusClient: ruleExecutionLogClientMock.create(), anomalyThreshold: 55, description: 'some description', enabled: true, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index 8c256c54c24ab..a10247005c826 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -37,11 +37,8 @@ class PatchError extends Error { export const patchRules = async ({ rulesClient, - savedObjectsClient, author, buildingBlockType, - ruleStatusClient, - spaceId, description, eventCategoryOverride, falsePositives, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 06328137973c6..95139b4ae3d66 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -262,20 +262,17 @@ export interface CreateRulesOptions { } export interface UpdateRulesOptions { - isRuleRegistryEnabled: boolean; - spaceId: string; - ruleStatusClient: IRuleExecutionLogClient; rulesClient: RulesClient; defaultOutputIndex: string; existingRule: SanitizedAlert | null | undefined; ruleUpdate: UpdateRulesSchema; } -export interface PatchRulesOptions { - spaceId: string; - ruleStatusClient: IRuleExecutionLogClient; +export interface PatchRulesOptions extends Partial { rulesClient: RulesClient; - savedObjectsClient: SavedObjectsClientContract; + rule: SanitizedAlert | null | undefined; +} +interface PatchRulesFieldsOptions { anomalyThreshold: AnomalyThresholdOrUndefined; author: AuthorOrUndefined; buildingBlockType: BuildingBlockTypeOrUndefined; @@ -323,7 +320,6 @@ export interface PatchRulesOptions { version: VersionOrUndefined; exceptionsList: ListArrayOrUndefined; actions: RuleAlertAction[] | undefined; - rule: SanitizedAlert | null | undefined; namespace?: NamespaceOrUndefined; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index e24a6a883b6df..71ca8cf8f1dfa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -8,6 +8,7 @@ import { chunk } from 'lodash/fp'; import { SavedObjectsClientContract } from 'kibana/server'; import { AddPrepackagedRulesSchemaDecoded } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema'; +import { MAX_RULES_TO_UPDATE_IN_PARALLEL } from '../../../../common/constants'; import { RulesClient, PartialAlert } from '../../../../../alerting/server'; import { patchRules } from './patch_rules'; import { readRules } from './read_rules'; @@ -16,31 +17,6 @@ import { RuleParams } from '../schemas/rule_schemas'; import { IRuleExecutionLogClient } from '../rule_execution_log/types'; import { legacyMigrate } from './utils'; -/** - * How many rules to update at a time is set to 50 from errors coming from - * the slow environments such as cloud when the rule updates are > 100 we were - * seeing timeout issues. - * - * Since there is not timeout options at the alerting API level right now, we are - * at the mercy of the Elasticsearch server client/server default timeouts and what - * we are doing could be considered a workaround to not being able to increase the timeouts. - * - * However, other bad effects and saturation of connections beyond 50 makes this a "noisy neighbor" - * if we don't limit its number of connections as we increase the number of rules that can be - * installed at a time. - * - * Lastly, we saw weird issues where Chrome on upstream 408 timeouts will re-call the REST route - * which in turn could create additional connections we want to avoid. - * - * See file import_rules_route.ts for another area where 50 was chosen, therefore I chose - * 50 here to mimic it as well. If you see this re-opened or what similar to it, consider - * reducing the 50 above to a lower number. - * - * See the original ticket here: - * https://github.com/elastic/kibana/issues/94418 - */ -export const UPDATE_CHUNK_SIZE = 50; - /** * Updates the prepackaged rules given a set of rules and output index. * This implements a chunked approach to not saturate network connections and @@ -60,7 +36,7 @@ export const updatePrepackagedRules = async ( outputIndex: string, isRuleRegistryEnabled: boolean ): Promise => { - const ruleChunks = chunk(UPDATE_CHUNK_SIZE, rules); + const ruleChunks = chunk(MAX_RULES_TO_UPDATE_IN_PARALLEL, rules); for (const ruleChunk of ruleChunks) { const rulePromises = createPromises( rulesClient, @@ -162,7 +138,6 @@ export const createPromises = ( // or enable rules on the user when they were not expecting it if a rule updates return patchRules({ rulesClient, - savedObjectsClient, author, buildingBlockType, description, @@ -175,8 +150,6 @@ export const createPromises = ( outputIndex, rule: migratedRule, savedId, - spaceId, - ruleStatusClient, meta, filters, index, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index 476a9e4d615f2..62c59bc6a698f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -27,9 +27,7 @@ class UpdateError extends Error { } export const updateRules = async ({ - spaceId, rulesClient, - ruleStatusClient, defaultOutputIndex, existingRule, ruleUpdate, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index 4ab8afd796f6d..dee2006669f85 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -98,7 +98,7 @@ export interface UpdateProperties { timelineTitle: TimelineTitleOrUndefined; meta: MetaOrUndefined; machineLearningJobId: MachineLearningJobIdOrUndefined; - filters: PartialFilter[]; + filters: PartialFilter[] | undefined; index: IndexOrUndefined; interval: IntervalOrUndefined; maxSignals: MaxSignalsOrUndefined; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 7d893756a3144..541911289c3bc 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -37,6 +37,7 @@ import { createThresholdAlertType, } from './lib/detection_engine/rule_types'; import { initRoutes } from './routes'; +import { registerLimitedConcurrencyRoutes } from './routes/limited_concurrency'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; import { ManifestTask } from './endpoint/lib/artifacts'; @@ -52,7 +53,6 @@ import { DEFAULT_ALERTS_INDEX, } from '../common/constants'; import { registerEndpointRoutes } from './endpoint/routes/metadata'; -import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; import { registerActionRoutes } from './endpoint/routes/actions'; diff --git a/x-pack/plugins/security_solution/server/routes/limited_concurrency.ts b/x-pack/plugins/security_solution/server/routes/limited_concurrency.ts new file mode 100644 index 0000000000000..7e0b1686ee467 --- /dev/null +++ b/x-pack/plugins/security_solution/server/routes/limited_concurrency.ts @@ -0,0 +1,114 @@ +/* + * 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 { + CoreSetup, + KibanaRequest, + LifecycleResponseFactory, + OnPreAuthToolkit, +} from 'kibana/server'; +import { LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX } from '../../common/constants'; + +class MaxCounter { + constructor(private readonly max: number = 1) {} + private counter = 0; + valueOf() { + return this.counter; + } + increase() { + if (this.counter < this.max) { + this.counter += 1; + } + } + decrease() { + if (this.counter > 0) { + this.counter -= 1; + } + } + lessThanMax() { + return this.counter < this.max; + } +} + +function getRouteConcurrencyTag(request: KibanaRequest) { + const tags = request.route.options.tags; + return tags.find((tag) => tag.startsWith(LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX)); +} + +function shouldHandleRequest(request: KibanaRequest) { + return getRouteConcurrencyTag(request) !== undefined; +} + +function getRouteMaxConcurrency(request: KibanaRequest) { + const tag = getRouteConcurrencyTag(request); + return parseInt(tag?.split(':')[2] || '', 10); +} + +const initCounterMap = () => { + const counterMap = new Map(); + const getCounter = (request: KibanaRequest) => { + const path = request.route.path; + + if (!counterMap.has(path)) { + const maxCount = getRouteMaxConcurrency(request); + if (isNaN(maxCount)) { + return null; + } + + counterMap.set(path, new MaxCounter(maxCount)); + } + + return counterMap.get(path) as MaxCounter; + }; + + return { + getCounter, + }; +}; + +/** + * This method limits concurrency for routes + * It checks if route has tag that begins LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX prefix + * If tag is found and has concurrency number separated by colon ':', this max concurrency number will be applied to the route + * If tag is malformed, i.e. not valid concurrency number, max concurency will not be applied to the route + * @param core CoreSetup Context passed to the `setup` method of `standard` plugins. + */ +export function registerLimitedConcurrencyRoutes(core: CoreSetup) { + const countersMap = initCounterMap(); + + core.http.registerOnPreAuth(function preAuthHandler( + request: KibanaRequest, + response: LifecycleResponseFactory, + toolkit: OnPreAuthToolkit + ) { + if (!shouldHandleRequest(request)) { + return toolkit.next(); + } + + const counter = countersMap.getCounter(request); + + if (counter === null) { + return toolkit.next(); + } + + if (!counter.lessThanMax()) { + return response.customError({ + body: 'Too Many Requests', + statusCode: 429, + }); + } + + counter.increase(); + + // when request is completed or aborted, decrease counter + request.events.completed$.subscribe(() => { + counter.decrease(); + }); + + return toolkit.next(); + }); +} diff --git a/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts b/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts index 3a2e7ad160bd2..585044de5856a 100644 --- a/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts +++ b/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts @@ -9,7 +9,7 @@ import { initPromisePool } from './promise_pool'; const nextTick = () => new Promise((resolve) => setImmediate(resolve)); -const initPoolWithTasks = ({ concurrency = 1, items = [1, 2, 3] }) => { +const initPoolWithTasks = ({ concurrency = 1, items = [1, 2, 3] }, abortSignal?: AbortSignal) => { const asyncTasks: Record< number, { @@ -36,6 +36,7 @@ const initPoolWithTasks = ({ concurrency = 1, items = [1, 2, 3] }) => { }, }; }), + abortSignal, }); return [promisePool, asyncTasks] as const; @@ -112,7 +113,7 @@ describe('initPromisePool', () => { const { results, errors } = await promisePool; - // Check final reesuts + // Check final results expect(results).toEqual([1, 3]); expect(errors).toEqual([new Error(`Error processing 2`)]); }); @@ -167,8 +168,52 @@ describe('initPromisePool', () => { const { results, errors } = await promisePool; - // Check final reesuts + // Check final results expect(results).toEqual([1, 4, 5]); expect(errors).toEqual([new Error(`Error processing 2`), new Error(`Error processing 3`)]); }); + + it('should not execute tasks if abortSignal is aborted', async () => { + const abortSignal = { aborted: true }; + const [promisePool] = initPoolWithTasks( + { + concurrency: 2, + items: [1, 2, 3, 4, 5], + }, + abortSignal as AbortSignal + ); + + const { results, errors, abortedExecutionsCount } = await promisePool; + + // Check final results + expect(results).toEqual([]); + expect(errors).toEqual([]); + expect(abortedExecutionsCount).toEqual(5); + }); + + it('should abort executions of tasks if abortSignal was set to aborted during execution', async () => { + const abortSignal = { aborted: false }; + const [promisePool, asyncTasks] = initPoolWithTasks( + { + concurrency: 1, + items: [1, 2, 3], + }, + abortSignal as AbortSignal + ); + + // resolve first task, and abort execution + asyncTasks[1].resolve(); + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'resolved' }), + }); + + abortSignal.aborted = true; + + const { results, errors, abortedExecutionsCount } = await promisePool; + + // Check final results + expect(results).toEqual([1]); + expect(errors).toEqual([]); + expect(abortedExecutionsCount).toEqual(2); + }); }); diff --git a/x-pack/plugins/security_solution/server/utils/promise_pool.ts b/x-pack/plugins/security_solution/server/utils/promise_pool.ts index d0c848bc11787..ed0922b952c77 100644 --- a/x-pack/plugins/security_solution/server/utils/promise_pool.ts +++ b/x-pack/plugins/security_solution/server/utils/promise_pool.ts @@ -9,6 +9,7 @@ interface PromisePoolArgs { concurrency?: number; items: Item[]; executor: (item: Item) => Promise; + abortSignal?: AbortSignal; } /** @@ -18,13 +19,16 @@ interface PromisePoolArgs { * @param concurrency - number of tasks run in parallel * @param items - array of items to be passes to async executor * @param executor - an async function to be called with each provided item + * @param abortSignal - AbortSignal a signal object that allows to abort executing actions * - * @returns Struct holding results or errors of async tasks + * @returns Struct holding results or errors of async tasks, aborted executions count if applicable */ + export const initPromisePool = async ({ concurrency = 1, items, executor, + abortSignal, }: PromisePoolArgs) => { const tasks: Array> = []; const results: Result[] = []; @@ -37,6 +41,11 @@ export const initPromisePool = async ({ await Promise.race(tasks); } + // if abort signal was sent stop processing tasks further + if (abortSignal?.aborted === true) { + break; + } + const task: Promise = executor(item) .then((result) => { results.push(result); @@ -54,5 +63,10 @@ export const initPromisePool = async ({ // Wait for all remaining tasks to finish await Promise.all(tasks); - return { results, errors }; + const aborted = + abortSignal?.aborted === true + ? { abortedExecutionsCount: items.length - results.length - errors.length } + : undefined; + + return { results, errors, ...aborted }; }; diff --git a/x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts b/x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts new file mode 100644 index 0000000000000..95092a0e08218 --- /dev/null +++ b/x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX } from '../../common/constants'; + +/** + * Generates max concurrency tag, that can be passed to route tags + * @param maxConcurrency - number max concurrency to add to tag + * @returns string generetad route tag + * + */ +export const routeLimitedConcurrencyTag = (maxConcurrency: number) => + [LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX, maxConcurrency].join(':'); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts index bb117b50d5aed..1643c4851c024 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts @@ -11,7 +11,10 @@ import { DETECTION_ENGINE_RULES_BULK_ACTION, DETECTION_ENGINE_RULES_URL, } from '../../../../plugins/security_solution/common/constants'; -import { BulkAction } from '../../../../plugins/security_solution/common/detection_engine/schemas/common/schemas'; +import { + BulkAction, + BulkActionEditType, +} from '../../../../plugins/security_solution/common/detection_engine/schemas/common/schemas'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { binaryToString, @@ -29,6 +32,11 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const log = getService('log'); + const postBulkAction = () => + supertest.post(DETECTION_ENGINE_RULES_BULK_ACTION).set('kbn-xsrf', 'true'); + const fetchRule = (ruleId: string) => + supertest.get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`).set('kbn-xsrf', 'true'); + describe('perform_bulk_action', () => { beforeEach(async () => { await createSignalsIndex(supertest, log); @@ -42,9 +50,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should export rules', async () => { await createRule(supertest, log, getSimpleRule()); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.export }) .expect(200) .expect('Content-Type', 'application/ndjson') @@ -75,36 +81,26 @@ export default ({ getService }: FtrProviderContext): void => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.delete }) .expect(200); expect(body).to.eql({ success: true, rules_count: 1 }); - await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) - .set('kbn-xsrf', 'true') - .expect(404); + await await fetchRule(ruleId).expect(404); }); it('should enable rules', async () => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.enable }) .expect(200); expect(body).to.eql({ success: true, rules_count: 1 }); - const { body: ruleBody } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) - .set('kbn-xsrf', 'true') - .expect(200); + const { body: ruleBody } = await fetchRule(ruleId).expect(200); const referenceRule = getSimpleRuleOutput(ruleId); referenceRule.enabled = true; @@ -118,18 +114,13 @@ export default ({ getService }: FtrProviderContext): void => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId, true)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.disable }) .expect(200); expect(body).to.eql({ success: true, rules_count: 1 }); - const { body: ruleBody } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) - .set('kbn-xsrf', 'true') - .expect(200); + const { body: ruleBody } = await fetchRule(ruleId).expect(200); const referenceRule = getSimpleRuleOutput(ruleId); const storedRule = removeServerGeneratedProperties(ruleBody); @@ -141,9 +132,7 @@ export default ({ getService }: FtrProviderContext): void => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.duplicate }) .expect(200); @@ -156,5 +145,186 @@ export default ({ getService }: FtrProviderContext): void => { expect(rulesResponse.total).to.eql(2); }); + + describe('edit action', () => { + it('should set, add and delete tags in rules', async () => { + const ruleId = 'ruleId'; + const tags = ['tag1', 'tag2']; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const { body: setTagsBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_tags, + value: ['reset-tag'], + }, + ], + }) + .expect(200); + + expect(setTagsBody).to.eql({ success: true, rules_count: 1 }); + + const { body: setTagsRule } = await fetchRule(ruleId).expect(200); + + expect(setTagsRule.tags).to.eql(['reset-tag']); + + const { body: addTagsBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.add_tags, + value: tags, + }, + ], + }) + .expect(200); + + expect(addTagsBody).to.eql({ success: true, rules_count: 1 }); + + const { body: addedTagsRule } = await fetchRule(ruleId).expect(200); + + expect(addedTagsRule.tags).to.eql(['reset-tag', ...tags]); + + await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.delete_tags, + value: ['reset-tag', 'tag1'], + }, + ], + }) + .expect(200); + + const { body: deletedTagsRule } = await fetchRule(ruleId).expect(200); + + expect(deletedTagsRule.tags).to.eql(['tag2']); + }); + + it('should set, add and delete index patterns in rules', async () => { + const ruleId = 'ruleId'; + const indices = ['index1-*', 'index2-*']; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const { body: setIndexBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_index_patterns, + value: ['initial-index-*'], + }, + ], + }) + .expect(200); + + expect(setIndexBody).to.eql({ success: true, rules_count: 1 }); + + const { body: setIndexRule } = await fetchRule(ruleId).expect(200); + + expect(setIndexRule.index).to.eql(['initial-index-*']); + + const { body: addIndexBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.add_index_patterns, + value: indices, + }, + ], + }) + .expect(200); + + expect(addIndexBody).to.eql({ success: true, rules_count: 1 }); + + const { body: addIndexRule } = await fetchRule(ruleId).expect(200); + + expect(addIndexRule.index).to.eql(['initial-index-*', ...indices]); + + await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.delete_index_patterns, + value: ['index1-*'], + }, + ], + }) + .expect(200); + + const { body: deleteIndexRule } = await fetchRule(ruleId).expect(200); + + expect(deleteIndexRule.index).to.eql(['initial-index-*', 'index2-*']); + }); + + it('should set timeline values in rule', async () => { + const ruleId = 'ruleId'; + const timelineId = '91832785-286d-4ebe-b884-1a208d111a70'; + const timelineTitle = 'Test timeline'; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const { body } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: timelineId, + timeline_title: timelineTitle, + }, + }, + ], + }) + .expect(200); + + expect(body).to.eql({ success: true, rules_count: 1 }); + + const { body: rule } = await fetchRule(ruleId).expect(200); + + expect(rule.timeline_id).to.eql(timelineId); + expect(rule.timeline_title).to.eql(timelineTitle); + }); + }); + + it('should limit concurrent requests to 5', async () => { + const ruleId = 'ruleId'; + const timelineId = '91832785-286d-4ebe-b884-1a208d111a70'; + const timelineTitle = 'Test timeline'; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const responses = await Promise.all( + Array.from({ length: 10 }).map(() => + postBulkAction().send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: timelineId, + timeline_title: timelineTitle, + }, + }, + ], + }) + ) + ); + + expect(responses.filter((r) => r.body.statusCode === 429).length).to.eql(5); + }); }); };