[Security Solution] Disable legacy rules on upgrade to 8.x (#121442)

* Disable legacy rule and notify user to upgrade

* Ensure rules are disabled on upgrade

* Fix dupe detection on upgrade

* Revert "Fix dupe detection on upgrade"

This reverts commit 021ec0fac4.

* Add legacy notification

* Add tests for 8.0 security_solution rule migration

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Madison Caldwell 2022-01-10 13:57:51 -05:00 committed by GitHub
parent 1ca5b89038
commit 1ddb64758f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 49 additions and 1214 deletions

View file

@ -11,6 +11,7 @@ import { RawRule } from '../types';
import { SavedObjectUnsanitizedDoc } from 'kibana/server';
import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks';
import { migrationMocks } from 'src/core/server/mocks';
import { RuleType, ruleTypeMappings } from '@kbn/securitysolution-rules';
const migrationContext = migrationMocks.createContext();
const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup();
@ -2056,6 +2057,37 @@ describe('successful migrations', () => {
);
});
test('doesnt change AAD rule params if not a siem.signals rule', () => {
const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0'];
const alert = getMockData(
{ params: { outputIndex: 'output-index', type: 'query' }, alertTypeId: 'not.siem.signals' },
true
);
expect(migration800(alert, migrationContext).attributes.alertTypeId).toEqual(
'not.siem.signals'
);
expect(migration800(alert, migrationContext).attributes.enabled).toEqual(true);
expect(migration800(alert, migrationContext).attributes.params.outputIndex).toEqual(
'output-index'
);
});
test.each(Object.keys(ruleTypeMappings) as RuleType[])(
'Changes AAD rule params accordingly if rule is a siem.signals %p rule',
(ruleType) => {
const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0'];
const alert = getMockData(
{ params: { outputIndex: 'output-index', type: ruleType }, alertTypeId: 'siem.signals' },
true
);
expect(migration800(alert, migrationContext).attributes.alertTypeId).toEqual(
ruleTypeMappings[ruleType]
);
expect(migration800(alert, migrationContext).attributes.enabled).toEqual(false);
expect(migration800(alert, migrationContext).attributes.params.outputIndex).toEqual('');
}
);
describe('Metrics Inventory Threshold rule', () => {
test('Migrates incorrect action group spelling', () => {
const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0'];

View file

@ -131,7 +131,7 @@ export function getMigrations(
(doc: SavedObjectUnsanitizedDoc<RawRule>): doc is SavedObjectUnsanitizedDoc<RawRule> => true,
pipeMigrations(
addThreatIndicatorPathToThreatMatchRules,
addRACRuleTypes,
addSecuritySolutionAADRuleTypes,
fixInventoryThresholdGroupId
)
);
@ -652,7 +652,7 @@ function setLegacyId(doc: SavedObjectUnsanitizedDoc<RawRule>): SavedObjectUnsani
};
}
function addRACRuleTypes(
function addSecuritySolutionAADRuleTypes(
doc: SavedObjectUnsanitizedDoc<RawRule>
): SavedObjectUnsanitizedDoc<RawRule> {
const ruleType = doc.attributes.params.type;
@ -662,6 +662,7 @@ function addRACRuleTypes(
attributes: {
...doc.attributes,
alertTypeId: ruleTypeMappings[ruleType],
enabled: false,
params: {
...doc.attributes.params,
outputIndex: '',

View file

@ -1,599 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { loggingSystemMock } from 'src/core/server/mocks';
import { getAlertMock } from '../routes/__mocks__/request_responses';
import { signalRulesAlertType } from './signal_rule_alert_type';
import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks';
import {
getListsClient,
getExceptions,
checkPrivileges,
createSearchAfterReturnType,
} from './utils';
import { parseScheduleDates } from '@kbn/securitysolution-io-ts-utils';
import { RuleExecutorOptions, SearchAfterAndBulkCreateReturnType } from './types';
import { RuleAlertType } from '../rules/types';
import { listMock } from '../../../../../lists/server/mocks';
import { getListClientMock } from '../../../../../lists/server/services/lists/list_client.mock';
import { getExceptionListClientMock } from '../../../../../lists/server/services/exception_lists/exception_list_client.mock';
import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import type { TransportResult } from '@elastic/elasticsearch';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks';
import { queryExecutor } from './executors/query';
import { mlExecutor } from './executors/ml';
import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock';
import { errors } from '@elastic/elasticsearch';
import { allowedExperimentalValues } from '../../../../common/experimental_features';
import { scheduleNotificationActions } from '../notifications/schedule_notification_actions';
import { ruleExecutionLogClientMock } from '../rule_execution_log/__mocks__/rule_execution_log_client';
import { RuleExecutionStatus } from '../../../../common/detection_engine/schemas/common/schemas';
import { scheduleThrottledNotificationActions } from '../notifications/schedule_throttle_notification_actions';
import { eventLogServiceMock } from '../../../../../event_log/server/mocks';
import { createMockConfig } from '../routes/__mocks__';
jest.mock('./utils', () => {
const original = jest.requireActual('./utils');
return {
...original,
getListsClient: jest.fn(),
getExceptions: jest.fn(),
sortExceptionItems: jest.fn(),
checkPrivileges: jest.fn(),
};
});
jest.mock('../notifications/schedule_notification_actions');
jest.mock('./executors/query');
jest.mock('./executors/ml');
jest.mock('@kbn/securitysolution-io-ts-utils', () => {
const original = jest.requireActual('@kbn/securitysolution-io-ts-utils');
return {
...original,
parseScheduleDates: jest.fn(),
};
});
jest.mock('../notifications/schedule_throttle_notification_actions');
const mockRuleExecutionLogClient = ruleExecutionLogClientMock.create();
jest.mock('../rule_execution_log/rule_execution_log_client', () => ({
RuleExecutionLogClient: jest.fn().mockImplementation(() => mockRuleExecutionLogClient),
}));
const getPayload = (
ruleAlert: RuleAlertType,
services: AlertServicesMock
): RuleExecutorOptions => ({
alertId: ruleAlert.id,
services,
name: ruleAlert.name,
tags: ruleAlert.tags,
params: {
...ruleAlert.params,
},
state: {},
spaceId: '',
startedAt: new Date('2019-12-13T16:50:33.400Z'),
previousStartedAt: new Date('2019-12-13T16:40:33.400Z'),
createdBy: 'elastic',
updatedBy: 'elastic',
rule: {
name: ruleAlert.name,
tags: ruleAlert.tags,
consumer: 'foo',
producer: 'foo',
ruleTypeId: 'ruleType',
ruleTypeName: 'Name of rule',
enabled: true,
schedule: {
interval: '5m',
},
actions: ruleAlert.actions,
createdBy: 'elastic',
updatedBy: 'elastic',
createdAt: new Date('2019-12-13T16:50:33.400Z'),
updatedAt: new Date('2019-12-13T16:50:33.400Z'),
throttle: null,
notifyWhen: null,
},
});
// Deprecated
describe.skip('signal_rule_alert_type', () => {
const version = '8.0.0';
const jobsSummaryMock = jest.fn();
const mlMock = {
mlClient: {
callAsInternalUser: jest.fn(),
close: jest.fn(),
asScoped: jest.fn(),
},
jobServiceProvider: jest.fn().mockReturnValue({
jobsSummary: jobsSummaryMock,
}),
anomalyDetectorsProvider: jest.fn(),
mlSystemProvider: jest.fn(),
modulesProvider: jest.fn(),
resultsServiceProvider: jest.fn(),
alertingServiceProvider: jest.fn(),
};
let payload: jest.Mocked<RuleExecutorOptions>;
let alert: ReturnType<typeof signalRulesAlertType>;
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
let alertServices: AlertServicesMock;
let eventLogService: ReturnType<typeof eventLogServiceMock.create>;
beforeEach(() => {
alertServices = alertsMock.createAlertServices();
logger = loggingSystemMock.createLogger();
eventLogService = eventLogServiceMock.create();
(getListsClient as jest.Mock).mockReturnValue({
listClient: getListClientMock(),
exceptionsClient: getExceptionListClientMock(),
});
(getExceptions as jest.Mock).mockReturnValue([getExceptionListItemSchemaMock()]);
(checkPrivileges as jest.Mock).mockImplementation(async (_, indices) => {
return {
index: indices.reduce(
(acc: { index: { [x: string]: { read: boolean } } }, index: string) => {
return {
[index]: {
read: true,
},
...acc,
};
},
{}
),
};
});
const executorReturnValue = createSearchAfterReturnType({
createdSignalsCount: 10,
});
(queryExecutor as jest.Mock).mockClear();
(queryExecutor as jest.Mock).mockResolvedValue(executorReturnValue);
(mlExecutor as jest.Mock).mockClear();
(mlExecutor as jest.Mock).mockResolvedValue(executorReturnValue);
(parseScheduleDates as jest.Mock).mockReturnValue(moment(100));
const value: Partial<TransportResult<estypes.FieldCapsResponse>> = {
statusCode: 200,
body: {
indices: ['index1', 'index2', 'index3', 'index4'],
fields: {
'@timestamp': {
// @ts-expect-error not full interface
date: {
indices: ['index1', 'index2', 'index3', 'index4'],
searchable: true,
aggregatable: false,
},
},
},
},
};
alertServices.scopedClusterClient.asCurrentUser.fieldCaps.mockResolvedValue(
value as TransportResult<estypes.FieldCapsResponse>
);
const ruleAlert = getAlertMock(false, getQueryRuleParams());
alertServices.savedObjectsClient.get.mockResolvedValue({
id: 'id',
type: 'type',
references: [],
attributes: ruleAlert,
});
payload = getPayload(ruleAlert, alertServices) as jest.Mocked<RuleExecutorOptions>;
alert = signalRulesAlertType({
experimentalFeatures: allowedExperimentalValues,
logger,
eventsTelemetry: undefined,
version,
ml: mlMock,
lists: listMock.createSetup(),
config: createMockConfig(),
eventLogService,
});
mockRuleExecutionLogClient.logStatusChange.mockClear();
(scheduleThrottledNotificationActions as jest.Mock).mockClear();
});
describe('executor', () => {
it('should log success status if signals were created', async () => {
payload.previousStartedAt = null;
await alert.executor(payload);
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenLastCalledWith(
expect.objectContaining({
newStatus: RuleExecutionStatus.succeeded,
})
);
});
it('should warn about the gap between runs if gap is very large', async () => {
payload.previousStartedAt = moment(payload.startedAt).subtract(100, 'm').toDate();
await alert.executor(payload);
expect(logger.warn).toHaveBeenCalled();
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
newStatus: RuleExecutionStatus['going to run'],
})
);
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
newStatus: RuleExecutionStatus.failed,
metrics: {
executionGap: expect.any(Object),
},
})
);
});
it('should set a warning for when rules cannot read ALL provided indices', async () => {
(checkPrivileges as jest.Mock).mockResolvedValueOnce({
username: 'elastic',
has_all_requested: false,
cluster: {},
index: {
'myfa*': {
read: true,
},
'anotherindex*': {
read: true,
},
'some*': {
read: false,
},
},
application: {},
});
const newRuleAlert = getAlertMock(false, getQueryRuleParams());
newRuleAlert.params.index = ['some*', 'myfa*', 'anotherindex*'];
payload = getPayload(newRuleAlert, alertServices) as jest.Mocked<RuleExecutorOptions>;
await alert.executor(payload);
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
newStatus: RuleExecutionStatus['partial failure'],
message:
'This rule may not have the required read privileges to the following indices/index patterns: ["some*"]',
})
);
});
it('should set a failure status for when rules cannot read ANY provided indices', async () => {
(checkPrivileges as jest.Mock).mockResolvedValueOnce({
username: 'elastic',
has_all_requested: false,
cluster: {},
index: {
'myfa*': {
read: false,
},
'some*': {
read: false,
},
},
application: {},
});
const newRuleAlert = getAlertMock(false, getQueryRuleParams());
newRuleAlert.params.index = ['some*', 'myfa*', 'anotherindex*'];
payload = getPayload(newRuleAlert, alertServices) as jest.Mocked<RuleExecutorOptions>;
await alert.executor(payload);
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
newStatus: RuleExecutionStatus['partial failure'],
message:
'This rule may not have the required read privileges to the following indices/index patterns: ["myfa*","some*"]',
})
);
});
it('should NOT warn about the gap between runs if gap small', async () => {
payload.previousStartedAt = moment().subtract(10, 'm').toDate();
await alert.executor(payload);
expect(logger.warn).toHaveBeenCalledTimes(0);
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenCalledTimes(2);
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
newStatus: RuleExecutionStatus['going to run'],
})
);
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
newStatus: RuleExecutionStatus.succeeded,
})
);
});
it('should call scheduleActions if signalsCount was greater than 0 and rule has actions defined', async () => {
const ruleAlert = getAlertMock(false, getQueryRuleParams());
ruleAlert.actions = [
{
actionTypeId: '.slack',
params: {
message:
'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}',
},
group: 'default',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
},
];
alertServices.savedObjectsClient.get.mockResolvedValue({
id: 'id',
type: 'type',
references: [],
attributes: ruleAlert,
});
await alert.executor(payload);
});
it('should resolve results_link when meta is an empty object to use "/app/security"', async () => {
const ruleAlert = getAlertMock(false, getQueryRuleParams());
ruleAlert.params.meta = {};
ruleAlert.actions = [
{
actionTypeId: '.slack',
params: {
message:
'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}',
},
group: 'default',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
},
];
const modifiedPayload = getPayload(
ruleAlert,
alertServices
) as jest.Mocked<RuleExecutorOptions>;
await alert.executor(modifiedPayload);
expect(scheduleNotificationActions).toHaveBeenCalledWith(
expect.objectContaining({
resultsLink: `/app/security/detections/rules/id/${ruleAlert.id}?timerange=(global:(linkTo:!(timeline),timerange:(from:100,kind:absolute,to:100)),timeline:(linkTo:!(global),timerange:(from:100,kind:absolute,to:100)))`,
})
);
});
it('should resolve results_link when meta is undefined use "/app/security"', async () => {
const ruleAlert = getAlertMock(false, getQueryRuleParams());
delete ruleAlert.params.meta;
ruleAlert.actions = [
{
actionTypeId: '.slack',
params: {
message:
'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}',
},
group: 'default',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
},
];
const modifiedPayload = getPayload(
ruleAlert,
alertServices
) as jest.Mocked<RuleExecutorOptions>;
await alert.executor(modifiedPayload);
expect(scheduleNotificationActions).toHaveBeenCalledWith(
expect.objectContaining({
resultsLink: `/app/security/detections/rules/id/${ruleAlert.id}?timerange=(global:(linkTo:!(timeline),timerange:(from:100,kind:absolute,to:100)),timeline:(linkTo:!(global),timerange:(from:100,kind:absolute,to:100)))`,
})
);
});
it('should resolve results_link with a custom link', async () => {
const ruleAlert = getAlertMock(false, getQueryRuleParams());
ruleAlert.params.meta = { kibana_siem_app_url: 'http://localhost' };
ruleAlert.actions = [
{
actionTypeId: '.slack',
params: {
message:
'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}',
},
group: 'default',
id: '99403909-ca9b-49ba-9d7a-7e5320e68d05',
},
];
const modifiedPayload = getPayload(
ruleAlert,
alertServices
) as jest.Mocked<RuleExecutorOptions>;
await alert.executor(modifiedPayload);
expect(scheduleNotificationActions).toHaveBeenCalledWith(
expect.objectContaining({
resultsLink: `http://localhost/detections/rules/id/${ruleAlert.id}?timerange=(global:(linkTo:!(timeline),timerange:(from:100,kind:absolute,to:100)),timeline:(linkTo:!(global),timerange:(from:100,kind:absolute,to:100)))`,
})
);
});
describe('ML rule', () => {
it('should not call checkPrivileges if ML rule', async () => {
const ruleAlert = getAlertMock(false, getMlRuleParams());
alertServices.savedObjectsClient.get.mockResolvedValue({
id: 'id',
type: 'type',
references: [],
attributes: ruleAlert,
});
payload = getPayload(ruleAlert, alertServices) as jest.Mocked<RuleExecutorOptions>;
payload.previousStartedAt = null;
(checkPrivileges as jest.Mock).mockClear();
await alert.executor(payload);
expect(checkPrivileges).toHaveBeenCalledTimes(0);
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenLastCalledWith(
expect.objectContaining({
newStatus: RuleExecutionStatus.succeeded,
})
);
});
});
});
describe('should catch error', () => {
it('when bulk indexing failed', async () => {
const result: SearchAfterAndBulkCreateReturnType = {
success: false,
warning: false,
searchAfterTimes: [],
bulkCreateTimes: [],
lastLookBackDate: null,
createdSignalsCount: 0,
createdSignals: [],
warningMessages: [],
errors: ['Error that bubbled up.'],
};
(queryExecutor as jest.Mock).mockResolvedValue(result);
await alert.executor(payload);
expect(logger.error).toHaveBeenCalled();
expect(logger.error.mock.calls[0][0]).toContain(
'Bulk Indexing of signals failed: Error that bubbled up. name: "Detect Root/Admin Users" id: "04128c15-0d1b-4716-a4c5-46997ac7f3bd" rule id: "rule-1" signals index: ".siem-signals"'
);
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenLastCalledWith(
expect.objectContaining({
newStatus: RuleExecutionStatus.failed,
})
);
});
it('when error was thrown', async () => {
(queryExecutor as jest.Mock).mockRejectedValue({});
await alert.executor(payload);
expect(logger.error).toHaveBeenCalled();
expect(logger.error.mock.calls[0][0]).toContain('An error occurred during rule execution');
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenLastCalledWith(
expect.objectContaining({
newStatus: RuleExecutionStatus.failed,
})
);
});
it('and log failure with the default message', async () => {
(queryExecutor as jest.Mock).mockReturnValue(
elasticsearchClientMock.createErrorTransportRequestPromise(
new errors.ResponseError(
elasticsearchClientMock.createApiResponse({
statusCode: 400,
body: { error: { type: 'some_error_type' } },
})
)
)
);
await alert.executor(payload);
expect(logger.error).toHaveBeenCalled();
expect(logger.error.mock.calls[0][0]).toContain('An error occurred during rule execution');
expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenLastCalledWith(
expect.objectContaining({
newStatus: RuleExecutionStatus.failed,
})
);
});
it('should call scheduleThrottledNotificationActions if result is false to prevent the throttle from being reset', async () => {
const result: SearchAfterAndBulkCreateReturnType = {
success: false,
warning: false,
searchAfterTimes: [],
bulkCreateTimes: [],
lastLookBackDate: null,
createdSignalsCount: 0,
createdSignals: [],
warningMessages: [],
errors: ['Error that bubbled up.'],
};
(queryExecutor as jest.Mock).mockResolvedValue(result);
const ruleAlert = getAlertMock(false, getQueryRuleParams());
ruleAlert.throttle = '1h';
const payLoadWithThrottle = getPayload(
ruleAlert,
alertServices
) as jest.Mocked<RuleExecutorOptions>;
payLoadWithThrottle.rule.throttle = '1h';
alertServices.savedObjectsClient.get.mockResolvedValue({
id: 'id',
type: 'type',
references: [],
attributes: ruleAlert,
});
await alert.executor(payLoadWithThrottle);
expect(scheduleThrottledNotificationActions).toHaveBeenCalledTimes(1);
});
it('should NOT call scheduleThrottledNotificationActions if result is false and the throttle is not set', async () => {
const result: SearchAfterAndBulkCreateReturnType = {
success: false,
warning: false,
searchAfterTimes: [],
bulkCreateTimes: [],
lastLookBackDate: null,
createdSignalsCount: 0,
createdSignals: [],
warningMessages: [],
errors: ['Error that bubbled up.'],
};
(queryExecutor as jest.Mock).mockResolvedValue(result);
await alert.executor(payload);
expect(scheduleThrottledNotificationActions).toHaveBeenCalledTimes(0);
});
it('should call scheduleThrottledNotificationActions if an error was thrown to prevent the throttle from being reset', async () => {
(queryExecutor as jest.Mock).mockRejectedValue({});
const ruleAlert = getAlertMock(false, getQueryRuleParams());
ruleAlert.throttle = '1h';
const payLoadWithThrottle = getPayload(
ruleAlert,
alertServices
) as jest.Mocked<RuleExecutorOptions>;
payLoadWithThrottle.rule.throttle = '1h';
alertServices.savedObjectsClient.get.mockResolvedValue({
id: 'id',
type: 'type',
references: [],
attributes: ruleAlert,
});
await alert.executor(payLoadWithThrottle);
expect(scheduleThrottledNotificationActions).toHaveBeenCalledTimes(1);
});
it('should NOT call scheduleThrottledNotificationActions if an error was thrown to prevent the throttle from being reset if throttle is not defined', async () => {
const result: SearchAfterAndBulkCreateReturnType = {
success: false,
warning: false,
searchAfterTimes: [],
bulkCreateTimes: [],
lastLookBackDate: null,
createdSignalsCount: 0,
createdSignals: [],
warningMessages: [],
errors: ['Error that bubbled up.'],
};
(queryExecutor as jest.Mock).mockRejectedValue(result);
await alert.executor(payload);
expect(scheduleThrottledNotificationActions).toHaveBeenCalledTimes(0);
});
});
});

