diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index c746ed1006e56..411bb2a4457e6 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -250,6 +250,13 @@ export const DETECTION_ENGINE_RULES_PREVIEW = `${DETECTION_ENGINE_RULES_URL}/pre export const DETECTION_ENGINE_RULES_PREVIEW_INDEX_URL = `${DETECTION_ENGINE_RULES_PREVIEW}/index` as const; +/** + * Internal detection engine routes + */ +export const INTERNAL_DETECTION_ENGINE_URL = '/internal/detection_engine' as const; +export const INTERNAL_DETECTION_ENGINE_RULE_STATUS_URL = + `${INTERNAL_DETECTION_ENGINE_URL}/rules/_find_statuses` as const; + export const TIMELINE_RESOLVE_URL = '/api/timeline/resolve' as const; export const TIMELINE_URL = '/api/timeline' as const; export const TIMELINES_URL = '/api/timelines' as const; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/internal_find_rule_status_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/internal_find_rule_status_route.test.ts new file mode 100644 index 0000000000000..6aa0350e3e16b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/internal_find_rule_status_route.test.ts @@ -0,0 +1,110 @@ +/* + * 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 { INTERNAL_DETECTION_ENGINE_RULE_STATUS_URL } from '../../../../../common/constants'; +import { + ruleStatusRequest, + getAlertMock, + getRuleExecutionStatusSucceeded, + getRuleExecutionStatusFailed, +} from '../__mocks__/request_responses'; +import { serverMock, requestContextMock, requestMock } from '../__mocks__'; +import { findRulesStatusesRoute } from './find_rules_status_route'; +import { RuleStatusResponse } from '../../rules/types'; +import { AlertExecutionStatusErrorReasons } from '../../../../../../alerting/common'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; + +describe.each([ + ['Legacy', false], + ['RAC', true], +])(`${INTERNAL_DETECTION_ENGINE_RULE_STATUS_URL} - %s`, (_, isRuleRegistryEnabled) => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + + beforeEach(async () => { + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + + clients.ruleExecutionLogClient.getCurrentStatus.mockResolvedValue( + getRuleExecutionStatusSucceeded() + ); + clients.ruleExecutionLogClient.getLastFailures.mockResolvedValue([ + getRuleExecutionStatusFailed(), + ]); + clients.rulesClient.get.mockResolvedValue( + getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) + ); + + findRulesStatusesRoute(server.router); + }); + + describe('status codes with actionClient and alertClient', () => { + test('returns 200 when finding a single rule status with a valid rulesClient', async () => { + const response = await server.inject(ruleStatusRequest(), context); + expect(response.status).toEqual(200); + }); + + test('returns 404 if alertClient is not available on the route', async () => { + context.alerting.getRulesClient = jest.fn(); + const response = await server.inject(ruleStatusRequest(), context); + expect(response.status).toEqual(404); + expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); + }); + + test('catch error when status search throws error', async () => { + clients.ruleExecutionLogClient.getCurrentStatus.mockImplementation(async () => { + throw new Error('Test error'); + }); + const response = await server.inject(ruleStatusRequest(), context); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Test error', + status_code: 500, + }); + }); + + test('returns success if rule status client writes an error status', async () => { + // 0. task manager tried to run the rule but couldn't, so the alerting framework + // wrote an error to the executionStatus. + const failingExecutionRule = getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()); + failingExecutionRule.executionStatus = { + status: 'error', + lastExecutionDate: failingExecutionRule.executionStatus.lastExecutionDate, + error: { + reason: AlertExecutionStatusErrorReasons.Read, + message: 'oops', + }, + }; + + // 1. getFailingRules api found a rule where the executionStatus was 'error' + clients.rulesClient.get.mockResolvedValue({ + ...failingExecutionRule, + }); + + const response = await server.inject(ruleStatusRequest(), context); + const body: RuleStatusResponse = response.body; + expect(response.status).toEqual(200); + expect(body[ruleStatusRequest().body.ids[0]].current_status?.status).toEqual('failed'); + expect(body[ruleStatusRequest().body.ids[0]].current_status?.last_failure_message).toEqual( + 'Reason: read Message: oops' + ); + }); + }); + + describe('request validation', () => { + test('disallows singular id query param', async () => { + const request = requestMock.create({ + method: 'post', + path: INTERNAL_DETECTION_ENGINE_RULE_STATUS_URL, + body: { id: ['someId'] }, + }); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith('Invalid value "undefined" supplied to "ids"'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/internal_find_rule_status_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/internal_find_rule_status_route.ts new file mode 100644 index 0000000000000..8cccf4502eeb9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/internal_find_rule_status_route.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { transformError } from '@kbn/securitysolution-es-utils'; +import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { INTERNAL_DETECTION_ENGINE_RULE_STATUS_URL } from '../../../../../common/constants'; +import { buildSiemResponse, mergeStatuses, getFailingRules } from '../utils'; +import { + findRulesStatusesSchema, + FindRulesStatusesSchemaDecoded, +} from '../../../../../common/detection_engine/schemas/request/find_rule_statuses_schema'; +import { mergeAlertWithSidecarStatus } from '../../schemas/rule_converters'; + +/** + * Given a list of rule ids, return the current status and + * last five errors for each associated rule. + * + * @param router + * @returns RuleStatusResponse + */ +export const internalFindRuleStatusRoute = (router: SecuritySolutionPluginRouter) => { + router.post( + { + path: INTERNAL_DETECTION_ENGINE_RULE_STATUS_URL, + validate: { + body: buildRouteValidation( + findRulesStatusesSchema + ), + }, + options: { + tags: ['access:securitySolution'], + }, + }, + async (context, request, response) => { + const { body } = request; + const siemResponse = buildSiemResponse(response); + const rulesClient = context.alerting?.getRulesClient(); + + if (!rulesClient) { + return siemResponse.error({ statusCode: 404 }); + } + + const ruleId = body.ids[0]; + + try { + const ruleStatusClient = context.securitySolution.getExecutionLogClient(); + const spaceId = context.securitySolution.getSpaceId(); + + const [currentStatus, lastFailures, failingRules] = await Promise.all([ + ruleStatusClient.getCurrentStatus({ ruleId, spaceId }), + ruleStatusClient.getLastFailures({ ruleId, spaceId }), + getFailingRules([ruleId], rulesClient), + ]); + + const failingRule = failingRules[ruleId]; + let statuses = {}; + + if (currentStatus != null) { + const finalCurrentStatus = + failingRule != null + ? mergeAlertWithSidecarStatus(failingRule, currentStatus) + : currentStatus; + + statuses = mergeStatuses(ruleId, [finalCurrentStatus, ...lastFailures], statuses); + } + + return response.ok({ body: statuses }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index f3e8cc1dee4b1..addd3fbd5bd0a 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -36,6 +36,7 @@ import { performBulkActionRoute } from '../lib/detection_engine/routes/rules/per import { importRulesRoute } from '../lib/detection_engine/routes/rules/import_rules_route'; import { exportRulesRoute } from '../lib/detection_engine/routes/rules/export_rules_route'; import { findRulesStatusesRoute } from '../lib/detection_engine/routes/rules/find_rules_status_route'; +import { internalFindRuleStatusRoute } from '../lib/detection_engine/routes/rules/internal_find_rule_status_route'; import { getPrepackagedRulesStatusRoute } from '../lib/detection_engine/routes/rules/get_prepackaged_rules_status_route'; import { createTimelinesRoute, @@ -122,6 +123,7 @@ export const initRoutes = ( persistPinnedEventRoute(router, config, security); findRulesStatusesRoute(router); + internalFindRuleStatusRoute(router); // Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals // POST /api/detection_engine/signals/status