[Security Solution][Legacy Actions] - Update legacy action migration to account for more edge cases (#130511)

## Summary

Updates the legacy actions migration code to account for edge cases we had not initially caught. Thanks to testing from some teammates, they reported seeing the following behavior:

- Rules created pre 7.16 with no actions still create the legacy action sidecar (but not a `siem.notifications` legacy actions alert) which upon migration to 7.16+ was not being deleted
- Rules created pre 7.16 with actions that run on every rule run create the legacy action sidecar(but not a `siem.notifications` legacy actions alert) which upon migration to 7.16+ was not being deleted
- Rules created pre 7.16 with actions that were never enabled until 8.x did not have a `siem.notifications` legacy actions alert type created

Because the legacy migration code relied on checking if a corresponding `siem.notifications` SO existed to kick off the necessary cleanup/migration, the above edge cases  were not being caught.
This commit is contained in:
Yara Tercero 2022-05-04 08:45:24 -07:00 committed by GitHub
parent 956612d071
commit 29705c01e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 2584 additions and 36 deletions

View file

@ -9,7 +9,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils';
import { ruleTypeMappings } from '@kbn/securitysolution-rules';
import { SavedObjectsFindResponse } from '@kbn/core/server';
import { SavedObjectsFindResponse, SavedObjectsFindResult } from '@kbn/core/server';
import { ActionResult } from '@kbn/actions-plugin/server';
import {
@ -49,6 +49,8 @@ import {
} from '../../../../../common/detection_engine/schemas/common';
// eslint-disable-next-line no-restricted-imports
import type { LegacyRuleNotificationAlertType } from '../../notifications/legacy_types';
// eslint-disable-next-line no-restricted-imports
import { LegacyIRuleActionsAttributes } from '../../rule_actions/legacy_types';
import { RuleExecutionSummariesByRuleId } from '../../rule_execution_log';
export const typicalSetStatusSignalByIdsPayload = (): SetSignalsStatusSchemaDecoded => ({
@ -688,14 +690,20 @@ export const getSignalsMigrationStatusRequest = () =>
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetNotificationResult = (): LegacyRuleNotificationAlertType => ({
id: '200dbf2f-b269-4bf9-aa85-11ba32ba73ba',
export const legacyGetNotificationResult = ({
id = '456',
ruleId = '123',
}: {
id?: string;
ruleId?: string;
} = {}): LegacyRuleNotificationAlertType => ({
id,
name: 'Notification for Rule Test',
tags: [],
alertTypeId: 'siem.notifications',
consumer: 'siem',
params: {
ruleAlertId: '85b64e8a-2e40-4096-86af-5ac172c10825',
ruleAlertId: `${ruleId}`,
},
schedule: {
interval: '5m',
@ -732,10 +740,357 @@ export const legacyGetNotificationResult = (): LegacyRuleNotificationAlertType =
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetFindNotificationsResultWithSingleHit =
(): FindHit<LegacyRuleNotificationAlertType> => ({
export const legacyGetHourlyNotificationResult = (
id = '456',
ruleId = '123'
): LegacyRuleNotificationAlertType => ({
id,
name: 'Notification for Rule Test',
tags: [],
alertTypeId: 'siem.notifications',
consumer: 'siem',
params: {
ruleAlertId: `${ruleId}`,
},
schedule: {
interval: '1h',
},
enabled: true,
actions: [
{
group: 'default',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Test Actions',
},
actionTypeId: '.email',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
},
],
throttle: null,
notifyWhen: 'onActiveAlert',
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'),
executionStatus: {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
});
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetDailyNotificationResult = (
id = '456',
ruleId = '123'
): LegacyRuleNotificationAlertType => ({
id,
name: 'Notification for Rule Test',
tags: [],
alertTypeId: 'siem.notifications',
consumer: 'siem',
params: {
ruleAlertId: `${ruleId}`,
},
schedule: {
interval: '1d',
},
enabled: true,
actions: [
{
group: 'default',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Test Actions',
},
actionTypeId: '.email',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
},
],
throttle: null,
notifyWhen: 'onActiveAlert',
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'),
executionStatus: {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
});
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetWeeklyNotificationResult = (
id = '456',
ruleId = '123'
): LegacyRuleNotificationAlertType => ({
id,
name: 'Notification for Rule Test',
tags: [],
alertTypeId: 'siem.notifications',
consumer: 'siem',
params: {
ruleAlertId: `${ruleId}`,
},
schedule: {
interval: '7d',
},
enabled: true,
actions: [
{
group: 'default',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Test Actions',
},
actionTypeId: '.email',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
},
],
throttle: null,
notifyWhen: 'onActiveAlert',
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'),
executionStatus: {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
});
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetFindNotificationsResultWithSingleHit = (
ruleId = '123'
): FindHit<LegacyRuleNotificationAlertType> => ({
page: 1,
perPage: 1,
total: 1,
data: [legacyGetNotificationResult({ ruleId })],
});
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetSiemNotificationRuleNoActionsSOResult = (
ruleId = '123'
): SavedObjectsFindResult<LegacyIRuleActionsAttributes> => ({
type: 'siem-detection-engine-rule-actions',
id: 'ID_OF_LEGACY_SIDECAR_NO_ACTIONS',
namespaces: ['default'],
attributes: {
actions: [],
ruleThrottle: 'no_actions',
alertThrottle: null,
},
references: [{ id: ruleId, type: 'alert', name: 'alert_0' }],
migrationVersion: {
'siem-detection-engine-rule-actions': '7.11.2',
},
coreMigrationVersion: '7.15.2',
updated_at: '2022-03-31T19:06:40.473Z',
version: 'WzIzNywxXQ==',
score: 0,
});
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetSiemNotificationRuleEveryRunSOResult = (
ruleId = '123'
): SavedObjectsFindResult<LegacyIRuleActionsAttributes> => ({
type: 'siem-detection-engine-rule-actions',
id: 'ID_OF_LEGACY_SIDECAR_RULE_RUN_ACTIONS',
namespaces: ['default'],
attributes: {
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Test Actions',
},
action_type_id: '.email',
},
],
ruleThrottle: 'rule',
alertThrottle: null,
},
references: [{ id: ruleId, type: 'alert', name: 'alert_0' }],
migrationVersion: {
'siem-detection-engine-rule-actions': '7.11.2',
},
coreMigrationVersion: '7.15.2',
updated_at: '2022-03-31T19:06:40.473Z',
version: 'WzIzNywxXQ==',
score: 0,
});
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetSiemNotificationRuleHourlyActionsSOResult = (
ruleId = '123',
connectorId = '456'
): SavedObjectsFindResult<LegacyIRuleActionsAttributes> => ({
type: 'siem-detection-engine-rule-actions',
id: 'ID_OF_LEGACY_SIDECAR_HOURLY_ACTIONS',
namespaces: ['default'],
attributes: {
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Test Actions',
},
action_type_id: '.email',
},
],
ruleThrottle: '1h',
alertThrottle: '1h',
},
references: [
{ id: ruleId, type: 'alert', name: 'alert_0' },
{ id: connectorId, type: 'action', name: 'action_0' },
],
migrationVersion: {
'siem-detection-engine-rule-actions': '7.11.2',
},
coreMigrationVersion: '7.15.2',
updated_at: '2022-03-31T19:06:40.473Z',
version: 'WzIzNywxXQ==',
score: 0,
});
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetSiemNotificationRuleDailyActionsSOResult = (
ruleId = '123',
connectorId = '456'
): SavedObjectsFindResult<LegacyIRuleActionsAttributes> => ({
type: 'siem-detection-engine-rule-actions',
id: 'ID_OF_LEGACY_SIDECAR_DAILY_ACTIONS',
namespaces: ['default'],
attributes: {
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Test Actions',
},
action_type_id: '.email',
},
],
ruleThrottle: '1d',
alertThrottle: '1d',
},
references: [
{ id: ruleId, type: 'alert', name: 'alert_0' },
{ id: connectorId, type: 'action', name: 'action_0' },
],
migrationVersion: {
'siem-detection-engine-rule-actions': '7.11.2',
},
coreMigrationVersion: '7.15.2',
updated_at: '2022-03-31T19:06:40.473Z',
version: 'WzIzNywxXQ==',
score: 0,
});
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetSiemNotificationRuleWeeklyActionsSOResult = (
ruleId = '123',
connectorId = '456'
): SavedObjectsFindResult<LegacyIRuleActionsAttributes> => ({
type: 'siem-detection-engine-rule-actions',
id: 'ID_OF_LEGACY_SIDECAR_WEEKLY_ACTIONS',
namespaces: ['default'],
attributes: {
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Test Actions',
},
action_type_id: '.email',
},
],
ruleThrottle: '7d',
alertThrottle: '7d',
},
references: [
{ id: ruleId, type: 'alert', name: 'alert_0' },
{ id: connectorId, type: 'action', name: 'action_0' },
],
migrationVersion: {
'siem-detection-engine-rule-actions': '7.11.2',
},
coreMigrationVersion: '7.15.2',
updated_at: '2022-03-31T19:06:40.473Z',
version: 'WzIzNywxXQ==',
score: 0,
});
const getLegacyActionSOs = (ruleId = '123', connectorId = '456') => ({
none: () => legacyGetSiemNotificationRuleNoActionsSOResult(ruleId),
rule: () => legacyGetSiemNotificationRuleEveryRunSOResult(ruleId),
hourly: () => legacyGetSiemNotificationRuleHourlyActionsSOResult(ruleId, connectorId),
daily: () => legacyGetSiemNotificationRuleDailyActionsSOResult(ruleId, connectorId),
weekly: () => legacyGetSiemNotificationRuleWeeklyActionsSOResult(ruleId, connectorId),
});
/**
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
*/
export const legacyGetSiemNotificationRuleActionsSOResultWithSingleHit = (
actionTypes: Array<'none' | 'rule' | 'daily' | 'hourly' | 'weekly'>,
ruleId = '123',
connectorId = '456'
): SavedObjectsFindResponse<LegacyIRuleActionsAttributes> => {
const actions = getLegacyActionSOs(ruleId, connectorId);
return {
page: 1,
perPage: 1,
per_page: 1,
total: 1,
data: [legacyGetNotificationResult()],
});
saved_objects: actionTypes.map((type) => actions[type]()),
};
};