View file

@ -1,596 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable complexity */
import { Logger } from 'src/core/server';
import isEmpty from 'lodash/isEmpty';
import * as t from 'io-ts';
import { validateNonExact, parseScheduleDates } from '@kbn/securitysolution-io-ts-utils';
import { SIGNALS_ID } from '@kbn/securitysolution-rules';
import { DEFAULT_SEARCH_AFTER_PAGE_SIZE, SERVER_APP_ID } from '../../../../common/constants';
import { isMlRule } from '../../../../common/machine_learning/helpers';
import {
isThresholdRule,
isEqlRule,
isThreatMatchRule,
isQueryRule,
} from '../../../../common/detection_engine/utils';
import { SetupPlugins } from '../../../plugin';
import { getInputIndex } from './get_input_output_index';
import { SignalRuleAlertTypeDefinition, ThresholdAlertState } from './types';
import {
getListsClient,
getExceptions,
createSearchAfterReturnType,
checkPrivileges,
hasTimestampFields,
hasReadIndexPrivileges,
getRuleRangeTuples,
isMachineLearningParams,
} from './utils';
import { siemRuleActionGroups } from './siem_rule_action_groups';
import {
scheduleNotificationActions,
NotificationRuleTypeParams,
} from '../notifications/schedule_notification_actions';
import { buildRuleMessageFactory } from './rule_messages';
import { getNotificationResultsLink } from '../notifications/utils';
import { TelemetryEventsSender } from '../../telemetry/sender';
import { eqlExecutor } from './executors/eql';
import { queryExecutor } from './executors/query';
import { threatMatchExecutor } from './executors/threat_match';
import { thresholdExecutor } from './executors/threshold';
import { mlExecutor } from './executors/ml';
import {
eqlRuleParams,
machineLearningRuleParams,
queryRuleParams,
threatRuleParams,
thresholdRuleParams,
ruleParams,
RuleParams,
savedQueryRuleParams,
CompleteRule,
} from '../schemas/rule_schemas';
import { bulkCreateFactory } from './bulk_create_factory';
import { wrapHitsFactory } from './wrap_hits_factory';
import { wrapSequencesFactory } from './wrap_sequences_factory';
import { ConfigType } from '../../../config';
import { ExperimentalFeatures } from '../../../../common/experimental_features';
import { injectReferences, extractReferences } from './saved_object_references';
import {
IRuleExecutionLogClient,
RuleExecutionLogClient,
truncateMessageList,
} from '../rule_execution_log';
import { RuleExecutionStatus } from '../../../../common/detection_engine/schemas/common/schemas';
import { scheduleThrottledNotificationActions } from '../notifications/schedule_throttle_notification_actions';
import { IEventLogService } from '../../../../../event_log/server';
export const signalRulesAlertType = ({
logger,
eventsTelemetry,
experimentalFeatures,
version,
ml,
lists,
config,
eventLogService,
indexNameOverride,
ruleExecutionLogClientOverride,
refreshOverride,
}: {
logger: Logger;
eventsTelemetry: TelemetryEventsSender | undefined;
experimentalFeatures: ExperimentalFeatures;
version: string;
ml: SetupPlugins['ml'] | undefined;
lists: SetupPlugins['lists'] | undefined;
config: ConfigType;
eventLogService: IEventLogService;
indexNameOverride?: string;
refreshOverride?: string;
ruleExecutionLogClientOverride?: IRuleExecutionLogClient;
}): SignalRuleAlertTypeDefinition => {
const { alertMergeStrategy: mergeStrategy, alertIgnoreFields: ignoreFields } = config;
return {
id: SIGNALS_ID,
name: 'SIEM signal',
actionGroups: siemRuleActionGroups,
defaultActionGroupId: 'default',
useSavedObjectReferences: {
extractReferences: (params) => extractReferences({ logger, params }),
injectReferences: (params, savedObjectReferences) =>
injectReferences({ logger, params, savedObjectReferences }),
},
validate: {
params: {
validate: (object: unknown): RuleParams => {
const [validated, errors] = validateNonExact(object, ruleParams);
if (errors != null) {
throw new Error(errors);
}
if (validated == null) {
throw new Error('Validation of rule params failed');
}
return validated;
},
},
},
producer: SERVER_APP_ID,
minimumLicenseRequired: 'basic',
isExportable: false,
async executor({
previousStartedAt,
startedAt,
state,
alertId,
services,
params,
spaceId,
updatedBy: updatedByUser,
rule,
}) {
const { ruleId, maxSignals, meta, outputIndex, timestampOverride, type } = params;
const searchAfterSize = Math.min(maxSignals, DEFAULT_SEARCH_AFTER_PAGE_SIZE);
let hasError: boolean = false;
let result = createSearchAfterReturnType();
const ruleStatusClient = ruleExecutionLogClientOverride
? ruleExecutionLogClientOverride
: new RuleExecutionLogClient({
underlyingClient: config.ruleExecutionLog.underlyingClient,
savedObjectsClient: services.savedObjectsClient,
eventLogService,
logger,
});
const completeRule: CompleteRule<RuleParams> = {
alertId,
ruleConfig: rule,
ruleParams: params,
};
const {
actions,
name,
schedule: { interval },
ruleTypeId,
} = completeRule.ruleConfig;
const refresh = refreshOverride ?? actions.length ? 'wait_for' : false;
const buildRuleMessage = buildRuleMessageFactory({
id: alertId,
ruleId,
name,
index: indexNameOverride ?? outputIndex,
});
logger.debug(buildRuleMessage('[+] Starting Signal Rule execution'));
logger.debug(buildRuleMessage(`interval: ${interval}`));
let wroteWarningStatus = false;
const basicLogArguments = {
spaceId,
ruleId: alertId,
ruleName: name,
ruleType: ruleTypeId,
};
await ruleStatusClient.logStatusChange({
...basicLogArguments,
newStatus: RuleExecutionStatus['going to run'],
});
const notificationRuleParams: NotificationRuleTypeParams = {
...params,
name,
id: alertId,
};
// check if rule has permissions to access given index pattern
// move this collection of lines into a function in utils
// so that we can use it in create rules route, bulk, etc.
try {
if (!isMachineLearningParams(params)) {
const index = params.index;
const hasTimestampOverride = timestampOverride != null && !isEmpty(timestampOverride);
const inputIndices = await getInputIndex({
services,
version,
index,
experimentalFeatures,
});
const privileges = await checkPrivileges(services, inputIndices);
wroteWarningStatus = await hasReadIndexPrivileges({
...basicLogArguments,
privileges,
logger,
buildRuleMessage,
ruleStatusClient,
});
if (!wroteWarningStatus) {
const timestampFieldCaps = await services.scopedClusterClient.asCurrentUser.fieldCaps({
index,
fields: hasTimestampOverride
? ['@timestamp', timestampOverride as string]
: ['@timestamp'],
include_unmapped: true,
});
wroteWarningStatus = await hasTimestampFields({
...basicLogArguments,
timestampField: hasTimestampOverride ? (timestampOverride as string) : '@timestamp',
timestampFieldCapsResponse: timestampFieldCaps,
inputIndices,
ruleStatusClient,
logger,
buildRuleMessage,
});
}
}
} catch (exc) {
const errorMessage = buildRuleMessage(`Check privileges failed to execute ${exc}`);
logger.error(errorMessage);
await ruleStatusClient.logStatusChange({
...basicLogArguments,
message: errorMessage,
newStatus: RuleExecutionStatus['partial failure'],
});
wroteWarningStatus = true;
}
const { tuples, remainingGap } = getRuleRangeTuples({
logger,
previousStartedAt,
from: params.from,
to: params.to,
interval,
maxSignals,
buildRuleMessage,
startedAt,
});
if (remainingGap.asMilliseconds() > 0) {
const gapString = remainingGap.humanize();
const gapMessage = buildRuleMessage(
`${gapString} (${remainingGap.asMilliseconds()}ms) were not queried between this rule execution and the last execution, so signals may have been missed.`,
'Consider increasing your look behind time or adding more Kibana instances.'
);
logger.warn(gapMessage);
hasError = true;
await ruleStatusClient.logStatusChange({
...basicLogArguments,
newStatus: RuleExecutionStatus.failed,
message: gapMessage,
metrics: { executionGap: remainingGap },
});
}
try {
const { listClient, exceptionsClient } = getListsClient({
services,
updatedByUser,
spaceId,
lists,
savedObjectClient: services.savedObjectsClient,
});
const exceptionItems = await getExceptions({
client: exceptionsClient,
lists: params.exceptionsList ?? [],
});
const bulkCreate = bulkCreateFactory(
logger,
services.scopedClusterClient.asCurrentUser,
buildRuleMessage,
refresh,
indexNameOverride
);
const wrapHits = wrapHitsFactory({
completeRule,
signalsIndex: indexNameOverride ?? params.outputIndex,
mergeStrategy,
ignoreFields,
});
const wrapSequences = wrapSequencesFactory({
completeRule,
signalsIndex: params.outputIndex,
mergeStrategy,
ignoreFields,
});
if (isMlRule(type)) {
const mlRuleCompleteRule = asTypeSpecificCompleteRule(
completeRule,
machineLearningRuleParams
);
for (const tuple of tuples) {
result = await mlExecutor({
completeRule: mlRuleCompleteRule,
tuple,
ml,
listClient,
exceptionItems,
services,
logger,
buildRuleMessage,
bulkCreate,
wrapHits,
});
}
} else if (isThresholdRule(type)) {
const thresholdCompleteRule = asTypeSpecificCompleteRule(
completeRule,
thresholdRuleParams
);
for (const tuple of tuples) {
result = await thresholdExecutor({
completeRule: thresholdCompleteRule,
tuple,
exceptionItems,
experimentalFeatures,
services,
version,
logger,
buildRuleMessage,
startedAt,
state: state as ThresholdAlertState,
bulkCreate,
wrapHits,
});
}
} else if (isThreatMatchRule(type)) {
const threatCompleteRule = asTypeSpecificCompleteRule(completeRule, threatRuleParams);
for (const tuple of tuples) {
result = await threatMatchExecutor({
completeRule: threatCompleteRule,
tuple,
listClient,
exceptionItems,
experimentalFeatures,
services,
version,
searchAfterSize,
logger,
eventsTelemetry,
buildRuleMessage,
bulkCreate,
wrapHits,
});
}
} else if (isQueryRule(type)) {
const queryCompleteRule = validateQueryRuleTypes(completeRule);
for (const tuple of tuples) {
result = await queryExecutor({
completeRule: queryCompleteRule,
tuple,
listClient,
exceptionItems,
experimentalFeatures,
services,
version,
searchAfterSize,
logger,
eventsTelemetry,
buildRuleMessage,
bulkCreate,
wrapHits,
});
}
} else if (isEqlRule(type)) {
const eqlCompleteRule = asTypeSpecificCompleteRule(completeRule, eqlRuleParams);
for (const tuple of tuples) {
result = await eqlExecutor({
completeRule: eqlCompleteRule,
tuple,
exceptionItems,
experimentalFeatures,
services,
version,
searchAfterSize,
bulkCreate,
logger,
wrapHits,
wrapSequences,
});
}
}
if (result.warningMessages.length) {
const warningMessage = buildRuleMessage(
truncateMessageList(result.warningMessages).join()
);
await ruleStatusClient.logStatusChange({
...basicLogArguments,
newStatus: RuleExecutionStatus['partial failure'],
message: warningMessage,
});
}
if (result.success) {
if (actions.length) {
const fromInMs = parseScheduleDates(`now-${interval}`)?.format('x');
const toInMs = parseScheduleDates('now')?.format('x');
const resultsLink = getNotificationResultsLink({
from: fromInMs,
to: toInMs,
id: alertId,
kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined)
?.kibana_siem_app_url,
});
logger.debug(
buildRuleMessage(`Found ${result.createdSignalsCount} signals for notification.`)
);
if (completeRule.ruleConfig.throttle != null) {
await scheduleThrottledNotificationActions({
alertInstance: services.alertInstanceFactory(alertId),
throttle: completeRule.ruleConfig.throttle,
startedAt,
id: alertId,
kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined)
?.kibana_siem_app_url,
outputIndex: indexNameOverride ?? outputIndex,
ruleId,
signals: result.createdSignals,
esClient: services.scopedClusterClient.asCurrentUser,
notificationRuleParams,
logger,
});
} else if (result.createdSignalsCount) {
const alertInstance = services.alertInstanceFactory(alertId);
scheduleNotificationActions({
alertInstance,
signalsCount: result.createdSignalsCount,
signals: result.createdSignals,
resultsLink,
ruleParams: notificationRuleParams,
});
}
}
logger.debug(buildRuleMessage('[+] Signal Rule execution completed.'));
logger.debug(
buildRuleMessage(
`[+] Finished indexing ${result.createdSignalsCount} signals into ${
indexNameOverride ?? outputIndex
}`
)
);
if (!hasError && !wroteWarningStatus && !result.warning) {
await ruleStatusClient.logStatusChange({
...basicLogArguments,
newStatus: RuleExecutionStatus.succeeded,
message: 'succeeded',
metrics: {
indexingDurations: result.bulkCreateTimes,
searchDurations: result.searchAfterTimes,
lastLookBackDate: result.lastLookBackDate?.toISOString(),
},
});
}
logger.debug(
buildRuleMessage(
`[+] Finished indexing ${result.createdSignalsCount} ${
!isEmpty(tuples)
? `signals searched between date ranges ${JSON.stringify(tuples, null, 2)}`
: ''
}`
)
);
} else {
// NOTE: Since this is throttled we have to call it even on an error condition, otherwise it will "reset" the throttle and fire early
if (completeRule.ruleConfig.throttle != null) {
await scheduleThrottledNotificationActions({
alertInstance: services.alertInstanceFactory(alertId),
throttle: completeRule.ruleConfig.throttle ?? '',
startedAt,
id: completeRule.alertId,
kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined)
?.kibana_siem_app_url,
outputIndex,
ruleId,
signals: result.createdSignals,
esClient: services.scopedClusterClient.asCurrentUser,
notificationRuleParams,
logger,
});
}
const errorMessage = buildRuleMessage(
'Bulk Indexing of signals failed:',
truncateMessageList(result.errors).join()
);
logger.error(errorMessage);
await ruleStatusClient.logStatusChange({
...basicLogArguments,
newStatus: RuleExecutionStatus.failed,
message: errorMessage,
metrics: {
indexingDurations: result.bulkCreateTimes,
searchDurations: result.searchAfterTimes,
lastLookBackDate: result.lastLookBackDate?.toISOString(),
},
});
}
} catch (error) {
// NOTE: Since this is throttled we have to call it even on an error condition, otherwise it will "reset" the throttle and fire early
if (completeRule.ruleConfig.throttle != null) {
await scheduleThrottledNotificationActions({
alertInstance: services.alertInstanceFactory(alertId),
throttle: completeRule.ruleConfig.throttle ?? '',
startedAt,
id: completeRule.alertId,
kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined)
?.kibana_siem_app_url,
outputIndex,
ruleId,
signals: result.createdSignals,
esClient: services.scopedClusterClient.asCurrentUser,
notificationRuleParams,
logger,
});
}
const errorMessage = error.message ?? '(no error message given)';
const message = buildRuleMessage(
'An error occurred during rule execution:',
`message: "${errorMessage}"`
);
logger.error(message);
await ruleStatusClient.logStatusChange({
...basicLogArguments,
newStatus: RuleExecutionStatus.failed,
message,
metrics: {
indexingDurations: result.bulkCreateTimes,
searchDurations: result.searchAfterTimes,
lastLookBackDate: result.lastLookBackDate?.toISOString(),
},
});
}
},
};
};
const validateQueryRuleTypes = (completeRule: CompleteRule<RuleParams>) => {
if (completeRule.ruleParams.type === 'query') {
return asTypeSpecificCompleteRule(completeRule, queryRuleParams);
} else {
return asTypeSpecificCompleteRule(completeRule, savedQueryRuleParams);
}
};
/**
* This function takes a generic rule SavedObject and a type-specific schema for the rule params
* and validates the SavedObject params against the schema. If they validate, it returns a SavedObject
* where the params have been replaced with the validated params. This eliminates the need for logic that
* checks if the required type specific fields actually exist on the SO and prevents rule executors from
* accessing fields that only exist on other rule types.
*
* @param completeRule rule typed as an object with all fields from all different rule types
* @param schema io-ts schema for the specific rule type the SavedObject claims to be
*/
export const asTypeSpecificCompleteRule = <T extends t.Mixed>(
completeRule: CompleteRule<RuleParams>,
schema: T
) => {
const [validated, errors] = validateNonExact(completeRule.ruleParams, schema);
if (validated == null || errors != null) {
throw new Error(`Rule attempted to execute with invalid params: ${errors}`);
}
return {
...completeRule,
ruleParams: validated,
};
};

