diff --git a/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts index d2f84b4ee3708..ab1f35c54b5db 100644 --- a/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts @@ -16,6 +16,7 @@ describe('importQuerySchema', () => { as_new_list: false, overwrite: true, overwrite_exceptions: true, + overwrite_action_connectors: true, }; const decoded = importQuerySchema.decode(payload); const checked = exactCheck(payload, decoded); @@ -30,6 +31,7 @@ describe('importQuerySchema', () => { as_new_list: false, overwrite: 'wrong', overwrite_exceptions: true, + overwrite_action_connectors: true, }; const decoded = importQuerySchema.decode(payload); const checked = exactCheck(payload, decoded); @@ -48,6 +50,7 @@ describe('importQuerySchema', () => { as_new_list: false, overwrite: true, overwrite_exceptions: 'wrong', + overwrite_action_connectors: true, }; const decoded = importQuerySchema.decode(payload); const checked = exactCheck(payload, decoded); @@ -58,6 +61,24 @@ describe('importQuerySchema', () => { ]); expect(message.schema).toEqual({}); }); + test('it should NOT validate a non boolean value for "overwrite_action_connectors"', () => { + const payload: Omit & { + overwrite_action_connectors: string; + } = { + as_new_list: false, + overwrite: true, + overwrite_exceptions: true, + overwrite_action_connectors: 'wrong', + }; + const decoded = importQuerySchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "wrong" supplied to "overwrite_action_connectors"', + ]); + expect(message.schema).toEqual({}); + }); test('it should NOT allow an extra key to be sent in', () => { const payload: ImportQuerySchema & { diff --git a/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.ts b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.ts index ca0e35c4aa176..5b1ee38bb0855 100644 --- a/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.ts @@ -14,6 +14,7 @@ export const importQuerySchema = t.exact( t.partial({ overwrite: DefaultStringBooleanFalse, overwrite_exceptions: DefaultStringBooleanFalse, + overwrite_action_connectors: DefaultStringBooleanFalse, as_new_list: DefaultStringBooleanFalse, }) ); @@ -21,9 +22,10 @@ export const importQuerySchema = t.exact( export type ImportQuerySchema = t.TypeOf; export type ImportQuerySchemaDecoded = Omit< ImportQuerySchema, - 'overwrite' | 'overwrite_exceptions' | 'as_new_list' + 'overwrite' | 'overwrite_exceptions' | 'as_new_list' | 'overwrite_action_connectors' > & { overwrite: boolean; overwrite_exceptions: boolean; + overwrite_action_connectors: boolean; as_new_list: boolean; }; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts index 3c97080e2d655..ea6145b8f6e99 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts @@ -17,7 +17,10 @@ import { parseDuration } from '../../lib'; export async function validateActions( context: RulesClientContext, alertType: UntypedNormalizedRuleType, - data: Pick & { actions: NormalizedAlertAction[] } + data: Pick & { + actions: NormalizedAlertAction[]; + }, + allowMissingConnectorSecrets?: boolean ): Promise { const { actions, notifyWhen, throttle } = data; const hasRuleLevelNotifyWhen = typeof notifyWhen !== 'undefined'; @@ -35,20 +38,26 @@ export async function validateActions( const actionsUsingConnectorsWithMissingSecrets = actionResults.filter( (result) => result.isMissingSecrets ); - if (actionsUsingConnectorsWithMissingSecrets.length) { - errors.push( - i18n.translate('xpack.alerting.rulesClient.validateActions.misconfiguredConnector', { - defaultMessage: 'Invalid connectors: {groups}', - values: { - groups: actionsUsingConnectorsWithMissingSecrets - .map((connector) => connector.name) - .join(', '), - }, - }) - ); + if (allowMissingConnectorSecrets) { + context.logger.error( + `Invalid connectors with "allowMissingConnectorSecrets": ${actionsUsingConnectorsWithMissingSecrets + .map((connector) => connector.name) + .join(', ')}` + ); + } else { + errors.push( + i18n.translate('xpack.alerting.rulesClient.validateActions.misconfiguredConnector', { + defaultMessage: 'Invalid connectors: {groups}', + values: { + groups: actionsUsingConnectorsWithMissingSecrets + .map((connector) => connector.name) + .join(', '), + }, + }) + ); + } } - // check for actions with invalid action groups const { actionGroups: alertTypeActionGroups } = alertType; const usedAlertActionGroups = actions.map((action) => action.group); diff --git a/x-pack/plugins/alerting/server/rules_client/methods/create.ts b/x-pack/plugins/alerting/server/rules_client/methods/create.ts index e8dcefe4a9ef1..09dc8f62494b8 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/create.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/create.ts @@ -46,11 +46,12 @@ export interface CreateOptions { | 'nextRun' > & { actions: NormalizedAlertAction[] }; options?: SavedObjectOptions; + allowMissingConnectorSecrets?: boolean; } export async function create( context: RulesClientContext, - { data, options }: CreateOptions + { data, options, allowMissingConnectorSecrets }: CreateOptions ): Promise> { const id = options?.id || SavedObjectsUtils.generateId(); @@ -104,10 +105,11 @@ export async function create( } } - await validateActions(context, ruleType, data); + await validateActions(context, ruleType, data, allowMissingConnectorSecrets); await withSpan({ name: 'validateActions', type: 'rules' }, () => - validateActions(context, ruleType, data) + validateActions(context, ruleType, data, allowMissingConnectorSecrets) ); + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); if ( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/update.ts b/x-pack/plugins/alerting/server/rules_client/methods/update.ts index 8fa3855b95707..5e116f9f21b28 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/update.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/update.ts @@ -38,22 +38,23 @@ export interface UpdateOptions { throttle?: string | null; notifyWhen?: RuleNotifyWhenType | null; }; + allowMissingConnectorSecrets?: boolean; } export async function update( context: RulesClientContext, - { id, data }: UpdateOptions + { id, data, allowMissingConnectorSecrets }: UpdateOptions ): Promise> { return await retryIfConflicts( context.logger, `rulesClient.update('${id}')`, - async () => await updateWithOCC(context, { id, data }) + async () => await updateWithOCC(context, { id, data, allowMissingConnectorSecrets }) ); } async function updateWithOCC( context: RulesClientContext, - { id, data }: UpdateOptions + { id, data, allowMissingConnectorSecrets }: UpdateOptions ): Promise> { let alertSavedObject: SavedObject; @@ -99,7 +100,11 @@ async function updateWithOCC( context.ruleTypeRegistry.ensureRuleTypeEnabled(alertSavedObject.attributes.alertTypeId); - const updateResult = await updateAlert(context, { id, data }, alertSavedObject); + const updateResult = await updateAlert( + context, + { id, data, allowMissingConnectorSecrets }, + alertSavedObject + ); await Promise.all([ alertSavedObject.attributes.apiKey @@ -138,7 +143,7 @@ async function updateWithOCC( async function updateAlert( context: RulesClientContext, - { id, data }: UpdateOptions, + { id, data, allowMissingConnectorSecrets }: UpdateOptions, { attributes, version }: SavedObject ): Promise> { const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId); @@ -156,7 +161,7 @@ async function updateAlert( // Validate const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate?.params); - await validateActions(context, ruleType, data); + await validateActions(context, ruleType, data, allowMissingConnectorSecrets); // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index c11dc8be21ca4..dddfbe85f9ee3 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -3047,4 +3047,120 @@ describe('create()', () => { expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); }); + test('should create a rule even if action is missing secret when allowMissingConnectorSecrets is true', async () => { + const data = getMockData({ + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + ], + }); + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: '.slack', + config: {}, + isMissingSecrets: true, + name: 'Slack connector', + isPreconfigured: false, + isDeprecated: false, + }, + ]); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '1m' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: null, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: '.slack', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'action_1', + type: 'action', + id: '1', + }, + { + name: 'action_2', + type: 'action', + id: '2', + }, + ], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + scheduledTaskId: 'task-123', + }, + references: [], + }); + const result = await rulesClient.create({ data, allowMissingConnectorSecrets: true }); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": ".slack", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "1", + "notifyWhen": null, + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "1m", + }, + "scheduledTaskId": "task-123", + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index 44c4eeb50fe27..4d16f3e5d66a0 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -2022,6 +2022,155 @@ describe('update()', () => { expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); }); + test('should update a rule even if action is missing secret when allowMissingConnectorSecrets is true', async () => { + // Reset from default behaviour + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: '.slack', + config: {}, + isMissingSecrets: true, + name: 'slack connector', + isPreconfigured: false, + isDeprecated: false, + }, + ]); + actionsClient.isPreconfigured.mockReset(); + actionsClient.isPreconfigured.mockReturnValueOnce(false); + actionsClient.isPreconfigured.mockReturnValueOnce(true); + actionsClient.isPreconfigured.mockReturnValueOnce(true); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: '.slack', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + const result = await rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + }, + allowMissingConnectorSecrets: true, + }); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenNthCalledWith( + 1, + 'alert', + { + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: '.slack', + params: { + foo: true, + }, + }, + ], + alertTypeId: 'myType', + apiKey: null, + apiKeyOwner: null, + consumer: 'myApp', + enabled: true, + meta: { versionApiKeyLastmodified: 'v7.10.0' }, + name: 'abc', + notifyWhen: 'onActiveAlert', + params: { bar: true }, + schedule: { interval: '1m' }, + scheduledTaskId: 'task-123', + tags: ['foo'], + throttle: null, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + }, + { + id: '1', + overwrite: true, + references: [{ id: '1', name: 'action_0', type: 'action' }], + version: '123', + } + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": ".slack", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "createdAt": 2019-02-12T21:01:22.479Z, + "enabled": true, + "id": "1", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "1m", + }, + "scheduledTaskId": "task-123", + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { + namespace: 'default', + }); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); + expect(actionsClient.isPreconfigured).toHaveBeenCalledTimes(1); + }); test('logs warning when creating with an interval less than the minimum configured one when enforce = false', async () => { actionsClient.getBulk.mockReset(); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.test.ts index 2f11e1a9c120f..b2ff257ba2c4e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.test.ts @@ -24,6 +24,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -42,6 +46,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -60,6 +68,10 @@ describe('Import rules response schema', () => { exceptions_errors: [{ error: { status_code: 400, message: 'some message' } }], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -81,6 +93,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -102,6 +118,10 @@ describe('Import rules response schema', () => { ], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -120,6 +140,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -140,6 +164,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: -1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -178,6 +206,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded as UnsafeCastForTest); @@ -217,6 +249,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: 'hello', exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded as UnsafeCastForTest); @@ -238,6 +274,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -262,6 +302,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -270,4 +314,182 @@ describe('Import rules response schema', () => { expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_data"']); expect(message.schema).toEqual({}); }); + + test('it should validate an empty import response with a single connectors error', () => { + const payload: ImportRulesResponse = { + success: false, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [{ error: { status_code: 400, message: 'some message' } }], + action_connectors_warnings: [], + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + test('it should validate an empty import response with multiple errors', () => { + const payload: ImportRulesResponse = { + success: false, + success_count: 0, + rules_count: 0, + errors: [ + { error: { status_code: 400, message: 'some message' } }, + { error: { status_code: 500, message: 'some message' } }, + ], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [{ error: { status_code: 400, message: 'some message' } }], + action_connectors_warnings: [], + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + test('it should NOT validate action_connectors_success that is not boolean', () => { + type UnsafeCastForTest = Either< + Errors, + { + success: boolean; + action_connectors_success: string; + success_count: number; + errors: Array< + { + id?: string | undefined; + rule_id?: string | undefined; + } & { + error: { + status_code: number; + message: string; + }; + } + >; + } + >; + const payload: Omit & { + action_connectors_success: string; + } = { + success: true, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: 'invalid', + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded as UnsafeCastForTest); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "invalid" supplied to "action_connectors_success"', + ]); + expect(message.schema).toEqual({}); + }); + test('it should NOT validate a action_connectors_success_count that is a negative number', () => { + const payload: ImportRulesResponse = { + success: false, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: -1, + action_connectors_errors: [], + action_connectors_warnings: [], + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "action_connectors_success_count"', + ]); + expect(message.schema).toEqual({}); + }); + test('it should validate a action_connectors_warnings after importing successfully', () => { + const payload: ImportRulesResponse = { + success: false, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 1, + action_connectors_errors: [], + action_connectors_warnings: [{ type: 'type', message: 'message', actionPath: 'actionPath' }], + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + test('it should NOT validate a action_connectors_warnings that is not WarningSchema', () => { + type UnsafeCastForTest = Either< + Errors, + { + success: boolean; + action_connectors_warnings: string; + success_count: number; + errors: Array< + { + id?: string | undefined; + rule_id?: string | undefined; + } & { + error: { + status_code: number; + message: string; + }; + } + >; + } + >; + const payload: Omit & { + action_connectors_warnings: string; + } = { + success: true, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: 'invalid', + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded as UnsafeCastForTest); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "invalid" supplied to "action_connectors_warnings"', + ]); + expect(message.schema).toEqual({}); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.ts index 77ccd0812c2c9..99212b902c4d3 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; import { PositiveInteger } from '@kbn/securitysolution-io-ts-types'; -import { errorSchema } from '../../../../schemas/response/error_schema'; +import { errorSchema, warningSchema } from '../../../../schemas/response'; export type ImportRulesResponse = t.TypeOf; export const ImportRulesResponse = t.exact( @@ -19,5 +19,9 @@ export const ImportRulesResponse = t.exact( success: t.boolean, success_count: PositiveInteger, errors: t.array(errorSchema), + action_connectors_errors: t.array(errorSchema), + action_connectors_warnings: t.array(warningSchema), + action_connectors_success: t.boolean, + action_connectors_success_count: PositiveInteger, }) ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.mock.ts index 64b82abdb3755..9a26b1a09f066 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.mock.ts @@ -8,6 +8,7 @@ import { getExceptionExportDetailsMock } from '@kbn/lists-plugin/common/schemas/response/exception_export_details_schema.mock'; import type { ExportExceptionDetailsMock } from '@kbn/lists-plugin/common/schemas/response/exception_export_details_schema.mock'; import type { ExportRulesDetails } from './export_rules_details_schema'; +import type { DefaultActionConnectorDetails } from '../../../../../server/lib/detection_engine/rule_management/logic/export/get_export_rule_action_connectors'; interface RuleDetailsMock { totalCount?: number; @@ -16,6 +17,23 @@ interface RuleDetailsMock { missingRules?: Array>; } +export const getActionConnectorDetailsMock = (): DefaultActionConnectorDetails => ({ + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], + excluded_action_connection_count: 0, + excluded_action_connections: [], +}); +export const getOutputDetailsSampleWithActionConnectors = (): ExportRulesDetails => ({ + ...getOutputDetailsSample(), + ...getExceptionExportDetailsMock(), + exported_action_connector_count: 1, + missing_action_connection_count: 0, + missing_action_connections: [], + excluded_action_connection_count: 0, + excluded_action_connections: [], +}); + export const getOutputDetailsSample = (ruleDetails?: RuleDetailsMock): ExportRulesDetails => ({ exported_count: ruleDetails?.totalCount ?? 0, exported_rules_count: ruleDetails?.rulesCount ?? 0, @@ -29,6 +47,7 @@ export const getOutputDetailsSampleWithExceptions = ( ): ExportRulesDetails => ({ ...getOutputDetailsSample(ruleDetails), ...getExceptionExportDetailsMock(exceptionDetails), + ...getActionConnectorDetailsMock(), }); export const getSampleDetailsAsNdjson = (sample: ExportRulesDetails): string => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.test.ts index 2c84a34b34928..a91f59924ef72 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.test.ts @@ -18,15 +18,16 @@ import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts import { getOutputDetailsSample, + getOutputDetailsSampleWithActionConnectors, getOutputDetailsSampleWithExceptions, } from './export_rules_details_schema.mock'; import type { ExportRulesDetails } from './export_rules_details_schema'; -import { exportRulesDetailsWithExceptionsSchema } from './export_rules_details_schema'; +import { exportRulesDetailsWithExceptionsAndConnectorsSchema } from './export_rules_details_schema'; -describe('exportRulesDetailsWithExceptionsSchema', () => { +describe('exportRulesDetailsWithExceptionsAndConnectorsSchema', () => { test('it should validate export details response', () => { const payload = getOutputDetailsSample(); - const decoded = exportRulesDetailsWithExceptionsSchema.decode(payload); + const decoded = exportRulesDetailsWithExceptionsAndConnectorsSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -36,7 +37,7 @@ describe('exportRulesDetailsWithExceptionsSchema', () => { test('it should validate export details with exceptions details response', () => { const payload = getOutputDetailsSampleWithExceptions(); - const decoded = exportRulesDetailsWithExceptionsSchema.decode(payload); + const decoded = exportRulesDetailsWithExceptionsAndConnectorsSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -44,12 +45,21 @@ describe('exportRulesDetailsWithExceptionsSchema', () => { expect(message.schema).toEqual(payload); }); + test('it should validate export details with action connectors details response', () => { + const payload = getOutputDetailsSampleWithActionConnectors(); + const decoded = exportRulesDetailsWithExceptionsAndConnectorsSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); test('it should strip out extra keys', () => { const payload: ExportRulesDetails & { extraKey?: string; } = getOutputDetailsSample(); payload.extraKey = 'some extra key'; - const decoded = exportRulesDetailsWithExceptionsSchema.decode(payload); + const decoded = exportRulesDetailsWithExceptionsAndConnectorsSchema.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.ts index 85b423135566b..205861bdd42a7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.ts @@ -9,13 +9,6 @@ import * as t from 'io-ts'; import { exportExceptionDetails } from '@kbn/securitysolution-io-ts-list-types'; import { NonEmptyString } from '@kbn/securitysolution-io-ts-types'; -const createSchema = ( - requiredFields: Required, - optionalFields: Optional -) => { - return t.intersection([t.exact(t.type(requiredFields)), t.exact(t.partial(optionalFields))]); -}; - const exportRulesDetails = { exported_count: t.number, exported_rules_count: t.number, @@ -28,11 +21,38 @@ const exportRulesDetails = { ), missing_rules_count: t.number, }; +const excludedActionConnectors = t.intersection([ + t.exact( + t.type({ + id: NonEmptyString, + type: NonEmptyString, + }) + ), + t.exact(t.partial({ reason: t.string })), +]); + +const exportRuleActionConnectorsDetails = { + exported_action_connector_count: t.number, + missing_action_connection_count: t.number, + missing_action_connections: t.array( + t.exact( + t.type({ + id: NonEmptyString, + type: NonEmptyString, + }) + ) + ), + excluded_action_connection_count: t.number, + excluded_action_connections: t.array(excludedActionConnectors), +}; -// With exceptions -export const exportRulesDetailsWithExceptionsSchema = createSchema( - exportRulesDetails, - exportExceptionDetails -); +// With exceptions and connectors +export const exportRulesDetailsWithExceptionsAndConnectorsSchema = t.intersection([ + t.exact(t.type(exportRulesDetails)), + t.exact(t.partial(exportExceptionDetails)), + t.exact(t.partial(exportRuleActionConnectorsDetails)), +]); -export type ExportRulesDetails = t.TypeOf; +export type ExportRulesDetails = t.TypeOf< + typeof exportRulesDetailsWithExceptionsAndConnectorsSchema +>; diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/import/rule_to_import.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/import/rule_to_import.mock.ts index d1dc9e8ac4663..cec58d1c5fdc6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/import/rule_to_import.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/import/rule_to_import.mock.ts @@ -80,3 +80,41 @@ export const getImportThreatMatchRulesSchemaMock = (ruleId = 'rule-1'): RuleToIm }, ], }); + +export const webHookConnector = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: false, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', +}; + +export const ruleWithConnectorNdJSON = (): string => { + const items = [ + { + ...getImportRulesSchemaMock(), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: '.webhook', + params: {}, + }, + ], + }, + webHookConnector, + ]; + const stringOfExceptions = items.map((item) => JSON.stringify(item)); + + return stringOfExceptions.join('\n'); +}; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts index b20a956525e2e..76da687604028 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts @@ -6,3 +6,4 @@ */ export * from './error_schema'; +export * from './warning_schema'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/warning_schema/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/warning_schema/index.ts new file mode 100644 index 0000000000000..1a401d1941cb0 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/warning_schema/index.ts @@ -0,0 +1,24 @@ +/* + * 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 * as t from 'io-ts'; + +const partial = t.exact( + t.partial({ + buttonLabel: t.string, + }) +); +const required = t.exact( + t.type({ + type: t.string, + message: t.string, + actionPath: t.string, + }) +); + +export const warningSchema = t.intersection([partial, required]); +export type WarningSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 20f41f938502b..a80c7099c31ff 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -573,6 +573,11 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response { .pipe(($el) => $el.trigger('click')) .should('be.checked'); }; - +const selectOverwriteConnectorsRulesImport = () => { + cy.get(RULE_IMPORT_OVERWRITE_CONNECTORS_CHECKBOX) + .pipe(($el) => $el.trigger('click')) + .should('be.checked'); +}; export const importRulesWithOverwriteAll = (rulesFile: string) => { cy.get(RULE_IMPORT_MODAL).click(); cy.get(INPUT_FILE).should('exist'); cy.get(INPUT_FILE).trigger('click', { force: true }).attachFile(rulesFile).trigger('change'); selectOverwriteRulesImport(); selectOverwriteExceptionsRulesImport(); + selectOverwriteConnectorsRulesImport(); cy.get(RULE_IMPORT_MODAL_BUTTON).last().click({ force: true }); cy.get(INPUT_FILE).should('not.exist'); }; diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap index 47dd897f71ffb..3378225f6b602 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap @@ -90,13 +90,521 @@ Object {
+
+
+ +
+ +
+
+
+
+ + +
+
+ + + , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ImportDataModal should import file, with warnings 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+

+ title +

+
+
+
+
+

+ description +

+
+
+
+
+ +
+
+
+
+
+
+

+

+
+
+
+
+ 1 connector has sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+ + +
+
+
+
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ImportDataModal should import file, with warnings but no action_connectors_success_count 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+

+ title +

+
+
+
+
+

+ description +

+
+
+
+
+ +
+
+
+
+
+
diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/__snapshots__/action_connectors_warning.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/__snapshots__/action_connectors_warning.test.tsx.snap new file mode 100644 index 0000000000000..fba4aa3d0fb96 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/__snapshots__/action_connectors_warning.test.tsx.snap @@ -0,0 +1,678 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ActionConnectorWarnings should not render if importedActionConnectorsCount is falsy and empty warnings array 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ActionConnectorWarnings should not render if importedActionConnectorsCount is truthy and empty warnings array 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ActionConnectorWarnings should render if 1 connectors were imported and use the warning message with the correct imported number 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+

+

+
+
+
+
+ 1 connector has sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
+ , + "container":
+
+

+

+
+
+
+
+ 1 connector has sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ActionConnectorWarnings should render if 2 connectors were imported and use the button label when is set 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+

+

+
+
+
+
+ 2 connectors have sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
+ , + "container":
+
+

+

+
+
+
+
+ 2 connectors have sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ActionConnectorWarnings should render if 2 connectors were imported and use the warning message with the correct imported number 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+

+

+
+
+
+
+ 2 connectors have sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
+ , + "container":
+
+

+

+
+
+
+
+ 2 connectors have sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/action_connectors_warning.test.tsx b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/action_connectors_warning.test.tsx new file mode 100644 index 0000000000000..a71ce2beeaebe --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/action_connectors_warning.test.tsx @@ -0,0 +1,104 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import { ActionConnectorWarnings } from '.'; + +jest.mock('../../../lib/kibana/kibana_react', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { http: { basePath: { prepend: jest.fn() } } }, + }), +})); +describe('ActionConnectorWarnings', () => { + test('should not render if importedActionConnectorsCount is falsy and empty warnings array', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.queryByTestId('actionConnectorsWarningsCallOut')).not.toBeInTheDocument(); + }); + test('should not render if importedActionConnectorsCount is truthy and empty warnings array', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.queryByTestId('actionConnectorsWarningsCallOut')).not.toBeInTheDocument(); + }); + test('should render if 1 connectors were imported and use the warning message with the correct imported number', () => { + const wrapper = render( + + ); + const { getByTestId } = wrapper; + expect(wrapper).toMatchSnapshot(); + expect(getByTestId('actionConnectorsWarningsCallOutTitle').textContent).toBe( + '1 connector imported' + ); + expect(getByTestId('actionConnectorsWarningsCallOutMessage').textContent).toBe( + '1 connector has sensitive information that requires updates.' + ); + }); + test('should render if 2 connectors were imported and use the warning message with the correct imported number', () => { + const wrapper = render( + + ); + const { getByTestId } = wrapper; + expect(wrapper).toMatchSnapshot(); + expect(getByTestId('actionConnectorsWarningsCallOutTitle').textContent).toBe( + '2 connectors imported' + ); + expect(getByTestId('actionConnectorsWarningsCallOutMessage').textContent).toBe( + '2 connectors have sensitive information that requires updates.' + ); + expect(getByTestId('actionConnectorsWarningsCallOutButton').textContent).toBe( + 'Go to connectors' + ); + }); + test('should render if 2 connectors were imported and use the button label when is set', () => { + const wrapper = render( + + ); + const { getByTestId } = wrapper; + expect(wrapper).toMatchSnapshot(); + expect(getByTestId('actionConnectorsWarningsCallOutTitle').textContent).toBe( + '2 connectors imported' + ); + expect(getByTestId('actionConnectorsWarningsCallOutMessage').textContent).toBe( + '2 connectors have sensitive information that requires updates.' + ); + expect(getByTestId('actionConnectorsWarningsCallOutButton').textContent).toBe('Connectors'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/index.tsx b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/index.tsx new file mode 100644 index 0000000000000..ad72893f66d87 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/index.tsx @@ -0,0 +1,65 @@ +/* + * 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 React from 'react'; +import type { FC } from 'react'; + +import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import type { WarningSchema } from '../../../../../common/detection_engine/schemas/response'; +import { useKibana } from '../../../lib/kibana/kibana_react'; +import * as i18n from '../translations'; + +interface ActionConnectorWarningsComponentProps { + actionConnectorsWarnings: WarningSchema[]; + importedActionConnectorsCount?: number; +} +const ActionConnectorWarningsComponent: FC = ({ + actionConnectorsWarnings, + importedActionConnectorsCount, +}) => { + const { http } = useKibana().services; + + if (!importedActionConnectorsCount || !actionConnectorsWarnings.length) return null; + const { actionPath, message, buttonLabel } = actionConnectorsWarnings[0]; + + return ( + + {i18n.ACTION_CONNECTORS_WARNING_TITLE(importedActionConnectorsCount)} + + } + color="warning" + > + + + {message} + + + + + {buttonLabel || i18n.ACTION_CONNECTORS_WARNING_BUTTON} + + + + + + ); +}; + +ActionConnectorWarningsComponent.displayName = 'ActionConnectorWarningsComponent'; + +export const ActionConnectorWarnings = React.memo(ActionConnectorWarningsComponent); + +ActionConnectorWarnings.displayName = 'ActionConnectorWarnings'; diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.test.tsx index 515019785534f..1986539e578e0 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.test.tsx @@ -12,6 +12,11 @@ import { ImportDataModalComponent } from '.'; jest.mock('../../lib/kibana'); +jest.mock('../../lib/kibana/kibana_react', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { http: { basePath: { prepend: jest.fn() } } }, + }), +})); jest.mock('../../hooks/use_app_toasts', () => ({ useAppToasts: jest.fn().mockReturnValue({ addError: jest.fn(), @@ -25,6 +30,9 @@ const importData = jest.fn().mockReturnValue({ success: true, errors: [] }); const file = new File(['file'], 'image1.png', { type: 'image/png' }); describe('ImportDataModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('renders correctly against snapshot', () => { const wrapper = render( { successMessage={jest.fn((totalCount) => 'successMessage')} title="title" showExceptionsCheckBox={true} + showActionConnectorsCheckBox={true} /> ); const overwriteCheckbox: HTMLInputElement = queryByTestId( - 'import-data-modal-checkbox-label' + 'importDataModalCheckboxLabel' ) as HTMLInputElement; const exceptionCheckbox: HTMLInputElement = queryByTestId( - 'import-data-modal-exceptions-checkbox-label' + 'importDataModalExceptionsCheckboxLabel' + ) as HTMLInputElement; + const connectorsCheckbox: HTMLInputElement = queryByTestId( + 'importDataModalActionConnectorsCheckbox' ) as HTMLInputElement; await waitFor(() => fireEvent.click(overwriteCheckbox)); await waitFor(() => fireEvent.click(exceptionCheckbox)); + await waitFor(() => fireEvent.click(connectorsCheckbox)); await waitFor(() => fireEvent.change(queryByTestId('rule-file-picker') as HTMLInputElement, { @@ -111,6 +124,7 @@ describe('ImportDataModal', () => { ); expect(overwriteCheckbox.checked).toBeTruthy(); expect(exceptionCheckbox.checked).toBeTruthy(); + expect(connectorsCheckbox.checked).toBeTruthy(); await waitFor(() => { fireEvent.click(queryByTestId('import-data-modal-button') as HTMLButtonElement); @@ -120,6 +134,7 @@ describe('ImportDataModal', () => { expect(overwriteCheckbox.checked).toBeFalsy(); expect(exceptionCheckbox.checked).toBeFalsy(); + expect(connectorsCheckbox.checked).toBeFalsy(); }); test('should uncheck the selected checkboxes after closing the Flyout', async () => { const { queryByTestId, getAllByRole } = render( @@ -138,20 +153,25 @@ describe('ImportDataModal', () => { successMessage={jest.fn((totalCount) => 'successMessage')} title="title" showExceptionsCheckBox={true} + showActionConnectorsCheckBox={true} /> ); const closeButton = getAllByRole('button')[0]; const overwriteCheckbox: HTMLInputElement = queryByTestId( - 'import-data-modal-checkbox-label' + 'importDataModalCheckboxLabel' ) as HTMLInputElement; const exceptionCheckbox: HTMLInputElement = queryByTestId( - 'import-data-modal-exceptions-checkbox-label' + 'importDataModalExceptionsCheckboxLabel' + ) as HTMLInputElement; + const connectorsCheckbox: HTMLInputElement = queryByTestId( + 'importDataModalActionConnectorsCheckbox' ) as HTMLInputElement; await waitFor(() => fireEvent.click(overwriteCheckbox)); await waitFor(() => fireEvent.click(exceptionCheckbox)); + await waitFor(() => fireEvent.click(connectorsCheckbox)); await waitFor(() => fireEvent.change(queryByTestId('rule-file-picker') as HTMLInputElement, { @@ -168,5 +188,91 @@ describe('ImportDataModal', () => { expect(overwriteCheckbox.checked).toBeFalsy(); expect(exceptionCheckbox.checked).toBeFalsy(); + expect(connectorsCheckbox.checked).toBeFalsy(); + }); + + test('should import file, with warnings but no action_connectors_success_count', async () => { + const importWithWarning = jest.fn().mockReturnValue({ + ...importData(), + action_connectors_warnings: [ + { message: 'message', actionPath: 'path', buttonLabel: 'buttonLabel' }, + ], + action_connectors_success_count: 0, + }); + const wrapper = render( + 'successMessage')} + title="title" + /> + ); + const { queryByTestId } = wrapper; + await waitFor(() => { + fireEvent.change(queryByTestId('rule-file-picker') as HTMLInputElement, { + target: { files: [file] }, + }); + }); + await waitFor(() => { + fireEvent.click(queryByTestId('import-data-modal-button') as HTMLButtonElement); + }); + expect(wrapper).toMatchSnapshot(); + expect(queryByTestId('actionConnectorsWarningsCallOut')).not.toBeInTheDocument(); + expect(importWithWarning).toHaveBeenCalled(); + expect(closeModal).not.toHaveBeenCalled(); + expect(importComplete).toHaveBeenCalled(); + }); + test('should import file, with warnings', async () => { + const importWithWarning = jest.fn().mockReturnValue({ + ...importData(), + action_connectors_warnings: [ + { + message: '1 connector has sensitive information that requires updates.', + actionPath: 'path', + buttonLabel: 'buttonLabel', + }, + ], + action_connectors_success_count: 1, + }); + const wrapper = render( + 'successMessage')} + title="title" + /> + ); + const { queryByTestId } = wrapper; + await waitFor(() => { + fireEvent.change(queryByTestId('rule-file-picker') as HTMLInputElement, { + target: { files: [file] }, + }); + }); + await waitFor(() => { + fireEvent.click(queryByTestId('import-data-modal-button') as HTMLButtonElement); + }); + expect(wrapper).toMatchSnapshot(); + expect(queryByTestId('actionConnectorsWarningsCallOut')).toBeInTheDocument(); + expect(importWithWarning).toHaveBeenCalled(); + expect(importComplete).toHaveBeenCalled(); + expect(closeModal).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx index a291265bc234d..1b2cf3e516e5e 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React, { useCallback, useState } from 'react'; import { EuiButton, EuiButtonEmpty, @@ -18,7 +19,7 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; -import React, { useCallback, useState } from 'react'; +import type { WarningSchema } from '../../../../common/detection_engine/schemas/response'; import type { ImportDataResponse, @@ -27,6 +28,7 @@ import type { import { useAppToasts } from '../../hooks/use_app_toasts'; import * as i18n from './translations'; import { showToasterMessage } from './utils'; +import { ActionConnectorWarnings } from './action_connectors_warning'; interface ImportDataModalProps { checkBoxLabel: string; @@ -43,6 +45,7 @@ interface ImportDataModalProps { subtitle: string; successMessage: (totalCount: number) => string; title: string; + showActionConnectorsCheckBox?: boolean; } /** @@ -59,6 +62,7 @@ export const ImportDataModalComponent = ({ importData, showCheckBox = true, showExceptionsCheckBox = false, + showActionConnectorsCheckBox = false, showModal, submitBtnText, subtitle, @@ -69,15 +73,35 @@ export const ImportDataModalComponent = ({ const [isImporting, setIsImporting] = useState(false); const [overwrite, setOverwrite] = useState(false); const [overwriteExceptions, setOverwriteExceptions] = useState(false); + const [overwriteActionConnectors, setOverwriteActionConnectors] = useState(false); const { addError, addSuccess } = useAppToasts(); - + const [actionConnectorsWarnings, setActionConnectorsWarnings] = useState( + [] + ); + const [importedActionConnectorsCount, setImportedActionConnectorsCount] = useState< + number | undefined + >(0); const cleanupAndCloseModal = useCallback(() => { - setIsImporting(false); - setSelectedFiles(null); closeModal(); setOverwrite(false); setOverwriteExceptions(false); - }, [setIsImporting, setSelectedFiles, closeModal, setOverwrite, setOverwriteExceptions]); + setOverwriteActionConnectors(false); + setActionConnectorsWarnings([]); + }, [closeModal, setOverwrite, setOverwriteExceptions]); + + const onImportComplete = useCallback( + (callCleanup: boolean) => { + setIsImporting(false); + setSelectedFiles(null); + importComplete(); + + if (callCleanup) { + importComplete(); + cleanupAndCloseModal(); + } + }, + [cleanupAndCloseModal, importComplete] + ); const importDataCallback = useCallback(async () => { if (selectedFiles != null) { @@ -85,25 +109,28 @@ export const ImportDataModalComponent = ({ const abortCtrl = new AbortController(); try { - const importResponse = await importData({ + const { action_connectors_warnings: warnings, ...importResponse } = await importData({ fileToImport: selectedFiles[0], overwrite, overwriteExceptions, + overwriteActionConnectors, signal: abortCtrl.signal, }); + const connectorsCount = importResponse.action_connectors_success_count; + setActionConnectorsWarnings(warnings as WarningSchema[]); + setImportedActionConnectorsCount(connectorsCount); showToasterMessage({ importResponse, exceptionsIncluded: showExceptionsCheckBox, + actionConnectorsIncluded: showActionConnectorsCheckBox, successMessage, errorMessage, errorMessageDetailed: failedDetailed, addError, addSuccess, }); - - importComplete(); - cleanupAndCloseModal(); + onImportComplete(!warnings?.length); } catch (error) { cleanupAndCloseModal(); addError(error, { title: errorMessage(1) }); @@ -114,13 +141,15 @@ export const ImportDataModalComponent = ({ importData, overwrite, overwriteExceptions, + overwriteActionConnectors, showExceptionsCheckBox, successMessage, errorMessage, failedDetailed, addError, addSuccess, - importComplete, + showActionConnectorsCheckBox, + onImportComplete, cleanupAndCloseModal, ]); @@ -136,6 +165,9 @@ export const ImportDataModalComponent = ({ setOverwriteExceptions((shouldOverwrite) => !shouldOverwrite); }, []); + const handleActionConnectorsCheckboxClick = useCallback(() => { + setOverwriteActionConnectors((shouldOverwrite) => !shouldOverwrite); + }, []); return ( <> {showModal && ( @@ -163,24 +195,41 @@ export const ImportDataModalComponent = ({ isLoading={isImporting} /> + + + + + {showCheckBox && ( <> {showExceptionsCheckBox && ( )} + {showActionConnectorsCheckBox && ( + + )} )} diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/import_data_modal/translations.ts index 763396ae62d79..11ae7dd972a57 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/translations.ts @@ -20,7 +20,12 @@ export const OVERWRITE_EXCEPTIONS_LABEL = i18n.translate( defaultMessage: 'Overwrite existing exception lists with conflicting "list_id"', } ); - +export const OVERWRITE_ACTION_CONNECTORS_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.overwriteActionConnectorsLabel', + { + defaultMessage: 'Overwrite existing connectors with conflicting action "id"', + } +); export const SUCCESSFULLY_IMPORTED_EXCEPTIONS = (totalExceptions: number) => i18n.translate( 'xpack.securitySolution.detectionEngine.components.importRuleModal.exceptionsSuccessLabel', @@ -30,6 +35,15 @@ export const SUCCESSFULLY_IMPORTED_EXCEPTIONS = (totalExceptions: number) => 'Successfully imported {totalExceptions} {totalExceptions, plural, =1 {exception} other {exceptions}}.', } ); +export const SUCCESSFULLY_IMPORTED_CONNECTORS = (totalConnectors: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.connectorsSuccessLabel', + { + values: { totalConnectors }, + defaultMessage: + 'Successfully imported {totalConnectors} {totalConnectors, plural, =1 {connector} other {connectors}}.', + } + ); export const IMPORT_FAILED = (totalExceptions: number) => i18n.translate( @@ -40,3 +54,36 @@ export const IMPORT_FAILED = (totalExceptions: number) => 'Failed to import {totalExceptions} {totalExceptions, plural, =1 {exception} other {exceptions}}', } ); +export const IMPORT_CONNECTORS_FAILED = (totalConnectors: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.importConnectorsFailedLabel', + { + values: { totalConnectors }, + defaultMessage: + 'Failed to import {totalConnectors} {totalConnectors, plural, =1 {connector} other {connectors}}', + } + ); + +export const ACTION_CONNECTORS_WARNING_TITLE = (totalConnectors: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.actionConnectorsWarningTitle', + { + values: { totalConnectors }, + defaultMessage: + '{totalConnectors} {totalConnectors, plural, =1 {connector} other {connectors}} imported', + } + ); + +export const ACTION_CONNECTORS_WARNING_BUTTON = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.actionConnectorsWarningButton', + { + defaultMessage: 'Go to connectors', + } +); + +export const ACTION_CONNECTORS_ADDITIONAL_PRIVILEGES = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.actionConnectorsAdditionalPrivilegesError', + { + defaultMessage: 'You need additional privileges to import rules with actions.', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.test.ts b/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.test.ts index b5609a79eba6c..6b432edfb5bd1 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.test.ts @@ -9,7 +9,7 @@ import { showToasterMessage } from './utils'; describe('showToasterMessage', () => { describe('exceptionsIncluded is false', () => { - it('displays main success message if import was successful', () => { + it('should display main success message if import was successful', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -20,6 +20,7 @@ describe('showToasterMessage', () => { errors: [], }, exceptionsIncluded: false, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -32,7 +33,7 @@ describe('showToasterMessage', () => { expect(addError).not.toHaveBeenCalled(); }); - it('displays main error message if import was not successful', () => { + it('should display main error message if import was not successful', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -51,6 +52,7 @@ describe('showToasterMessage', () => { ], }, exceptionsIncluded: false, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -67,7 +69,7 @@ describe('showToasterMessage', () => { }); describe('exceptionsIncluded is true', () => { - it('displays success message for rules and exceptions if both succeed', () => { + it('should display success message for rules and exceptions if both succeed', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -82,6 +84,7 @@ describe('showToasterMessage', () => { exceptions_success_count: 1, }, exceptionsIncluded: true, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -95,7 +98,7 @@ describe('showToasterMessage', () => { expect(addError).not.toHaveBeenCalled(); }); - it('displays error message for rules and exceptions if both fail', () => { + it('should display error message for rules and exceptions if both fail', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -133,6 +136,7 @@ describe('showToasterMessage', () => { exceptions_success_count: 0, }, exceptionsIncluded: true, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -154,7 +158,7 @@ describe('showToasterMessage', () => { expect(addSuccess).not.toHaveBeenCalled(); }); - it('displays only a rule toaster if no exceptions were imported', () => { + it('should display only a rule toaster if no exceptions were imported', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -169,6 +173,7 @@ describe('showToasterMessage', () => { exceptions_success_count: 0, }, exceptionsIncluded: true, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -181,7 +186,7 @@ describe('showToasterMessage', () => { expect(addError).not.toHaveBeenCalled(); }); - it('displays only an exceptions toaster if no rules were imported', () => { + it('should display only an exceptions toaster if no rules were imported', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -196,6 +201,7 @@ describe('showToasterMessage', () => { exceptions_success_count: 1, }, exceptionsIncluded: true, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -208,4 +214,277 @@ describe('showToasterMessage', () => { expect(addError).not.toHaveBeenCalled(); }); }); + + describe('actionConnectorsIncluded is false', () => { + it('should display main success message if import was successful', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: true, + success_count: 1, + errors: [], + }, + exceptionsIncluded: false, + actionConnectorsIncluded: false, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addSuccess).toHaveBeenCalledTimes(1); + expect(addSuccess).toHaveBeenCalledWith('success: 1'); + expect(addError).not.toHaveBeenCalled(); + }); + + it('should display main error message if import was not successful', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: false, + success_count: 0, + errors: [ + { + rule_id: 'rule_id', + error: { + status_code: 400, + message: 'an error message', + }, + }, + ], + }, + exceptionsIncluded: false, + actionConnectorsIncluded: false, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addError).toHaveBeenCalledTimes(1); + expect(addError).toHaveBeenCalledWith(new Error('errorDetailed: an error message'), { + title: 'error: 1', + }); + expect(addSuccess).not.toHaveBeenCalled(); + }); + }); + describe('actionConnectorsIncluded is true', () => { + it('should display success message for rules and connectors if both succeed', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: true, + success_count: 1, + rules_count: 1, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_errors: [], + action_connectors_success_count: 1, + }, + exceptionsIncluded: false, + actionConnectorsIncluded: true, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addSuccess).toHaveBeenCalledTimes(2); + expect(addSuccess).toHaveBeenNthCalledWith(1, 'success: 1'); + expect(addSuccess).toHaveBeenNthCalledWith(2, 'Successfully imported 1 connector.'); + expect(addError).not.toHaveBeenCalled(); + }); + + it('should display 1 error message for rules and connectors even when both fail', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: false, + success_count: 1, + rules_count: 2, + action_connectors_success: false, + errors: [ + { + rule_id: 'rule_id', + error: { + status_code: 400, + message: 'an error message', + }, + }, + ], + action_connectors_errors: [ + { + rule_id: 'rule_id', + error: { + status_code: 400, + message: 'an error message', + }, + }, + { + rule_id: 'rule_id_1', + error: { + status_code: 400, + message: 'another error message', + }, + }, + ], + exceptions_success: true, + exceptions_success_count: 0, + }, + exceptionsIncluded: false, + actionConnectorsIncluded: true, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addError).toHaveBeenCalledTimes(1); + + expect(addError).toHaveBeenCalledWith( + new Error('errorDetailed: an error message. errorDetailed: another error message'), + { + title: 'Failed to import 2 connectors', + } + ); + expect(addSuccess).not.toHaveBeenCalled(); + }); + + it('should display only a rule toaster if no connectors were imported', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: true, + success_count: 1, + rules_count: 1, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_errors: [], + action_connectors_success: true, + }, + exceptionsIncluded: true, + actionConnectorsIncluded: false, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addSuccess).toHaveBeenCalledTimes(1); + expect(addSuccess).toHaveBeenNthCalledWith(1, 'success: 1'); + expect(addError).not.toHaveBeenCalled(); + }); + + it('should display only a connector toaster if no rules were imported', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: true, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success_count: 1, + action_connectors_success: true, + }, + exceptionsIncluded: true, + actionConnectorsIncluded: true, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addSuccess).toHaveBeenCalledTimes(1); + expect(addSuccess).toHaveBeenNthCalledWith(1, 'Successfully imported 1 connector.'); + expect(addError).not.toHaveBeenCalled(); + }); + it('should display the user friendly message in case of additional privileges', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: false, + success_count: 1, + rules_count: 2, + action_connectors_success: false, + errors: [ + { + rule_id: 'unknown', + error: { + status_code: 403, + message: + 'You may not have actions privileges required to import rules with actions', + }, + }, + ], + action_connectors_errors: [ + { + rule_id: 'unknown', + error: { + status_code: 403, + message: + 'You may not have actions privileges required to import rules with actions', + }, + }, + { + rule_id: 'unknown', + error: { + status_code: 403, + message: + 'You may not have actions privileges required to import rules with actions', + }, + }, + ], + exceptions_success: true, + exceptions_success_count: 0, + }, + exceptionsIncluded: false, + actionConnectorsIncluded: true, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addError).toHaveBeenCalledTimes(1); + + expect(addError).toHaveBeenCalledWith( + new Error( + 'errorDetailed: You need additional privileges to import rules with actions.. errorDetailed: You need additional privileges to import rules with actions.' + ), + { + title: 'Failed to import 2 connectors', + } + ); + expect(addSuccess).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.ts b/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.ts index 8211c01a7bffa..92c6f9f666e4b 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.ts +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.ts @@ -30,9 +30,32 @@ export const formatError = ( return error; }; +const mapErrorMessageToUserMessage = ( + actionConnectorsErrors: Array +) => { + return actionConnectorsErrors.map((connectorError) => { + const { error } = connectorError; + const { status_code: statusCode, message: originalMessage } = error || {}; + let message; + switch (statusCode) { + case 403: + message = i18n.ACTION_CONNECTORS_ADDITIONAL_PRIVILEGES; + break; + + default: + message = originalMessage; + + break; + } + + return { ...connectorError, error: { ...error, message } }; + }); +}; + export const showToasterMessage = ({ importResponse, exceptionsIncluded, + actionConnectorsIncluded, successMessage, errorMessage, errorMessageDetailed, @@ -41,53 +64,68 @@ export const showToasterMessage = ({ }: { importResponse: ImportDataResponse; exceptionsIncluded: boolean; + actionConnectorsIncluded: boolean; successMessage: (totalCount: number) => string; errorMessage: (totalCount: number) => string; errorMessageDetailed: (message: string) => string; addError: (error: unknown, options: ErrorToastOptions) => Toast; addSuccess: (toastOrTitle: ToastInput, options?: ToastOptions | undefined) => Toast; }) => { - // if import includes exceptions - if (exceptionsIncluded) { - // rules response actions - if (importResponse.success && importResponse.success_count > 0) { - addSuccess(successMessage(importResponse.success_count)); - } - - if (importResponse.errors.length > 0 && importResponse.rules_count) { - const error = formatError(errorMessageDetailed, importResponse, importResponse.errors); - addError(error, { - title: errorMessage(importResponse.rules_count - importResponse.success_count), - }); - } - - // exceptions response actions + if (importResponse.success) { + if (importResponse.success_count > 0) addSuccess(successMessage(importResponse.success_count)); if ( + exceptionsIncluded && importResponse.exceptions_success && importResponse.exceptions_success_count != null && importResponse.exceptions_success_count > 0 ) { addSuccess(i18n.SUCCESSFULLY_IMPORTED_EXCEPTIONS(importResponse.exceptions_success_count)); } + if ( + actionConnectorsIncluded && + importResponse.action_connectors_success && + importResponse.action_connectors_success_count != null && + importResponse.action_connectors_success_count > 0 + ) { + addSuccess( + i18n.SUCCESSFULLY_IMPORTED_CONNECTORS(importResponse.action_connectors_success_count) + ); + } + return; + } + + if (importResponse.errors.length > 0) { + if ( + actionConnectorsIncluded && + importResponse.action_connectors_errors != null && + importResponse.action_connectors_errors.length > 0 + ) { + const userErrorMessages = mapErrorMessageToUserMessage( + importResponse.action_connectors_errors + ); + const connectorError = formatError(errorMessageDetailed, importResponse, userErrorMessages); - if (importResponse.exceptions_errors != null && importResponse.exceptions_errors.length > 0) { - const error = formatError( + return addError(connectorError, { + title: i18n.IMPORT_CONNECTORS_FAILED(userErrorMessages.length), + }); + } + const ruleError = formatError(errorMessageDetailed, importResponse, importResponse.errors); + addError(ruleError, { title: errorMessage(importResponse.errors.length) }); + + if ( + exceptionsIncluded && + importResponse.exceptions_errors != null && + importResponse.exceptions_errors.length > 0 + ) { + const exceptionError = formatError( errorMessageDetailed, importResponse, importResponse.exceptions_errors ); - addError(error, { title: i18n.IMPORT_FAILED(importResponse.exceptions_errors.length) }); - } - } else { - // rules response actions - if (importResponse.success) { - addSuccess(successMessage(importResponse.success_count)); - } - - if (importResponse.errors.length > 0) { - const error = formatError(errorMessageDetailed, importResponse, importResponse.errors); - addError(error, { title: errorMessage(importResponse.errors.length) }); + addError(exceptionError, { + title: i18n.IMPORT_FAILED(importResponse.exceptions_errors.length), + }); } } }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index bf1ac32d2bc78..a1d59efa4c30a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -493,6 +493,7 @@ describe('Detections Rules API', () => { }, query: { overwrite: false, + overwrite_action_connectors: false, overwrite_exceptions: false, }, }); @@ -510,6 +511,7 @@ describe('Detections Rules API', () => { query: { overwrite: true, overwrite_exceptions: false, + overwrite_action_connectors: false, }, }); }); @@ -523,6 +525,10 @@ describe('Detections Rules API', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); const resp = await importRules({ fileToImport, signal: abortCtrl.signal }); expect(resp).toEqual({ @@ -533,6 +539,10 @@ describe('Detections Rules API', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index c867b6b3fd792..aec165167db59 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -341,6 +341,7 @@ export const importRules = async ({ fileToImport, overwrite = false, overwriteExceptions = false, + overwriteActionConnectors = false, signal, }: ImportDataProps): Promise => { const formData = new FormData(); @@ -351,7 +352,11 @@ export const importRules = async ({ { method: 'POST', headers: { 'Content-Type': undefined }, - query: { overwrite, overwrite_exceptions: overwriteExceptions }, + query: { + overwrite, + overwrite_exceptions: overwriteExceptions, + overwrite_action_connectors: overwriteActionConnectors, + }, body: formData, signal, } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index 6b520659090d9..246a000356761 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -29,6 +29,7 @@ import { import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; import { PositiveInteger } from '@kbn/securitysolution-io-ts-types'; +import type { WarningSchema } from '../../../../common/detection_engine/schemas/response'; import { RuleExecutionSummary } from '../../../../common/detection_engine/rule_monitoring'; import { AlertSuppression, @@ -267,6 +268,7 @@ export interface ImportDataProps { fileToImport: File; overwrite?: boolean; overwriteExceptions?: boolean; + overwriteActionConnectors?: boolean; signal: AbortSignal; } @@ -304,6 +306,10 @@ export interface ImportDataResponse { exceptions_success?: boolean; exceptions_success_count?: number; exceptions_errors?: ExceptionsImportError[]; + action_connectors_success?: boolean; + action_connectors_success_count?: number; + action_connectors_errors?: Array; + action_connectors_warnings?: WarningSchema[]; } export interface ExportDocumentsProps { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx index f02075809a46d..a26517e447d7a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx @@ -108,6 +108,7 @@ const RulesPageComponent: React.FC = () => { title={i18n.IMPORT_RULE} showExceptionsCheckBox showCheckBox + showActionConnectorsCheckBox /> diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index 2234f09cb1888..e3fabc5366980 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -326,12 +326,18 @@ export const performBulkActionRoute = ( 'alerting', 'licensing', 'lists', + 'actions', ]); const rulesClient = ctx.alerting.getRulesClient(); const exceptionsClient = ctx.lists?.getExceptionListClient(); const savedObjectsClient = ctx.core.savedObjects.client; + const { getExporter, getClient } = (await ctx.core).savedObjects; + const client = getClient({ includedHiddenTypes: ['action'] }); + + const exporter = getExporter(client); + const mlAuthz = buildMlAuthz({ license: ctx.licensing.license, ml, @@ -556,10 +562,12 @@ export const performBulkActionRoute = ( exceptionsClient, savedObjectsClient, rules.map(({ params }) => ({ rule_id: params.ruleId })), - logger + logger, + exporter, + request ); - const responseBody = `${exported.rulesNdjson}${exported.exceptionLists}${exported.exportDetails}`; + const responseBody = `${exported.rulesNdjson}${exported.exceptionLists}${exported.actionConnectors}${exported.exportDetails}`; return response.ok({ headers: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts index ec96f38f5f86e..a1a5f798fc002 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts @@ -45,8 +45,14 @@ export const exportRulesRoute = ( const siemResponse = buildSiemResponse(response); const rulesClient = (await context.alerting).getRulesClient(); const exceptionsClient = (await context.lists)?.getExceptionListClient(); - const savedObjectsClient = (await context.core).savedObjects.client; + const { + getExporter, + getClient, + client: savedObjectsClient, + } = (await context.core).savedObjects; + const client = getClient({ includedHiddenTypes: ['action'] }); + const actionsExporter = getExporter(client); try { const exportSizeLimit = config.maxRuleImportExportSize; if (request.body?.objects != null && request.body.objects.length > exportSizeLimit) { @@ -66,20 +72,29 @@ export const exportRulesRoute = ( } } - const exportedRulesAndExceptions = + const exportedRulesAndReferences = request.body?.objects != null ? await getExportByObjectIds( rulesClient, exceptionsClient, savedObjectsClient, request.body.objects, - logger + logger, + actionsExporter, + request ) - : await getExportAll(rulesClient, exceptionsClient, savedObjectsClient, logger); + : await getExportAll( + rulesClient, + exceptionsClient, + savedObjectsClient, + logger, + actionsExporter, + request + ); const responseBody = request.query.exclude_export_details - ? exportedRulesAndExceptions.rulesNdjson - : `${exportedRulesAndExceptions.rulesNdjson}${exportedRulesAndExceptions.exceptionLists}${exportedRulesAndExceptions.exportDetails}`; + ? exportedRulesAndReferences.rulesNdjson + : `${exportedRulesAndReferences.rulesNdjson}${exportedRulesAndReferences.exceptionLists}${exportedRulesAndReferences.actionConnectors}${exportedRulesAndReferences.exportDetails}`; return response.ok({ headers: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.test.ts index 3d466d2505074..bb1bb7d55244a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.test.ts @@ -107,6 +107,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -146,6 +150,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -171,6 +179,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -196,6 +208,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -218,6 +234,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); }); @@ -242,6 +262,10 @@ describe('Import rules route', () => { rules_count: 2, exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -262,6 +286,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -302,6 +330,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -332,6 +364,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -353,6 +389,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); }); @@ -387,6 +427,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -407,6 +451,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts index e306caac12194..ddcbf2cbdd6e4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts @@ -16,7 +16,6 @@ import type { ImportQuerySchemaDecoded } from '@kbn/securitysolution-io-ts-types import { importQuerySchema } from '@kbn/securitysolution-io-ts-types'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../../../common/constants'; -import type { RuleToImport } from '../../../../../../../common/detection_engine/rule_management'; import { ImportRulesResponse } from '../../../../../../../common/detection_engine/rule_management'; import type { SecuritySolutionPluginRouter } from '../../../../../../types'; @@ -28,7 +27,6 @@ import { isBulkError, isImportRegular, buildSiemResponse } from '../../../../rou import { getTupleDuplicateErrorsAndUniqueRules, - getInvalidConnectors, migrateLegacyActionsIds, } from '../../../utils/utils'; import { createRulesAndExceptionsStreamFromNdJson } from '../../../logic/import/create_rules_stream_from_ndjson'; @@ -38,6 +36,7 @@ import { importRules as importRulesHelper } from '../../../logic/import/import_r import { getReferencedExceptionLists } from '../../../logic/import/gather_referenced_exceptions'; import { importRuleExceptions } from '../../../logic/import/import_rule_exceptions'; import type { HapiReadableStream } from '../../../logic/import/hapi_readable_stream'; +import { importRuleActionConnectors } from '../../../logic/import/action_connectors/import_rule_action_connectors'; const CHUNK_PARSED_OBJECT_SIZE = 50; @@ -81,6 +80,8 @@ export const importRulesRoute = ( const actionSOClient = ctx.core.savedObjects.getClient({ includedHiddenTypes: ['action'], }); + const actionsImporter = ctx.core.savedObjects.getImporter(actionSOClient); + const savedObjectsClient = ctx.core.savedObjects.client; const exceptionsClient = ctx.lists?.getExceptionListClient(); @@ -104,7 +105,7 @@ export const importRulesRoute = ( // parse file to separate out exceptions from rules const readAllStream = createRulesAndExceptionsStreamFromNdJson(objectLimit); - const [{ exceptions, rules }] = await createPromiseFromStreams< + const [{ exceptions, rules, actionConnectors }] = await createPromiseFromStreams< RuleExceptionsPromiseFromStreams[] >([request.body.file as HapiReadableStream, ...readAllStream]); @@ -119,7 +120,6 @@ export const importRulesRoute = ( overwrite: request.query.overwrite_exceptions, maxExceptionsImportSize: objectLimit, }); - // report on duplicate rules const [duplicateIdErrors, parsedObjectsWithoutDuplicateErrors] = getTupleDuplicateErrorsAndUniqueRules(rules, request.query.overwrite); @@ -129,20 +129,23 @@ export const importRulesRoute = ( actionSOClient ); - let parsedRules; - let actionErrors: BulkError[] = []; - const actualRules = rules.filter((rule): rule is RuleToImport => !(rule instanceof Error)); - - if (actualRules.some((rule) => rule.actions && rule.actions.length > 0)) { - const [nonExistentActionErrors, uniqueParsedObjects] = await getInvalidConnectors( - migratedParsedObjectsWithoutDuplicateErrors, - actionsClient - ); - parsedRules = uniqueParsedObjects; - actionErrors = nonExistentActionErrors; - } else { - parsedRules = migratedParsedObjectsWithoutDuplicateErrors; - } + // import actions-connectors + const { + successCount: actionConnectorSuccessCount, + success: actionConnectorSuccess, + warnings: actionConnectorWarnings, + errors: actionConnectorErrors, + } = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter, + rules: migratedParsedObjectsWithoutDuplicateErrors, + overwrite: request.query.overwrite_action_connectors, + }); + const parsedRules = actionConnectorErrors.length + ? [] + : migratedParsedObjectsWithoutDuplicateErrors; + // gather all exception lists that the imported rules reference const foundReferencedExceptionLists = await getReferencedExceptionLists({ rules: parsedRules, @@ -153,7 +156,7 @@ export const importRulesRoute = ( const importRuleResponse: ImportRuleResponse[] = await importRulesHelper({ ruleChunks: chunkParseObjects, - rulesResponseAcc: [...actionErrors, ...duplicateIdErrors], + rulesResponseAcc: [...actionConnectorErrors, ...duplicateIdErrors], mlAuthz, overwriteRules: request.query.overwrite, rulesClient, @@ -161,6 +164,7 @@ export const importRulesRoute = ( exceptionsClient, spaceId: ctx.securitySolution.getSpaceId(), existingLists: foundReferencedExceptionLists, + allowMissingConnectorSecrets: !!actionConnectors.length, }); const errorsResp = importRuleResponse.filter((resp) => isBulkError(resp)) as BulkError[]; @@ -179,6 +183,10 @@ export const importRulesRoute = ( exceptions_errors: exceptionsErrors, exceptions_success: exceptionsSuccess, exceptions_success_count: exceptionsSuccessCount, + action_connectors_success: actionConnectorSuccess, + action_connectors_success_count: actionConnectorSuccessCount, + action_connectors_errors: actionConnectorErrors, + action_connectors_warnings: actionConnectorWarnings, }; const [validated, errors] = validate(importRules, ImportRulesResponse); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/create_rules.ts index f621542fe433f..416edb039919e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/create_rules.ts @@ -19,6 +19,7 @@ export interface CreateRulesOptions id?: string; immutable?: boolean; defaultEnabled?: boolean; + allowMissingConnectorSecrets?: boolean; } export const createRules = async ({ @@ -27,6 +28,7 @@ export const createRules = async ({ id, immutable = false, defaultEnabled = true, + allowMissingConnectorSecrets, }: CreateRulesOptions): Promise> => { const internalRule = convertCreateAPIToInternalSchema(params, immutable, defaultEnabled); const rule = await rulesClient.create({ @@ -34,6 +36,7 @@ export const createRules = async ({ id, }, data: internalRule, + allowMissingConnectorSecrets, }); // Mute the rule if it is first created with the explicit no actions diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/patch_rules.ts index 656366ec9f92b..45d1a21325abd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/patch_rules.ts @@ -16,12 +16,14 @@ export interface PatchRulesOptions { rulesClient: RulesClient; nextParams: PatchRuleRequestBody; existingRule: RuleAlertType | null | undefined; + allowMissingConnectorSecrets?: boolean; } export const patchRules = async ({ rulesClient, existingRule, nextParams, + allowMissingConnectorSecrets, }: PatchRulesOptions): Promise | null> => { if (existingRule == null) { return null; @@ -32,6 +34,7 @@ export const patchRules = async ({ const update = await rulesClient.update({ id: existingRule.id, data: patchedRule, + allowMissingConnectorSecrets, }); if (nextParams.throttle !== undefined) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.test.ts index b7395236a2152..d5511ccf7b1cc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.test.ts @@ -24,13 +24,17 @@ import { getQueryRuleParams } from '../../../rule_schema/mocks'; import { getExceptionListClientMock } from '@kbn/lists-plugin/server/services/exception_lists/exception_list_client.mock'; import type { loggingSystemMock } from '@kbn/core/server/mocks'; 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'; const exceptionsClient = getExceptionListClientMock(); describe('getExportAll', () => { let logger: ReturnType; const { clients } = requestContextMock.createTools(); - + const exporterMock = savedObjectsExporterMock.create(); + const requestMock = mockRouter.createKibanaRequest(); beforeEach(async () => { clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse()); }); @@ -55,7 +59,9 @@ describe('getExportAll', () => { rulesClient, exceptionsClient, clients.savedObjectsClient, - logger + logger, + exporterMock, + requestMock ); const rulesJson = JSON.parse(exports.rulesNdjson); const detailsJson = JSON.parse(exports.exportDetails); @@ -114,6 +120,11 @@ describe('getExportAll', () => { 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: [], }); }); @@ -133,12 +144,186 @@ describe('getExportAll', () => { rulesClient, exceptionsClient, clients.savedObjectsClient, - logger + logger, + exporterMock, + requestMock ); expect(exports).toEqual({ rulesNdjson: '', exportDetails: getSampleDetailsAsNdjson(details), exceptionLists: '', + actionConnectors: '', + }); + }); + test('it will export with rule and action connectors', async () => { + const rulesClient = rulesClientMock.create(); + const result = getFindResultWithSingleHit(); + const alert = { + ...getRuleMock(getQueryRuleParams()), + actions: [ + { + group: 'default', + id: '123', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + actionTypeId: '.slack', + }, + ], + }; + + 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); + let eventCount = 0; + const readable = new Readable({ + objectMode: true, + read() { + if (eventCount === 0) { + eventCount += 1; + return this.push({ + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + type: 'action', + updated_at: '2023-01-11T11:30:31.683Z', + created_at: '2023-01-11T11:30:31.683Z', + version: 'WzE2MDYsMV0=', + attributes: { + actionTypeId: '.slack', + name: 'slack', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }); + } + if (eventCount === 1) { + eventCount += 1; + return this.push({ + exportedCount: 1, + missingRefCount: 0, + missingReferences: [], + excludedObjectsCount: 0, + excludedObjects: [], + }); + } + return this.push(null); + }, + }); + + const exporterMockWithConnector = { + exportByObjects: () => jest.fn().mockReturnValueOnce(readable), + + exportByTypes: jest.fn(), + }; + const exports = await getExportAll( + rulesClient, + exceptionsClient, + clients.savedObjectsClient, + logger, + exporterMockWithConnector as never, + requestMock + ); + const rulesJson = JSON.parse(exports.rulesNdjson); + const detailsJson = JSON.parse(exports.exportDetails); + const actionConnectorsJSON = JSON.parse(exports.actionConnectors); + expect(rulesJson).toEqual({ + author: ['Elastic'], + actions: [ + { + group: 'default', + id: '123', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + action_type_id: '.slack', + }, + ], + 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, + exceptions_list: getListArrayMock(), + }); + expect(detailsJson).toEqual({ + exported_exception_list_count: 1, + exported_exception_list_item_count: 1, + exported_count: 4, + 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: [], + }); + expect(actionConnectorsJSON).toEqual({ + attributes: { + actionTypeId: '.slack', + config: {}, + isMissingSecrets: true, + name: 'slack', + secrets: {}, + }, + coreMigrationVersion: '8.7.0', + created_at: '2023-01-11T11:30:31.683Z', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + migrationVersion: { + action: '8.3.0', + }, + references: [], + type: 'action', + updated_at: '2023-01-11T11:30:31.683Z', + version: 'WzE2MDYsMV0=', }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts index 058559de7db59..88f3e9019d493 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts @@ -7,13 +7,14 @@ import { transformDataToNdjson } from '@kbn/securitysolution-utils'; -import type { Logger } from '@kbn/core/server'; +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 { getNonPackagedRules } from '../search/get_existing_prepackaged_rules'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { transformAlertsToRules } from '../../utils/utils'; import { getRuleExceptionsForExport } from './get_export_rule_exceptions'; +import { getRuleActionConnectorsForExport } from './get_export_rule_action_connectors'; // eslint-disable-next-line no-restricted-imports import { legacyGetBulkRuleActionsSavedObject } from '../../../rule_actions_legacy'; @@ -22,11 +23,14 @@ export const getExportAll = async ( rulesClient: RulesClient, exceptionsClient: ExceptionListClient | undefined, savedObjectsClient: RuleExecutorServices['savedObjectsClient'], - logger: Logger + logger: Logger, + actionsExporter: ISavedObjectsExporter, + request: KibanaRequest ): Promise<{ rulesNdjson: string; exportDetails: string; exceptionLists: string | null; + actionConnectors: string; }> => { const ruleAlertTypes = await getNonPackagedRules({ rulesClient }); const alertIds = ruleAlertTypes.map((rule) => rule.id); @@ -44,7 +48,15 @@ export const getExportAll = async ( const { exportData: exceptionLists, exportDetails: exceptionDetails } = await getRuleExceptionsForExport(exceptions, exceptionsClient); + // Retrieve Action-Connectors + const { actionConnectors, actionConnectorDetails } = await getRuleActionConnectorsForExport( + rules, + actionsExporter, + request + ); + const rulesNdjson = transformDataToNdjson(rules); - const exportDetails = getExportDetailsNdjson(rules, [], exceptionDetails); - return { rulesNdjson, exportDetails, exceptionLists }; + const exportDetails = getExportDetailsNdjson(rules, [], exceptionDetails, actionConnectorDetails); + + return { rulesNdjson, exportDetails, exceptionLists, actionConnectors }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts index 02b83342cd846..570954ba9c8b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { Readable } from 'stream'; import type { RulesErrors } from './get_export_by_object_ids'; import { getExportByObjectIds, getRulesFromObjects } from './get_export_by_object_ids'; import type { FindHit } from '../../../routes/__mocks__/request_responses'; @@ -22,6 +22,8 @@ import { } from '../../../../../../common/detection_engine/rule_management/mocks'; import { getQueryRuleParams } from '../../../rule_schema/mocks'; import { getExceptionListClientMock } from '@kbn/lists-plugin/server/services/exception_lists/exception_list_client.mock'; +import { savedObjectsExporterMock } from '@kbn/core-saved-objects-import-export-server-mocks'; +import { mockRouter } from '@kbn/core-http-router-server-mocks'; const exceptionsClient = getExceptionListClientMock(); import type { loggingSystemMock } from '@kbn/core/server/mocks'; @@ -30,6 +32,8 @@ import { requestContextMock } from '../../../routes/__mocks__/request_context'; describe('get_export_by_object_ids', () => { let logger: ReturnType; const { clients } = requestContextMock.createTools(); + const exporterMock = savedObjectsExporterMock.create(); + const requestMock = mockRouter.createKibanaRequest(); beforeEach(() => { jest.resetAllMocks(); @@ -50,7 +54,9 @@ describe('get_export_by_object_ids', () => { exceptionsClient, clients.savedObjectsClient, objects, - logger + logger, + exporterMock, + requestMock ); const exportsObj = { rulesNdjson: JSON.parse(exports.rulesNdjson), @@ -112,6 +118,11 @@ describe('get_export_by_object_ids', () => { 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: [], }, }); }); @@ -137,7 +148,9 @@ describe('get_export_by_object_ids', () => { exceptionsClient, clients.savedObjectsClient, objects, - logger + logger, + exporterMock, + requestMock ); const details = getOutputDetailsSampleWithExceptions({ missingRules: [{ rule_id: 'rule-1' }], @@ -147,6 +160,180 @@ describe('get_export_by_object_ids', () => { rulesNdjson: '', exportDetails: getSampleDetailsAsNdjson(details), exceptionLists: '', + actionConnectors: '', + }); + }); + + test('it will export with rule and action connectors', async () => { + const rulesClient = rulesClientMock.create(); + const result = getFindResultWithSingleHit(); + const alert = { + ...getRuleMock(getQueryRuleParams()), + actions: [ + { + group: 'default', + id: '123', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + actionTypeId: '.slack', + }, + ], + }; + + 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); + let eventCount = 0; + const readable = new Readable({ + objectMode: true, + read() { + if (eventCount === 0) { + eventCount += 1; + return this.push({ + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + type: 'action', + updated_at: '2023-01-11T11:30:31.683Z', + created_at: '2023-01-11T11:30:31.683Z', + version: 'WzE2MDYsMV0=', + attributes: { + actionTypeId: '.slack', + name: 'slack', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }); + } + if (eventCount === 1) { + eventCount += 1; + return this.push({ + exportedCount: 1, + missingRefCount: 0, + missingReferences: [], + excludedObjectsCount: 0, + excludedObjects: [], + }); + } + return this.push(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 + ); + const rulesJson = JSON.parse(exports.rulesNdjson); + const detailsJson = JSON.parse(exports.exportDetails); + const actionConnectorsJSON = JSON.parse(exports.actionConnectors); + expect(rulesJson).toEqual({ + author: ['Elastic'], + actions: [ + { + group: 'default', + id: '123', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + action_type_id: '.slack', + }, + ], + 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, + exceptions_list: getListArrayMock(), + }); + expect(detailsJson).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: [], + }); + expect(actionConnectorsJSON).toEqual({ + attributes: { + actionTypeId: '.slack', + config: {}, + isMissingSecrets: true, + name: 'slack', + secrets: {}, + }, + coreMigrationVersion: '8.7.0', + created_at: '2023-01-11T11:30:31.683Z', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + migrationVersion: { + action: '8.3.0', + }, + references: [], + type: 'action', + updated_at: '2023-01-11T11:30:31.683Z', + version: 'WzE2MDYsMV0=', }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts index e15ab98e0d0ca..90938420e64b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts @@ -6,9 +6,10 @@ */ import { chunk } from 'lodash'; + import { transformDataToNdjson } from '@kbn/securitysolution-utils'; -import type { Logger } from '@kbn/core/server'; +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'; @@ -17,6 +18,7 @@ import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { isAlertType } from '../../../rule_schema'; import { findRules } from '../search/find_rules'; import { getRuleExceptionsForExport } from './get_export_rule_exceptions'; +import { getRuleActionConnectorsForExport } from './get_export_rule_action_connectors'; // eslint-disable-next-line no-restricted-imports import { legacyGetBulkRuleActionsSavedObject } from '../../../rule_actions_legacy'; @@ -44,11 +46,14 @@ export const getExportByObjectIds = async ( exceptionsClient: ExceptionListClient | undefined, savedObjectsClient: RuleExecutorServices['savedObjectsClient'], objects: Array<{ rule_id: string }>, - logger: Logger + logger: Logger, + actionsExporter: ISavedObjectsExporter, + request: KibanaRequest ): Promise<{ rulesNdjson: string; exportDetails: string; exceptionLists: string | null; + actionConnectors: string; }> => { const rulesAndErrors = await getRulesFromObjects( rulesClient, @@ -56,23 +61,33 @@ export const getExportByObjectIds = async ( objects, logger ); + const { rules, missingRules } = rulesAndErrors; // Retrieve exceptions - const exceptions = rulesAndErrors.rules.flatMap((rule) => rule.exceptions_list ?? []); + const exceptions = rules.flatMap((rule) => rule.exceptions_list ?? []); const { exportData: exceptionLists, exportDetails: exceptionDetails } = await getRuleExceptionsForExport(exceptions, exceptionsClient); - const rulesNdjson = transformDataToNdjson(rulesAndErrors.rules); + // Retrieve Action-Connectors + const { actionConnectors, actionConnectorDetails } = await getRuleActionConnectorsForExport( + rules, + actionsExporter, + request + ); + + const rulesNdjson = transformDataToNdjson(rules); const exportDetails = getExportDetailsNdjson( - rulesAndErrors.rules, - rulesAndErrors.missingRules, - exceptionDetails + rules, + missingRules, + exceptionDetails, + actionConnectorDetails ); return { rulesNdjson, exportDetails, exceptionLists, + actionConnectors, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_details_ndjson.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_details_ndjson.ts index 465c4b53b1e51..9efbe665812c9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_details_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_details_ndjson.ts @@ -8,23 +8,29 @@ import type { ExportExceptionDetails } from '@kbn/securitysolution-io-ts-list-types'; import type { ExportRulesDetails } from '../../../../../../common/detection_engine/rule_management'; import type { RuleResponse } from '../../../../../../common/detection_engine/rule_schema'; +import type { DefaultActionConnectorDetails } from './get_export_rule_action_connectors'; export const getExportDetailsNdjson = ( rules: RuleResponse[], missingRules: Array<{ rule_id: string }> = [], - exceptionDetails?: ExportExceptionDetails + exceptionDetails?: ExportExceptionDetails, + actionConnectorDetails?: DefaultActionConnectorDetails ): string => { + let exportedCount = rules.length; + if (actionConnectorDetails != null) + exportedCount += actionConnectorDetails.exported_action_connector_count; + if (exceptionDetails != null) + exportedCount += + exceptionDetails.exported_exception_list_count + + exceptionDetails.exported_exception_list_item_count; + const stringified: ExportRulesDetails = { - exported_count: - exceptionDetails == null - ? rules.length - : rules.length + - exceptionDetails.exported_exception_list_count + - exceptionDetails.exported_exception_list_item_count, + exported_count: exportedCount, exported_rules_count: rules.length, missing_rules: missingRules, missing_rules_count: missingRules.length, ...exceptionDetails, + ...actionConnectorDetails, }; return `${JSON.stringify(stringified)}\n`; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_rule_action_connectors.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_rule_action_connectors.ts new file mode 100644 index 0000000000000..04472ff6b4598 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_rule_action_connectors.ts @@ -0,0 +1,92 @@ +/* + * 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 { KibanaRequest } from '@kbn/core-http-server'; +import type { SavedObjectTypeIdTuple } from '@kbn/core-saved-objects-common'; +import type { + SavedObjectsExportResultDetails, + ISavedObjectsExporter, + SavedObjectsExportExcludedObject, + SavedObject, +} from '@kbn/core-saved-objects-server'; +import { createConcatStream, createMapStream, createPromiseFromStreams } from '@kbn/utils'; +import type { RuleResponse } from '../../../../../../common/detection_engine/rule_schema'; + +export interface DefaultActionConnectorDetails { + exported_action_connector_count: number; + missing_action_connection_count: number; + missing_action_connections: SavedObjectTypeIdTuple[]; + excluded_action_connection_count: number; + excluded_action_connections: SavedObjectsExportExcludedObject[]; +} + +const defaultActionConnectorDetails: DefaultActionConnectorDetails = { + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], + excluded_action_connection_count: 0, + excluded_action_connections: [], +}; + +const mapExportedActionConnectorsDetailsToDefault = ( + exportDetails: SavedObjectsExportResultDetails +): DefaultActionConnectorDetails => { + return { + exported_action_connector_count: exportDetails.exportedCount, + missing_action_connection_count: exportDetails.missingRefCount, + missing_action_connections: exportDetails.missingReferences, + excluded_action_connection_count: exportDetails.excludedObjectsCount, + excluded_action_connections: exportDetails.excludedObjects, + }; +}; + +export const getRuleActionConnectorsForExport = async ( + rules: RuleResponse[], + actionsExporter: ISavedObjectsExporter, + request: KibanaRequest +) => { + const exportedActionConnectors: { + actionConnectors: string; + actionConnectorDetails: DefaultActionConnectorDetails; + } = { + actionConnectors: '', + actionConnectorDetails: defaultActionConnectorDetails, + }; + + const actionsIds = [...new Set(rules.flatMap((rule) => rule.actions.map(({ id }) => id)))]; + + if (!actionsIds.length) return exportedActionConnectors; + + const getActionsByObjectsParam = actionsIds.map((id) => ({ type: 'action', id })); + const actionDetails = await actionsExporter.exportByObjects({ + objects: getActionsByObjectsParam, + request, + }); + + if (!actionDetails) { + exportedActionConnectors.actionConnectorDetails = { + exported_action_connector_count: 0, + missing_action_connection_count: actionsIds.length, + missing_action_connections: [], // TODO: check how to generate SO + excluded_action_connection_count: 0, + excluded_action_connections: [], + }; + return exportedActionConnectors; + } + + const actionsConnectorsToExport: SavedObject[] = await createPromiseFromStreams([ + actionDetails, + createMapStream((obj: SavedObject | SavedObjectsExportResultDetails) => { + if ('exportedCount' in obj) + exportedActionConnectors.actionConnectorDetails = + mapExportedActionConnectorsDetailsToDefault(obj); + else return JSON.stringify(obj); + }), + createConcatStream([]), + ]); + exportedActionConnectors.actionConnectors = actionsConnectorsToExport.join('\n'); + return exportedActionConnectors; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.test.ts new file mode 100644 index 0000000000000..455274006297e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.test.ts @@ -0,0 +1,383 @@ +/* + * 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 { actionsClientMock } from '@kbn/actions-plugin/server/actions_client.mock'; +import { + getImportRulesSchemaMock, + webHookConnector, +} from '../../../../../../../common/detection_engine/rule_management/model/import/rule_to_import.mock'; +import { importRuleActionConnectors } from './import_rule_action_connectors'; +import { coreMock } from '@kbn/core/server/mocks'; + +const rules = [ + { + ...getImportRulesSchemaMock(), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: '.webhook', + params: {}, + }, + ], + }, +]; +const rulesWithoutActions = [ + { + ...getImportRulesSchemaMock(), + actions: [], + }, +]; +const actionConnectors = [webHookConnector]; +const actionsClient = actionsClientMock.create(); +actionsClient.getAll.mockResolvedValue([]); +const core = coreMock.createRequestHandlerContext(); + +describe('checkRuleExceptionReferences', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should show an error message when the user has a Read Actions permission and stops the importing ', async () => { + const newCore = coreMock.createRequestHandlerContext(); + const error = { + output: { payload: { message: 'Unable to bulk_create action' }, statusCode: 403 }, + }; + newCore.savedObjects.getImporter = jest.fn().mockReturnValueOnce({ + import: jest.fn().mockImplementation(() => { + throw error; + }), + }); + const actionsImporter2 = newCore.savedObjects.getImporter; + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter2() as never, + rules, + overwrite: false, + }); + + expect(res).toEqual({ + success: false, + successCount: 0, + errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unable to bulk_create action', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + warnings: [], + }); + }); + + it('should return import 1 connector successfully', async () => { + core.savedObjects.getImporter = jest.fn().mockReturnValueOnce({ + import: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + successResults: [], + errors: [], + warnings: [], + }), + }); + const actionsImporter = core.savedObjects.getImporter; + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter() as never, + rules, + overwrite: false, + }); + + expect(res).toEqual({ + success: true, + successCount: 1, + successResults: [], + errors: [], + warnings: [], + }); + }); + it('should return import 1 connector successfully only if id is duplicated', async () => { + core.savedObjects.getImporter = jest.fn().mockReturnValueOnce({ + import: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + successResults: [], + errors: [], + warnings: [], + }), + }); + const actionsImporter = core.savedObjects.getImporter; + + const ruleWith2Connectors = [ + { + ...getImportRulesSchemaMock(), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + action_type_id: '.slack', + }, + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + action_type_id: '.slack', + }, + ], + }, + ]; + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter() as never, + rules: ruleWith2Connectors, + overwrite: false, + }); + + expect(res).toEqual({ + success: true, + successCount: 1, + successResults: [], + errors: [], + warnings: [], + }); + }); + + it('should show an error message when the user has an old imported rule with a missing connector data', async () => { + const actionsImporter = core.savedObjects.getImporter; + + const res = await importRuleActionConnectors({ + actionConnectors: [], + actionsClient, + actionsImporter: actionsImporter() as never, + rules, + overwrite: false, + }); + + expect(res).toEqual({ + success: false, + successCount: 0, + errors: [ + { + error: { + message: + '1 connector is missing. Connector id missing is: cabc78e0-9031-11ed-b076-53cc4d57aaf1', + status_code: 404, + }, + rule_id: 'rule-1', + }, + ], + warnings: [], + }); + }); + it('should show an error message when the user has an old imported rule with a 2 missing connectors data', async () => { + const actionsImporter = core.savedObjects.getImporter; + + const res = await importRuleActionConnectors({ + actionConnectors: [], + actionsClient, + actionsImporter: actionsImporter() as never, + rules: [ + { + ...getImportRulesSchemaMock(), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: '.webhook', + params: {}, + }, + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf2', + action_type_id: '.webhook', + params: {}, + }, + ], + }, + ], + overwrite: false, + }); + + expect(res).toEqual({ + success: false, + successCount: 0, + errors: [ + { + error: { + message: + '2 connectors are missing. Connector ids missing are: cabc78e0-9031-11ed-b076-53cc4d57aaf1, cabc78e0-9031-11ed-b076-53cc4d57aaf2', + status_code: 404, + }, + rule_id: 'rule-1', + }, + ], + warnings: [], + }); + }); + it('should show an error message when the user has 2 imported rules with a 2 missing connectors data', async () => { + const actionsImporter = core.savedObjects.getImporter; + + const res = await importRuleActionConnectors({ + actionConnectors: [], + actionsClient, + actionsImporter: actionsImporter() as never, + rules: [ + { + ...getImportRulesSchemaMock(), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: '.webhook', + params: {}, + }, + ], + }, + { + ...getImportRulesSchemaMock('rule-2'), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf2', + action_type_id: '.webhook', + params: {}, + }, + ], + }, + ], + overwrite: false, + }); + + expect(res).toEqual({ + success: false, + successCount: 0, + errors: [ + { + error: { + message: + '2 connectors are missing. Connector ids missing are: cabc78e0-9031-11ed-b076-53cc4d57aaf1, cabc78e0-9031-11ed-b076-53cc4d57aaf2', + status_code: 404, + }, + rule_id: 'rule-1,rule-2', + }, + ], + warnings: [], + }); + }); + + it('should skip importing the action-connectors if the actions array is empty, even if the user has exported-connectors in the file', async () => { + core.savedObjects.getImporter = jest.fn().mockReturnValue({ + import: jest.fn().mockResolvedValue({ + success: true, + successCount: 2, + successResults: [], + errors: [], + warnings: [], + }), + }); + const actionsImporter2 = core.savedObjects.getImporter; + const actionsImporter2Import = actionsImporter2().import; + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter2Import as never, + rules: rulesWithoutActions, + overwrite: false, + }); + + expect(res).toEqual({ + success: true, + successCount: 0, + errors: [], + warnings: [], + }); + expect(actionsImporter2Import).not.toBeCalled(); + }); + + it('should skip importing the action-connectors if all connectors have been imported/created before', async () => { + actionsClient.getAll.mockResolvedValue([ + { + actionTypeId: '.webhook', + name: 'webhook', + isPreconfigured: true, + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + referencedByCount: 1, + isDeprecated: false, + }, + ]); + const actionsImporter2 = core.savedObjects.getImporter; + const actionsImporter2Import = actionsImporter2().import; + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter2Import as never, + rules, + overwrite: false, + }); + + expect(res).toEqual({ + success: true, + successCount: 0, + errors: [], + warnings: [], + }); + expect(actionsImporter2Import).not.toBeCalled(); + }); + + it('should not skip importing the action-connectors if all connectors have been imported/created before when overwrite is true', async () => { + core.savedObjects.getImporter = jest.fn().mockReturnValueOnce({ + import: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + successResults: [], + errors: [], + warnings: [], + }), + }); + const actionsImporter = core.savedObjects.getImporter; + + actionsClient.getAll.mockResolvedValue([ + { + actionTypeId: '.webhook', + name: 'webhook', + isPreconfigured: true, + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + referencedByCount: 1, + isDeprecated: false, + }, + ]); + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter() as never, + rules, + overwrite: true, + }); + + expect(res).toEqual({ + success: true, + successCount: 1, + errors: [], + warnings: [], + successResults: [], + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.ts new file mode 100644 index 0000000000000..3d2fc9fb7c6b6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.ts @@ -0,0 +1,84 @@ +/* + * 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 { Readable } from 'stream'; + +import type { SavedObjectsImportResponse } from '@kbn/core-saved-objects-common'; +import type { SavedObject } from '@kbn/core-saved-objects-server'; + +import type { WarningSchema } from '../../../../../../../common/detection_engine/schemas/response'; +import { + checkIfActionsHaveMissingConnectors, + filterExistingActionConnectors, + getActionConnectorRules, + handleActionsHaveNoConnectors, + mapSOErrorToRuleError, + returnErroredImportResult, +} from './utils'; +import type { ImportRuleActionConnectorsParams, ImportRuleActionConnectorsResult } from './types'; + +export const importRuleActionConnectors = async ({ + actionConnectors, + actionsClient, + actionsImporter, + rules, + overwrite, +}: ImportRuleActionConnectorsParams): Promise => { + try { + const actionConnectorRules = getActionConnectorRules(rules); + const actionsIds: string[] = Object.keys(actionConnectorRules); + + if (!actionsIds.length) + return { + success: true, + errors: [], + successCount: 0, + warnings: [], + }; + + if (overwrite && !actionConnectors.length) + return handleActionsHaveNoConnectors(actionsIds, actionConnectorRules); + + let actionConnectorsToImport: SavedObject[] = actionConnectors; + + if (!overwrite) { + const newIdsToAdd = await filterExistingActionConnectors(actionsClient, actionsIds); + + const foundMissingConnectors = checkIfActionsHaveMissingConnectors( + actionConnectors, + newIdsToAdd, + actionConnectorRules + ); + if (foundMissingConnectors) return foundMissingConnectors; + // filter out existing connectors + actionConnectorsToImport = actionConnectors.filter(({ id }) => newIdsToAdd.includes(id)); + } + if (!actionConnectorsToImport.length) + return { + success: true, + errors: [], + successCount: 0, + warnings: [], + }; + + const readStream = Readable.from(actionConnectorsToImport); + const { success, successCount, successResults, warnings, errors }: SavedObjectsImportResponse = + await actionsImporter.import({ + readStream, + overwrite, + createNewCopies: false, + }); + return { + success, + successCount, + successResults, + errors: errors ? mapSOErrorToRuleError(errors) : [], + warnings: (warnings as WarningSchema[]) || [], + }; + } catch (error) { + return returnErroredImportResult(error); + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/types.ts new file mode 100644 index 0000000000000..8ba6c41c42dde --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/types.ts @@ -0,0 +1,44 @@ +/* + * 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 { ISavedObjectsImporter, SavedObject } from '@kbn/core-saved-objects-server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { + SavedObjectsImportFailure, + SavedObjectsImportSuccess, +} from '@kbn/core-saved-objects-common'; +import type { RuleToImport } from '../../../../../../../common/detection_engine/rule_management'; +import type { WarningSchema } from '../../../../../../../common/detection_engine/schemas/response'; +import type { BulkError } from '../../../../routes/utils'; + +export interface ImportRuleActionConnectorsResult { + success: boolean; + successCount: number; + successResults?: SavedObjectsImportSuccess[]; + errors: BulkError[] | []; + warnings: WarningSchema[] | []; +} + +export interface ImportRuleActionConnectorsParams { + actionConnectors: SavedObject[]; + actionsClient: ActionsClient; + actionsImporter: ISavedObjectsImporter; + rules: Array; + overwrite: boolean; +} + +export interface SOError { + output: { statusCode: number; payload: { message: string } }; +} + +export interface ConflictError { + type: string; +} + +export type ErrorType = SOError | ConflictError | SavedObjectsImportFailure | Error; +export interface ActionRules { + [actionsIds: string]: string[]; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/utils/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/utils/index.ts new file mode 100644 index 0000000000000..6d9b3b9da9e6d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/utils/index.ts @@ -0,0 +1,129 @@ +/* + * 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 { pick } from 'lodash'; +import type { SavedObjectsImportFailure } from '@kbn/core-saved-objects-common'; +import type { SavedObject } from '@kbn/core-saved-objects-server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { BulkError } from '../../../../../routes/utils'; +import { createBulkErrorObject } from '../../../../../routes/utils'; +import type { RuleToImport } from '../../../../../../../../common/detection_engine/rule_management'; +import type { + ActionRules, + ConflictError, + ErrorType, + ImportRuleActionConnectorsResult, + SOError, +} from '../types'; + +export const returnErroredImportResult = (error: ErrorType): ImportRuleActionConnectorsResult => ({ + success: false, + errors: [handleActionConnectorsErrors(error)], + successCount: 0, + warnings: [], +}); + +export const handleActionsHaveNoConnectors = ( + actionsIds: string[], + actionConnectorRules: ActionRules +): ImportRuleActionConnectorsResult => { + const ruleIds: string = [...new Set(Object.values(actionConnectorRules).flat())].join(); + + if (actionsIds && actionsIds.length) { + const errors: BulkError[] = []; + const errorMessage = + actionsIds.length > 1 + ? 'connectors are missing. Connector ids missing are:' + : 'connector is missing. Connector id missing is:'; + errors.push( + createBulkErrorObject({ + statusCode: 404, + message: `${actionsIds.length} ${errorMessage} ${actionsIds.join(', ')}`, + ruleId: ruleIds, + }) + ); + return { + success: false, + errors, + successCount: 0, + warnings: [], + }; + } + return { + success: true, + errors: [], + successCount: 0, + warnings: [], + }; +}; + +export const handleActionConnectorsErrors = (error: ErrorType, id?: string): BulkError => { + let statusCode: number | null = null; + let message: string = ''; + if ('output' in error) { + statusCode = (error as SOError).output.statusCode; + message = (error as SOError).output.payload?.message; + } + switch (statusCode) { + case null: + return createBulkErrorObject({ + statusCode: 500, + message: + (error as ConflictError)?.type === 'conflict' + ? 'There is a conflict' + : (error as Error).message + ? (error as Error).message + : '', + }); + + case 403: + return createBulkErrorObject({ + id, + statusCode, + message: `You may not have actions privileges required to import rules with actions: ${message}`, + }); + + default: + return createBulkErrorObject({ + id, + statusCode, + message, + }); + } +}; + +export const mapSOErrorToRuleError = (errors: SavedObjectsImportFailure[]): BulkError[] => { + return errors.map(({ id, error }) => handleActionConnectorsErrors(error, id)); +}; + +export const filterExistingActionConnectors = async ( + actionsClient: ActionsClient, + actionsIds: string[] +) => { + const storedConnectors = await actionsClient.getAll(); + const storedActionIds: string[] = storedConnectors.map(({ id }) => id); + return actionsIds.filter((id) => !storedActionIds.includes(id)); +}; +export const getActionConnectorRules = (rules: Array) => + rules.reduce((acc: { [actionsIds: string]: string[] }, rule) => { + if (rule instanceof Error) return acc; + rule.actions?.forEach(({ id }) => (acc[id] = [...(acc[id] || []), rule.rule_id])); + return acc; + }, {}); +export const checkIfActionsHaveMissingConnectors = ( + actionConnectors: SavedObject[], + newIdsToAdd: string[], + actionConnectorRules: ActionRules +) => { + // if new action-connectors don't have exported connectors will fail with missing connectors + if (actionConnectors.length < newIdsToAdd.length) { + const actionConnectorsIds = actionConnectors.map(({ id }) => id); + const missingActionConnector = newIdsToAdd.filter((id) => !actionConnectorsIds.includes(id)); + const missingActionRules = pick(actionConnectorRules, [...missingActionConnector]); + return handleActionsHaveNoConnectors(missingActionConnector, missingActionRules); + } + return null; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/create_rules_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/create_rules_stream_from_ndjson.ts index e325496225d9f..4414b6063c473 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/create_rules_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/create_rules_stream_from_ndjson.ts @@ -24,6 +24,7 @@ import type { ImportExceptionsListSchema, } from '@kbn/securitysolution-io-ts-list-types'; +import type { SavedObject } from '@kbn/core-saved-objects-server'; import { RuleToImport, validateRuleToImport, @@ -41,7 +42,9 @@ export const validateRulesStream = (): Transform => { return createMapStream<{ exceptions: Array; rules: Array; + actionConnectors: SavedObject[]; }>((items) => ({ + actionConnectors: items.actionConnectors, exceptions: items.exceptions, rules: validateRules(items.rules), })); @@ -80,10 +83,14 @@ export const sortImports = (): Transform => { return createReduceStream<{ exceptions: Array; rules: Array; + actionConnectors: SavedObject[]; }>( (acc, importItem) => { if (has('list_id', importItem) || has('item_id', importItem) || has('entries', importItem)) { return { ...acc, exceptions: [...acc.exceptions, importItem] }; + } + if (has('attributes', importItem)) { + return { ...acc, actionConnectors: [...acc.actionConnectors, importItem] }; } else { return { ...acc, rules: [...acc.rules, importItem] }; } @@ -91,6 +98,7 @@ export const sortImports = (): Transform => { { exceptions: [], rules: [], + actionConnectors: [], } ); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts index 694bc916fefe3..5a8d6f9a34e87 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { SavedObject, SavedObjectsClientContract } from '@kbn/core/server'; import type { ImportExceptionsListSchema, ImportExceptionListItemSchema, @@ -31,6 +31,7 @@ export type PromiseFromStreams = RuleToImport | Error; export interface RuleExceptionsPromiseFromStreams { rules: PromiseFromStreams[]; exceptions: Array; + actionConnectors: SavedObject[]; } /** @@ -60,6 +61,7 @@ export const importRules = async ({ exceptionsClient, spaceId, existingLists, + allowMissingConnectorSecrets, }: { ruleChunks: PromiseFromStreams[][]; rulesResponseAcc: ImportRuleResponse[]; @@ -70,6 +72,7 @@ export const importRules = async ({ exceptionsClient: ExceptionListClient | undefined; spaceId: string; existingLists: Record; + allowMissingConnectorSecrets?: boolean; }) => { let importRuleResponse: ImportRuleResponse[] = [...rulesResponseAcc]; @@ -118,6 +121,7 @@ export const importRules = async ({ ...parsedRule, exceptions_list: [...exceptions], }, + allowMissingConnectorSecrets, }); resolve({ rule_id: parsedRule.rule_id, @@ -136,6 +140,7 @@ export const importRules = async ({ ...parsedRule, exceptions_list: [...exceptions], }, + allowMissingConnectorSecrets, }); resolve({ rule_id: parsedRule.rule_id, diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 4522a35f793ec..531fd7c0a6f38 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -133,6 +133,8 @@ "@kbn/cypress-config", "@kbn/controls-plugin", "@kbn/shared-ux-utility", + "@kbn/core-saved-objects-common", + "@kbn/core-saved-objects-import-export-server-mocks", "@kbn/user-profile-components", "@kbn/guided-onboarding", "@kbn/securitysolution-ecs", diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts index 833b9ccb9ee1f..2d1ddd342a549 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts @@ -87,6 +87,11 @@ export default ({ getService }: FtrProviderContext): void => { 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: [], }); }); diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts index d32e93b481547..2630174529b75 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts @@ -73,6 +73,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -147,6 +151,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -170,6 +178,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -227,6 +239,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -245,6 +261,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -277,6 +297,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -301,6 +325,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -365,6 +393,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -404,6 +436,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts index b30e16681d488..60af78ec6cff9 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts @@ -88,6 +88,11 @@ export default ({ getService }: FtrProviderContext): void => { 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: [], }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts index 48c4408e8555e..169fe074604fa 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts @@ -135,9 +135,14 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); - it('should successfully import rules with actions when user has "read" actions privileges', async () => { + + it('should not import rules with actions when user has "read" actions privileges', async () => { // create a new action const { body: hookAction } = await supertest .post('/api/actions/action') @@ -149,27 +154,71 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: hookAction.id, + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', action_type_id: hookAction.actionTypeId, params: {}, }, ], }; + const ruleWithConnector = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; + const { body } = await supertestWithoutAuth .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .auth(ROLES.hunter, 'changeme') .set('kbn-xsrf', 'true') - .attach('file', ruleToNdjson(simpleRule), 'rules.ndjson') + .attach( + 'file', + Buffer.from(toNdJsonString([simpleRule, ruleWithConnector])), + 'rules.ndjson' + ) .expect(200); expect(body).to.eql({ - errors: [], - success: true, - success_count: 1, + errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unable to bulk_create action', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + success: false, + success_count: 0, rules_count: 1, exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unable to bulk_create action', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + action_connectors_warnings: [], }); }); it('should not import rules with actions when a user has no actions privileges', async () => { @@ -184,17 +233,39 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: hookAction.id, + id: 'cabc78e0-9031-11ed-b076-53cc4d57axy1', action_type_id: hookAction.actionTypeId, params: {}, }, ], }; + const ruleWithConnector = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57axy1', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; + const { body } = await supertestWithoutAuth .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .auth(ROLES.hunter_no_actions, 'changeme') .set('kbn-xsrf', 'true') - .attach('file', ruleToNdjson(simpleRule), 'rules.ndjson') + .attach( + 'file', + Buffer.from(toNdJsonString([simpleRule, ruleWithConnector])), + 'rules.ndjson' + ) .expect(200); expect(body).to.eql({ success: false, @@ -208,18 +279,24 @@ export default ({ getService }: FtrProviderContext): void => { }, rule_id: '(unknown id)', }, - { - error: { - message: `1 connector is missing. Connector id missing is: ${hookAction.id}`, - status_code: 404, - }, - rule_id: 'rule-1', - }, ], rules_count: 1, exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unauthorized to get actions', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + action_connectors_warnings: [], }); }); }); @@ -367,6 +444,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -404,6 +485,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -430,6 +515,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -448,6 +537,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -480,6 +573,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -504,6 +601,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -568,6 +669,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -607,6 +712,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -688,6 +797,78 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [ + { + rule_id: 'rule-1', + error: { + status_code: 404, + message: '1 connector is missing. Connector id missing is: 123', + }, + }, + ], + }); + }); + it('should give single connector warning back if we have a single connector missing secret', async () => { + const simpleRule: ReturnType = { + ...getSimpleRule('rule-1'), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf9', + action_type_id: '.webhook', + params: {}, + }, + ], + }; + const ruleWithConnector = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf9', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach( + 'file', + Buffer.from(toNdJsonString([simpleRule, ruleWithConnector])), + 'rules.ndjson' + ) + .expect(200); + + expect(body).to.eql({ + success: true, + success_count: 1, + rules_count: 1, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 1, + action_connectors_warnings: [ + { + actionPath: '/app/management/insightsAndAlerting/triggersActionsConnectors', + buttonLabel: 'Go to connectors', + message: '1 connector has sensitive information that require updates.', + type: 'action_required', + }, + ], + action_connectors_errors: [], }); }); @@ -709,6 +890,7 @@ export default ({ getService }: FtrProviderContext): void => { }, ], }; + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') @@ -722,10 +904,14 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); - it('should be able to import 2 rules with action connectors that exist', async () => { + it('should be able to import 2 rules with action connectors', async () => { // create a new action const { body: hookAction } = await supertest .post('/api/actions/action') @@ -738,7 +924,7 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: hookAction.id, + id: 'cabc78e0-9031-11ed-b076-53cc4d57abc6', action_type_id: hookAction.actionTypeId, params: {}, }, @@ -750,18 +936,57 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: hookAction.id, - action_type_id: hookAction.actionTypeId, + id: 'f4e74ab0-9e59-11ed-a3db-f9134a9ce951', + action_type_id: '.index', params: {}, }, ], }; + + const connector1 = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57abc6', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: false, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; + const connector2 = { + id: 'f4e74ab0-9e59-11ed-a3db-f9134a9ce951', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.index', + name: 'index', + isMissingSecrets: false, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; const rule1String = JSON.stringify(rule1); const rule2String = JSON.stringify(rule2); - const buffer = Buffer.from(`${rule1String}\n${rule2String}\n`); + const connector12String = JSON.stringify(connector1); + const connector22String = JSON.stringify(connector2); + const buffer = Buffer.from( + `${rule1String}\n${rule2String}\n${connector12String}\n${connector22String}\n` + ); const { body } = await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) .set('kbn-xsrf', 'true') .attach('file', buffer, 'rules.ndjson') .expect(200); @@ -774,6 +999,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 2, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -790,7 +1019,7 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: hookAction.id, + id: 'cabc78e0-9031-11ed-b076-53cc4d57aayo', action_type_id: hookAction.actionTypeId, params: {}, }, @@ -802,15 +1031,36 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: '123', // <-- This does not exist - action_type_id: hookAction.actionTypeId, + id: 'cabc78e0-9031-11ed-b076-53cc4d57aa22', // <-- This does not exist + action_type_id: '.index', params: {}, }, ], }; + + const connector = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aayo', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: false, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; + const rule1String = JSON.stringify(rule1); const rule2String = JSON.stringify(rule2); - const buffer = Buffer.from(`${rule1String}\n${rule2String}\n`); + const connector2String = JSON.stringify(connector); + + const buffer = Buffer.from(`${rule1String}\n${rule2String}\n${connector2String}\n`); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) @@ -820,20 +1070,34 @@ export default ({ getService }: FtrProviderContext): void => { expect(body).to.eql({ success: false, - success_count: 1, + success_count: 0, rules_count: 2, errors: [ { rule_id: 'rule-2', error: { status_code: 404, - message: '1 connector is missing. Connector id missing is: 123', + message: + '1 connector is missing. Connector id missing is: cabc78e0-9031-11ed-b076-53cc4d57aa22', }, }, ], exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + error: { + status_code: 404, + message: + '1 connector is missing. Connector id missing is: cabc78e0-9031-11ed-b076-53cc4d57aa22', + }, + rule_id: 'rule-2', + }, + ], + action_connectors_warnings: [], }); }); @@ -974,6 +1238,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -1045,6 +1313,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -1144,6 +1416,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -1274,6 +1550,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts index 06e55fdfdda1b..13f1f99fc3b23 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts @@ -106,6 +106,100 @@ export default ({ getService }: FtrProviderContext): void => { 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: [], + }); + }); + it('should export rules with actions connectors', async () => { + // create new actions + const webHookAction = await createWebHookConnector(); + + const defaultRuleAction = { + id: webHookAction.id, + action_type_id: '.webhook', + group: 'default', + params: { + body: '{"test":"a default action"}', + }, + }; + + const ruleId = 'rule-1'; + await createRule(supertest, log, { + ...getSimpleRule(ruleId), + actions: [defaultRuleAction], + }); + const exportedConnectors = { + attributes: { + actionTypeId: '.webhook', + config: { + hasAuth: true, + method: 'post', + url: 'http://localhost', + }, + isMissingSecrets: true, + name: 'Some connector', + secrets: {}, + }, + coreMigrationVersion: '8.7.0', + id: webHookAction.id, + migrationVersion: { + action: '8.3.0', + }, + references: [], + type: 'action', + }; + + const { body } = await postBulkAction() + .send({ query: '', action: BulkActionType.export }) + .expect(200) + .expect('Content-Type', 'application/ndjson') + .expect('Content-Disposition', 'attachment; filename="rules_export.ndjson"') + .parse(binaryToString); + + const [ruleJson, connectorsJson, exportDetailsJson] = body.toString().split(/\n/); + + const rule = removeServerGeneratedProperties(JSON.parse(ruleJson)); + expect(rule).to.eql({ + ...getSimpleRuleOutput(), + throttle: 'rule', + actions: [ + { + action_type_id: '.webhook', + group: 'default', + id: webHookAction.id, + params: { + body: '{"test":"a default action"}', + }, + }, + ], + }); + const { attributes, id, type } = JSON.parse(connectorsJson); + expect(attributes.actionTypeId).to.eql(exportedConnectors.attributes.actionTypeId); + expect(id).to.eql(exportedConnectors.id); + expect(type).to.eql(exportedConnectors.type); + expect(attributes.name).to.eql(exportedConnectors.attributes.name); + expect(attributes.secrets).to.eql(exportedConnectors.attributes.secrets); + expect(attributes.isMissingSecrets).to.eql(exportedConnectors.attributes.isMissingSecrets); + const exportDetails = JSON.parse(exportDetailsJson); + expect(exportDetails).to.eql({ + 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: [], }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts index 3ff748fa5259d..135bf49c15ff9 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts @@ -133,6 +133,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); it('should successfully import rules with actions when user has "read" actions privileges', async () => { @@ -168,6 +172,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); it('should not import rules with actions when a user has no actions privileges', async () => { @@ -202,7 +210,7 @@ export default ({ getService }: FtrProviderContext): void => { { error: { message: - 'You may not have actions privileges required to import rules with actions: Unauthorized to get actions', + 'You may not have actions privileges required to import rules with actions: Unable to bulk_create action', status_code: 403, }, rule_id: '(unknown id)', @@ -218,6 +226,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); }); @@ -268,6 +280,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -305,6 +321,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -331,6 +351,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -349,6 +373,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -381,6 +409,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -405,6 +437,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -469,6 +505,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -508,6 +548,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -589,6 +633,18 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + rule_id: 'rule-1', + error: { + status_code: 404, + message: '1 connector is missing. Connector id missing is: 123', + }, + }, + ], + action_connectors_warnings: [], }); }); @@ -623,6 +679,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -675,6 +735,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -735,6 +799,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -875,6 +943,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -946,6 +1018,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -1045,6 +1121,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -1175,6 +1255,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); });