[Security Solution] [Detections] Display rule warning when action is disabled but rule ran successfully (#182741)

## Summary

Ref: https://github.com/elastic/security-team/issues/8699


Co-authored-by: Yara Tercero <yctercero@users.noreply.github.com>
Co-authored-by: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com>
This commit is contained in:
Devin W. Hurley 2024-05-29 20:05:05 -04:00 committed by GitHub
parent 454ef7f5a3
commit 00c922fae3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 189 additions and 4 deletions

View file

@ -35,6 +35,7 @@ const createSetupMock = () => {
getActionsHealth: jest.fn(),
getActionsConfigurationUtilities: jest.fn(),
setEnabledConnectorTypes: jest.fn(),
isActionTypeEnabled: jest.fn(),
};
return mock;
};

View file

@ -420,6 +420,43 @@ describe('Actions Plugin', () => {
expect(pluginStart.isActionTypeEnabled('.slack')).toBeFalsy();
});
it('should set connector type enabled and check isActionTypeEnabled with plugin setup method', async () => {
setup(getConfig());
// coreMock.createSetup doesn't support Plugin generics
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup);
pluginSetup.registerType({
id: '.server-log',
name: 'Server log',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
});
pluginSetup.registerType({
id: '.slack',
name: 'Slack',
minimumLicenseRequired: 'gold',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
});
pluginSetup.setEnabledConnectorTypes(['.server-log']);
// checking isActionTypeEnabled via plugin setup, not plugin start
expect(pluginSetup.isActionTypeEnabled('.server-log')).toBeTruthy();
expect(pluginSetup.isActionTypeEnabled('.slack')).toBeFalsy();
});
it('should set all the connector types enabled when null or ["*"] passed', async () => {
setup(getConfig());
// coreMock.createSetup doesn't support Plugin generics

View file

@ -141,6 +141,7 @@ export interface PluginSetupContract {
getActionsHealth: () => { hasPermanentEncryptionKey: boolean };
getActionsConfigurationUtilities: () => ActionsConfigurationUtilities;
setEnabledConnectorTypes: (connectorTypes: EnabledConnectorTypes) => void;
isActionTypeEnabled(id: string, options?: { notifyUsage: boolean }): boolean;
}
export interface PluginStartContract {
@ -403,6 +404,9 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
);
}
},
isActionTypeEnabled: (id, options = { notifyUsage: false }) => {
return this.actionTypeRegistry!.isActionTypeEnabled(id, options);
},
};
}

View file