View file

@ -21,6 +21,15 @@ import { installPrepackagedTimelines } from '../../../timeline/routes/prepackage
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { elasticsearchClientMock } from '@kbn/core/server/elasticsearch/client/mocks';
import { getQueryRuleParams } from '../../schemas/rule_schemas.mock';
import { legacyMigrate } from '../../rules/utils';
jest.mock('../../rules/utils', () => {
const actual = jest.requireActual('../../rules/utils');
return {
...actual,
legacyMigrate: jest.fn(),
};
});
jest.mock('../../rules/get_prepackaged_rules', () => {
return {
@ -92,6 +101,8 @@ describe('add_prepackaged_rules_route', () => {
errors: [],
});
(legacyMigrate as jest.Mock).mockResolvedValue(getRuleMock(getQueryRuleParams()));
context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue(
elasticsearchClientMock.createSuccessTransportRequestPromise(getBasicEmptySearchResponse())
);

View file

@ -13,10 +13,20 @@ import {
getFindResultWithSingleHit,
getDeleteRequestById,
getEmptySavedObjectsResponse,
getRuleMock,
} from '../__mocks__/request_responses';
import { requestContextMock, serverMock, requestMock } from '../__mocks__';
import { deleteRulesRoute } from './delete_rules_route';
import { getQueryRuleParams } from '../../schemas/rule_schemas.mock';
import { legacyMigrate } from '../../rules/utils';
jest.mock('../../rules/utils', () => {
const actual = jest.requireActual('../../rules/utils');
return {
...actual,
legacyMigrate: jest.fn(),
};
});
describe('delete_rules', () => {
let server: ReturnType<typeof serverMock.create>;
@ -29,6 +39,8 @@ describe('delete_rules', () => {
clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit());
clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse());
(legacyMigrate as jest.Mock).mockResolvedValue(getRuleMock(getQueryRuleParams()));
deleteRulesRoute(server.router);
});
@ -54,6 +66,7 @@ describe('delete_rules', () => {
test('returns 404 when deleting a single rule that does not exist with a valid actionClient and alertClient', async () => {
clients.rulesClient.find.mockResolvedValue(getEmptyFindResult());
(legacyMigrate as jest.Mock).mockResolvedValue(null);
const response = await server.inject(
getDeleteRequest(),
requestContextMock.convertContext(context)

View file

@ -23,9 +23,18 @@ import { patchRulesBulkRoute } from './patch_rules_bulk_route';
import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock';
import { getQueryRuleParams } from '../../schemas/rule_schemas.mock';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { legacyMigrate } from '../../rules/utils';
jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create());
jest.mock('../../rules/utils', () => {
const actual = jest.requireActual('../../rules/utils');
return {
...actual,
legacyMigrate: jest.fn(),
};
});
describe('patch_rules_bulk', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
@ -40,6 +49,8 @@ describe('patch_rules_bulk', () => {
clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists
clients.rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); // update succeeds
(legacyMigrate as jest.Mock).mockResolvedValue(getRuleMock(getQueryRuleParams()));
patchRulesBulkRoute(server.router, ml, logger);
});
@ -54,6 +65,7 @@ describe('patch_rules_bulk', () => {
test('returns an error in the response when updating a single rule that does not exist', async () => {
clients.rulesClient.find.mockResolvedValue(getEmptyFindResult());
(legacyMigrate as jest.Mock).mockResolvedValue(null);
const response = await server.inject(
getPatchBulkRequest(),
requestContextMock.convertContext(context)
@ -148,6 +160,8 @@ describe('patch_rules_bulk', () => {
describe('request validation', () => {
test('rejects payloads with no ID', async () => {
(legacyMigrate as jest.Mock).mockResolvedValue(null);
const request = requestMock.create({
method: 'patch',
path: DETECTION_ENGINE_RULES_BULK_UPDATE,

View file

@ -21,9 +21,18 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__';
import { patchRulesRoute } from './patch_rules_route';
import { getPatchRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema.mock';
import { getQueryRuleParams } from '../../schemas/rule_schemas.mock';
import { legacyMigrate } from '../../rules/utils';
jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create());
jest.mock('../../rules/utils', () => {
const actual = jest.requireActual('../../rules/utils');
return {
...actual,
legacyMigrate: jest.fn(),
};
});
describe('patch_rules', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
@ -41,6 +50,8 @@ describe('patch_rules', () => {
getRuleExecutionSummarySucceeded()
);
(legacyMigrate as jest.Mock).mockResolvedValue(getRuleMock(getQueryRuleParams()));
patchRulesRoute(server.router, ml);
});
@ -55,6 +66,7 @@ describe('patch_rules', () => {
test('returns 404 when updating a single rule that does not exist', async () => {
clients.rulesClient.find.mockResolvedValue(getEmptyFindResult());
(legacyMigrate as jest.Mock).mockResolvedValue(null);
const response = await server.inject(
getPatchRequest(),
requestContextMock.convertContext(context)
@ -67,6 +79,7 @@ describe('patch_rules', () => {
});
test('returns error if requesting a non-rule', async () => {
(legacyMigrate as jest.Mock).mockResolvedValue(null);
clients.rulesClient.find.mockResolvedValue(nonRuleFindResult());
const response = await server.inject(
getPatchRequest(),

View file

@ -14,6 +14,7 @@ import {
getBulkActionEditRequest,
getFindResultWithSingleHit,
getFindResultWithMultiHits,
getRuleMock,
} from '../__mocks__/request_responses';
import { requestContextMock, serverMock, requestMock } from '../__mocks__';
import { performBulkActionRoute } from './perform_bulk_action_route';
@ -23,10 +24,20 @@ import {
} from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { readRules } from '../../rules/read_rules';
import { legacyMigrate } from '../../rules/utils';
import { getQueryRuleParams } from '../../schemas/rule_schemas.mock';
jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create());
jest.mock('../../rules/read_rules', () => ({ readRules: jest.fn() }));
jest.mock('../../rules/utils', () => {
const actual = jest.requireActual('../../rules/utils');
return {
...actual,
legacyMigrate: jest.fn(),
};
});
describe('perform_bulk_action', () => {
const readRulesMock = readRules as jest.Mock;
let server: ReturnType<typeof serverMock.create>;
@ -40,6 +51,8 @@ describe('perform_bulk_action', () => {
logger = loggingSystemMock.createLogger();
({ clients, context } = requestContextMock.createTools());
ml = mlServicesMock.createSetupContract();
(legacyMigrate as jest.Mock).mockResolvedValue(getRuleMock(getQueryRuleParams()));
clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit());
performBulkActionRoute(server.router, ml, logger);
});
@ -220,7 +233,10 @@ describe('perform_bulk_action', () => {
readRulesMock.mockImplementationOnce(() =>
Promise.resolve({ ...mockRule, params: { ...mockRule.params, type: 'machine_learning' } })
);
(legacyMigrate as jest.Mock).mockResolvedValue({
...mockRule,
params: { ...mockRule.params, type: 'machine_learning' },
});
const request = requestMock.create({
method: 'patch',
path: DETECTION_ENGINE_RULES_BULK_ACTION,
@ -271,7 +287,10 @@ describe('perform_bulk_action', () => {
readRulesMock.mockImplementationOnce(() =>
Promise.resolve({ ...mockRule, params: { ...mockRule.params, index: ['index-*'] } })
);
(legacyMigrate as jest.Mock).mockResolvedValue({
...mockRule,
params: { ...mockRule.params, index: ['index-*'] },
});
const request = requestMock.create({
method: 'patch',
path: DETECTION_ENGINE_RULES_BULK_ACTION,

View file

@ -21,9 +21,18 @@ import { BulkError } from '../utils';
import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock';
import { getQueryRuleParams } from '../../schemas/rule_schemas.mock';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { legacyMigrate } from '../../rules/utils';
jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create());
jest.mock('../../rules/utils', () => {
const actual = jest.requireActual('../../rules/utils');
return {
...actual,
legacyMigrate: jest.fn(),
};
});
describe('update_rules_bulk', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
@ -40,6 +49,8 @@ describe('update_rules_bulk', () => {
clients.appClient.getSignalsIndex.mockReturnValue('.siem-signals-test-index');
(legacyMigrate as jest.Mock).mockResolvedValue(getRuleMock(getQueryRuleParams()));
updateRulesBulkRoute(server.router, ml, logger);
});
@ -54,6 +65,8 @@ describe('update_rules_bulk', () => {
test('returns 200 as a response when updating a single rule that does not exist', async () => {
clients.rulesClient.find.mockResolvedValue(getEmptyFindResult());
(legacyMigrate as jest.Mock).mockResolvedValue(null);
const expected: BulkError[] = [
{
error: { message: 'rule_id: "rule-1" not found', status_code: 404 },
@ -116,6 +129,8 @@ describe('update_rules_bulk', () => {
describe('request validation', () => {
test('rejects payloads with no ID', async () => {
(legacyMigrate as jest.Mock).mockResolvedValue(null);
const noIdRequest = requestMock.create({
method: 'put',
path: DETECTION_ENGINE_RULES_BULK_UPDATE,

View file

@ -21,9 +21,18 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
import { updateRulesRoute } from './update_rules_route';
import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock';
import { getQueryRuleParams } from '../../schemas/rule_schemas.mock';
import { legacyMigrate } from '../../rules/utils';
jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create());
jest.mock('../../rules/utils', () => {
const actual = jest.requireActual('../../rules/utils');
return {
...actual,
legacyMigrate: jest.fn(),
};
});
describe('update_rules', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
@ -42,6 +51,8 @@ describe('update_rules', () => {
);
clients.appClient.getSignalsIndex.mockReturnValue('.siem-signals-test-index');
(legacyMigrate as jest.Mock).mockResolvedValue(getRuleMock(getQueryRuleParams()));
updateRulesRoute(server.router, ml);
});
@ -56,6 +67,7 @@ describe('update_rules', () => {
test('returns 404 when updating a single rule that does not exist', async () => {
clients.rulesClient.find.mockResolvedValue(getEmptyFindResult());
(legacyMigrate as jest.Mock).mockResolvedValue(null);
const response = await server.inject(
getUpdateRequest(),
requestContextMock.convertContext(context)
@ -69,6 +81,7 @@ describe('update_rules', () => {
});
test('returns error when updating non-rule', async () => {
(legacyMigrate as jest.Mock).mockResolvedValue(null);
clients.rulesClient.find.mockResolvedValue(nonRuleFindResult());
const response = await server.inject(
getUpdateRequest(),

View file

@ -7,14 +7,24 @@
import { rulesClientMock } from '@kbn/alerting-plugin/server/mocks';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import { getFindResultWithSingleHit } from '../routes/__mocks__/request_responses';
import { getRuleMock, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses';
import { updatePrepackagedRules } from './update_prepacked_rules';
import { patchRules } from './patch_rules';
import { getAddPrepackagedRulesSchemaDecodedMock } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock';
import { ruleExecutionLogMock } from '../rule_execution_log/__mocks__';
import { legacyMigrate } from './utils';
import { getQueryRuleParams } from '../schemas/rule_schemas.mock';
jest.mock('./patch_rules');
jest.mock('./utils', () => {
const actual = jest.requireActual('./utils');
return {
...actual,
legacyMigrate: jest.fn(),
};
});
describe('updatePrepackagedRules', () => {
let rulesClient: ReturnType<typeof rulesClientMock.create>;
let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>;
@ -24,6 +34,8 @@ describe('updatePrepackagedRules', () => {
rulesClient = rulesClientMock.create();
savedObjectsClient = savedObjectsClientMock.create();
ruleExecutionLog = ruleExecutionLogMock.forRoutes.create();
(legacyMigrate as jest.Mock).mockResolvedValue(getRuleMock(getQueryRuleParams()));
});
it('should omit actions and enabled when calling patchRules', async () => {

View file

@ -13,6 +13,8 @@ import {
transformToAlertThrottle,
transformFromAlertThrottle,
transformActions,
legacyMigrate,
getUpdatedActionsParams,
} from './utils';
import { RuleAction, SanitizedRule } from '@kbn/alerting-plugin/common';
import { RuleParams } from '../schemas/rule_schemas';
@ -23,6 +25,67 @@ import {
import { FullResponseSchema } from '../../../../common/detection_engine/schemas/request';
// eslint-disable-next-line no-restricted-imports
import { LegacyRuleActions } from '../rule_actions/legacy_types';
import {
getEmptyFindResult,
legacyGetSiemNotificationRuleActionsSOResultWithSingleHit,
legacyGetDailyNotificationResult,
legacyGetHourlyNotificationResult,
legacyGetWeeklyNotificationResult,
} from '../routes/__mocks__/request_responses';
import { requestContextMock } from '../routes/__mocks__';
const getRuleLegacyActions = (): SanitizedRule<RuleParams> =>
({
id: '123',
notifyWhen: 'onThrottleInterval',
name: 'Simple Rule Query',
tags: ['__internal_rule_id:ruleId', '__internal_immutable:false'],
alertTypeId: 'siem.queryRule',
consumer: 'siem',
enabled: true,
throttle: '1h',
apiKeyOwner: 'elastic',
createdBy: 'elastic',
updatedBy: 'elastic',
muteAll: false,
mutedInstanceIds: [],
monitoring: { execution: { history: [], calculated_metrics: { success_ratio: 0 } } },
mapped_params: { risk_score: 1, severity: '60-high' },
schedule: { interval: '5m' },
actions: [],
params: {
author: [],
description: 'Simple Rule Query',
ruleId: 'ruleId',
falsePositives: [],
from: 'now-6m',
immutable: false,
outputIndex: '.siem-signals-default',
maxSignals: 100,
riskScore: 1,
riskScoreMapping: [],
severity: 'high',
severityMapping: [],
threat: [],
to: 'now',
references: [],
version: 1,
exceptionsList: [],
type: 'query',
language: 'kuery',
index: ['auditbeat-*'],
query: 'user.name: root or user.name: admin',
},
snoozeEndTime: null,
updatedAt: '2022-03-31T21:47:25.695Z',
createdAt: '2022-03-31T21:47:16.379Z',
scheduledTaskId: '21bb9b60-b13c-11ec-99d0-asdfasdfasf',
executionStatus: {
status: 'pending',
lastExecutionDate: '2022-03-31T21:47:25.695Z',
lastDuration: 0,
},
} as unknown as SanitizedRule<RuleParams>);
describe('utils', () => {
describe('#calculateInterval', () => {
@ -588,4 +651,415 @@ describe('utils', () => {
]);
});
});
describe('#legacyMigrate', () => {
const ruleId = '123';
const connectorId = '456';
const { clients } = requestContextMock.createTools();
beforeEach(() => {
jest.resetAllMocks();
});
test('it does no cleanup or migration if no legacy reminants found', async () => {
clients.rulesClient.find.mockResolvedValueOnce(getEmptyFindResult());
clients.savedObjectsClient.find.mockResolvedValueOnce({
page: 0,
per_page: 0,
total: 0,
saved_objects: [],
});
const rule = {
...getRuleLegacyActions(),
id: ruleId,
actions: [],
throttle: null,
notifyWhen: 'onActiveAlert',
muteAll: true,
} as SanitizedRule<RuleParams>;
const migratedRule = await legacyMigrate({
rulesClient: clients.rulesClient,
savedObjectsClient: clients.savedObjectsClient,
rule,
});
expect(clients.rulesClient.delete).not.toHaveBeenCalled();
expect(clients.savedObjectsClient.delete).not.toHaveBeenCalled();
expect(migratedRule).toEqual(rule);
});
// Even if a rule is created with no actions pre 7.16, a
// siem-detection-engine-rule-actions SO is still created
test('it migrates a rule with no actions', async () => {
// siem.notifications is not created for a rule with no actions
clients.rulesClient.find.mockResolvedValueOnce(getEmptyFindResult());
// siem-detection-engine-rule-actions SO is still created
clients.savedObjectsClient.find.mockResolvedValueOnce(
legacyGetSiemNotificationRuleActionsSOResultWithSingleHit(['none'], ruleId, connectorId)
);
const migratedRule = await legacyMigrate({
rulesClient: clients.rulesClient,
savedObjectsClient: clients.savedObjectsClient,
rule: {
...getRuleLegacyActions(),
id: ruleId,
actions: [],
throttle: null,
notifyWhen: 'onActiveAlert',
muteAll: true,
},
});
expect(clients.rulesClient.delete).not.toHaveBeenCalled();
expect(clients.savedObjectsClient.delete).toHaveBeenCalledWith(
'siem-detection-engine-rule-actions',
'ID_OF_LEGACY_SIDECAR_NO_ACTIONS'
);
expect(migratedRule?.actions).toEqual([]);
expect(migratedRule?.throttle).toBeNull();
expect(migratedRule?.muteAll).toBeTruthy();
expect(migratedRule?.notifyWhen).toEqual('onActiveAlert');
});
test('it migrates a rule with every rule run action', async () => {
// siem.notifications is not created for a rule with actions run every rule run
clients.rulesClient.find.mockResolvedValueOnce(getEmptyFindResult());
// siem-detection-engine-rule-actions SO is still created
clients.savedObjectsClient.find.mockResolvedValueOnce(
legacyGetSiemNotificationRuleActionsSOResultWithSingleHit(['rule'], ruleId, connectorId)
);
const migratedRule = await legacyMigrate({
rulesClient: clients.rulesClient,
savedObjectsClient: clients.savedObjectsClient,
rule: {
...getRuleLegacyActions(),
id: ruleId,
actions: [
{
actionTypeId: '.email',
params: {
subject: 'Test Actions',
to: ['test@test.com'],
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
},
id: connectorId,
group: 'default',
},
],
throttle: null,
notifyWhen: 'onActiveAlert',
muteAll: false,
},
});
expect(clients.rulesClient.delete).not.toHaveBeenCalled();
expect(clients.savedObjectsClient.delete).toHaveBeenCalledWith(
'siem-detection-engine-rule-actions',
'ID_OF_LEGACY_SIDECAR_RULE_RUN_ACTIONS'
);
expect(migratedRule?.actions).toEqual([
{
id: connectorId,
actionTypeId: '.email',
group: 'default',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
subject: 'Test Actions',
to: ['test@test.com'],
},
},
]);
expect(migratedRule?.notifyWhen).toEqual('onActiveAlert');
expect(migratedRule?.throttle).toBeNull();
expect(migratedRule?.muteAll).toBeFalsy();
});
test('it migrates a rule with daily legacy actions', async () => {
// siem.notifications is not created for a rule with no actions
clients.rulesClient.find.mockResolvedValueOnce({
page: 1,
perPage: 1,
total: 1,
data: [legacyGetDailyNotificationResult(connectorId, ruleId)],
});
// siem-detection-engine-rule-actions SO is still created
clients.savedObjectsClient.find.mockResolvedValueOnce(
legacyGetSiemNotificationRuleActionsSOResultWithSingleHit(['daily'], ruleId, connectorId)
);
const migratedRule = await legacyMigrate({
rulesClient: clients.rulesClient,
savedObjectsClient: clients.savedObjectsClient,
rule: {
...getRuleLegacyActions(),
id: ruleId,
actions: [],
throttle: null,
notifyWhen: 'onActiveAlert',
},
});
expect(clients.rulesClient.delete).toHaveBeenCalledWith({ id: '456' });
expect(clients.savedObjectsClient.delete).toHaveBeenCalledWith(
'siem-detection-engine-rule-actions',
'ID_OF_LEGACY_SIDECAR_DAILY_ACTIONS'
);
expect(migratedRule?.actions).toEqual([
{
actionTypeId: '.email',
group: 'default',
id: connectorId,
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Test Actions',
},
},
]);
expect(migratedRule?.throttle).toEqual('1d');
expect(migratedRule?.notifyWhen).toEqual('onThrottleInterval');
expect(migratedRule?.muteAll).toBeFalsy();
});
test('it migrates a rule with hourly legacy actions', async () => {
// siem.notifications is not created for a rule with no actions
clients.rulesClient.find.mockResolvedValueOnce({
page: 1,
perPage: 1,
total: 1,
data: [legacyGetHourlyNotificationResult(connectorId, ruleId)],
});
// siem-detection-engine-rule-actions SO is still created
clients.savedObjectsClient.find.mockResolvedValueOnce(
legacyGetSiemNotificationRuleActionsSOResultWithSingleHit(['hourly'], ruleId, connectorId)
);
const migratedRule = await legacyMigrate({
rulesClient: clients.rulesClient,
savedObjectsClient: clients.savedObjectsClient,
rule: {
...getRuleLegacyActions(),
id: ruleId,
actions: [],
throttle: null,
notifyWhen: 'onActiveAlert',
},
});
expect(clients.rulesClient.delete).toHaveBeenCalledWith({ id: '456' });
expect(clients.savedObjectsClient.delete).toHaveBeenCalledWith(
'siem-detection-engine-rule-actions',
'ID_OF_LEGACY_SIDECAR_HOURLY_ACTIONS'
);
expect(migratedRule?.actions).toEqual([
{
actionTypeId: '.email',
group: 'default',
id: connectorId,
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Test Actions',
},
},
]);
expect(migratedRule?.throttle).toEqual('1h');
expect(migratedRule?.notifyWhen).toEqual('onThrottleInterval');
expect(migratedRule?.muteAll).toBeFalsy();
});
test('it migrates a rule with weekly legacy actions', async () => {
// siem.notifications is not created for a rule with no actions
clients.rulesClient.find.mockResolvedValueOnce({
page: 1,
perPage: 1,
total: 1,
data: [legacyGetWeeklyNotificationResult(connectorId, ruleId)],
});
// siem-detection-engine-rule-actions SO is still created
clients.savedObjectsClient.find.mockResolvedValueOnce(
legacyGetSiemNotificationRuleActionsSOResultWithSingleHit(['weekly'], ruleId, connectorId)
);
const migratedRule = await legacyMigrate({
rulesClient: clients.rulesClient,
savedObjectsClient: clients.savedObjectsClient,
rule: {
...getRuleLegacyActions(),
id: ruleId,
actions: [],
throttle: null,
notifyWhen: 'onActiveAlert',
},
});
expect(clients.rulesClient.delete).toHaveBeenCalledWith({ id: '456' });
expect(clients.savedObjectsClient.delete).toHaveBeenCalledWith(
'siem-detection-engine-rule-actions',
'ID_OF_LEGACY_SIDECAR_WEEKLY_ACTIONS'
);
expect(migratedRule?.actions).toEqual([
{
actionTypeId: '.email',
group: 'default',
id: connectorId,
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Test Actions',
},
},
]);
expect(migratedRule?.throttle).toEqual('7d');
expect(migratedRule?.notifyWhen).toEqual('onThrottleInterval');
expect(migratedRule?.muteAll).toBeFalsy();
});
});
describe('#getUpdatedActionsParams', () => {
it('updates one action', () => {
const { id, ...rule } = {
...getRuleLegacyActions(),
id: '123',
actions: [],
throttle: null,
notifyWhen: 'onActiveAlert',
} as SanitizedRule<RuleParams>;
expect(
getUpdatedActionsParams({
rule: {
...rule,
id,
},
ruleThrottle: '1h',
actions: [
{
actionRef: 'action_0',
group: 'default',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['a@a.com'],
subject: 'Test Actions',
},
action_type_id: '.email',
},
],
references: [
{
id: '61ec7a40-b076-11ec-bb3f-1f063f8e06cf',
type: 'alert',
name: 'alert_0',
},
{
id: '1234',
type: 'action',
name: 'action_0',
},
],
})
).toEqual({
...rule,
actions: [
{
actionTypeId: '.email',
group: 'default',
id: '1234',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
subject: 'Test Actions',
to: ['a@a.com'],
},
},
],
throttle: '1h',
notifyWhen: 'onThrottleInterval',
});
});
it('updates multiple actions', () => {
const { id, ...rule } = {
...getRuleLegacyActions(),
id: '123',
actions: [],
throttle: null,
notifyWhen: 'onActiveAlert',
} as SanitizedRule<RuleParams>;
expect(
getUpdatedActionsParams({
rule: {
...rule,
id,
},
ruleThrottle: '1h',
actions: [
{
actionRef: 'action_0',
group: 'default',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
to: ['test@test.com'],
subject: 'Rule email',
},
action_type_id: '.email',
},
{
actionRef: 'action_1',
group: 'default',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
},
action_type_id: '.slack',
},
],
references: [
{
id: '064e3160-b076-11ec-bb3f-1f063f8e06cf',
type: 'alert',
name: 'alert_0',
},
{
id: 'c95cb100-b075-11ec-bb3f-1f063f8e06cf',
type: 'action',
name: 'action_0',
},
{
id: '207fa0e0-c04e-11ec-8a52-4fb92379525a',
type: 'action',
name: 'action_1',
},
],
})
).toEqual({
...rule,
actions: [
{
actionTypeId: '.email',
group: 'default',
id: 'c95cb100-b075-11ec-bb3f-1f063f8e06cf',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
subject: 'Rule email',
to: ['test@test.com'],
},
},
{
actionTypeId: '.slack',
group: 'default',
id: '207fa0e0-c04e-11ec-8a52-4fb92379525a',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
},
},
],
throttle: '1h',
notifyWhen: 'onThrottleInterval',
});
});
});
});

View file

@ -28,6 +28,7 @@ import type {
} from '@kbn/securitysolution-io-ts-alerting-types';
import type { ListArrayOrUndefined } from '@kbn/securitysolution-io-ts-list-types';
import type { VersionOrUndefined } from '@kbn/securitysolution-io-ts-types';
import { SavedObjectReference } from '@kbn/core/server';
import { RuleAction, RuleNotifyWhenType, SanitizedRule } from '@kbn/alerting-plugin/common';
import { RulesClient } from '@kbn/alerting-plugin/server';
import {
@ -63,7 +64,11 @@ import {
NOTIFICATION_THROTTLE_RULE,
} from '../../../../common/constants';
// eslint-disable-next-line no-restricted-imports
import { LegacyRuleActions } from '../rule_actions/legacy_types';
import {
LegacyIRuleActionsAttributes,
LegacyRuleActions,
LegacyRuleAlertSavedObjectAction,
} from '../rule_actions/legacy_types';
import { FullResponseSchema } from '../../../../common/detection_engine/schemas/request';
import { transformAlertToRuleAction } from '../../../../common/detection_engine/transform_actions';
// eslint-disable-next-line no-restricted-imports
@ -302,6 +307,59 @@ export const maybeMute = async ({
}
};
/**
* Translate legacy action sidecar action to rule action
*/
export const getUpdatedActionsParams = ({
rule,
ruleThrottle,
actions,
references,
}: {
rule: SanitizedRule<RuleParams>;
ruleThrottle: string | null;
actions: LegacyRuleAlertSavedObjectAction[];
references: SavedObjectReference[];
}): Omit<SanitizedRule<RuleParams>, 'id'> => {
const { id, ...restOfRule } = rule;
const actionReference = references.reduce<Record<string, SavedObjectReference>>(
(acc, reference) => {
acc[reference.name] = reference;
return acc;
},
{}
);
if (isEmpty(actionReference)) {
throw new Error(
`An error occurred migrating legacy action for rule with id:${id}. Connector reference id not found.`
);
}
// If rule has an action on any other interval (other than on every
// rule run), need to move the action info from the sidecar/legacy action
// into the rule itself
return {
...restOfRule,
actions: actions.reduce<RuleAction[]>((acc, action) => {
const { actionRef, action_type_id: actionTypeId, ...resOfAction } = action;
if (!actionReference[actionRef]) {
return acc;
}
return [
...acc,
{
...resOfAction,
id: actionReference[actionRef].id,
actionTypeId,
},
];
}, []),
throttle: transformToAlertThrottle(ruleThrottle),
notifyWhen: transformToNotifyWhen(ruleThrottle),
};
};
/**
* Determines if rule needs to be migrated from legacy actions
* and returns necessary pieces for the updated rule
@ -332,7 +390,7 @@ export const legacyMigrate = async ({
},
},
}),
savedObjectsClient.find({
savedObjectsClient.find<LegacyIRuleActionsAttributes>({
type: legacyRuleActionsSavedObjectType,
hasReference: {
type: 'alert',
@ -341,29 +399,57 @@ export const legacyMigrate = async ({
}),
]);
if (siemNotification != null && siemNotification.data.length > 0) {
await Promise.all([
rulesClient.delete({ id: siemNotification.data[0].id }),
legacyRuleActionsSO != null && legacyRuleActionsSO.saved_objects.length > 0
? savedObjectsClient.delete(
legacyRuleActionsSavedObjectType,
legacyRuleActionsSO.saved_objects[0].id
)
: null,
]);
const siemNotificationsExist = siemNotification != null && siemNotification.data.length > 0;
const legacyRuleNotificationSOsExist =
legacyRuleActionsSO != null && legacyRuleActionsSO.saved_objects.length > 0;
// Assumption: if no legacy sidecar SO or notification rule types exist
// that reference the rule in question, assume rule actions are not legacy
if (!siemNotificationsExist && !legacyRuleNotificationSOsExist) {
return rule;
}
// If the legacy notification rule type ("siem.notification") exist,
// migration and cleanup are needed
if (siemNotificationsExist) {
await rulesClient.delete({ id: siemNotification.data[0].id });
}
// If legacy notification sidecar ("siem-detection-engine-rule-actions")
// exist, migration and cleanup are needed
if (legacyRuleNotificationSOsExist) {
// Delete the legacy sidecar SO
await savedObjectsClient.delete(
legacyRuleActionsSavedObjectType,
legacyRuleActionsSO.saved_objects[0].id
);
// If "siem-detection-engine-rule-actions" notes that `ruleThrottle` is
// "no_actions" or "rule", rule has no actions or rule is set to run
// action on every rule run. In these cases, sidecar deletion is the only
// cleanup needed and updates to the "throttle" and "notifyWhen". "siem.notification" are
// not created for these action types
if (
legacyRuleActionsSO.saved_objects[0].attributes.ruleThrottle === 'no_actions' ||
legacyRuleActionsSO.saved_objects[0].attributes.ruleThrottle === 'rule'
) {
return rule;
}
// Use "legacyRuleActionsSO" instead of "siemNotification" as "siemNotification" is not created
// until a rule is run and added to task manager. That means that if by chance a user has a rule
// with actions which they have yet to enable, the actions would be lost. Instead,
// "legacyRuleActionsSO" is created on rule creation (pre 7.15) and we can rely on it to be there
const migratedRule = getUpdatedActionsParams({
rule,
ruleThrottle: legacyRuleActionsSO.saved_objects[0].attributes.ruleThrottle,
actions: legacyRuleActionsSO.saved_objects[0].attributes.actions,
references: legacyRuleActionsSO.saved_objects[0].references,
});
const { id, ...restOfRule } = rule;
const migratedRule = {
...restOfRule,
actions: siemNotification.data[0].actions,
throttle: siemNotification.data[0].schedule.interval,
notifyWhen: transformToNotifyWhen(siemNotification.data[0].throttle),
};
await rulesClient.update({
id: rule.id,
data: migratedRule,
});
return { id: rule.id, ...migratedRule };
}
return rule;
};

View file

@ -20,6 +20,7 @@ import {
getSimpleRuleOutput,
getSimpleRuleOutputWithoutRuleId,
getSimpleRuleWithoutRuleId,
getSlackAction,
getWebHookAction,
removeServerGeneratedProperties,
removeServerGeneratedPropertiesIncludingRuleId,
@ -148,7 +149,7 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: hookAction } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.send(getSlackAction())
.expect(200);
// create a rule without actions

View file

@ -20,6 +20,7 @@ import {
getSimpleRuleOutput,
getSimpleRuleOutputWithoutRuleId,
getSimpleRuleWithoutRuleId,
getSlackAction,
getWebHookAction,
removeServerGeneratedProperties,
removeServerGeneratedPropertiesIncludingRuleId,
@ -277,7 +278,7 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: hookAction } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.send(getSlackAction())
.expect(200);
// create a rule without actions
@ -318,12 +319,12 @@ export default ({ getService }: FtrProviderContext): void => {
const { body: hookAction1 } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.send(getSlackAction())
.expect(200);
const { body: hookAction2 } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.send(getSlackAction())
.expect(200);
// create 2 rules without actions

View file

@ -13,6 +13,9 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
describe('', function () {
this.tags('ciGroup11');
// !!NOTE: For new routes that do any updates on a rule, please ensure that you are including the legacy
// action migration code. We are monitoring legacy action telemetry to clean up once we see their
// existence being near 0.
loadTestFile(require.resolve('./aliases'));
loadTestFile(require.resolve('./add_actions'));
loadTestFile(require.resolve('./update_actions'));
@ -33,6 +36,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./get_rule_execution_events'));
loadTestFile(require.resolve('./import_rules'));
loadTestFile(require.resolve('./import_export_rules'));
loadTestFile(require.resolve('./legacy_actions_migrations'));
loadTestFile(require.resolve('./read_rules'));
loadTestFile(require.resolve('./resolve_read_rules'));
loadTestFile(require.resolve('./update_rules'));

View file

@ -0,0 +1,322 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import {
getLegacyActionSOById,
getLegacyActionNotificationSOById,
getRuleSOById,
} from '../../utils';
/**
* @deprecated Once the legacy notification system is removed, remove this test too.
*/
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertest');
const es = getService('es');
const esArchiver = getService('esArchiver');
// This test suite is not meant to test a specific route, but to test the legacy action migration
// code that lives in multiple routes. This code is also tested in each of the routes it lives in
// but not in as much detail and relying on mocks. This test loads an es_archive containing rules
// created in 7.15 with legacy actions.
// For new routes that do any updates on a rule, please ensure that you are including the legacy
// action migration code. We are monitoring legacy action telemetry to clean up once we see their
// existence being near 0.
describe('migrate_legacy_actions', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/security_solution/legacy_actions');
});
after(async () => {
await esArchiver.unload(
'x-pack/test/functional/es_archives/security_solution/legacy_actions'
);
});
it('migrates legacy actions for rule with no actions', async () => {
const soId = '9095ee90-b075-11ec-bb3f-1f063f8e06cf';
const ruleId = '2297be91-894c-4831-830f-b424a0ec84f0';
const legacySidecarId = '926668d0-b075-11ec-bb3f-1f063f8e06cf';
// check for legacy sidecar action
const sidecarActionSO = await getLegacyActionSOById(es, legacySidecarId);
expect(sidecarActionSO.hits.hits.length).to.eql(1);
// check for legacy notification SO
// should not have been created for a rule with no actions
const legacyNotificationSO = await getLegacyActionNotificationSOById(es, soId);
expect(legacyNotificationSO.hits.hits.length).to.eql(0);
// patch enable the rule
// any route that edits the rule should trigger the migration
await supertest
.patch(DETECTION_ENGINE_RULES_URL)
.set('kbn-xsrf', 'true')
.send({ rule_id: ruleId, enabled: false })
.expect(200);
const {
hits: {
hits: [{ _source: ruleSO }],
},
} = await getRuleSOById(es, soId);
// Sidecar should be removed
const sidecarActionsSOAfterMigration = await getLegacyActionSOById(es, legacySidecarId);
expect(sidecarActionsSOAfterMigration.hits.hits.length).to.eql(0);
expect(ruleSO?.alert.actions).to.eql([]);
expect(ruleSO?.alert.throttle).to.eql(null);
expect(ruleSO?.alert.notifyWhen).to.eql('onActiveAlert');
});
it('migrates legacy actions for rule with action run on every run', async () => {
const soId = 'dc6595f0-b075-11ec-bb3f-1f063f8e06cf';
const ruleId = '72a0d429-363b-4f70-905e-c6019a224d40';
const legacySidecarId = 'dde13970-b075-11ec-bb3f-1f063f8e06cf';
// check for legacy sidecar action
const sidecarActionSO = await getLegacyActionSOById(es, legacySidecarId);
expect(sidecarActionSO.hits.hits.length).to.eql(1);
// check for legacy notification SO
// should not have been created for a rule that runs on every rule run
const legacyNotificationSO = await getLegacyActionNotificationSOById(es, soId);
expect(legacyNotificationSO.hits.hits.length).to.eql(0);
// patch enable the rule
// any route that edits the rule should trigger the migration
await supertest
.patch(DETECTION_ENGINE_RULES_URL)
.set('kbn-xsrf', 'true')
.send({ rule_id: ruleId, enabled: false })
.expect(200);
const {
hits: {
hits: [{ _source: ruleSO }],
},
} = await getRuleSOById(es, soId);
// Sidecar should be removed
const sidecarActionsSOAfterMigration = await getLegacyActionSOById(es, legacySidecarId);
expect(sidecarActionsSOAfterMigration.hits.hits.length).to.eql(0);
expect(ruleSO?.alert.actions).to.eql([
{
actionRef: 'action_0',
actionTypeId: '.email',
group: 'default',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
subject: 'Test Actions',
to: ['test@test.com'],
},
},
]);
expect(ruleSO?.alert.throttle).to.eql(null);
expect(ruleSO?.alert.notifyWhen).to.eql('onActiveAlert');
expect(ruleSO?.references).to.eql([
{
id: 'c95cb100-b075-11ec-bb3f-1f063f8e06cf',
name: 'action_0',
type: 'action',
},
]);
});
it('migrates legacy actions for rule with action run hourly', async () => {
const soId = '064e3160-b076-11ec-bb3f-1f063f8e06cf';
const ruleId = '4c056b05-75ac-4209-be32-82100f771eb4';
const legacySidecarId = '07aa8d10-b076-11ec-bb3f-1f063f8e06cf';
// check for legacy sidecar action
const sidecarActionSO = await getLegacyActionSOById(es, legacySidecarId);
expect(sidecarActionSO.hits.hits.length).to.eql(1);
// check for legacy notification SO
const legacyNotificationSO = await getLegacyActionNotificationSOById(es, soId);
expect(legacyNotificationSO.hits.hits.length).to.eql(1);
// patch enable the rule
// any route that edits the rule should trigger the migration
await supertest
.patch(DETECTION_ENGINE_RULES_URL)
.set('kbn-xsrf', 'true')
.send({ rule_id: ruleId, enabled: false })
.expect(200);
const {
hits: {
hits: [{ _source: ruleSO }],
},
} = await getRuleSOById(es, soId);
// Sidecar should be removed
const sidecarActionsSOAfterMigration = await getLegacyActionSOById(es, legacySidecarId);
expect(sidecarActionsSOAfterMigration.hits.hits.length).to.eql(0);
// Legacy notification should be removed
const legacyNotificationSOAfterMigration = await getLegacyActionNotificationSOById(es, soId);
expect(legacyNotificationSOAfterMigration.hits.hits.length).to.eql(0);
expect(ruleSO?.alert.actions).to.eql([
{
actionTypeId: '.email',
params: {
subject: 'Rule email',
to: ['test@test.com'],
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
},
actionRef: 'action_0',
group: 'default',
},
{
actionTypeId: '.slack',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
},
actionRef: 'action_1',
group: 'default',
},
]);
expect(ruleSO?.alert.throttle).to.eql('1h');
expect(ruleSO?.alert.notifyWhen).to.eql('onThrottleInterval');
expect(ruleSO?.references).to.eql([
{
id: 'c95cb100-b075-11ec-bb3f-1f063f8e06cf',
name: 'action_0',
type: 'action',
},
{
id: '207fa0e0-c04e-11ec-8a52-4fb92379525a',
name: 'action_1',
type: 'action',
},
]);
});
it('migrates legacy actions for rule with action run daily', async () => {
const soId = '27639570-b076-11ec-bb3f-1f063f8e06cf';
const ruleId = '8e2c8550-f13f-4e21-be0c-92148d71a5f1';
const legacySidecarId = '291ae260-b076-11ec-bb3f-1f063f8e06cf';
// check for legacy sidecar action
const sidecarActionSO = await getLegacyActionSOById(es, legacySidecarId);
expect(sidecarActionSO.hits.hits.length).to.eql(1);
// check for legacy notification SO
const legacyNotificationSO = await getLegacyActionNotificationSOById(es, soId);
expect(legacyNotificationSO.hits.hits.length).to.eql(1);
// patch enable the rule
await supertest
.patch(DETECTION_ENGINE_RULES_URL)
.set('kbn-xsrf', 'true')
.send({ rule_id: ruleId, enabled: false })
.expect(200);
const {
hits: {
hits: [{ _source: ruleSO }],
},
} = await getRuleSOById(es, soId);
// Sidecar should be removed
const sidecarActionsSOAfterMigration = await getLegacyActionSOById(es, legacySidecarId);
expect(sidecarActionsSOAfterMigration.hits.hits.length).to.eql(0);
// Legacy notification should be removed
const legacyNotificationSOAfterMigration = await getLegacyActionNotificationSOById(es, soId);
expect(legacyNotificationSOAfterMigration.hits.hits.length).to.eql(0);
expect(ruleSO?.alert.actions).to.eql([
{
actionRef: 'action_0',
actionTypeId: '.email',
group: 'default',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
subject: 'Test Actions',
to: ['test@test.com'],
},
},
]);
expect(ruleSO?.alert.throttle).to.eql('1d');
expect(ruleSO?.alert.notifyWhen).to.eql('onThrottleInterval');
expect(ruleSO?.references).to.eql([
{
id: 'c95cb100-b075-11ec-bb3f-1f063f8e06cf',
name: 'action_0',
type: 'action',
},
]);
});
it('migrates legacy actions for rule with action run weekly', async () => {
const soId = '61ec7a40-b076-11ec-bb3f-1f063f8e06cf';
const ruleId = '05fbdd2a-e802-420b-bdc3-95ae0acca454';
const legacySidecarId = '63aa2fd0-b076-11ec-bb3f-1f063f8e06cf';
// check for legacy sidecar action
const sidecarActionSO = await getLegacyActionSOById(es, legacySidecarId);
expect(sidecarActionSO.hits.hits.length).to.eql(1);
// check for legacy notification SO
const legacyNotificationSO = await getLegacyActionNotificationSOById(es, soId);
expect(legacyNotificationSO.hits.hits.length).to.eql(1);
// patch enable the rule
await supertest
.patch(DETECTION_ENGINE_RULES_URL)
.set('kbn-xsrf', 'true')
.send({ rule_id: ruleId, enabled: false })
.expect(200);
const {
hits: {
hits: [{ _source: ruleSO }],
},
} = await getRuleSOById(es, soId);
// Sidecar should be removed
const sidecarActionsSOAfterMigration = await getLegacyActionSOById(es, legacySidecarId);
expect(sidecarActionsSOAfterMigration.hits.hits.length).to.eql(0);
// Legacy notification should be removed
const legacyNotificationSOAfterMigration = await getLegacyActionNotificationSOById(es, soId);
expect(legacyNotificationSOAfterMigration.hits.hits.length).to.eql(0);
expect(ruleSO?.alert.actions).to.eql([
{
actionRef: 'action_0',
actionTypeId: '.email',
group: 'default',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
subject: 'Test Actions',
to: ['test@test.com'],
},
},
]);
expect(ruleSO?.alert.throttle).to.eql('7d');
expect(ruleSO?.alert.notifyWhen).to.eql('onThrottleInterval');
expect(ruleSO?.references).to.eql([
{
id: 'c95cb100-b075-11ec-bb3f-1f063f8e06cf',
name: 'action_0',
type: 'action',
},
]);
});
});
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Client } from '@elastic/elasticsearch';
import { SavedObjectReference } from '@kbn/core/server';
import type { SearchResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { LegacyRuleNotificationAlertTypeParams } from '@kbn/security-solution-plugin/server/lib/detection_engine/notifications/legacy_types';
interface LegacyActionNotificationSO extends LegacyRuleNotificationAlertTypeParams {
references: SavedObjectReference[];
}
/**
* Fetch all legacy action sidecar notification SOs from the .kibana index
* @param es The ElasticSearch service
*/
export const getLegacyActionNotificationSO = async (
es: Client
): Promise<SearchResponse<LegacyActionNotificationSO>> =>
es.search({
index: '.kibana',
q: 'alert.alertTypeId:siem.notifications',
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Client } from '@elastic/elasticsearch';
import { SavedObjectReference } from '@kbn/core/server';
import type { SearchResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { LegacyRuleNotificationAlertTypeParams } from '@kbn/security-solution-plugin/server/lib/detection_engine/notifications/legacy_types';
interface LegacyActionNotificationSO extends LegacyRuleNotificationAlertTypeParams {
references: SavedObjectReference[];
}
/**
* Fetch legacy action sidecar notification SOs from the .kibana index
* @param es The ElasticSearch service
* @param id SO id
*/
export const getLegacyActionNotificationSOById = async (
es: Client,
id: string
): Promise<SearchResponse<LegacyActionNotificationSO>> =>
es.search({
index: '.kibana',
q: `alert.alertTypeId:siem.notifications AND alert.params.ruleAlertId:"${id}"`,
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Client } from '@elastic/elasticsearch';
import { SavedObjectReference } from '@kbn/core/server';
import type { SearchResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { LegacyRuleActions } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_actions/legacy_types';
interface LegacyActionSO extends LegacyRuleActions {
references: SavedObjectReference[];
}
/**
* Fetch legacy action sidecar SOs from the .kibana index
* @param es The ElasticSearch service
* @param id SO id
*/
export const getLegacyActionSOById = async (
es: Client,
id: string
): Promise<SearchResponse<LegacyActionSO>> =>
es.search({
index: '.kibana',
q: `type:siem-detection-engine-rule-actions AND _id:"siem-detection-engine-rule-actions:${id}"`,
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Client } from '@elastic/elasticsearch';
import { SavedObjectReference } from '@kbn/core/server';
import type { SearchResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { Rule } from '@kbn/alerting-plugin/common';
interface RuleSO {
alert: Rule;
references: SavedObjectReference[];
}
/**
* Fetch legacy action sidecar SOs from the .kibana index
* @param es The ElasticSearch service
* @param id SO id
*/
export const getRuleSOById = async (es: Client, id: string): Promise<SearchResponse<RuleSO>> =>
es.search({
index: '.kibana',
q: `type:alert AND _id:"alert:${id}"`,
});

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
export const getSlackAction = () => ({
actionTypeId: '.slack',
secrets: {
webhookUrl: 'http://localhost:123',
},
name: 'Slack connector',
});

View file

@ -35,7 +35,10 @@ export * from './get_detection_metrics_from_body';
export * from './get_eql_rule_for_signal_testing';
export * from './get_event_log_execute_complete_by_id';
export * from './get_index_name_from_load';
export * from './get_legacy_action_notification_so';
export * from './get_legacy_action_notifications_so_by_id';
export * from './get_legacy_action_so';
export * from './get_legacy_actions_so_by_id';
export * from './get_open_signals';
export * from './get_prepackaged_rule_status';
export * from './get_query_all_signals';
@ -44,6 +47,7 @@ export * from './get_query_signals_ids';
export * from './get_query_signals_rule_id';
export * from './get_rule';
export * from './get_rule_for_signal_testing';
export * from './get_rule_so_by_id';
export * from './get_rule_for_signal_testing_with_timestamp_override';
export * from './get_rule_with_web_hook_action';
export * from './get_saved_query_rule_for_signal_testing';
@ -70,6 +74,7 @@ export * from './get_stats';
export * from './get_stats_url';
export * from './get_threat_match_rule_for_signal_testing';
export * from './get_threshold_rule_for_signal_testing';
export * from './get_slack_action';
export * from './get_web_hook_action';
export * from './index_event_log_execution_events';
export * from './install_prepackaged_rules';

File diff suppressed because it is too large Load diff