[Security Solution] [Action Connectors] Fix exporting predefined connectors (#153687)

## Summary

- Addresses https://github.com/elastic/kibana/issues/153619 

**Reason:**

- As predefined connectors are not Saved objects, the export method was
failing to get their exported objects.

**Solution:**

- Filter out `Predefined Action` ids from the user's action ids because
we don't need to export them as they are already in the user env, and
they won't be removed or changed

**References** 


https://www.elastic.co/guide/en/kibana/8.7/pre-configured-connectors.html

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Wafaa Nasr 2023-03-30 15:53:17 +01:00 committed by GitHub
parent 4d24951a6a
commit e52eb267b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 477 additions and 16 deletions

View file

@ -332,6 +332,7 @@ export const performBulkActionRoute = (
const rulesClient = ctx.alerting.getRulesClient();
const exceptionsClient = ctx.lists?.getExceptionListClient();
const savedObjectsClient = ctx.core.savedObjects.client;
const actionsClient = (await ctx.actions)?.getActionsClient();
const { getExporter, getClient } = (await ctx.core).savedObjects;
const client = getClient({ includedHiddenTypes: ['action'] });
@ -565,7 +566,8 @@ export const performBulkActionRoute = (
rules.map(({ params }) => ({ rule_id: params.ruleId })),
logger,
exporter,
request
request,
actionsClient
);
const responseBody = `${exported.rulesNdjson}${exported.exceptionLists}${exported.actionConnectors}${exported.exportDetails}`;

View file

@ -45,6 +45,8 @@ export const exportRulesRoute = (
const siemResponse = buildSiemResponse(response);
const rulesClient = (await context.alerting).getRulesClient();
const exceptionsClient = (await context.lists)?.getExceptionListClient();
const actionsClient = (await context.actions)?.getActionsClient();
const {
getExporter,
getClient,
@ -81,7 +83,8 @@ export const exportRulesRoute = (
request.body.objects,
logger,
actionsExporter,
request
request,
actionsClient
)
: await getExportAll(
rulesClient,
@ -89,7 +92,8 @@ export const exportRulesRoute = (
savedObjectsClient,
logger,
actionsExporter,
request
request,
actionsClient
);
const responseBody = request.query.exclude_export_details

View file

@ -27,16 +27,41 @@ import { requestContextMock } from '../../../routes/__mocks__/request_context';
import { savedObjectsExporterMock } from '@kbn/core-saved-objects-import-export-server-mocks';
import { mockRouter } from '@kbn/core-http-router-server-mocks';
import { Readable } from 'stream';
import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client.mock';
const exceptionsClient = getExceptionListClientMock();
const connectors = [
{
id: '123',
actionTypeId: '.slack',
name: 'slack',
config: {},
isPreconfigured: false,
isDeprecated: false,
referencedByCount: 1,
},
{
id: '456',
actionTypeId: '.email',
name: 'Email (preconfigured)',
config: {},
isPreconfigured: true,
isDeprecated: false,
referencedByCount: 1,
},
];
describe('getExportAll', () => {
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
const { clients } = requestContextMock.createTools();
const exporterMock = savedObjectsExporterMock.create();
const requestMock = mockRouter.createKibanaRequest();
const actionsClient = actionsClientMock.create();
beforeEach(async () => {
clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse());
actionsClient.getAll.mockImplementation(async () => {
return connectors;
});
});
test('it exports everything from the alerts client', async () => {
@ -61,7 +86,8 @@ describe('getExportAll', () => {
clients.savedObjectsClient,
logger,
exporterMock,
requestMock
requestMock,
actionsClient
);
const rulesJson = JSON.parse(exports.rulesNdjson);
const detailsJson = JSON.parse(exports.exportDetails);
@ -147,7 +173,8 @@ describe('getExportAll', () => {
clients.savedObjectsClient,
logger,
exporterMock,
requestMock
requestMock,
actionsClient
);
expect(exports).toEqual({
rulesNdjson: '',
@ -232,7 +259,8 @@ describe('getExportAll', () => {
clients.savedObjectsClient,
logger,
exporterMockWithConnector as never,
requestMock
requestMock,
actionsClient
);
const rulesJson = JSON.parse(exports.rulesNdjson);
const detailsJson = JSON.parse(exports.exportDetails);
@ -328,4 +356,126 @@ describe('getExportAll', () => {
version: 'WzE2MDYsMV0=',
});
});
test('it will export rule without its action connectors as they are Preconfigured', async () => {
const rulesClient = rulesClientMock.create();
const result = getFindResultWithSingleHit();
const alert = {
...getRuleMock(getQueryRuleParams()),
actions: [
{
group: 'default',
id: '456',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
},
actionTypeId: '.email',
},
],
};
alert.params = {
...alert.params,
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
threat: getThreatMock(),
meta: { someMeta: 'someField' },
timelineId: 'some-timeline-id',
timelineTitle: 'some-timeline-title',
};
result.data = [alert];
rulesClient.find.mockResolvedValue(result);
const readable = new Readable({
objectMode: true,
read() {
return null;
},
});
const exporterMockWithConnector = {
exportByObjects: () => jest.fn().mockReturnValueOnce(readable),
exportByTypes: jest.fn(),
};
const exports = await getExportAll(
rulesClient,
exceptionsClient,
clients.savedObjectsClient,
logger,
exporterMockWithConnector as never,
requestMock,
actionsClient
);
const rulesJson = JSON.parse(exports.rulesNdjson);
const detailsJson = JSON.parse(exports.exportDetails);
expect(rulesJson).toEqual({
author: ['Elastic'],
actions: [
{
group: 'default',
id: '456',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
},
action_type_id: '.email',
},
],
building_block_type: 'default',
created_at: '2019-12-13T16:40:33.400Z',
updated_at: '2019-12-13T16:40:33.400Z',
created_by: 'elastic',
description: 'Detecting root and admin users',
enabled: true,
false_positives: [],
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
from: 'now-6m',
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
immutable: false,
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
interval: '5m',
rule_id: 'rule-1',
language: 'kuery',
license: 'Elastic License',
output_index: '.siem-signals',
max_signals: 10000,
risk_score: 50,
risk_score_mapping: [],
name: 'Detect Root/Admin Users',
query: 'user.name: root or user.name: admin',
references: ['http://example.com', 'https://example.com'],
related_integrations: [],
required_fields: [],
setup: '',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
meta: { someMeta: 'someField' },
severity: 'high',
severity_mapping: [],
updated_by: 'elastic',
tags: [],
to: 'now',
type: 'query',
threat: getThreatMock(),
throttle: 'rule',
note: '# Investigative notes',
version: 1,
revision: 0,
exceptions_list: getListArrayMock(),
});
expect(detailsJson).toEqual({
exported_exception_list_count: 1,
exported_exception_list_item_count: 1,
exported_count: 3,
exported_rules_count: 1,
missing_exception_list_item_count: 0,
missing_exception_list_items: [],
missing_exception_lists: [],
missing_exception_lists_count: 0,
missing_rules: [],
missing_rules_count: 0,
excluded_action_connection_count: 0,
excluded_action_connections: [],
exported_action_connector_count: 0,
missing_action_connection_count: 0,
missing_action_connections: [],
});
});
});

View file

@ -10,6 +10,7 @@ import { transformDataToNdjson } from '@kbn/securitysolution-utils';
import type { ISavedObjectsExporter, KibanaRequest, Logger } from '@kbn/core/server';
import type { ExceptionListClient } from '@kbn/lists-plugin/server';
import type { RulesClient, RuleExecutorServices } from '@kbn/alerting-plugin/server';
import type { ActionsClient } from '@kbn/actions-plugin/server';
import { getNonPackagedRules } from '../search/get_existing_prepackaged_rules';
import { getExportDetailsNdjson } from './get_export_details_ndjson';
import { transformAlertsToRules, transformRuleToExportableFormat } from '../../utils/utils';
@ -25,7 +26,8 @@ export const getExportAll = async (
savedObjectsClient: RuleExecutorServices['savedObjectsClient'],
logger: Logger,
actionsExporter: ISavedObjectsExporter,
request: KibanaRequest
request: KibanaRequest,
actionsClient: ActionsClient
): Promise<{
rulesNdjson: string;
exportDetails: string;
@ -53,7 +55,8 @@ export const getExportAll = async (
const { actionConnectors, actionConnectorDetails } = await getRuleActionConnectorsForExport(
rules,
actionsExporter,
request
request,
actionsClient
);
const rulesNdjson = transformDataToNdjson(exportRules);

View file

@ -28,19 +28,43 @@ import { mockRouter } from '@kbn/core-http-router-server-mocks';
const exceptionsClient = getExceptionListClientMock();
import type { loggingSystemMock } from '@kbn/core/server/mocks';
import { requestContextMock } from '../../../routes/__mocks__/request_context';
import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client.mock';
const connectors = [
{
id: '123',
actionTypeId: '.slack',
name: 'slack',
config: {},
isPreconfigured: false,
isDeprecated: false,
referencedByCount: 1,
},
{
id: '456',
actionTypeId: '.email',
name: 'Email (preconfigured)',
config: {},
isPreconfigured: true,
isDeprecated: false,
referencedByCount: 1,
},
];
describe('get_export_by_object_ids', () => {
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
const { clients } = requestContextMock.createTools();
const exporterMock = savedObjectsExporterMock.create();
const requestMock = mockRouter.createKibanaRequest();
const actionsClient = actionsClientMock.create();
beforeEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
jest.clearAllMocks();
clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse());
actionsClient.getAll.mockImplementation(async () => {
return connectors;
});
});
describe('getExportByObjectIds', () => {
@ -56,7 +80,8 @@ describe('get_export_by_object_ids', () => {
objects,
logger,
exporterMock,
requestMock
requestMock,
actionsClient
);
const exportsObj = {
rulesNdjson: JSON.parse(exports.rulesNdjson),
@ -151,7 +176,8 @@ describe('get_export_by_object_ids', () => {
objects,
logger,
exporterMock,
requestMock
requestMock,
actionsClient
);
const details = getOutputDetailsSampleWithExceptions({
missingRules: [{ rule_id: 'rule-1' }],
@ -242,7 +268,8 @@ describe('get_export_by_object_ids', () => {
objects,
logger,
exporterMockWithConnector as never,
requestMock
requestMock,
actionsClient
);
const rulesJson = JSON.parse(exports.rulesNdjson);
const detailsJson = JSON.parse(exports.exportDetails);
@ -338,6 +365,129 @@ describe('get_export_by_object_ids', () => {
version: 'WzE2MDYsMV0=',
});
});
test('it will export rule without its action connectors as they are Preconfigured', async () => {
const rulesClient = rulesClientMock.create();
const result = getFindResultWithSingleHit();
const alert = {
...getRuleMock(getQueryRuleParams()),
actions: [
{
group: 'default',
id: '456',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
},
actionTypeId: '.email',
},
],
};
alert.params = {
...alert.params,
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
threat: getThreatMock(),
meta: { someMeta: 'someField' },
timelineId: 'some-timeline-id',
timelineTitle: 'some-timeline-title',
};
result.data = [alert];
rulesClient.find.mockResolvedValue(result);
const readable = new Readable({
objectMode: true,
read() {
return null;
},
});
const objects = [{ rule_id: 'rule-1' }];
const exporterMockWithConnector = {
exportByObjects: () => jest.fn().mockReturnValueOnce(readable),
exportByTypes: jest.fn(),
};
const exports = await getExportByObjectIds(
rulesClient,
exceptionsClient,
clients.savedObjectsClient,
objects,
logger,
exporterMockWithConnector as never,
requestMock,
actionsClient
);
const rulesJson = JSON.parse(exports.rulesNdjson);
const detailsJson = JSON.parse(exports.exportDetails);
expect(rulesJson).toEqual({
author: ['Elastic'],
actions: [
{
group: 'default',
id: '456',
params: {
message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts',
},
action_type_id: '.email',
},
],
building_block_type: 'default',
created_at: '2019-12-13T16:40:33.400Z',
updated_at: '2019-12-13T16:40:33.400Z',
created_by: 'elastic',
description: 'Detecting root and admin users',
enabled: true,
false_positives: [],
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
from: 'now-6m',
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
immutable: false,
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
interval: '5m',
rule_id: 'rule-1',
language: 'kuery',
license: 'Elastic License',
output_index: '.siem-signals',
max_signals: 10000,
risk_score: 50,
risk_score_mapping: [],
name: 'Detect Root/Admin Users',
query: 'user.name: root or user.name: admin',
references: ['http://example.com', 'https://example.com'],
related_integrations: [],
required_fields: [],
setup: '',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
meta: { someMeta: 'someField' },
severity: 'high',
severity_mapping: [],
updated_by: 'elastic',
tags: [],
to: 'now',
type: 'query',
threat: getThreatMock(),
throttle: 'rule',
note: '# Investigative notes',
version: 1,
revision: 0,
exceptions_list: getListArrayMock(),
});
expect(detailsJson).toEqual({
exported_exception_list_count: 0,
exported_exception_list_item_count: 0,
exported_count: 1,
exported_rules_count: 1,
missing_exception_list_item_count: 0,
missing_exception_list_items: [],
missing_exception_lists: [],
missing_exception_lists_count: 0,
missing_rules: [],
missing_rules_count: 0,
excluded_action_connection_count: 0,
excluded_action_connections: [],
exported_action_connector_count: 0,
missing_action_connection_count: 0,
missing_action_connections: [],
});
});
});
describe('getRulesFromObjects', () => {

View file

@ -13,6 +13,7 @@ import type { ISavedObjectsExporter, KibanaRequest, Logger } from '@kbn/core/ser
import type { ExceptionListClient } from '@kbn/lists-plugin/server';
import type { RulesClient, RuleExecutorServices } from '@kbn/alerting-plugin/server';
import type { ActionsClient } from '@kbn/actions-plugin/server';
import { getExportDetailsNdjson } from './get_export_details_ndjson';
import { isAlertType } from '../../../rule_schema';
@ -49,7 +50,8 @@ export const getExportByObjectIds = async (
objects: Array<{ rule_id: string }>,
logger: Logger,
actionsExporter: ISavedObjectsExporter,
request: KibanaRequest
request: KibanaRequest,
actionsClient: ActionsClient
): Promise<{
rulesNdjson: string;
exportDetails: string;
@ -73,7 +75,8 @@ export const getExportByObjectIds = async (
const { actionConnectors, actionConnectorDetails } = await getRuleActionConnectorsForExport(
rules,
actionsExporter,
request
request,
actionsClient
);
const rulesNdjson = transformDataToNdjson(rules);

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ActionsClient } from '@kbn/actions-plugin/server';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { SavedObjectTypeIdTuple } from '@kbn/core-saved-objects-common';
import type {
@ -42,11 +43,30 @@ const mapExportedActionConnectorsDetailsToDefault = (
excluded_action_connections: exportDetails.excludedObjects,
};
};
const filterOutPredefinedActionConnectorsIds = async (
actionsClient: ActionsClient,
actionsIdsToExport: string[]
): Promise<string[]> => {
const allActions = await actionsClient.getAll();
const predefinedActionsIds = allActions
.filter(({ isPreconfigured }) => isPreconfigured)
.map(({ id }) => id);
if (predefinedActionsIds.length)
return actionsIdsToExport.filter((id) => !predefinedActionsIds.includes(id));
return actionsIdsToExport;
};
// This function is used to get, and return the exported actions' connectors'
// using the ISavedObjectsExporter and it filters out any Preconfigured
// Connectors as they shouldn't be exported, imported or changed
// by the user, that's why the function also accepts the actionsClient
// to getAll actions connectors
export const getRuleActionConnectorsForExport = async (
rules: RuleResponse[],
actionsExporter: ISavedObjectsExporter,
request: KibanaRequest
request: KibanaRequest,
actionsClient: ActionsClient
) => {
const exportedActionConnectors: {
actionConnectors: string;
@ -56,7 +76,11 @@ export const getRuleActionConnectorsForExport = async (
actionConnectorDetails: defaultActionConnectorDetails,
};
const actionsIds = [...new Set(rules.flatMap((rule) => rule.actions.map(({ id }) => id)))];
let actionsIds = [...new Set(rules.flatMap((rule) => rule.actions.map(({ id }) => id)))];
if (!actionsIds.length) return exportedActionConnectors;
// handle preconfigured connectors
actionsIds = await filterOutPredefinedActionConnectorsIds(actionsClient, actionsIds);
if (!actionsIds.length) return exportedActionConnectors;

View file

@ -78,6 +78,20 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s
'previewTelemetryUrlEnabled',
])}`,
'--xpack.task_manager.poll_interval=1000',
`--xpack.actions.preconfigured=${JSON.stringify({
'my-test-email': {
actionTypeId: '.email',
name: 'TestEmail#xyz',
config: {
from: 'me@test.com',
service: '__json',
},
secrets: {
user: 'user',
password: 'password',
},
},
})}`,
...(ssl
? [
`--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`,

View file

@ -270,6 +270,117 @@ export default ({ getService }: FtrProviderContext): void => {
expect(secondRule).toEqual(outputRule2);
});
it('should export actions connectors with the rule', async () => {
// create a new action
const { body: hookAction } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.expect(200);
const action = {
group: 'default',
id: hookAction.id,
action_type_id: hookAction.actionTypeId,
params: {},
};
const rule1: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-1'),
actions: [action],
};
await createRule(supertest, log, rule1);
const { body } = await supertest
.post(`${DETECTION_ENGINE_RULES_URL}/_export`)
.set('kbn-xsrf', 'true')
.send()
.expect(200)
.parse(binaryToString);
const connectorsObjectParsed = JSON.parse(body.toString().split(/\n/)[1]);
const exportDetailsParsed = JSON.parse(body.toString().split(/\n/)[2]);
expect(connectorsObjectParsed).toEqual(
expect.objectContaining({
attributes: {
actionTypeId: '.webhook',
config: {
hasAuth: true,
headers: null,
method: 'post',
url: 'http://localhost',
},
isMissingSecrets: true,
name: 'Some connector',
secrets: {},
},
references: [],
type: 'action',
})
);
expect(exportDetailsParsed).toEqual({
exported_exception_list_count: 0,
exported_exception_list_item_count: 0,
exported_count: 2,
exported_rules_count: 1,
missing_exception_list_item_count: 0,
missing_exception_list_items: [],
missing_exception_lists: [],
missing_exception_lists_count: 0,
missing_rules: [],
missing_rules_count: 0,
excluded_action_connection_count: 0,
excluded_action_connections: [],
exported_action_connector_count: 1,
missing_action_connection_count: 0,
missing_action_connections: [],
});
});
it('should export rule without the action connector if it is Preconfigured Connector', async () => {
const action = {
group: 'default',
id: 'my-test-email',
action_type_id: '.email',
params: {},
};
const rule1: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-1'),
actions: [action],
};
await createRule(supertest, log, rule1);
const { body } = await supertest
.post(`${DETECTION_ENGINE_RULES_URL}/_export`)
.set('kbn-xsrf', 'true')
.send()
.expect(200)
.parse(binaryToString);
const exportDetailsParsed = JSON.parse(body.toString().split(/\n/)[1]);
expect(exportDetailsParsed).toEqual({
exported_exception_list_count: 0,
exported_exception_list_item_count: 0,
exported_count: 1,
exported_rules_count: 1,
missing_exception_list_item_count: 0,
missing_exception_list_items: [],
missing_exception_lists: [],
missing_exception_lists_count: 0,
missing_rules: [],
missing_rules_count: 0,
excluded_action_connection_count: 0,
excluded_action_connections: [],
exported_action_connector_count: 0,
missing_action_connection_count: 0,
missing_action_connections: [],
});
});
/**
* Tests the legacy actions to ensure we can export legacy notifications
* @deprecated Once the legacy notification system is removed, remove this test too.