diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index c3fc4aea77863..ec720164e9bd7 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -52,12 +52,18 @@ export const IP_REPUTATION_LINKS_SETTING_DEFAULT = `[ */ export const SIGNALS_ID = `${APP_ID}.signals`; +/** + * Id for the notifications alerting type + */ +export const NOTIFICATIONS_ID = `${APP_ID}.notifications`; + /** * Special internal structure for tags for signals. This is used * to filter out tags that have internal structures within them. */ export const INTERNAL_IDENTIFIER = '__internal'; export const INTERNAL_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_id`; +export const INTERNAL_RULE_ALERT_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_alert_id`; export const INTERNAL_IMMUTABLE_KEY = `${INTERNAL_IDENTIFIER}_immutable`; /** @@ -87,3 +93,5 @@ export const DETECTION_ENGINE_QUERY_SIGNALS_URL = `${DETECTION_ENGINE_SIGNALS_UR * Common naming convention for an unauthenticated user */ export const UNAUTHENTICATED_USER = 'Unauthenticated'; + +export const NOTIFICATION_THROTTLE_RULE = 'rule'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.test.ts similarity index 100% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.test.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts similarity index 83% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts index c1c17d2c70836..aeb4d53933022 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts +++ b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertAction } from '../../../../../../../plugins/alerting/common'; -import { RuleAlertAction } from '../types'; +import { AlertAction } from '../../../../../plugins/alerting/common'; +import { RuleAlertAction } from './types'; export const transformRuleToAlertAction = ({ group, diff --git a/x-pack/legacy/plugins/siem/common/detection_engine/types.ts b/x-pack/legacy/plugins/siem/common/detection_engine/types.ts new file mode 100644 index 0000000000000..0de370b11cdaf --- /dev/null +++ b/x-pack/legacy/plugins/siem/common/detection_engine/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertAction } from '../../../../../plugins/alerting/common'; + +export type RuleAlertAction = Omit & { + action_type_id: string; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts new file mode 100644 index 0000000000000..e14d20e3bc56e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { addTags } from './add_tags'; +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +describe('add_tags', () => { + test('it should add a rule id as an internal structure', () => { + const tags = addTags([], 'rule-1'); + expect(tags).toEqual([`${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); + + test('it should not allow duplicate tags to be created', () => { + const tags = addTags(['tag-1', 'tag-1'], 'rule-1'); + expect(tags).toEqual(['tag-1', `${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); + + test('it should not allow duplicate internal tags to be created when called two times in a row', () => { + const tags1 = addTags(['tag-1'], 'rule-1'); + const tags2 = addTags(tags1, 'rule-1'); + expect(tags2).toEqual(['tag-1', `${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts new file mode 100644 index 0000000000000..6955e57d099be --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +export const addTags = (tags: string[] = [], ruleAlertId: string): string[] => + Array.from(new Set([...tags, `${INTERNAL_RULE_ALERT_ID_KEY}:${ruleAlertId}`])); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts new file mode 100644 index 0000000000000..f83a8d40d6ae1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildSignalsSearchQuery } from './build_signals_query'; + +describe('buildSignalsSearchQuery', () => { + it('returns proper query object', () => { + const index = 'index'; + const ruleId = 'ruleId-12'; + const from = '123123123'; + const to = '1123123123'; + + expect( + buildSignalsSearchQuery({ + index, + from, + to, + ruleId, + }) + ).toEqual({ + index, + body: { + query: { + bool: { + filter: [ + { + bool: { + should: { + match: { + 'signal.rule.rule_id': ruleId, + }, + }, + minimum_should_match: 1, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts new file mode 100644 index 0000000000000..001650e5b2005 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface BuildSignalsSearchQuery { + ruleId: string; + index: string; + from: string; + to: string; +} + +export const buildSignalsSearchQuery = ({ ruleId, index, from, to }: BuildSignalsSearchQuery) => ({ + index, + body: { + query: { + bool: { + filter: [ + { + bool: { + should: { + match: { + 'signal.rule.rule_id': ruleId, + }, + }, + minimum_should_match: 1, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + }, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts new file mode 100644 index 0000000000000..dea42b0c852f9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { createNotifications } from './create_notifications'; + +describe('createNotifications', () => { + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('calls the alertsClient with proper params', async () => { + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + await createNotifications({ + alertsClient, + actions: [], + ruleAlertId, + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + ruleAlertId, + }), + }), + }) + ); + }); + + it('calls the alertsClient with transformed actions', async () => { + const action = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signalsCount}} signals' }, + action_type_id: '.slack', + }; + await createNotifications({ + alertsClient, + actions: [action], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actions: expect.arrayContaining([ + { + group: action.group, + id: action.id, + params: action.params, + actionTypeId: '.slack', + }, + ]), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts new file mode 100644 index 0000000000000..3a1697f1c8afc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Alert } from '../../../../../../../plugins/alerting/common'; +import { APP_ID, NOTIFICATIONS_ID } from '../../../../common/constants'; +import { CreateNotificationParams } from './types'; +import { addTags } from './add_tags'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; + +export const createNotifications = async ({ + alertsClient, + actions, + enabled, + ruleAlertId, + interval, + name, + tags, +}: CreateNotificationParams): Promise => + alertsClient.create({ + data: { + name, + tags: addTags(tags, ruleAlertId), + alertTypeId: NOTIFICATIONS_ID, + consumer: APP_ID, + params: { + ruleAlertId, + }, + schedule: { interval }, + enabled, + actions: actions?.map(transformRuleToAlertAction), + throttle: null, + }, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts new file mode 100644 index 0000000000000..7e5c0eaf6286e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { deleteNotifications } from './delete_notifications'; +import { readNotifications } from './read_notifications'; +jest.mock('./read_notifications'); + +describe('deleteNotifications', () => { + let alertsClient: ReturnType; + const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('should return null if notification was not found', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(result).toBe(null); + }); + + it('should call alertsClient.delete if notification was found', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: notificationId, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: notificationId }); + }); + + it('should call alertsClient.delete if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: null }); + }); + + it('should return null if alertsClient.delete rejects with 404 if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + alertsClient.delete.mockRejectedValue({ + output: { + statusCode: 404, + }, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual(null); + }); + + it('should return error object if alertsClient.delete rejects with status different than 404 and if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const errorObject = { + output: { + statusCode: 500, + }, + }; + + alertsClient.delete.mockRejectedValue(errorObject); + + let errorResult; + try { + await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + } catch (error) { + errorResult = error; + } + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(errorResult).toEqual(errorObject); + }); + + it('should return null if notification.id and id were null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteNotifications({ + alertsClient, + id: undefined, + ruleAlertId, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts new file mode 100644 index 0000000000000..7e244f96f1649 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { readNotifications } from './read_notifications'; +import { DeleteNotificationParams } from './types'; + +export const deleteNotifications = async ({ + alertsClient, + id, + ruleAlertId, +}: DeleteNotificationParams) => { + const notification = await readNotifications({ alertsClient, id, ruleAlertId }); + if (notification == null) { + return null; + } + + if (notification.id != null) { + await alertsClient.delete({ id: notification.id }); + return notification; + } else if (id != null) { + try { + await alertsClient.delete({ id }); + return notification; + } catch (err) { + if (err.output.statusCode === 404) { + return null; + } else { + throw err; + } + } + } else { + return null; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts new file mode 100644 index 0000000000000..0e9e4a8370ec8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getFilter } from './find_notifications'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; + +describe('find_notifications', () => { + test('it returns a full filter with an AND if sent down', () => { + expect(getFilter('alert.attributes.enabled: true')).toEqual( + `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID} AND alert.attributes.enabled: true` + ); + }); + + test('it returns existing filter with no AND when not set', () => { + expect(getFilter(null)).toEqual(`alert.attributes.alertTypeId: ${NOTIFICATIONS_ID}`); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts new file mode 100644 index 0000000000000..fcdeda608fe4e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FindResult } from '../../../../../../../plugins/alerting/server'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; +import { FindNotificationParams } from './types'; + +export const getFilter = (filter: string | null | undefined) => { + if (filter == null) { + return `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID}`; + } else { + return `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID} AND ${filter}`; + } +}; + +export const findNotifications = async ({ + alertsClient, + perPage, + page, + fields, + filter, + sortField, + sortOrder, +}: FindNotificationParams): Promise => + alertsClient.find({ + options: { + fields, + page, + perPage, + filter: getFilter(filter), + sortOrder, + sortField, + }, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts new file mode 100644 index 0000000000000..6ae7922660bd7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { getNotificationResultsLink } from './utils'; +import { NotificationExecutorOptions } from './types'; +import { parseScheduleDates } from '../signals/utils'; +import { buildSignalsSearchQuery } from './build_signals_query'; + +interface SignalsCountResults { + signalsCount: string; + resultsLink: string; +} + +interface GetSignalsCount { + from: Date | string; + to: Date | string; + ruleAlertId: string; + ruleId: string; + index: string; + kibanaUrl: string | undefined; + callCluster: NotificationExecutorOptions['services']['callCluster']; +} + +export const getSignalsCount = async ({ + from, + to, + ruleAlertId, + ruleId, + index, + callCluster, + kibanaUrl = '', +}: GetSignalsCount): Promise => { + const fromMoment = moment.isDate(from) ? moment(from) : parseScheduleDates(from); + const toMoment = moment.isDate(to) ? moment(to) : parseScheduleDates(to); + + if (!fromMoment || !toMoment) { + throw new Error(`There was an issue with parsing ${from} or ${to} into Moment object`); + } + + const fromInMs = fromMoment.format('x'); + const toInMs = toMoment.format('x'); + + const query = buildSignalsSearchQuery({ + index, + ruleId, + to: toInMs, + from: fromInMs, + }); + + const result = await callCluster('count', query); + const resultsLink = getNotificationResultsLink({ + baseUrl: kibanaUrl, + id: ruleAlertId, + from: fromInMs, + to: toInMs, + }); + + return { + signalsCount: result.count, + resultsLink, + }; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts new file mode 100644 index 0000000000000..834ad2460959c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { readNotifications } from './read_notifications'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { + getNotificationResult, + getFindNotificationsResultWithSingleHit, +} from '../routes/__mocks__/request_responses'; + +class TestError extends Error { + constructor() { + super(); + + this.name = 'CustomError'; + this.output = { statusCode: 404 }; + } + public output: { statusCode: number }; +} + +describe('read_notifications', () => { + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + describe('readNotifications', () => { + test('should return the output from alertsClient if id is set but ruleAlertId is undefined', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(getNotificationResult()); + }); + test('should return null if saved object found by alerts client given id is not alert type', async () => { + const result = getNotificationResult(); + delete result.alertTypeId; + alertsClient.get.mockResolvedValue(result); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + + test('should return error if alerts client throws 404 error on get', async () => { + alertsClient.get.mockImplementation(() => { + throw new TestError(); + }); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + + test('should return error if alerts client throws error on get', async () => { + alertsClient.get.mockImplementation(() => { + throw new Error('Test error'); + }); + try { + await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + } catch (exc) { + expect(exc.message).toEqual('Test error'); + } + }); + + test('should return the output from alertsClient if id is set but ruleAlertId is null', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: null, + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return the output from alertsClient if id is undefined but ruleAlertId is set', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return null if the output from alertsClient with ruleAlertId set is empty', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue({ data: [], page: 0, perPage: 1, total: 0 }); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(null); + }); + + test('should return the output from alertsClient if id is null but ruleAlertId is set', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: null, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return null if id and ruleAlertId are null', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: null, + ruleAlertId: null, + }); + expect(rule).toEqual(null); + }); + + test('should return null if id and ruleAlertId are undefined', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts new file mode 100644 index 0000000000000..87bdd6f3f40e1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SanitizedAlert } from '../../../../../../../plugins/alerting/common'; +import { ReadNotificationParams, isAlertType } from './types'; +import { findNotifications } from './find_notifications'; +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +export const readNotifications = async ({ + alertsClient, + id, + ruleAlertId, +}: ReadNotificationParams): Promise => { + if (id != null) { + try { + const notification = await alertsClient.get({ id }); + if (isAlertType(notification)) { + return notification; + } else { + return null; + } + } catch (err) { + if (err?.output?.statusCode === 404) { + return null; + } else { + // throw non-404 as they would be 500 or other internal errors + throw err; + } + } + } else if (ruleAlertId != null) { + const notificationFromFind = await findNotifications({ + alertsClient, + filter: `alert.attributes.tags: "${INTERNAL_RULE_ALERT_ID_KEY}:${ruleAlertId}"`, + page: 1, + }); + if (notificationFromFind.data.length === 0 || !isAlertType(notificationFromFind.data[0])) { + return null; + } else { + return notificationFromFind.data[0]; + } + } else { + // should never get here, and yet here we are. + return null; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts new file mode 100644 index 0000000000000..ff0126b129636 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { loggerMock } from 'src/core/server/logging/logger.mock'; +import { getResult } from '../routes/__mocks__/request_responses'; +import { rulesNotificationAlertType } from './rules_notification_alert_type'; +import { buildSignalsSearchQuery } from './build_signals_query'; +import { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { NotificationExecutorOptions } from './types'; +jest.mock('./build_signals_query'); + +describe('rules_notification_alert_type', () => { + let payload: NotificationExecutorOptions; + let alert: ReturnType; + let alertInstanceMock: Record; + let alertInstanceFactoryMock: () => AlertInstance; + let savedObjectsClient: ReturnType; + let logger: ReturnType; + let callClusterMock: jest.Mock; + + beforeEach(() => { + alertInstanceMock = { + scheduleActions: jest.fn(), + replaceState: jest.fn(), + }; + alertInstanceMock.replaceState.mockReturnValue(alertInstanceMock); + alertInstanceFactoryMock = jest.fn().mockReturnValue(alertInstanceMock); + callClusterMock = jest.fn(); + savedObjectsClient = savedObjectsClientMock.create(); + logger = loggerMock.create(); + + payload = { + alertId: '1111', + services: { + savedObjectsClient, + alertInstanceFactory: alertInstanceFactoryMock, + callCluster: callClusterMock, + }, + params: { ruleAlertId: '2222' }, + state: {}, + spaceId: '', + name: 'name', + tags: [], + startedAt: new Date('2019-12-14T16:40:33.400Z'), + previousStartedAt: new Date('2019-12-13T16:40:33.400Z'), + createdBy: 'elastic', + updatedBy: 'elastic', + }; + + alert = rulesNotificationAlertType({ + logger, + }); + }); + + describe('executor', () => { + it('throws an error if rule alert was not found', async () => { + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + attributes: {}, + type: 'type', + references: [], + }); + await alert.executor(payload); + expect(logger.error).toHaveBeenCalledWith( + `Saved object for alert ${payload.params.ruleAlertId} was not found` + ); + }); + + it('should call buildSignalsSearchQuery with proper params', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 0, + }); + + await alert.executor(payload); + + expect(buildSignalsSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + from: '1576255233400', + index: '.siem-signals', + ruleId: 'rule-1', + to: '1576341633400', + }) + ); + }); + + it('should not call alertInstanceFactory if signalsCount was 0', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 0, + }); + + await alert.executor(payload); + + expect(alertInstanceFactoryMock).not.toHaveBeenCalled(); + }); + + it('should call scheduleActions if signalsCount was greater than 0', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 10, + }); + + await alert.executor(payload); + + expect(alertInstanceFactoryMock).toHaveBeenCalled(); + expect(alertInstanceMock.replaceState).toHaveBeenCalledWith( + expect.objectContaining({ signalsCount: 10 }) + ); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + rule: expect.objectContaining({ + name: ruleAlert.name, + }), + }) + ); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts new file mode 100644 index 0000000000000..c5dc4c3a27e16 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger } from 'src/core/server'; +import { schema } from '@kbn/config-schema'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; + +import { NotificationAlertTypeDefinition } from './types'; +import { getSignalsCount } from './get_signals_count'; +import { RuleAlertAttributes } from '../signals/types'; +import { siemRuleActionGroups } from '../signals/siem_rule_action_groups'; +import { scheduleNotificationActions } from './schedule_notification_actions'; + +export const rulesNotificationAlertType = ({ + logger, +}: { + logger: Logger; +}): NotificationAlertTypeDefinition => ({ + id: NOTIFICATIONS_ID, + name: 'SIEM Notifications', + actionGroups: siemRuleActionGroups, + defaultActionGroupId: 'default', + validate: { + params: schema.object({ + ruleAlertId: schema.string(), + }), + }, + async executor({ startedAt, previousStartedAt, alertId, services, params }) { + const ruleAlertSavedObject = await services.savedObjectsClient.get( + 'alert', + params.ruleAlertId + ); + + if (!ruleAlertSavedObject.attributes.params) { + logger.error(`Saved object for alert ${params.ruleAlertId} was not found`); + return; + } + + const { params: ruleAlertParams, name: ruleName } = ruleAlertSavedObject.attributes; + const ruleParams = { ...ruleAlertParams, name: ruleName }; + + const { signalsCount, resultsLink } = await getSignalsCount({ + from: previousStartedAt ?? `now-${ruleParams.interval}`, + to: startedAt, + index: ruleParams.outputIndex, + ruleId: ruleParams.ruleId!, + kibanaUrl: ruleAlertParams.meta?.kibanaUrl as string, + ruleAlertId: ruleAlertSavedObject.id, + callCluster: services.callCluster, + }); + + logger.info( + `Found ${signalsCount} signals using signal rule name: "${ruleParams.name}", id: "${params.ruleAlertId}", rule_id: "${ruleParams.ruleId}" in "${ruleParams.outputIndex}" index` + ); + + if (signalsCount) { + const alertInstance = services.alertInstanceFactory(alertId); + scheduleNotificationActions({ alertInstance, signalsCount, resultsLink, ruleParams }); + } + }, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts new file mode 100644 index 0000000000000..9c38c88a12411 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { RuleTypeParams } from '../types'; + +type NotificationRuleTypeParams = RuleTypeParams & { + name: string; +}; + +interface ScheduleNotificationActions { + alertInstance: AlertInstance; + signalsCount: string; + resultsLink: string; + ruleParams: NotificationRuleTypeParams; +} + +export const scheduleNotificationActions = ({ + alertInstance, + signalsCount, + resultsLink, + ruleParams, +}: ScheduleNotificationActions): AlertInstance => + alertInstance + .replaceState({ + signalsCount, + }) + .scheduleActions('default', { + resultsLink, + rule: ruleParams, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts new file mode 100644 index 0000000000000..4fce037b483d5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loggerMock } from 'src/core/server/logging/logger.mock'; +import { getNotificationResult, getResult } from '../routes/__mocks__/request_responses'; +import { isAlertTypes, isNotificationAlertExecutor } from './types'; +import { rulesNotificationAlertType } from './rules_notification_alert_type'; + +describe('types', () => { + it('isAlertTypes should return true if is RuleNotificationAlertType type', () => { + expect(isAlertTypes([getNotificationResult()])).toEqual(true); + }); + + it('isAlertTypes should return false if is not RuleNotificationAlertType', () => { + expect(isAlertTypes([getResult()])).toEqual(false); + }); + + it('isNotificationAlertExecutor should return true it passed object is NotificationAlertTypeDefinition type', () => { + expect( + isNotificationAlertExecutor(rulesNotificationAlertType({ logger: loggerMock.create() })) + ).toEqual(true); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts new file mode 100644 index 0000000000000..edcd821353bc8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AlertsClient, + PartialAlert, + AlertType, + State, + AlertExecutorOptions, +} from '../../../../../../../plugins/alerting/server'; +import { Alert } from '../../../../../../../plugins/alerting/common'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; + +export interface RuleNotificationAlertType extends Alert { + params: { + ruleAlertId: string; + }; +} + +export interface FindNotificationParams { + alertsClient: AlertsClient; + perPage?: number; + page?: number; + sortField?: string; + filter?: string; + fields?: string[]; + sortOrder?: 'asc' | 'desc'; +} + +export interface FindNotificationsRequestParams { + per_page: number; + page: number; + search?: string; + sort_field?: string; + filter?: string; + fields?: string[]; + sort_order?: 'asc' | 'desc'; +} + +export interface Clients { + alertsClient: AlertsClient; +} + +export type UpdateNotificationParams = Omit & { + actions: RuleAlertAction[]; + id?: string; + tags?: string[]; + interval: string | null; + ruleAlertId: string; +} & Clients; + +export type DeleteNotificationParams = Clients & { + id?: string; + ruleAlertId?: string; +}; + +export interface NotificationAlertParams { + actions: RuleAlertAction[]; + enabled: boolean; + ruleAlertId: string; + interval: string; + name: string; + tags?: string[]; + throttle?: null; +} + +export type CreateNotificationParams = NotificationAlertParams & Clients; + +export interface ReadNotificationParams { + alertsClient: AlertsClient; + id?: string | null; + ruleAlertId?: string | null; +} + +export const isAlertTypes = ( + partialAlert: PartialAlert[] +): partialAlert is RuleNotificationAlertType[] => { + return partialAlert.every(rule => isAlertType(rule)); +}; + +export const isAlertType = ( + partialAlert: PartialAlert +): partialAlert is RuleNotificationAlertType => { + return partialAlert.alertTypeId === NOTIFICATIONS_ID; +}; + +export type NotificationExecutorOptions = Omit & { + params: { + ruleAlertId: string; + }; +}; + +// This returns true because by default a NotificationAlertTypeDefinition is an AlertType +// since we are only increasing the strictness of params. +export const isNotificationAlertExecutor = ( + obj: NotificationAlertTypeDefinition +): obj is AlertType => { + return true; +}; + +export type NotificationAlertTypeDefinition = Omit & { + executor: ({ services, params, state }: NotificationExecutorOptions) => Promise; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts new file mode 100644 index 0000000000000..e1b452c911443 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { updateNotifications } from './update_notifications'; +import { readNotifications } from './read_notifications'; +import { createNotifications } from './create_notifications'; +import { getNotificationResult } from '../routes/__mocks__/request_responses'; +jest.mock('./read_notifications'); +jest.mock('./create_notifications'); + +describe('updateNotifications', () => { + const notification = getNotificationResult(); + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('should update the existing notification if interval provided', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + + await updateNotifications({ + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: notification.id, + data: expect.objectContaining({ + params: expect.objectContaining({ + ruleAlertId: 'new-rule-id', + }), + }), + }) + ); + }); + + it('should create a new notification if did not exist', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + + const params = { + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }; + + await updateNotifications(params); + + expect(createNotifications).toHaveBeenCalledWith(expect.objectContaining(params)); + }); + + it('should delete notification if notification was found and interval is null', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + + await updateNotifications({ + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: null, + name: '', + tags: [], + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notification.id, + }) + ); + }); + + it('should call the alertsClient with transformed actions', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + const action = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signalsCount}} signals' }, + action_type_id: '.slack', + }; + await updateNotifications({ + alertsClient, + actions: [action], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actions: expect.arrayContaining([ + { + group: action.group, + id: action.id, + params: action.params, + actionTypeId: '.slack', + }, + ]), + }), + }) + ); + }); + + it('returns null if notification was not found and interval was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + const result = await updateNotifications({ + alertsClient, + actions: [], + enabled: true, + id: notification.id, + ruleAlertId, + name: notification.name, + tags: notification.tags, + interval: null, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts new file mode 100644 index 0000000000000..3197d21c0e95a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { readNotifications } from './read_notifications'; +import { UpdateNotificationParams } from './types'; +import { addTags } from './add_tags'; +import { createNotifications } from './create_notifications'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; + +export const updateNotifications = async ({ + alertsClient, + actions, + enabled, + id, + ruleAlertId, + name, + tags, + interval, +}: UpdateNotificationParams): Promise => { + const notification = await readNotifications({ alertsClient, id, ruleAlertId }); + + if (interval && notification) { + const result = await alertsClient.update({ + id: notification.id, + data: { + tags: addTags(tags, ruleAlertId), + name, + schedule: { + interval, + }, + actions: actions?.map(transformRuleToAlertAction), + params: { + ruleAlertId, + }, + throttle: null, + }, + }); + return result; + } + + if (interval && !notification) { + const result = await createNotifications({ + alertsClient, + enabled, + tags, + name, + interval, + actions, + ruleAlertId, + }); + return result; + } + + if (!interval && notification) { + await alertsClient.delete({ id: notification.id }); + return null; + } + + return null; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts new file mode 100644 index 0000000000000..4c3f311d10acc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getNotificationResultsLink } from './utils'; + +describe('utils', () => { + it('getNotificationResultsLink', () => { + const resultLink = getNotificationResultsLink({ + baseUrl: 'http://localhost:5601', + id: 'notification-id', + from: '00000', + to: '1111', + }); + expect(resultLink).toEqual( + `http://localhost:5601/app/siem#/detections/rules/id/notification-id?timerange=(global:(linkTo:!(timeline),timerange:(from:00000,kind:absolute,to:1111)),timeline:(linkTo:!(global),timerange:(from:00000,kind:absolute,to:1111)))` + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts new file mode 100644 index 0000000000000..ed502d31d2fb5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getNotificationResultsLink = ({ + baseUrl, + id, + from, + to, +}: { + baseUrl: string; + id: string; + from: string; + to: string; +}) => + `${baseUrl}/app/siem#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 0e0ab58a7a199..6435410f31797 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -28,6 +28,7 @@ import { } from '../../rules/types'; import { RuleAlertParamsRest, PrepackagedRules } from '../../types'; import { requestMock } from './request'; +import { RuleNotificationAlertType } from '../../notifications/types'; export const mockPrepackagedRule = (): PrepackagedRules => ({ rule_id: 'rule-1', @@ -204,11 +205,11 @@ export const getPrepackagedRulesStatusRequest = () => path: `${DETECTION_ENGINE_PREPACKAGED_URL}/_status`, }); -export interface FindHit { +export interface FindHit { page: number; perPage: number; total: number; - data: RuleAlertType[]; + data: T[]; } export const getEmptyFindResult = (): FindHit => ({ @@ -309,6 +310,27 @@ export const createMlRuleRequest = () => { }); }; +export const createRuleWithActionsRequest = () => { + const payload = typicalPayload(); + + return requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: { + ...payload, + throttle: '5m', + actions: [ + { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signalsCount}} signals' }, + action_type_id: '.slack', + }, + ], + }, + }); +}; + export const getSetSignalStatusByIdsRequest = () => requestMock.create({ method: 'post', @@ -616,3 +638,45 @@ export const getEmptyIndex = (): { _shards: Partial } => ({ export const getNonEmptyIndex = (): { _shards: Partial } => ({ _shards: { total: 1 }, }); + +export const getNotificationResult = (): RuleNotificationAlertType => ({ + id: '200dbf2f-b269-4bf9-aa85-11ba32ba73ba', + name: 'Notification for Rule Test', + tags: ['__internal_rule_alert_id:85b64e8a-2e40-4096-86af-5ac172c10825'], + alertTypeId: 'siem.notifications', + consumer: 'siem', + params: { + ruleAlertId: '85b64e8a-2e40-4096-86af-5ac172c10825', + }, + schedule: { + interval: '5m', + }, + enabled: true, + actions: [ + { + actionTypeId: '.slack', + params: { + message: 'Rule generated {{state.signalsCount}} signals\n\n{{rule.name}}\n{{resultsLink}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ], + throttle: null, + apiKey: null, + apiKeyOwner: 'elastic', + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: new Date('2020-03-21T11:15:13.530Z'), + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '62b3a130-6b70-11ea-9ce9-6b9818c4cbd7', + updatedAt: new Date('2020-03-21T12:37:08.730Z'), +}); + +export const getFindNotificationsResultWithSingleHit = (): FindHit => ({ + page: 1, + perPage: 1, + total: 1, + data: [getNotificationResult()], +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 1a4e19c2047b5..14592dd499d43 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -15,10 +15,13 @@ import { getEmptyIndex, getFindResultWithSingleHit, createMlRuleRequest, + createRuleWithActionsRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesRoute } from './create_rules_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +import { createNotifications } from '../../notifications/create_notifications'; +jest.mock('../../notifications/create_notifications'); describe('create_rules', () => { let server: ReturnType; @@ -65,6 +68,18 @@ describe('create_rules', () => { }); }); + describe('creating a Notification if throttle and actions were provided ', () => { + it('is successful', async () => { + const response = await server.inject(createRuleWithActionsRequest(), context); + expect(response.status).toEqual(200); + expect(createNotifications).toHaveBeenCalledWith( + expect.objectContaining({ + ruleAlertId: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + }); + describe('unhappy paths', () => { test('it returns a 400 if the index does not exist', async () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index cee9054cf922e..1fbbb5274d738 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -17,6 +17,7 @@ import { transformValidate } from './validate'; import { getIndexExists } from '../../index/get_index_exists'; import { createRulesSchema } from '../schemas/create_rules_schema'; import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { createNotifications } from '../../notifications/create_notifications'; export const createRulesRoute = (router: IRouter): void => { router.post( @@ -131,6 +132,18 @@ export const createRulesRoute = (router: IRouter): void => { version: 1, lists, }); + + if (throttle && actions.length) { + await createNotifications({ + alertsClient, + enabled, + name, + interval, + actions, + ruleAlertId: createdRule.id, + }); + } + const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts index c56f34588cbc6..85cfeefdceead 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts @@ -16,6 +16,7 @@ import { DeleteRulesRequestParams, } from '../../rules/types'; import { deleteRules } from '../../rules/delete_rules'; +import { deleteNotifications } from '../../notifications/delete_notifications'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; type Config = RouteConfig; @@ -57,6 +58,7 @@ export const deleteRulesBulkRoute = (router: IRouter) => { ruleId, }); if (rule != null) { + await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts index 753b281dbc09e..6fd50abd9364a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -16,6 +16,7 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; +import { deleteNotifications } from '../../notifications/delete_notifications'; export const deleteRulesRoute = (router: IRouter) => { router.delete( @@ -52,6 +53,7 @@ export const deleteRulesRoute = (router: IRouter) => { ruleId, }); if (rule != null) { + await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index 7e56c32ade92a..f8cca6494e000 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -16,6 +16,7 @@ import { getIdError } from './utils'; import { transformValidate } from './validate'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { updateRules } from '../../rules/update_rules'; +import { updateNotifications } from '../../notifications/update_notifications'; export const updateRulesRoute = (router: IRouter) => { router.put( @@ -117,7 +118,17 @@ export const updateRulesRoute = (router: IRouter) => { version, lists, }); + if (rule != null) { + await updateNotifications({ + alertsClient, + actions, + enabled, + ruleAlertId: rule.id, + interval: throttle, + name, + }); + const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index e0ecbdedaac7c..a0458dc3a133d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -29,7 +29,7 @@ import { OutputError, } from '../utils'; import { hasListsFeature } from '../../feature_flags'; -import { transformAlertToRuleAction } from '../../rules/transform_actions'; +import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions'; type PromiseFromStreams = ImportRuleAlertRest | Error; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts index 2b18e1b9bf52c..b10627d151fa2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts @@ -5,7 +5,8 @@ */ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; -import { ThreatParams, PrepackagedRules, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, PrepackagedRules } from '../../types'; import { addPrepackagedRulesSchema } from './add_prepackaged_rules_schema'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts index d9c3055512815..08bd01ee9a1a0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { createRulesSchema } from './create_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts index ffb49896ef7c7..c8e5bb981f921 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts @@ -10,7 +10,8 @@ import { importRulesQuerySchema, importRulesPayloadSchema, } from './import_rules_schema'; -import { ThreatParams, ImportRuleAlertRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, ImportRuleAlertRest } from '../../types'; import { ImportRulesRequestParams } from '../../rules/types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts index 42945e0970cba..45b5028f392b9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { patchRulesSchema } from './patch_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('patch rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts index db3709cd6b126..6f6beea7fa5fb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { updateRulesSchema } from './update_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts index db70b90d5a17c..a45b28ba3e105 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts @@ -6,12 +6,12 @@ import { Alert } from '../../../../../../../plugins/alerting/common'; import { APP_ID, SIGNALS_ID } from '../../../../common/constants'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { CreateRuleParams } from './types'; import { addTags } from './add_tags'; import { hasListsFeature } from '../feature_flags'; -import { transformRuleToAlertAction } from './transform_actions'; -export const createRules = ({ +export const createRules = async ({ alertsClient, actionsClient, // TODO: Use this actionsClient once we have actions such as email, etc... actions, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts new file mode 100644 index 0000000000000..38fc1dc5d1930 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { deleteRules } from './delete_rules'; +import { readRules } from './read_rules'; +jest.mock('./read_rules'); + +describe('deleteRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; + const ruleId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + }); + + it('should return null if notification was not found', async () => { + (readRules as jest.Mock).mockResolvedValue(null); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId, + }); + + expect(result).toBe(null); + }); + + it('should call alertsClient.delete if notification was found', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: notificationId, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: notificationId }); + }); + + it('should call alertsClient.delete if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: null }); + }); + + it('should return null if alertsClient.delete rejects with 404 if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + alertsClient.delete.mockRejectedValue({ + output: { + statusCode: 404, + }, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual(null); + }); + + it('should return error object if alertsClient.delete rejects with status different than 404 and if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const errorObject = { + output: { + statusCode: 500, + }, + }; + + alertsClient.delete.mockRejectedValue(errorObject); + + let errorResult; + try { + await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + } catch (error) { + errorResult = error; + } + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(errorResult).toEqual(errorObject); + }); + + it('should return null if ruleId and id was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: undefined, + ruleId: null, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts index b424d2912ebc8..cd18bee6f606f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts @@ -7,7 +7,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; -import { getMlResult } from '../routes/__mocks__/request_responses'; +import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { patchRules } from './patch_rules'; describe('patchRules', () => { @@ -21,6 +21,59 @@ describe('patchRules', () => { savedObjectsClient = savedObjectsClientMock.create(); }); + it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue(getResult()); + + await patchRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: false, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.disable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + + it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue({ + ...getResult(), + enabled: false, + }); + + await patchRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: true, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.enable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + it('calls the alertsClient with ML params', async () => { alertsClient.get.mockResolvedValue(getMlResult()); const params = { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index 5b6fd08a9ea89..5394af526c917 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -6,12 +6,12 @@ import { defaults } from 'lodash/fp'; import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { readRules } from './read_rules'; import { PatchRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion, calculateName, calculateInterval } from './utils'; -import { transformRuleToAlertAction } from './transform_actions'; export const patchRules = async ({ alertsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts index 862ea9d2dcbe5..38a883329318b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts @@ -8,7 +8,7 @@ import { readRules } from './read_rules'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { getResult, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; -class TestError extends Error { +export class TestError extends Error { constructor() { // Pass remaining arguments (including vendor specific ones) to parent constructor super(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts index 967a32df20c3b..af00816abfc3d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts @@ -7,7 +7,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; -import { getMlResult } from '../routes/__mocks__/request_responses'; +import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { updateRules } from './update_rules'; describe('updateRules', () => { @@ -21,6 +21,59 @@ describe('updateRules', () => { savedObjectsClient = savedObjectsClientMock.create(); }); + it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue(getResult()); + + await updateRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: false, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.disable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + + it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue({ + ...getResult(), + enabled: false, + }); + + await updateRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: true, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.enable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + it('calls the alertsClient with ML params', async () => { alertsClient.get.mockResolvedValue(getMlResult()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index a80f986482010..72cbc959c0105 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -5,13 +5,13 @@ */ import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { readRules } from './read_rules'; import { IRuleSavedAttributesSavedObjectAttributes, UpdateRuleParams } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion } from './utils'; import { hasListsFeature } from '../feature_flags'; -import { transformRuleToAlertAction } from './transform_actions'; export const updateRules = async ({ alertsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts index adbd5f81d372a..f485769dffabc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts @@ -8,7 +8,8 @@ import { SignalSourceHit, SignalHit } from './types'; import { buildRule } from './build_rule'; import { buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; interface BuildBulkBodyParams { doc: SignalSourceHit; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts index e94ca18b186e4..1de80ca0b7eaf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts @@ -5,7 +5,8 @@ */ import { pickBy } from 'lodash/fp'; -import { RuleTypeParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams, OutputRuleAlertRest } from '../types'; interface BuildRuleParams { ruleParams: RuleTypeParams; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index 95adb90172404..66e9f42061658 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -9,7 +9,8 @@ import { SearchResponse } from 'elasticsearch'; import { Logger } from '../../../../../../../../src/core/server'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { singleBulkCreate } from './single_bulk_create'; import { AnomalyResults, Anomaly } from '../../machine_learning'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts index b49f43ce9e7ac..86d1278031695 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts @@ -510,6 +510,20 @@ describe('get_filter', () => { ).rejects.toThrow('savedId parameter should be defined'); }); + test('throws on machine learning query', async () => { + await expect( + getFilter({ + type: 'machine_learning', + filters: undefined, + language: undefined, + query: undefined, + savedId: 'some-id', + services: servicesMock, + index: undefined, + }) + ).rejects.toThrow('Unsupported Rule of type "machine_learning" supplied to getFilter'); + }); + test('it works with references and does not add indexes', () => { const esQuery = getQueryFilter( '(event.module:suricata and event.kind:alert) and suricata.eve.alert.signature_id: (2610182 or 2610183 or 2610184 or 2610185 or 2610186 or 2610187)', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts index a12778d5b8f16..4f1a187a82937 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -5,7 +5,8 @@ */ import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { Logger } from '../../../../../../../../src/core/server'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 89dcd3274ebed..03d48a6b27867 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -5,13 +5,17 @@ */ import { Logger } from 'src/core/server'; -import { SIGNALS_ID, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants'; +import { + SIGNALS_ID, + DEFAULT_SEARCH_AFTER_PAGE_SIZE, + NOTIFICATION_THROTTLE_RULE, +} from '../../../../common/constants'; import { buildEventsSearchQuery } from './build_events_query'; import { getInputIndex } from './get_input_output_index'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { getFilter } from './get_filter'; -import { SignalRuleAlertTypeDefinition, AlertAttributes } from './types'; +import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; import { getGapBetweenRuns } from './utils'; import { writeSignalRuleExceptionToSavedObject } from './write_signal_rule_exception_to_saved_object'; import { signalParamsSchema } from './signal_params_schema'; @@ -22,6 +26,8 @@ import { getCurrentStatusSavedObject } from './get_current_status_saved_object'; import { writeCurrentStatusSucceeded } from './write_current_status_succeeded'; import { findMlSignals } from './find_ml_signals'; import { bulkCreateMlSignals } from './bulk_create_ml_signals'; +import { getSignalsCount } from '../notifications/get_signals_count'; +import { scheduleNotificationActions } from '../notifications/schedule_notification_actions'; export const signalRulesAlertType = ({ logger, @@ -46,6 +52,7 @@ export const signalRulesAlertType = ({ index, filters, language, + meta, machineLearningJobId, outputIndex, savedId, @@ -53,7 +60,10 @@ export const signalRulesAlertType = ({ to, type, } = params; - const savedObject = await services.savedObjectsClient.get('alert', alertId); + const savedObject = await services.savedObjectsClient.get( + 'alert', + alertId + ); const ruleStatusSavedObjects = await getRuleStatusSavedObjects({ alertId, @@ -76,6 +86,7 @@ export const signalRulesAlertType = ({ enabled, schedule: { interval }, throttle, + params: ruleParams, } = savedObject.attributes; const updatedAt = savedObject.updated_at ?? ''; @@ -199,6 +210,36 @@ export const signalRulesAlertType = ({ } if (creationSucceeded) { + if (meta?.throttle === NOTIFICATION_THROTTLE_RULE && actions.length) { + const notificationRuleParams = { + ...ruleParams, + name, + }; + const { signalsCount, resultsLink } = await getSignalsCount({ + from: `now-${interval}`, + to: 'now', + index: ruleParams.outputIndex, + ruleId: ruleParams.ruleId!, + kibanaUrl: meta?.kibanaUrl as string, + ruleAlertId: savedObject.id, + callCluster: services.callCluster, + }); + + logger.info( + `Found ${signalsCount} signals using signal rule name: "${notificationRuleParams.name}", id: "${notificationRuleParams.ruleId}", rule_id: "${notificationRuleParams.ruleId}" in "${notificationRuleParams.outputIndex}" index` + ); + + if (signalsCount) { + const alertInstance = services.alertInstanceFactory(alertId); + scheduleNotificationActions({ + alertInstance, + signalsCount, + resultsLink, + ruleParams: notificationRuleParams, + }); + } + } + logger.debug( `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", output_index: "${outputIndex}"` ); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts index 333a938e09d45..e2e4471f609ac 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts @@ -8,7 +8,8 @@ import { countBy, isEmpty } from 'lodash'; import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { SignalSearchResponse, BulkResponse } from './types'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { generateId } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { Logger } from '../../../../../../../../src/core/server'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts index 06acff825f68e..93c48ed38c7c4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RuleAlertParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleAlertParams, OutputRuleAlertRest } from '../types'; import { SearchResponse } from '../../types'; import { AlertType, @@ -159,3 +160,7 @@ export interface AlertAttributes { }; throttle: string | null; } + +export interface RuleAlertAttributes extends AlertAttributes { + params: RuleAlertParams; +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index 2cbdc7db3ba64..aae8763a7ea39 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertAction } from '../../../../../../plugins/alerting/common'; import { CallAPIOptions } from '../../../../../../../src/core/server'; import { Filter } from '../../../../../../../src/plugins/data/server'; import { IRuleStatusAttributes } from './rules/types'; import { ListsDefaultArraySchema } from './routes/schemas/types/lists_default_array'; +import { RuleAlertAction } from '../../../common/detection_engine/types'; export type PartialFilter = Partial; @@ -24,10 +24,6 @@ export interface ThreatParams { technique: IMitreAttack[]; } -export type RuleAlertAction = Omit & { - action_type_id: string; -}; - // Notice below we are using lists: ListsDefaultArraySchema[]; which is coming directly from the response output section. // TODO: Eventually this whole RuleAlertParams will be replaced with io-ts. For now we can slowly strangle it out and reduce duplicate types // We don't have the input types defined through io-ts just yet but as we being introducing types from there we will more and more remove @@ -56,7 +52,7 @@ export interface RuleAlertParams { query: string | undefined | null; references: string[]; savedId?: string | undefined | null; - meta: Record | undefined | null; + meta: Record | undefined | null; severity: string; tags: string[]; to: string; @@ -123,6 +119,7 @@ export type OutputRuleAlertRest = RuleAlertParamsRest & { created_by: string | undefined | null; updated_by: string | undefined | null; immutable: boolean; + throttle: string | undefined | null; }; export type ImportRuleAlertRest = Omit & { diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index c505edc79bc76..7008872a6f3cd 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -27,6 +27,8 @@ import { compose } from './lib/compose/kibana'; import { initRoutes } from './routes'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; +import { rulesNotificationAlertType } from './lib/detection_engine/notifications/rules_notification_alert_type'; +import { isNotificationAlertExecutor } from './lib/detection_engine/notifications/types'; import { noteSavedObjectType, pinnedEventSavedObjectType, @@ -151,12 +153,20 @@ export class Plugin { }); if (plugins.alerting != null) { - const type = signalRulesAlertType({ + const signalRuleType = signalRulesAlertType({ logger: this.logger, version: this.context.env.packageInfo.version, }); - if (isAlertExecutor(type)) { - plugins.alerting.registerType(type); + const ruleNotificationType = rulesNotificationAlertType({ + logger: this.logger, + }); + + if (isAlertExecutor(signalRuleType)) { + plugins.alerting.registerType(signalRuleType); + } + + if (isNotificationAlertExecutor(ruleNotificationType)) { + plugins.alerting.registerType(ruleNotificationType); } }