View file

@ -38,8 +38,6 @@ import {
} from './lib/detection_engine/rule_types';
import { initRoutes } from './routes';
import { registerLimitedConcurrencyRoutes } from './routes/limited_concurrency';
import { isAlertExecutor } from './lib/detection_engine/signals/types';
import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type';
import { ManifestTask } from './endpoint/lib/artifacts';
import { CheckMetadataTransformsTask } from './endpoint/lib/metadata';
import { initSavedObjects } from './saved_objects';
@ -281,24 +279,9 @@ export class Plugin implements ISecuritySolutionPlugin {
plugins.features.registerKibanaFeature(getKibanaPrivilegesFeaturePrivileges(ruleTypes));
plugins.features.registerKibanaFeature(getCasesKibanaFeature());
// Continue to register legacy rules against alerting client exposed through rule-registry
if (plugins.alerting != null) {
const signalRuleType = signalRulesAlertType({
logger,
eventsTelemetry: this.telemetryEventsSender,
version: pluginContext.env.packageInfo.version,
ml: plugins.ml,
lists: plugins.lists,
config,
experimentalFeatures,
eventLogService,
});
const ruleNotificationType = legacyRulesNotificationAlertType({ logger });
if (isAlertExecutor(signalRuleType)) {
plugins.alerting.registerType(signalRuleType);
}
if (legacyIsNotificationAlertExecutor(ruleNotificationType)) {
plugins.alerting.registerType(ruleNotificationType);
}

View file

@ -360,5 +360,18 @@ export default function createGetTests({ getService }: FtrProviderContext) {
'metrics.inventory_threshold.fired'
);
});
it('8.0 migrates and disables pre-existing rules', async () => {
const response = await es.get<{ alert: RawRule }>(
{
index: '.kibana',
id: 'alert:38482620-ef1b-11eb-ad71-7de7959be71c',
},
{ meta: true }
);
expect(response.statusCode).to.eql(200);
expect(response.body._source?.alert?.alertTypeId).to.be('siem.queryRule');
expect(response.body._source?.alert?.enabled).to.be(false);
});
});
}

View file

@ -346,6 +346,7 @@
"consumer" : "alertsFixture",
"params" : {
"ruleId" : "4ec223b9-77fa-4895-8539-6b3e586a2858",
"type": "query",
"exceptionsList" : [
{
"id" : "endpoint_list",