@ -15,6 +15,7 @@ import { mlPluginServerMock } from '@kbn/ml-plugin/server/mocks';
import type { IRuleDataClient } from '@kbn/rule-registry-plugin/server';
import { ruleRegistryMocks } from '@kbn/rule-registry-plugin/server/mocks';
import { eventLogServiceMock } from '@kbn/event-log-plugin/server/mocks';
import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server';
import type { PluginSetupContract as AlertingPluginSetupContract } from '@kbn/alerting-plugin/server';
import type { ConfigType } from '../../../../config';
import type { AlertAttributes } from '../types';
@ -49,6 +50,21 @@ export const createRuleTypeMocks = (
getConfig: () => ({ run: { alerts: { max: DEFAULT_MAX_ALERTS } } }),
} as AlertingPluginSetupContract;
const actions = {
registerType: jest.fn(),
registerSubActionConnectorType: jest.fn(),
isPreconfiguredConnector: (connectorId: string) => false,
getSubActionConnectorClass: jest.fn(),
getCaseConnectorClass: jest.fn(),
getActionsHealth: jest.fn(),
getActionsConfigurationUtilities: jest.fn(),
setEnabledConnectorTypes: jest.fn(),
isActionTypeEnabled: () => true,
} as ActionsPluginSetupContract;
const scheduleActions = jest.fn();
const mockSavedObjectsClient = savedObjectsClientMock.create();
@ -92,6 +108,7 @@ export const createRuleTypeMocks = (
return {
dependencies: {
actions,
alerting,
config$: mockedConfig$,
lists: listMock.createSetup(),

View file

@ -26,6 +26,7 @@ import {
hasTimestampFields,
isMachineLearningParams,
isEsqlParams,
getDisabledActionsWarningText,
} from './utils/utils';
import { DEFAULT_MAX_SIGNALS, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants';
import type { CreateSecurityRuleTypeWrapper } from './types';
@ -67,6 +68,7 @@ export const securityRuleTypeFieldMap = {
export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
({
lists,
actions,
logger,
config,
publicBaseUrl,
@ -472,6 +474,28 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
};
}
const disabledActions = rule.actions.filter(
(action) => !actions.isActionTypeEnabled(action.actionTypeId)
);
const createdSignalsCount = result.createdSignals.length;
if (disabledActions.length > 0) {
const disabledActionsWarning = getDisabledActionsWarningText({
alertsCreated: createdSignalsCount > 0,
disabledActions,
});
if (result.warningMessages.length) {
result.warningMessages.push(disabledActionsWarning);
} else {
warningMessage = [
...(warningMessage ? [warningMessage] : []),
disabledActionsWarning,
].join(', ');
wroteWarningStatus = true;
}
}
if (result.warningMessages.length) {
await ruleExecutionLogger.logStatusChange({
newStatus: RuleExecutionStatusEnum['partial failure'],
@ -484,8 +508,6 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
});
}
const createdSignalsCount = result.createdSignals.length;
if (result.success) {
ruleExecutionLogger.debug('Security Rule execution completed');
ruleExecutionLogger.debug(

View file

@ -37,8 +37,9 @@ describe('Custom Query Alerts', () => {
const publicBaseUrl = 'http://somekibanabaseurl.com';
const { dependencies, executor, services } = mocks;
const { alerting, lists, logger, ruleDataClient } = dependencies;
const { actions, alerting, lists, logger, ruleDataClient } = dependencies;
const securityRuleTypeWrapper = createSecurityRuleTypeWrapper({
actions,
lists,
logger,
config: createMockConfig(),

View file

@ -129,6 +129,7 @@ export type SecurityAlertType<
export interface CreateSecurityRuleTypeWrapperProps {
lists: SetupPlugins['lists'];
actions: SetupPlugins['actions'];
logger: Logger;
config: ConfigType;
publicBaseUrl: string | undefined;

View file

@ -11,6 +11,8 @@ import type { TransportResult } from '@elastic/elasticsearch';
import { ALERT_REASON, ALERT_RULE_PARAMETERS, ALERT_UUID, TIMESTAMP } from '@kbn/rule-data-utils';
import type { RuleExecutorServicesMock } from '@kbn/alerting-plugin/server/mocks';
import type { SanitizedRuleAction } from '@kbn/alerting-plugin/common';
import { alertsMock } from '@kbn/alerting-plugin/server/mocks';
import { listMock } from '@kbn/lists-plugin/server/mocks';
import type { ExceptionListClient } from '@kbn/lists-plugin/server';
@ -45,6 +47,7 @@ import {
getField,
addToSearchAfterReturn,
getUnprocessedExceptionsWarnings,
getDisabledActionsWarningText,
} from './utils';
import type { BulkResponseErrorAggregation, SearchAfterAndBulkCreateReturnType } from '../types';
import {
@ -1751,4 +1754,74 @@ describe('utils', () => {
);
});
});
describe('getDisabledActionsWarningText', () => {
const alertsCreated = true;
const alertsNotCreated = false;
const singleDisabledAction = [{ actionTypeId: '.webhook' }];
const multipleDisabledActionsDiffType = [
{ actionTypeId: '.webhook' },
{ actionTypeId: '.pagerduty' },
];
const multipleDisabledActionsSameType = [
{ actionTypeId: '.webhook' },
{ actionTypeId: '.webhook' },
];
test('returns string for single disabled action with alerts generated', () => {
const warning = getDisabledActionsWarningText({
alertsCreated,
disabledActions: singleDisabledAction as SanitizedRuleAction[],
});
expect(warning).toEqual(
'This rule generated alerts but did not send external notifications because rule action connector .webhook is not enabled. To send notifications, you need a higher Security Analytics license / tier'
);
});
test('returns string for single disabled action with no alerts generated', () => {
const warning = getDisabledActionsWarningText({
alertsCreated: alertsNotCreated,
disabledActions: singleDisabledAction as SanitizedRuleAction[],
});
expect(warning).toEqual(
'Rule action connector .webhook is not enabled. To send notifications, you need a higher Security Analytics license / tier'
);
});
test('returns string for multiple distinct disabled action types with alerts generated', () => {
const warning = getDisabledActionsWarningText({
alertsCreated,
disabledActions: multipleDisabledActionsDiffType as SanitizedRuleAction[],
});
expect(warning).toEqual(
'This rule generated alerts but did not send external notifications because rule action connectors .webhook, .pagerduty are not enabled. To send notifications, you need a higher Security Analytics license / tier'
);
});
test('returns string for multiple distinct disabled action types with alerts NOT generated', () => {
const warning = getDisabledActionsWarningText({
alertsCreated: alertsNotCreated,
disabledActions: multipleDisabledActionsDiffType as SanitizedRuleAction[],
});
expect(warning).toEqual(
'Rule action connectors .webhook, .pagerduty are not enabled. To send notifications, you need a higher Security Analytics license / tier'
);
});
test('returns string for multiple same type disabled action types with alerts generated', () => {
const warning = getDisabledActionsWarningText({
alertsCreated,
disabledActions: multipleDisabledActionsSameType as SanitizedRuleAction[],
});
expect(warning).toEqual(
'This rule generated alerts but did not send external notifications because rule action connector .webhook is not enabled. To send notifications, you need a higher Security Analytics license / tier'
);
});
test('returns string for multiple same type disabled action types with alerts NOT generated', () => {
const warning = getDisabledActionsWarningText({
alertsCreated: alertsNotCreated,
disabledActions: multipleDisabledActionsSameType as SanitizedRuleAction[],
});
expect(warning).toEqual(
'Rule action connector .webhook is not enabled. To send notifications, you need a higher Security Analytics license / tier'
);
});
});
});

View file

@ -31,6 +31,7 @@ import type {
} from '@kbn/alerting-plugin/server';
import { parseDuration } from '@kbn/alerting-plugin/server';
import type { ExceptionListClient, ListClient, ListPluginSetup } from '@kbn/lists-plugin/server';
import type { SanitizedRuleAction } from '@kbn/alerting-plugin/common';
import type { TimestampOverride } from '../../../../../common/api/detection_engine/model/rule_schema';
import type { Privilege } from '../../../../../common/api/detection_engine';
import { RuleExecutionStatusEnum } from '../../../../../common/api/detection_engine/rule_monitoring';
@ -1006,3 +1007,26 @@ export const getMaxSignalsWarning = (): string => {
export const getSuppressionMaxSignalsWarning = (): string => {
return `This rule reached the maximum alert limit for the rule execution. Some alerts were not created or suppressed.`;
};
export const getDisabledActionsWarningText = ({
alertsCreated,
disabledActions,
}: {
alertsCreated: boolean;
disabledActions: SanitizedRuleAction[];
}) => {
const uniqueActionTypes = new Set(disabledActions.map((action) => action.actionTypeId));
const actionTypesJoined = [...uniqueActionTypes].join(', ');
// This rule generated alerts but did not send external notifications because rule action connectors ${actionTypes} aren't enabled. To send notifications, you need a higher Security Analytics tier.
const alertsGeneratedText = alertsCreated
? 'This rule generated alerts but did not send external notifications because rule action'
: 'Rule action';
if (uniqueActionTypes.size > 1) {
return `${alertsGeneratedText} connectors ${actionTypesJoined} are not enabled. To send notifications, you need a higher Security Analytics license / tier`;
} else {
return `${alertsGeneratedText} connector ${actionTypesJoined} is not enabled. To send notifications, you need a higher Security Analytics license / tier`;
}
};

View file

@ -300,6 +300,7 @@ export class Plugin implements ISecuritySolutionPlugin {
const securityRuleTypeOptions = {
lists: plugins.lists,
actions: plugins.actions,
logger: this.logger,
config: this.config,
publicBaseUrl: core.http.basePath.publicBaseUrl,

View file

@ -16,6 +16,10 @@ import type {
PluginSetupContract as AlertingPluginSetup,
PluginStartContract as AlertingPluginStart,
} from '@kbn/alerting-plugin/server';
import type {
PluginSetupContract as ActionsPluginSetup,
PluginStartContract as ActionsPluginStartContract,
} from '@kbn/actions-plugin/server';
import type { CasesServerStart, CasesServerSetup } from '@kbn/cases-plugin/server';
import type { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server';
import type { IEventLogClientService, IEventLogService } from '@kbn/event-log-plugin/server';
@ -42,12 +46,12 @@ import type { SharePluginStart } from '@kbn/share-plugin/server';
import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server';
import type { PluginSetup as UnifiedSearchServerPluginSetup } from '@kbn/unified-search-plugin/server';
import type { ElasticAssistantPluginStart } from '@kbn/elastic-assistant-plugin/server';
import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server';
import type { ProductFeaturesService } from './lib/product_features_service/product_features_service';
import type { ExperimentalFeatures } from '../common';
export interface SecuritySolutionPluginSetupDependencies {
alerting: AlertingPluginSetup;
actions: ActionsPluginSetup;
cases: CasesServerSetup;
cloud: CloudSetup;
data: DataPluginSetup;